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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte>) → string` (RFC 4648 §5, no `=` padding)
and `Base64Url.Decode(ReadOnlySpan<char>) → 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
Expand Down
27 changes: 27 additions & 0 deletions netcrypto-prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte>) → string`
(RFC 4648 §5, no `=` padding) and `Decode(ReadOnlySpan<char>) → 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.
Expand Down
43 changes: 33 additions & 10 deletions samples/NetCrypto.Samples.Encryption/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)");
Expand All @@ -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.
Expand All @@ -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");
Expand Down Expand Up @@ -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 ===");

Expand Down
11 changes: 8 additions & 3 deletions src/NetCrypto/AesCbcHmacCipher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ namespace NetCrypto;
/// </remarks>
public static class AesCbcHmacCipher
{
private const int KeySizeBytes = 64;
private const int IvSizeBytes = 16;
private const int TagSizeBytes = 32;
/// <summary>Key size in bytes for A256CBC-HS512: 64 (MAC_KEY ‖ ENC_KEY, 32 bytes each — RFC 7518 §5.2.2.1).</summary>
public const int KeySizeBytes = 64;

/// <summary>AES-CBC initialization-vector size in bytes for A256CBC-HS512: 16.</summary>
public const int IvSizeBytes = 16;

/// <summary>Authentication-tag size in bytes for A256CBC-HS512: 32 (the leftmost half of the HMAC-SHA-512 output).</summary>
public const int TagSizeBytes = 32;

/// <summary>
/// Encrypts <paramref name="plaintext"/> per RFC 7518 §5.2.2.1 (A256CBC-HS512).
Expand Down
11 changes: 8 additions & 3 deletions src/NetCrypto/AesGcmCipher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ namespace NetCrypto;
/// </remarks>
public static class AesGcmCipher
{
private const int KeySizeBytes = 32;
private const int NonceSizeBytes = 12;
private const int TagSizeBytes = 16;
/// <summary>Key size in bytes for AES-256-GCM (<c>A256GCM</c>): 32.</summary>
public const int KeySizeBytes = 32;

/// <summary>Nonce size in bytes for AES-256-GCM: 12 (96-bit, the JOSE/RFC 7518 §5.3 value).</summary>
public const int NonceSizeBytes = 12;

/// <summary>Authentication-tag size in bytes for AES-256-GCM: 16.</summary>
public const int TagSizeBytes = 16;

/// <summary>
/// Encrypts <paramref name="plaintext"/> with AES-256-GCM.
Expand Down
49 changes: 49 additions & 0 deletions src/NetCrypto/Base64Url.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace NetCrypto;

/// <summary>
/// Base64url (RFC 4648 §5) without padding — the byte-to-text encoding used at every JOSE/JWK
/// boundary (protected headers, signatures, JWE <c>iv</c>/<c>ciphertext</c>/<c>tag</c>/<c>encrypted_key</c>,
/// JWK <c>x</c>/<c>y</c>/<c>d</c>, <c>apu</c>/<c>apv</c>). A single source of truth for the foundation's
/// consumers, so they do not each re-implement it and risk subtle padding/charset divergence.
/// </summary>
/// <remarks>
/// Thin wrapper over the BCL <see cref="System.Buffers.Text.Base64Url"/>. <see cref="Encode"/> never
/// emits <c>=</c> padding. <see cref="Decode"/> tolerates input with or without trailing <c>=</c> 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.
/// </remarks>
public static class Base64Url
{
/// <summary>Encode bytes as base64url (RFC 4648 §5) with no trailing <c>=</c> padding.</summary>
/// <param name="data">The bytes to encode. May be empty (returns an empty string).</param>
/// <returns>The unpadded base64url text.</returns>
public static string Encode(ReadOnlySpan<byte> data) =>
System.Buffers.Text.Base64Url.EncodeToString(data);

/// <summary>
/// Decode base64url (RFC 4648 §5) text, tolerating input with or without trailing <c>=</c> padding.
/// Whitespace and any other character outside the base64url alphabet are rejected (not stripped).
/// </summary>
/// <param name="text">The base64url text to decode. A <see cref="string"/> converts implicitly.</param>
/// <returns>The decoded bytes.</returns>
/// <exception cref="System.FormatException">If <paramref name="text"/> contains a character outside the
/// base64url alphabet (including whitespace) or is otherwise not valid base64url.</exception>
public static byte[] Decode(ReadOnlySpan<char> 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);
}
}
12 changes: 12 additions & 0 deletions src/NetCrypto/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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<byte> peerPublicKey, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<byte[]!>!
NetCrypto.InMemoryKeyStore.DeriveSharedSecretAsync(string! alias, System.ReadOnlyMemory<byte> peerPublicKey, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<byte[]!>!
static NetCrypto.Base64Url.Decode(System.ReadOnlySpan<char> text) -> byte[]!
static NetCrypto.Base64Url.Encode(System.ReadOnlySpan<byte> data) -> string!
11 changes: 8 additions & 3 deletions src/NetCrypto/XChaCha20Poly1305Cipher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ namespace NetCrypto;
/// </remarks>
public static class XChaCha20Poly1305Cipher
{
private const int KeySizeBytes = 32;
private const int NonceSizeBytes = 24;
private const int TagSizeBytes = 16;
/// <summary>Key size in bytes for XChaCha20-Poly1305 (<c>XC20P</c>): 32.</summary>
public const int KeySizeBytes = 32;

/// <summary>Nonce size in bytes for XChaCha20-Poly1305: 24 (the extended nonce, safe to choose at random).</summary>
public const int NonceSizeBytes = 24;

/// <summary>Poly1305 authentication-tag size in bytes for XChaCha20-Poly1305: 16.</summary>
public const int TagSizeBytes = 16;

private static readonly AeadAlgorithm Algorithm = AeadAlgorithm.XChaCha20Poly1305;

Expand Down
126 changes: 126 additions & 0 deletions tests/NetCrypto.Tests/Codecs/Base64UrlTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System.Security.Cryptography;
using FluentAssertions;
using NetCrypto;

namespace NetCrypto.Tests.Codecs;

/// <summary>
/// Issue #12 (G4) — base64url (RFC 4648 §5, no padding), the JOSE/JWK byte-to-text boundary.
/// </summary>
public class Base64UrlTests
{
[Fact]
public void Encode_Empty_ReturnsEmptyString()
{
Base64Url.Encode(ReadOnlySpan<byte>.Empty).Should().BeEmpty();
}

[Fact]
public void Decode_Empty_ReturnsEmptyArray()
{
Base64Url.Decode(ReadOnlySpan<char>.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<FormatException>();
}

[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<FormatException>();
}

[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<FormatException>();
slash.Should().Throw<FormatException>();
}
}
Loading
Loading