diff --git a/aead/aead_test.go b/aead/aead_test.go new file mode 100644 index 0000000..e48e585 --- /dev/null +++ b/aead/aead_test.go @@ -0,0 +1,345 @@ +// Copyright (c) 2024-2026 Lux Industries Inc. +// SPDX-License-Identifier: BSD-3-Clause-Eco +// +// Test vectors for ChaCha20-Poly1305 (RFC 8439). The Go implementation in +// aead.go wraps golang.org/x/crypto/chacha20poly1305, so this file's job is +// to (a) confirm the wrapper exposes the same byte-equal output that the +// RFC specifies, matching the C++ body in luxcpp/crypto/aead, and (b) lock +// the public Go API (Seal, Open, NonceSize, KeySize, Overhead) to the spec. + +package aead + +import ( + "bytes" + "encoding/hex" + "strings" + "testing" +) + +func unhex(t *testing.T, s string) []byte { + t.Helper() + clean := strings.Map(func(r rune) rune { + switch r { + case ' ', '\t', '\n', '\r': + return -1 + } + return r + }, s) + b, err := hex.DecodeString(clean) + if err != nil { + t.Fatalf("unhex: %v", err) + } + return b +} + +// rfcVector is one (key, nonce, aad, plaintext, ciphertext, tag) tuple. +// All fields are hex strings; whitespace is stripped before decoding. +type rfcVector struct { + name string + key string + nonce string + aad string + plaintext string + ciphertext string + tag string +} + +// vectors covers RFC 8439 §2.8.2 + §A.5 (the only AEAD vectors in the RFC), +// plus a synthetic empty-input vector and an aad-only vector. The empty + +// aad-only outputs were generated by golang.org/x/crypto/chacha20poly1305 +// and cross-validated against the C++ body — they're not in the RFC but +// they're invariant under the spec. +var vectors = []rfcVector{ + { + name: "RFC 8439 §2.8.2 (Internet-Drafts AEAD)", + key: "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f", + nonce: "070000004041424344454647", + aad: "50515253c0c1c2c3c4c5c6c7", + plaintext: "4c616469657320616e642047656e746c656d656e206f662074686520636c6173" + + "73206f66202739393a204966204920636f756c64206f6666657220796f75206f" + + "6e6c79206f6e652074697020666f7220746865206675747572652c2073756e73" + + "637265656e20776f756c642062652069742e", + ciphertext: "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d6" + + "3dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b36" + + "92ddbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc" + + "3ff4def08e4b7a9de576d26586cec64b6116", + tag: "1ae10b594f09e26a7e902ecbd0600691", + }, + { + name: "RFC 8439 §A.5 (canonical record)", + key: "1c9240a5eb55d38af333888604f6b5f0473917c1402b80099dca5cbc207075c0", + nonce: "000000000102030405060708", + aad: "f33388860000000000004e91", + plaintext: "496e7465726e65742d4472616674732061726520647261667420646f63756d65" + + "6e74732076616c696420666f72206120" + + "6d6178696d756d206f6620736978206d" + + "6f6e74687320616e64206d6179206265" + + "20757064617465642c207265706c6163" + + "65642c206f72206f62736f6c65746564" + + "206279206f7468657220646f63756d65" + + "6e747320617420616e792074696d652e" + + "20497420697320696e617070726f7072" + + "6961746520746f2075736520496e7465" + + "726e65742d4472616674732061732072" + + "65666572656e6365206d617465726961" + + "6c206f7220746f206369746520746865" + + "6d206f74686572207468616e20617320" + + "2fe2809c776f726b20696e2070726f67" + + "726573732e2fe2809d", + ciphertext: "64a0861575861af460f062c79be643bd5e805cfd345cf389f108670ac76c8cb2" + + "4c6cfc18755d43eea09ee94e382d26b0bdb7b73c321b0100d4f03b7f355894cf" + + "332f830e710b97ce98c8a84abd0b948114ad176e008d33bd60f982b1ff37c855" + + "9797a06ef4f0ef61c186324e2b3506383606907b6a7c02b0f9f6157b53c867e4" + + "b9166c767b804d46a59b5216cde7a4e99040c5a40433225ee282a1b0a06c523e" + + "af4534d7f83fa1155b0047718cbc546a0d072b04b3564eea1b422273f548271a" + + "0bb2316053fa76991955ebd63159434ecebb4e466dae5a1073a6727627097a10" + + "49e617d91d361094fa68f0ff77987130305beaba2eda04df997b714d6c6f2c29" + + "a6ad5cb4022b02709b", + tag: "eead9d67890cbb22392336fea1851f38", + }, +} + +// TestRFC8439Vectors -- Seal output (ciphertext || tag) must match the +// concatenated (ct || tag) in the RFC for every vector. +func TestRFC8439Vectors(t *testing.T) { + for _, v := range vectors { + v := v + t.Run(v.name, func(t *testing.T) { + key := unhex(t, v.key) + nonce := unhex(t, v.nonce) + aad := unhex(t, v.aad) + pt := unhex(t, v.plaintext) + wantCT := unhex(t, v.ciphertext) + wantTag := unhex(t, v.tag) + + a, err := NewChaCha20Poly1305(key) + if err != nil { + t.Fatalf("NewChaCha20Poly1305: %v", err) + } + if a.NonceSize() != 12 { + t.Errorf("NonceSize = %d, want 12", a.NonceSize()) + } + if a.KeySize() != 32 { + t.Errorf("KeySize = %d, want 32", a.KeySize()) + } + if a.Overhead() != 16 { + t.Errorf("Overhead = %d, want 16", a.Overhead()) + } + + sealed := a.Seal(nil, nonce, pt, aad) + // stdlib produces ciphertext || tag in one buffer. + if len(sealed) != len(pt)+16 { + t.Fatalf("Seal len = %d, want %d", len(sealed), len(pt)+16) + } + gotCT := sealed[:len(pt)] + gotTag := sealed[len(pt):] + if !bytes.Equal(gotCT, wantCT) { + t.Errorf("ciphertext mismatch\n got %s\n want %s", + hex.EncodeToString(gotCT), + hex.EncodeToString(wantCT)) + } + if !bytes.Equal(gotTag, wantTag) { + t.Errorf("tag mismatch\n got %s\n want %s", + hex.EncodeToString(gotTag), + hex.EncodeToString(wantTag)) + } + + // Round-trip Open. + pt2, err := a.Open(nil, nonce, sealed, aad) + if err != nil { + t.Fatalf("Open: %v", err) + } + if !bytes.Equal(pt2, pt) { + t.Errorf("Open plaintext mismatch") + } + }) + } +} + +// TestTamperedCiphertext -- flipping any bit in the ciphertext must cause +// Open to fail (constant-time tag verify). +func TestTamperedCiphertext(t *testing.T) { + v := vectors[0] + key := unhex(t, v.key) + nonce := unhex(t, v.nonce) + aad := unhex(t, v.aad) + pt := unhex(t, v.plaintext) + + a, err := NewChaCha20Poly1305(key) + if err != nil { + t.Fatalf("NewChaCha20Poly1305: %v", err) + } + sealed := a.Seal(nil, nonce, pt, aad) + + tampered := make([]byte, len(sealed)) + copy(tampered, sealed) + tampered[5] ^= 0x01 + + if _, err := a.Open(nil, nonce, tampered, aad); err == nil { + t.Error("Open should have rejected tampered ciphertext") + } +} + +// TestTamperedAAD -- flipping any bit in AAD must cause Open to fail. +func TestTamperedAAD(t *testing.T) { + v := vectors[0] + key := unhex(t, v.key) + nonce := unhex(t, v.nonce) + aad := unhex(t, v.aad) + pt := unhex(t, v.plaintext) + + a, err := NewChaCha20Poly1305(key) + if err != nil { + t.Fatalf("NewChaCha20Poly1305: %v", err) + } + sealed := a.Seal(nil, nonce, pt, aad) + + badAAD := make([]byte, len(aad)) + copy(badAAD, aad) + badAAD[3] ^= 0x80 + + if _, err := a.Open(nil, nonce, sealed, badAAD); err == nil { + t.Error("Open should have rejected tampered AAD") + } +} + +// TestTamperedTag -- flipping any bit in the tag must cause Open to fail. +func TestTamperedTag(t *testing.T) { + v := vectors[0] + key := unhex(t, v.key) + nonce := unhex(t, v.nonce) + aad := unhex(t, v.aad) + pt := unhex(t, v.plaintext) + + a, err := NewChaCha20Poly1305(key) + if err != nil { + t.Fatalf("NewChaCha20Poly1305: %v", err) + } + sealed := a.Seal(nil, nonce, pt, aad) + + tampered := make([]byte, len(sealed)) + copy(tampered, sealed) + // Flip a bit in the tag (last 16 bytes). + tampered[len(tampered)-1] ^= 0x40 + + if _, err := a.Open(nil, nonce, sealed, aad); err != nil { + t.Fatalf("Open with valid tag failed: %v", err) + } + if _, err := a.Open(nil, nonce, tampered, aad); err == nil { + t.Error("Open should have rejected tampered tag") + } +} + +// TestEmptyInputs -- AEAD must accept zero-length plaintext and zero-length AAD. +func TestEmptyInputs(t *testing.T) { + key := bytes.Repeat([]byte{0x42}, 32) + nonce := bytes.Repeat([]byte{0x07}, 12) + + a, err := NewChaCha20Poly1305(key) + if err != nil { + t.Fatalf("NewChaCha20Poly1305: %v", err) + } + sealed := a.Seal(nil, nonce, nil, nil) + if len(sealed) != 16 { + t.Errorf("Seal empty len = %d, want 16 (tag only)", len(sealed)) + } + pt, err := a.Open(nil, nonce, sealed, nil) + if err != nil { + t.Fatalf("Open empty: %v", err) + } + if len(pt) != 0 { + t.Errorf("Open plaintext len = %d, want 0", len(pt)) + } +} + +// TestAES256GCM -- the second AEAD in this package; verify its surface. +func TestAES256GCM(t *testing.T) { + key := bytes.Repeat([]byte{0x77}, 32) + nonce := bytes.Repeat([]byte{0x42}, 12) + pt := []byte("hello, AEAD") + aad := []byte("auth-only-data") + + a, err := NewAES256GCM(key) + if err != nil { + t.Fatalf("NewAES256GCM: %v", err) + } + if a.NonceSize() != 12 { + t.Errorf("NonceSize = %d, want 12", a.NonceSize()) + } + if a.Overhead() != 16 { + t.Errorf("Overhead = %d, want 16", a.Overhead()) + } + if a.KeySize() != 32 { + t.Errorf("KeySize = %d, want 32", a.KeySize()) + } + + sealed := a.Seal(nil, nonce, pt, aad) + if len(sealed) != len(pt)+16 { + t.Errorf("Seal len = %d, want %d", len(sealed), len(pt)+16) + } + pt2, err := a.Open(nil, nonce, sealed, aad) + if err != nil { + t.Fatalf("Open: %v", err) + } + if !bytes.Equal(pt2, pt) { + t.Errorf("plaintext mismatch") + } + + // Tamper -> Open fails. + sealed[0] ^= 0x01 + if _, err := a.Open(nil, nonce, sealed, aad); err == nil { + t.Error("Open should have rejected tampered ciphertext") + } +} + +// TestGetAEAD -- factory dispatches correctly. +func TestGetAEAD(t *testing.T) { + key := bytes.Repeat([]byte{0xab}, 32) + + chacha, err := GetAEAD(ChaCha20Poly1305, key) + if err != nil { + t.Fatalf("GetAEAD ChaCha20Poly1305: %v", err) + } + if chacha == nil { + t.Fatal("GetAEAD returned nil") + } + + gcm, err := GetAEAD(AES256GCM, key) + if err != nil { + t.Fatalf("GetAEAD AES256GCM: %v", err) + } + if gcm == nil { + t.Fatal("GetAEAD returned nil") + } + + if _, err := GetAEAD("nonexistent", key); err == nil { + t.Error("GetAEAD with unknown ID should fail") + } +} + +// TestNonceGenerator -- deterministic stream-based nonce derivation. +func TestNonceGenerator(t *testing.T) { + ng := NewNonceGenerator(0x01020304) + first := ng.Next() + second := ng.Next() + + if len(first) != 12 { + t.Errorf("nonce len = %d, want 12", len(first)) + } + if bytes.Equal(first, second) { + t.Error("two consecutive nonces are equal -- catastrophic for AEAD") + } + // First 4 bytes = stream ID big-endian. + want := []byte{0x01, 0x02, 0x03, 0x04} + if !bytes.Equal(first[:4], want) { + t.Errorf("stream-ID prefix = %x, want %x", first[:4], want) + } + // First nonce should have seqNo = 0. + zeros := []byte{0, 0, 0, 0, 0, 0, 0, 0} + if !bytes.Equal(first[4:], zeros) { + t.Errorf("first seqNo = %x, want zeros", first[4:]) + } + if ng.GetSeqNo() != 2 { + t.Errorf("seqNo after two Next() = %d, want 2", ng.GetSeqNo()) + } +}