From b2432da197a55484c34d93dd235badde1243023e Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 23 Oct 2025 19:06:33 +0200 Subject: [PATCH] Add AES-CTR exhaustion tests and update documentation --- README.md | 10 +- stream/aesctr.go | 119 +++++++++++++++++++ test/stream/aesctr_test.go | 173 ++++++++++++++++++++++++++++ test/stream/testdata/aesctr_kat.txt | 13 +++ 4 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 stream/aesctr.go create mode 100644 test/stream/aesctr_test.go create mode 100644 test/stream/testdata/aesctr_kat.txt diff --git a/README.md b/README.md index ac69c2b..269b6e8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/stream/aesctr.go b/stream/aesctr.go new file mode 100644 index 0000000..3a74873 --- /dev/null +++ b/stream/aesctr.go @@ -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 } diff --git a/test/stream/aesctr_test.go b/test/stream/aesctr_test.go new file mode 100644 index 0000000..1b6f585 --- /dev/null +++ b/test/stream/aesctr_test.go @@ -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) + } +} diff --git a/test/stream/testdata/aesctr_kat.txt b/test/stream/testdata/aesctr_kat.txt new file mode 100644 index 0000000..3bad506 --- /dev/null +++ b/test/stream/testdata/aesctr_kat.txt @@ -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