Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,12 @@ primitives can be swapped transparently.
`stream.NewChaCha20` and `stream.NewXChaCha20` expose the shared `stream.Stream` interface (with `Reset`, `KeyStream`,
and `XORKeyStream`) so applications can swap keystream generators without touching call sites.

| Algorithm | Constructor | Key | Nonce | Notes | RFC / Spec |
|-----------|-------------------------|-----|-------|---------------------------------------------|------------------------------------------------------------------------------------------------|
| ChaCha20 | `stream.NewChaCha20()` | 32B | 12B | IETF variant with configurable counter | [RFC 8439](https://www.rfc-editor.org/rfc/rfc8439.html) |
| XChaCha20 | `stream.NewXChaCha20()` | 32B | 24B | HChaCha20-derived subkeys and raw keystream | [draft-irtf-cfrg-xchacha-03](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha-03) |
| Algorithm | Constructor | Key | Nonce | Notes | RFC / Spec
|
|-----------|-------------------------|-----------|-------|------------------------------------------------------|-------------------------------------------------------------------------------|
| AES-CTR | `stream.NewAESCTR()` | 16/24/32B | 12B | 96-bit nonce with 32-bit counter (NIST layout) | [NIST SP 800-38A](https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38a.pdf) |
| ChaCha20 | `stream.NewChaCha20()` | 32B | 12B | IETF variant with configurable counter | [RFC 8439](https://www.rfc-editor.org/rfc/rfc8439.html) |
| XChaCha20 | `stream.NewXChaCha20()` | 32B | 24B | HChaCha20-derived subkeys and raw keystream | [draft-irtf-cfrg-xchacha-03](https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha-03) |

### Block ciphers

Expand Down
119 changes: 119 additions & 0 deletions stream/aesctr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package stream

import (
"crypto/aes"
"crypto/cipher"
"encoding/binary"
"errors"
"math"
)

const (
aesCTRBlockSize = 16
aesCTRNonceSize = 12
)

var (
errAESCTRInvalidKey = errors.New("aesctr: invalid key length")
errAESCTRInvalidNonce = errors.New("aesctr: invalid nonce length")

_ Stream = (*aesCTRCipher)(nil)
)

type aesCTRCipher struct {
block cipher.Block
nonce [aesCTRNonceSize]byte
counter uint32
exhausted bool

keystream [aesCTRBlockSize]byte
offset int
}

// NewAESCTR returns an AES-CTR stream cipher implementing Stream.
func NewAESCTR(key, nonce []byte, counter uint32) (Stream, error) {
return newAESCTR(key, nonce, counter)
}

func newAESCTR(key, nonce []byte, counter uint32) (*aesCTRCipher, error) {
switch len(key) {
case 16, 24, 32:
default:
return nil, errAESCTRInvalidKey
}
if len(nonce) != aesCTRNonceSize {
return nil, errAESCTRInvalidNonce
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
var n [aesCTRNonceSize]byte
copy(n[:], nonce)
return &aesCTRCipher{
block: block,
nonce: n,
counter: counter,
offset: aesCTRBlockSize,
}, nil
}

func (c *aesCTRCipher) KeyStream(dst []byte) {
for len(dst) > 0 {
if c.offset == len(c.keystream) {
c.refill()
}
n := copy(dst, c.keystream[c.offset:])
c.offset += n
dst = dst[n:]
}
}

func (c *aesCTRCipher) XORKeyStream(dst, src []byte) {
if len(src) != len(dst) {
panic("aesctr: dst and src lengths differ")
}
for len(src) > 0 {
if c.offset == len(c.keystream) {
c.refill()
}
remain := len(c.keystream) - c.offset
n := len(src)
if n > remain {
n = remain
}
copy(dst[:n], src[:n])
keystream := c.keystream[c.offset : c.offset+n]
for i := 0; i < n; i++ {
dst[i] ^= keystream[i]
}
c.offset += n
dst = dst[n:]
src = src[n:]
}
}

func (c *aesCTRCipher) Reset(counter uint32) {
c.counter = counter
c.offset = aesCTRBlockSize
c.exhausted = false
}

func (c *aesCTRCipher) refill() {
if c.exhausted {
panic("aesctr: keystream exhausted")
}
var counterBlock [aesCTRBlockSize]byte
copy(counterBlock[:aesCTRNonceSize], c.nonce[:])
binary.BigEndian.PutUint32(counterBlock[aesCTRNonceSize:], c.counter)
c.block.Encrypt(c.keystream[:], counterBlock[:])
if c.counter == math.MaxUint32 {
c.exhausted = true
} else {
c.counter++
}
c.offset = 0
}

// AESCTRNonceSize returns the AES-CTR nonce size in bytes.
func AESCTRNonceSize() int { return aesCTRNonceSize }
173 changes: 173 additions & 0 deletions test/stream/aesctr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package stream_test

import (
"bytes"
_ "embed"
"math"
"strconv"
"strings"
"testing"

"github.com/AeonDave/cryptonite-go/stream"
testutil "github.com/AeonDave/cryptonite-go/test/internal/testutil"
)

//go:embed testdata/aesctr_kat.txt
var aesctrKAT string

type aesctrCase struct {
variant string
key []byte
nonce []byte
counter uint32
plaintext []byte
ciphertext []byte
}

func parseAESCTRKAT(t *testing.T) []aesctrCase {
t.Helper()
lines := strings.Split(aesctrKAT, "\n")
var cases []aesctrCase
for i := 0; i < len(lines); {
line := strings.TrimSpace(lines[i])
if line == "" {
i++
continue
}
if !strings.HasPrefix(line, "Variant =") {
t.Fatalf("unexpected label on line %d: %q", i+1, lines[i])
}
variant := strings.TrimSpace(strings.TrimPrefix(line, "Variant ="))
i++
var (
key, nonce, plaintext, ciphertext []byte
counter uint32
haveCounter bool
)
for i < len(lines) {
l := strings.TrimSpace(lines[i])
if l == "" {
i++
break
}
switch {
case strings.HasPrefix(l, "Key ="):
key = testutil.MustHex(t, strings.TrimSpace(strings.TrimPrefix(l, "Key =")))
case strings.HasPrefix(l, "Nonce ="):
nonce = testutil.MustHex(t, strings.TrimSpace(strings.TrimPrefix(l, "Nonce =")))
case strings.HasPrefix(l, "Counter ="):
value := strings.TrimSpace(strings.TrimPrefix(l, "Counter ="))
n, err := strconv.ParseUint(value, 10, 32)
if err != nil {
t.Fatalf("variant %q: invalid counter %q: %v", variant, value, err)
}
counter = uint32(n)
haveCounter = true
case strings.HasPrefix(l, "Plaintext ="):
plaintext = testutil.MustHex(t, strings.TrimSpace(strings.TrimPrefix(l, "Plaintext =")))
case strings.HasPrefix(l, "Ciphertext ="):
ciphertext = testutil.MustHex(t, strings.TrimSpace(strings.TrimPrefix(l, "Ciphertext =")))
default:
t.Fatalf("variant %q: unexpected attribute on line %d: %q", variant, i+1, lines[i])
}
i++
}
if !haveCounter {
t.Fatalf("variant %q missing counter", variant)
}
cases = append(cases, aesctrCase{
variant: variant,
key: key,
nonce: nonce,
counter: counter,
plaintext: plaintext,
ciphertext: ciphertext,
})
}
return cases
}

func TestAESCTRKAT(t *testing.T) {
cases := parseAESCTRKAT(t)
if len(cases) == 0 {
t.Fatal("no AES-CTR cases parsed")
}
for _, tc := range cases {
c, err := stream.NewAESCTR(tc.key, tc.nonce, tc.counter)
if err != nil {
t.Fatalf("%s: constructor failed: %v", tc.variant, err)
}
if len(tc.plaintext) == 0 || len(tc.ciphertext) == 0 {
t.Fatalf("%s: missing plaintext or ciphertext", tc.variant)
}
dst := make([]byte, len(tc.plaintext))
c.XORKeyStream(dst, tc.plaintext)
if !bytes.Equal(dst, tc.ciphertext) {
t.Fatalf("%s: ciphertext mismatch\n got %x\nwant %x", tc.variant, dst, tc.ciphertext)
}
c.Reset(tc.counter)
copy(dst, tc.ciphertext)
c.XORKeyStream(dst, dst)
if !bytes.Equal(dst, tc.plaintext) {
t.Fatalf("%s: decrypt mismatch\n got %x\nwant %x", tc.variant, dst, tc.plaintext)
}
keystream := make([]byte, len(tc.plaintext))
for i := range keystream {
keystream[i] = tc.plaintext[i] ^ tc.ciphertext[i]
}
c.Reset(tc.counter)
got := make([]byte, len(keystream))
c.KeyStream(got)
if !bytes.Equal(got, keystream) {
t.Fatalf("%s: keystream mismatch\n got %x\nwant %x", tc.variant, got, keystream)
}
}
}

func TestAESCTRInvalidParameters(t *testing.T) {
if _, err := stream.NewAESCTR(make([]byte, 15), make([]byte, stream.AESCTRNonceSize()), 0); err == nil {
t.Fatal("expected error for short key")
}
if _, err := stream.NewAESCTR(make([]byte, 16), make([]byte, stream.AESCTRNonceSize()-1), 0); err == nil {
t.Fatal("expected error for short nonce")
}
}

func TestAESCTRKeystreamExhaustion(t *testing.T) {
key := make([]byte, 16)
nonce := make([]byte, stream.AESCTRNonceSize())
c, err := stream.NewAESCTR(key, nonce, math.MaxUint32)
if err != nil {
t.Fatalf("NewAESCTR failed: %v", err)
}

block := make([]byte, 16)
c.KeyStream(block)

var panicked bool
func() {
defer func() {
r := recover()
if r == nil {
t.Fatal("expected keystream exhaustion panic")
}
if s, ok := r.(string); ok && s != "aesctr: keystream exhausted" {
t.Fatalf("unexpected panic message: %v", r)
}
panicked = true
}()

tmp := make([]byte, 1)
c.KeyStream(tmp)
}()
if !panicked {
t.Fatal("expected keystream exhaustion panic")
}

c.Reset(math.MaxUint32)
got := make([]byte, len(block))
c.KeyStream(got)
if !bytes.Equal(got, block) {
t.Fatalf("keystream mismatch after reset\n got %x\nwant %x", got, block)
}
}
13 changes: 13 additions & 0 deletions test/stream/testdata/aesctr_kat.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Variant = AES-128-CTR SP800-38A F.5.1
Key = 2b7e151628aed2a6abf7158809cf4f3c
Nonce = f0f1f2f3f4f5f6f7f8f9fafb
Counter = 4244504319
Plaintext = 6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710
Ciphertext = 874d6191b620e3261bef6864990db6ce9806f66b7970fdff8617187bb9fffdff5ae4df3edbd5d35e5b4f09020db03eab1e031dda2fbe03d1792170a0f3009cee

Variant = AES-256-CTR SP800-38A F.5.5
Key = 603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4
Nonce = f0f1f2f3f4f5f6f7f8f9fafb
Counter = 4244504319
Plaintext = 6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710
Ciphertext = 601ec313775789a5b7a7f504bbf3d228f443e3ca4d62b59aca84e990cacaf5c52b0930daa23de94ce87017ba2d84988ddfc9c58db67aada613c2dd08457941a6