From 11ea20664f707121699a92b44f92ea662cab1c3c Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Sun, 14 Jun 2026 14:13:04 -0400 Subject: [PATCH 1/2] Add base64url codec + unified AEAD size metadata (closes #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two shared-primitive ergonomics gaps every JOSE/DIDComm consumer of NetCrypto otherwise re-implements locally. G4 — base64url codec. New public static class Base64Url: Encode(ReadOnlySpan) -> string // RFC 4648 §5, no '=' padding Decode(ReadOnlySpan) -> byte[] // tolerates optional padding A thin wrapper over the BCL System.Buffers.Text.Base64Url, giving the foundation a single source of truth for the JOSE/JWK byte boundary (headers, signatures, JWE iv/ciphertext/tag/encrypted_key, JWK x/y/d, apu/apv). G5 — unified AEAD size metadata. Each content-encryption cipher now exposes its key/nonce/tag sizes as public const int (previously private): AesGcmCipher KeySizeBytes 32, NonceSizeBytes 12, TagSizeBytes 16 AesCbcHmacCipher KeySizeBytes 64, IvSizeBytes 16, TagSizeBytes 32 XChaCha20Poly1305Cipher KeySizeBytes 32, NonceSizeBytes 24, TagSizeBytes 16 so a JOSE builder can size the CEK and IV/nonce from the source of truth instead of a hard-coded table. The IV/nonce name follows each cipher's own parameter. - PublicAPI.Unshipped.txt: record the new type, methods, and constants. - Tests: Base64Url (RFC 7515 A.1 JOSE vector, no-padding, url-safe alphabet, padding-tolerant decode, FormatException on invalid, round-trip 0..64 bytes); AeadSizeMetadata (constant values + they match what each cipher accepts/produces). - Encryption sample: source key/nonce/iv sizes from the constants and add a Base64url section (also satisfies the FR-17 API-coverage check). - PRD FR-16b and the CHANGELOG. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++ netcrypto-prd.md | 25 +++++ .../NetCrypto.Samples.Encryption/Program.cs | 43 ++++++-- src/NetCrypto/AesCbcHmacCipher.cs | 11 +- src/NetCrypto/AesGcmCipher.cs | 11 +- src/NetCrypto/Base64Url.cs | 27 +++++ src/NetCrypto/PublicAPI.Unshipped.txt | 12 +++ src/NetCrypto/XChaCha20Poly1305Cipher.cs | 11 +- .../NetCrypto.Tests/Codecs/Base64UrlTests.cs | 100 ++++++++++++++++++ .../Encryption/AeadSizeMetadataTests.cs | 87 +++++++++++++++ 10 files changed, 316 insertions(+), 19 deletions(-) create mode 100644 src/NetCrypto/Base64Url.cs create mode 100644 tests/NetCrypto.Tests/Codecs/Base64UrlTests.cs create mode 100644 tests/NetCrypto.Tests/Encryption/AeadSizeMetadataTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index fc201d6..fd2b7ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ 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), 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..6a04adc 100644 --- a/netcrypto-prd.md +++ b/netcrypto-prd.md @@ -278,6 +278,31 @@ 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. + ### 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..e3da2ba --- /dev/null +++ b/src/NetCrypto/Base64Url.cs @@ -0,0 +1,27 @@ +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; accepts input with or without trailing padding. +/// +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 = padding. + /// The base64url text to decode. A converts implicitly. + /// The decoded bytes. + /// If is not valid base64url. + public static byte[] Decode(ReadOnlySpan text) => + 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..71e4da7 --- /dev/null +++ b/tests/NetCrypto.Tests/Codecs/Base64UrlTests.cs @@ -0,0 +1,100 @@ +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(); + } +} 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(); + } +} From 8db617f03fa6e8243c53f1100e28ac7be6f1950c Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Sun, 14 Jun 2026 15:37:58 -0400 Subject: [PATCH 2/2] Base64Url.Decode: reject whitespace/non-alphabet chars (adversarial finding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial pass found that Decode delegated straight to the BCL System.Buffers.Text.Base64Url, which silently STRIPS ASCII whitespace before decoding — so "QU JD", "\nQUJD\n", and "QUJD" all decoded to the same bytes, and Decode(" ") returned an empty array. For a canonical JOSE/JWK primitive whose whole point is to avoid charset divergence, mapping several wire forms to one byte string is wrong, and it contradicted the documented "throws FormatException on invalid input" contract. Decode now validates the input alphabet up front and rejects any character outside [A-Za-z0-9-_=] (including whitespace) with FormatException; optional trailing padding is still tolerated (the BCL still validates '=' placement). Added regression tests for whitespace (incl. all-whitespace) and the standard-base64 '+'/'/' characters; updated the XML doc, PRD FR-16b, and the CHANGELOG. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 ++-- netcrypto-prd.md | 4 ++- src/NetCrypto/Base64Url.cs | 32 ++++++++++++++++--- .../NetCrypto.Tests/Codecs/Base64UrlTests.cs | 26 +++++++++++++++ 4 files changed, 60 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2b7ed..00ab32b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,9 +18,10 @@ Targeting **1.1.0** — additive changes from the didcomm-dotnet → NetCrypto i 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), 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) + 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 diff --git a/netcrypto-prd.md b/netcrypto-prd.md index 6a04adc..cb8c091 100644 --- a/netcrypto-prd.md +++ b/netcrypto-prd.md @@ -301,7 +301,9 @@ it lands in the foundation package because that module does not exist yet. - [ ] 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. + 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/`) diff --git a/src/NetCrypto/Base64Url.cs b/src/NetCrypto/Base64Url.cs index e3da2ba..8e33673 100644 --- a/src/NetCrypto/Base64Url.cs +++ b/src/NetCrypto/Base64Url.cs @@ -8,7 +8,10 @@ namespace NetCrypto; /// /// /// Thin wrapper over the BCL . never -/// emits = padding; accepts input with or without trailing padding. +/// 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 { @@ -18,10 +21,29 @@ public static class Base64Url public static string Encode(ReadOnlySpan data) => System.Buffers.Text.Base64Url.EncodeToString(data); - /// Decode base64url (RFC 4648 §5) text, tolerating input with or without = padding. + /// + /// 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 is not valid base64url. - public static byte[] Decode(ReadOnlySpan text) => - System.Buffers.Text.Base64Url.DecodeFromChars(text); + /// 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/tests/NetCrypto.Tests/Codecs/Base64UrlTests.cs b/tests/NetCrypto.Tests/Codecs/Base64UrlTests.cs index 71e4da7..734d5bc 100644 --- a/tests/NetCrypto.Tests/Codecs/Base64UrlTests.cs +++ b/tests/NetCrypto.Tests/Codecs/Base64UrlTests.cs @@ -97,4 +97,30 @@ public void Decode_InvalidCharacter_Throws() 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(); + } }