diff --git a/CHANGELOG.md b/CHANGELOG.md index fc201d6..00ab32b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ Targeting **1.1.0** — additive changes from the didcomm-dotnet → NetCrypto i non-extractable / HSM-bound key participate in ECDH-based decryption (JOSE `ECDH-ES`/`ECDH-1PU`, DIDComm anoncrypt/authcrypt) without the private scalar ever leaving the store. Implemented by `InMemoryKeyStore` for X25519, P-256, P-384, and P-521; demonstrated in the `KeyAgreement` sample. (#11) +- **`Base64Url` codec** — `Base64Url.Encode(ReadOnlySpan) → string` (RFC 4648 §5, no `=` padding) + and `Base64Url.Decode(ReadOnlySpan) → byte[]` (tolerates optional padding but otherwise strict — + rejects whitespace and any non-alphabet character rather than silently stripping it, so each byte string + has exactly one accepted textual form), a thin wrapper over the BCL `System.Buffers.Text.Base64Url`. + A single source of truth for the JOSE/JWK byte boundary so consumers stop re-implementing it. (#12) +- **Unified AEAD size metadata** — each content-encryption cipher now exposes its key/nonce/tag sizes as + `public const int`: `AesGcmCipher` (32/12/16), `AesCbcHmacCipher` (`KeySizeBytes` 64 / `IvSizeBytes` 16 / + `TagSizeBytes` 32), `XChaCha20Poly1305Cipher` (32/24/16). A JOSE builder can size the CEK and IV/nonce + from the source of truth instead of a hard-coded table. (#12) ### Security - **`JwkConverter.ExtractPublicKey` now documents its on-curve guarantee.** The method already diff --git a/netcrypto-prd.md b/netcrypto-prd.md index 40c585b..cb8c091 100644 --- a/netcrypto-prd.md +++ b/netcrypto-prd.md @@ -278,6 +278,33 @@ New thin extension (modeled on `NetDid.Extensions.DependencyInjection`): `public - [ ] Key length ≠ 32 or nonce length ≠ 24 → `ArgumentException`. - [ ] Round-trip property test (random key/nonce/AAD, plaintext lengths 0–4096) is identity. +### FR-16b — AEAD size metadata & base64url codec (1.1.0 ergonomics, issue #12) + +Two shared-primitive ergonomics gaps every JOSE/DIDComm consumer otherwise re-implements locally. + +**G5 — unified AEAD size metadata.** Each content-encryption cipher type exposes its key/nonce/tag +sizes as `public const int` so a JOSE builder can allocate the CEK and IV/nonce **before** calling +`Encrypt` and validate against the source of truth instead of a hard-coded table: +`AesGcmCipher` (`KeySizeBytes` 32, `NonceSizeBytes` 12, `TagSizeBytes` 16), `AesCbcHmacCipher` +(`KeySizeBytes` 64, `IvSizeBytes` 16, `TagSizeBytes` 32), `XChaCha20Poly1305Cipher` +(`KeySizeBytes` 32, `NonceSizeBytes` 24, `TagSizeBytes` 16). The IV/nonce name follows each cipher's +own parameter (CBC has an IV; GCM/XChaCha have a nonce). + +**G4 — base64url codec.** `public static class Base64Url` provides `Encode(ReadOnlySpan) → string` +(RFC 4648 §5, no `=` padding) and `Decode(ReadOnlySpan) → byte[]` (tolerates optional padding), +a thin wrapper over the BCL `System.Buffers.Text.Base64Url`. This is the single source of truth for the +JOSE/JWK byte boundary (headers, signatures, JWE `iv`/`ciphertext`/`tag`/`encrypted_key`, JWK +`x`/`y`/`d`, `apu`/`apv`). Placement note: base64url arguably belongs in a future JOSE-enveloping module; +it lands in the foundation package because that module does not exist yet. + +**Acceptance criteria:** +- [ ] Each AEAD cipher type exposes its key/nonce/tag sizes as public constants, asserted against the + bytes the cipher actually accepts and produces (a key one byte short of `KeySizeBytes` is rejected). +- [ ] `Base64Url` round-trips the RFC 7515 Appendix A.1 JOSE vector, emits no `=` padding, uses the + URL-safe alphabet (`-`/`_`), tolerates padded input on decode, and throws `FormatException` on invalid + input — including **whitespace** and any non-alphabet character (strict; the bare BCL decoder would + silently strip whitespace, which a canonical JOSE primitive must not). + ### FR-17 — Developer examples (`samples/`) Every public API is exemplified by simple, runnable programs under `samples/`, following the `net-did` `samples/` convention. This is the developer learning path: a developer must be able to learn correct usage of any public API by reading samples alone, **never** by reading unit or integration tests. diff --git a/samples/NetCrypto.Samples.Encryption/Program.cs b/samples/NetCrypto.Samples.Encryption/Program.cs index c9453a6..2b12232 100644 --- a/samples/NetCrypto.Samples.Encryption/Program.cs +++ b/samples/NetCrypto.Samples.Encryption/Program.cs @@ -20,13 +20,16 @@ // 1. AES-256-GCM (JOSE "A256GCM") — key 32, nonce 12, tag 16 // ------------------------------------------------------- Console.WriteLine("=== AES-256-GCM (A256GCM) ==="); -Console.WriteLine(" Sizes: key = 32 bytes, nonce = 12 bytes, tag = 16 bytes"); +// Each cipher type publishes its key/nonce/tag sizes as public constants, so a +// caller allocating the CEK and IV/nonce up front (as a JOSE builder must, before +// it can call Encrypt) validates against the source of truth instead of a hard-coded table. +Console.WriteLine($" Sizes: key = {AesGcmCipher.KeySizeBytes} bytes, nonce = {AesGcmCipher.NonceSizeBytes} bytes, tag = {AesGcmCipher.TagSizeBytes} bytes"); // Keys and nonces come from a cryptographically secure RNG. A GCM nonce // must NEVER be reused with the same key — reuse breaks both secrecy and // authenticity — so generate a fresh one per message. -var gcmKey = RandomNumberGenerator.GetBytes(32); -var gcmNonce = RandomNumberGenerator.GetBytes(12); +var gcmKey = RandomNumberGenerator.GetBytes(AesGcmCipher.KeySizeBytes); +var gcmNonce = RandomNumberGenerator.GetBytes(AesGcmCipher.NonceSizeBytes); var (gcmCiphertext, gcmTag) = AesGcmCipher.Encrypt(gcmKey, gcmNonce, plaintext, aad); Console.WriteLine($" Ciphertext: {gcmCiphertext.Length} bytes (same length as plaintext — GCM is a stream construction)"); @@ -41,13 +44,13 @@ // 2. AES-256-CBC + HMAC-SHA-512 (JOSE "A256CBC-HS512") — key 64, IV 16, tag 32 // ------------------------------------------------------- Console.WriteLine("=== AES-256-CBC + HMAC-SHA-512 (A256CBC-HS512) ==="); -Console.WriteLine(" Sizes: key = 64 bytes, IV = 16 bytes, tag = 32 bytes"); +Console.WriteLine($" Sizes: key = {AesCbcHmacCipher.KeySizeBytes} bytes, IV = {AesCbcHmacCipher.IvSizeBytes} bytes, tag = {AesCbcHmacCipher.TagSizeBytes} bytes"); // The 64-byte key is really two keys (RFC 7518 §5.2.2): the first 32 bytes // MAC the data (HMAC-SHA-512), the last 32 bytes encrypt it (AES-256-CBC). // That is why this AEAD needs twice the key material of the others. -var cbcKey = RandomNumberGenerator.GetBytes(64); -var cbcIv = RandomNumberGenerator.GetBytes(16); +var cbcKey = RandomNumberGenerator.GetBytes(AesCbcHmacCipher.KeySizeBytes); +var cbcIv = RandomNumberGenerator.GetBytes(AesCbcHmacCipher.IvSizeBytes); var (cbcCiphertext, cbcTag) = AesCbcHmacCipher.Encrypt(cbcKey, cbcIv, plaintext, aad); // CBC pads to a 16-byte block boundary, so the ciphertext grows — unlike GCM/XChaCha. @@ -62,13 +65,13 @@ // 3. XChaCha20-Poly1305 (JOSE "XC20P") — key 32, nonce 24, tag 16 // ------------------------------------------------------- Console.WriteLine("=== XChaCha20-Poly1305 (XC20P) ==="); -Console.WriteLine(" Sizes: key = 32 bytes, nonce = 24 bytes, tag = 16 bytes"); +Console.WriteLine($" Sizes: key = {XChaCha20Poly1305Cipher.KeySizeBytes} bytes, nonce = {XChaCha20Poly1305Cipher.NonceSizeBytes} bytes, tag = {XChaCha20Poly1305Cipher.TagSizeBytes} bytes"); // The 24-byte extended nonce is the whole point of XChaCha20: it is large // enough that random nonces have no practical collision risk, so there is // no counter to manage — ideal when many parties encrypt under one key. -var xKey = RandomNumberGenerator.GetBytes(32); -var xNonce = RandomNumberGenerator.GetBytes(24); +var xKey = RandomNumberGenerator.GetBytes(XChaCha20Poly1305Cipher.KeySizeBytes); +var xNonce = RandomNumberGenerator.GetBytes(XChaCha20Poly1305Cipher.NonceSizeBytes); var (xCiphertext, xTag) = XChaCha20Poly1305Cipher.Encrypt(xKey, xNonce, plaintext, aad); Console.WriteLine($" Ciphertext: {xCiphertext.Length} bytes"); @@ -100,7 +103,27 @@ Console.WriteLine(); // ------------------------------------------------------- -// 5. Tampering is detected — the authentication guarantee +// 5. Base64url — the JOSE byte-to-text boundary +// ------------------------------------------------------- +Console.WriteLine("=== Base64url (RFC 4648 §5, no padding) ==="); + +// Every JOSE/JWK field that carries bytes — JWE iv/ciphertext/tag/encrypted_key, +// JWK x/y/d, apu/apv, signatures — is base64url with NO padding. Base64Url is the +// foundation's single source of truth for that encoding, so consumers do not each +// re-implement it and risk a padding/charset divergence. +var encodedCiphertext = Base64Url.Encode(gcmCiphertext); +var encodedTag = Base64Url.Encode(gcmTag); +Console.WriteLine($" ciphertext (b64url): {encodedCiphertext}"); +Console.WriteLine($" tag (b64url): {encodedTag}"); +Check(!encodedTag.Contains('='), "base64url output carries no '=' padding"); + +// Decode tolerates input with or without padding and round-trips exactly. +var decodedTag = Base64Url.Decode(encodedTag); +Check(decodedTag.AsSpan().SequenceEqual(gcmTag), "Base64Url.Decode round-trips the tag bytes"); +Console.WriteLine(); + +// ------------------------------------------------------- +// 6. Tampering is detected — the authentication guarantee // ------------------------------------------------------- Console.WriteLine("=== Tamper detection ==="); diff --git a/src/NetCrypto/AesCbcHmacCipher.cs b/src/NetCrypto/AesCbcHmacCipher.cs index 30fb09e..76e2159 100644 --- a/src/NetCrypto/AesCbcHmacCipher.cs +++ b/src/NetCrypto/AesCbcHmacCipher.cs @@ -18,9 +18,14 @@ namespace NetCrypto; /// public static class AesCbcHmacCipher { - private const int KeySizeBytes = 64; - private const int IvSizeBytes = 16; - private const int TagSizeBytes = 32; + /// Key size in bytes for A256CBC-HS512: 64 (MAC_KEY ‖ ENC_KEY, 32 bytes each — RFC 7518 §5.2.2.1). + public const int KeySizeBytes = 64; + + /// AES-CBC initialization-vector size in bytes for A256CBC-HS512: 16. + public const int IvSizeBytes = 16; + + /// Authentication-tag size in bytes for A256CBC-HS512: 32 (the leftmost half of the HMAC-SHA-512 output). + public const int TagSizeBytes = 32; /// /// Encrypts per RFC 7518 §5.2.2.1 (A256CBC-HS512). diff --git a/src/NetCrypto/AesGcmCipher.cs b/src/NetCrypto/AesGcmCipher.cs index dcaeef3..3355d3a 100644 --- a/src/NetCrypto/AesGcmCipher.cs +++ b/src/NetCrypto/AesGcmCipher.cs @@ -14,9 +14,14 @@ namespace NetCrypto; /// public static class AesGcmCipher { - private const int KeySizeBytes = 32; - private const int NonceSizeBytes = 12; - private const int TagSizeBytes = 16; + /// Key size in bytes for AES-256-GCM (A256GCM): 32. + public const int KeySizeBytes = 32; + + /// Nonce size in bytes for AES-256-GCM: 12 (96-bit, the JOSE/RFC 7518 §5.3 value). + public const int NonceSizeBytes = 12; + + /// Authentication-tag size in bytes for AES-256-GCM: 16. + public const int TagSizeBytes = 16; /// /// Encrypts with AES-256-GCM. diff --git a/src/NetCrypto/Base64Url.cs b/src/NetCrypto/Base64Url.cs new file mode 100644 index 0000000..8e33673 --- /dev/null +++ b/src/NetCrypto/Base64Url.cs @@ -0,0 +1,49 @@ +namespace NetCrypto; + +/// +/// Base64url (RFC 4648 §5) without padding — the byte-to-text encoding used at every JOSE/JWK +/// boundary (protected headers, signatures, JWE iv/ciphertext/tag/encrypted_key, +/// JWK x/y/d, apu/apv). A single source of truth for the foundation's +/// consumers, so they do not each re-implement it and risk subtle padding/charset divergence. +/// +/// +/// Thin wrapper over the BCL . never +/// emits = padding. tolerates input with or without trailing = padding, +/// but is otherwise strict: it rejects any character outside the base64url alphabet — including ASCII +/// whitespace (space, tab, CR, LF), which the bare BCL decoder would silently strip. A canonical JOSE +/// primitive must not map several wire forms to the same bytes, so whitespace is treated as invalid input. +/// +public static class Base64Url +{ + /// Encode bytes as base64url (RFC 4648 §5) with no trailing = padding. + /// The bytes to encode. May be empty (returns an empty string). + /// The unpadded base64url text. + public static string Encode(ReadOnlySpan data) => + System.Buffers.Text.Base64Url.EncodeToString(data); + + /// + /// Decode base64url (RFC 4648 §5) text, tolerating input with or without trailing = padding. + /// Whitespace and any other character outside the base64url alphabet are rejected (not stripped). + /// + /// The base64url text to decode. A converts implicitly. + /// The decoded bytes. + /// If contains a character outside the + /// base64url alphabet (including whitespace) or is otherwise not valid base64url. + public static byte[] Decode(ReadOnlySpan text) + { + // The BCL decoder strips ASCII whitespace before decoding, so "QU JD", "\nQUJD\n", and "QUJD" all + // decode to the same bytes. For a JOSE/JWK primitive that is a charset divergence — reject any + // non-alphabet character up front so each byte string has exactly one accepted textual form + // (modulo the documented optional padding, whose placement the BCL still validates). + foreach (char c in text) + { + bool inAlphabet = + (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '='; + if (!inAlphabet) + throw new FormatException("Input contains a character outside the base64url alphabet."); + } + + return System.Buffers.Text.Base64Url.DecodeFromChars(text); + } +} diff --git a/src/NetCrypto/PublicAPI.Unshipped.txt b/src/NetCrypto/PublicAPI.Unshipped.txt index f772328..03a7f9d 100644 --- a/src/NetCrypto/PublicAPI.Unshipped.txt +++ b/src/NetCrypto/PublicAPI.Unshipped.txt @@ -1,3 +1,15 @@ #nullable enable +const NetCrypto.AesCbcHmacCipher.IvSizeBytes = 16 -> int +const NetCrypto.AesCbcHmacCipher.KeySizeBytes = 64 -> int +const NetCrypto.AesCbcHmacCipher.TagSizeBytes = 32 -> int +const NetCrypto.AesGcmCipher.KeySizeBytes = 32 -> int +const NetCrypto.AesGcmCipher.NonceSizeBytes = 12 -> int +const NetCrypto.AesGcmCipher.TagSizeBytes = 16 -> int +const NetCrypto.XChaCha20Poly1305Cipher.KeySizeBytes = 32 -> int +const NetCrypto.XChaCha20Poly1305Cipher.NonceSizeBytes = 24 -> int +const NetCrypto.XChaCha20Poly1305Cipher.TagSizeBytes = 16 -> int +NetCrypto.Base64Url NetCrypto.IKeyStore.DeriveSharedSecretAsync(string! alias, System.ReadOnlyMemory peerPublicKey, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! NetCrypto.InMemoryKeyStore.DeriveSharedSecretAsync(string! alias, System.ReadOnlyMemory peerPublicKey, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static NetCrypto.Base64Url.Decode(System.ReadOnlySpan text) -> byte[]! +static NetCrypto.Base64Url.Encode(System.ReadOnlySpan data) -> string! diff --git a/src/NetCrypto/XChaCha20Poly1305Cipher.cs b/src/NetCrypto/XChaCha20Poly1305Cipher.cs index 2f681a5..10efbac 100644 --- a/src/NetCrypto/XChaCha20Poly1305Cipher.cs +++ b/src/NetCrypto/XChaCha20Poly1305Cipher.cs @@ -17,9 +17,14 @@ namespace NetCrypto; /// public static class XChaCha20Poly1305Cipher { - private const int KeySizeBytes = 32; - private const int NonceSizeBytes = 24; - private const int TagSizeBytes = 16; + /// Key size in bytes for XChaCha20-Poly1305 (XC20P): 32. + public const int KeySizeBytes = 32; + + /// Nonce size in bytes for XChaCha20-Poly1305: 24 (the extended nonce, safe to choose at random). + public const int NonceSizeBytes = 24; + + /// Poly1305 authentication-tag size in bytes for XChaCha20-Poly1305: 16. + public const int TagSizeBytes = 16; private static readonly AeadAlgorithm Algorithm = AeadAlgorithm.XChaCha20Poly1305; diff --git a/tests/NetCrypto.Tests/Codecs/Base64UrlTests.cs b/tests/NetCrypto.Tests/Codecs/Base64UrlTests.cs new file mode 100644 index 0000000..734d5bc --- /dev/null +++ b/tests/NetCrypto.Tests/Codecs/Base64UrlTests.cs @@ -0,0 +1,126 @@ +using System.Security.Cryptography; +using FluentAssertions; +using NetCrypto; + +namespace NetCrypto.Tests.Codecs; + +/// +/// Issue #12 (G4) — base64url (RFC 4648 §5, no padding), the JOSE/JWK byte-to-text boundary. +/// +public class Base64UrlTests +{ + [Fact] + public void Encode_Empty_ReturnsEmptyString() + { + Base64Url.Encode(ReadOnlySpan.Empty).Should().BeEmpty(); + } + + [Fact] + public void Decode_Empty_ReturnsEmptyArray() + { + Base64Url.Decode(ReadOnlySpan.Empty).Should().BeEmpty(); + } + + [Fact] + public void Encode_JoseVector_MatchesRfc7515AppendixA1() + { + // RFC 7515 Appendix A.1 — the JWS protected header octets and their base64url encoding. + byte[] header = + [ + 123, 34, 116, 121, 112, 34, 58, 34, 74, 87, 84, 34, 44, 13, 10, 32, 34, 97, 108, + 103, 34, 58, 34, 72, 83, 50, 53, 54, 34, 125 + ]; + + Base64Url.Encode(header).Should().Be("eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9"); + } + + [Fact] + public void Decode_JoseVector_RecoversOctets() + { + var decoded = Base64Url.Decode("eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9"); + + decoded.Should().Equal( + 123, 34, 116, 121, 112, 34, 58, 34, 74, 87, 84, 34, 44, 13, 10, 32, 34, 97, 108, + 103, 34, 58, 34, 72, 83, 50, 53, 54, 34, 125); + } + + [Fact] + public void Encode_NeverEmitsPadding() + { + // 1- and 2-byte inputs are the cases standard base64 would pad with '=' / '=='. + Base64Url.Encode(new byte[] { 0x01 }).Should().NotContain("="); + Base64Url.Encode(new byte[] { 0x01, 0x02 }).Should().NotContain("="); + } + + [Fact] + public void Encode_UsesUrlSafeAlphabet() + { + // The two alphabet positions that differ from standard base64: index 62 ('-' not '+') + // and index 63 ('_' not '/'). + Base64Url.Encode(new byte[] { 0xFB }).Should().Be("-w"); // first sextet 111110 = 62 = '-' + Base64Url.Encode(new byte[] { 0xFF, 0xFF, 0xFF }).Should().Be("____"); // four sextets of 63 = '_' + } + + [Fact] + public void Decode_ToleratesTrailingPadding() + { + // "AQ" encodes the single byte 0x01; standard base64 would pad it to "AQ==". A producer that + // emits padded base64url must still decode identically to the unpadded form. + var unpadded = Base64Url.Decode("AQ"); + var padded = Base64Url.Decode("AQ=="); + + unpadded.Should().Equal(0x01); + padded.Should().Equal(unpadded); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(31)] + [InlineData(32)] + [InlineData(64)] + public void EncodeThenDecode_RoundTripsAnyLength(int length) + { + var data = RandomNumberGenerator.GetBytes(length); + + var roundTripped = Base64Url.Decode(Base64Url.Encode(data)); + + roundTripped.Should().Equal(data); + } + + [Fact] + public void Decode_InvalidCharacter_Throws() + { + // '!' is outside the base64url alphabet. + var act = () => Base64Url.Decode("not-valid-base64!!"); + act.Should().Throw(); + } + + [Theory] + [InlineData("QU JD")] // internal space + [InlineData(" QUJD")] // leading space + [InlineData("QUJD ")] // trailing space + [InlineData("Q\tUJD")] // tab + [InlineData("\nQUJD\n")] // CR/LF (PEM-style) + [InlineData(" ")] // all whitespace — must NOT decode to an empty array + public void Decode_Whitespace_Throws(string input) + { + // Adversarial finding (#12): the bare BCL decoder silently strips ASCII whitespace, mapping + // several wire forms to the same bytes. A canonical JOSE primitive must reject it instead. + var act = () => Base64Url.Decode(input); + act.Should().Throw(); + } + + [Fact] + public void Decode_StandardBase64Alphabet_Throws() + { + // '+' and '/' are the standard-base64 (RFC 4648 §4) characters; the url-safe decoder must reject + // them, accepting only '-' and '_' for indices 62/63. + var plus = () => Base64Url.Decode("ab+c"); + var slash = () => Base64Url.Decode("ab/c"); + plus.Should().Throw(); + slash.Should().Throw(); + } +} diff --git a/tests/NetCrypto.Tests/Encryption/AeadSizeMetadataTests.cs b/tests/NetCrypto.Tests/Encryption/AeadSizeMetadataTests.cs new file mode 100644 index 0000000..bc26e67 --- /dev/null +++ b/tests/NetCrypto.Tests/Encryption/AeadSizeMetadataTests.cs @@ -0,0 +1,87 @@ +using System.Security.Cryptography; +using FluentAssertions; +using NetCrypto; + +namespace NetCrypto.Tests.Encryption; + +/// +/// Issue #12 (G5) — each AEAD cipher type exposes its key/nonce/tag sizes as public constants, +/// so a JOSE builder allocating the CEK and IV/nonce before Encrypt validates against the +/// source of truth instead of a hard-coded table. These tests pin the documented values +/// (A256GCM = 32/12/16, A256CBC-HS512 = 64/16/32, XC20P = 32/24/16) and assert the constants +/// actually match the bytes the ciphers accept and produce. +/// +public class AeadSizeMetadataTests +{ + [Fact] + public void AesGcm_Constants_HaveExpectedValues() + { + AesGcmCipher.KeySizeBytes.Should().Be(32); + AesGcmCipher.NonceSizeBytes.Should().Be(12); + AesGcmCipher.TagSizeBytes.Should().Be(16); + } + + [Fact] + public void AesCbcHmac_Constants_HaveExpectedValues() + { + AesCbcHmacCipher.KeySizeBytes.Should().Be(64); + AesCbcHmacCipher.IvSizeBytes.Should().Be(16); + AesCbcHmacCipher.TagSizeBytes.Should().Be(32); + } + + [Fact] + public void XChaCha20Poly1305_Constants_HaveExpectedValues() + { + XChaCha20Poly1305Cipher.KeySizeBytes.Should().Be(32); + XChaCha20Poly1305Cipher.NonceSizeBytes.Should().Be(24); + XChaCha20Poly1305Cipher.TagSizeBytes.Should().Be(16); + } + + [Fact] + public void AesGcm_ConstantsMatch_CipherBehavior() + { + var key = RandomNumberGenerator.GetBytes(AesGcmCipher.KeySizeBytes); + var nonce = RandomNumberGenerator.GetBytes(AesGcmCipher.NonceSizeBytes); + + var (ciphertext, tag) = AesGcmCipher.Encrypt(key, nonce, "msg"u8); + + tag.Length.Should().Be(AesGcmCipher.TagSizeBytes); + AesGcmCipher.Decrypt(key, nonce, ciphertext, tag).Should().Equal("msg"u8.ToArray()); + } + + [Fact] + public void AesCbcHmac_ConstantsMatch_CipherBehavior() + { + var key = RandomNumberGenerator.GetBytes(AesCbcHmacCipher.KeySizeBytes); + var iv = RandomNumberGenerator.GetBytes(AesCbcHmacCipher.IvSizeBytes); + + var (ciphertext, tag) = AesCbcHmacCipher.Encrypt(key, iv, "msg"u8); + + tag.Length.Should().Be(AesCbcHmacCipher.TagSizeBytes); + AesCbcHmacCipher.Decrypt(key, iv, ciphertext, tag).Should().Equal("msg"u8.ToArray()); + } + + [Fact] + public void XChaCha20Poly1305_ConstantsMatch_CipherBehavior() + { + var key = RandomNumberGenerator.GetBytes(XChaCha20Poly1305Cipher.KeySizeBytes); + var nonce = RandomNumberGenerator.GetBytes(XChaCha20Poly1305Cipher.NonceSizeBytes); + + var (ciphertext, tag) = XChaCha20Poly1305Cipher.Encrypt(key, nonce, "msg"u8); + + tag.Length.Should().Be(XChaCha20Poly1305Cipher.TagSizeBytes); + XChaCha20Poly1305Cipher.Decrypt(key, nonce, ciphertext, tag).Should().Equal("msg"u8.ToArray()); + } + + [Fact] + public void WrongKeyLength_DerivedFromConstant_Throws() + { + // A key one byte short of the published size must be rejected — proves the constant is the + // exact contract, not a documentation-only hint. + var shortKey = RandomNumberGenerator.GetBytes(AesGcmCipher.KeySizeBytes - 1); + var nonce = RandomNumberGenerator.GetBytes(AesGcmCipher.NonceSizeBytes); + + var act = () => AesGcmCipher.Encrypt(shortKey, nonce, "msg"u8); + act.Should().Throw(); + } +}