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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<!-- Preview line: hyphenated SemVer suffix marks every build as a NuGet prerelease.
Release CI overrides this from the git tag (e.g. v1.0.0-preview.1 -> 1.0.0-preview.1). -->
<NetCryptoVersion Condition="'$(NetCryptoVersion)' == ''">1.0.0-preview.2</NetCryptoVersion>
<NetCryptoVersion Condition="'$(NetCryptoVersion)' == ''">1.0.0-preview.3</NetCryptoVersion>
<Deterministic>true</Deterministic>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSourceRevisionInInformationalVersion>true</IncludeSourceRevisionInInformationalVersion>
Expand Down
9 changes: 8 additions & 1 deletion native/zkryptium-ffi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,14 @@ advisory is:

CI ignores this advisory via `cargo audit --ignore RUSTSEC-2026-0097`.
Any other warning fails the job — keeping the dependency tree clean is
intentional. Re-evaluate on every `zkryptium` upgrade.
intentional.

**Remediation is gated on upstream:** the advisory is fixed in `rand 0.9`, which
`rand 0.8.5` (pulled transitively by `zkryptium 0.6.1`) cannot reach without a
breaking change. There is no in-tree pin that resolves it. Drop the `--ignore`
the moment a `zkryptium` release bumps its `rand` dependency past 0.8.x; re-run
the BBS draft-10 fixture tests when taking that upgrade (the wrapped BBS build
must still match the spec).

## Troubleshooting

Expand Down
2 changes: 1 addition & 1 deletion netcrypto-prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ No type from `NSec.*`, `NBitcoin.*`, `Nethermind.*`, or `NetCrypto.Native` in an
Any JSON handling uses `System.Text.Json` (or `Microsoft.IdentityModel.Tokens` 8.x). **AC:** dependency-graph check from AC-1.

### NFR-3 — Input validation
Every public method validates lengths/nulls and throws `ArgumentException`/`ArgumentNullException` with the parameter name before any crypto operation. **AC:** per-primitive negative tests exist (each FR above includes them); no public method can be made to throw `IndexOutOfRangeException`/`NullReferenceException` from bad input (fuzz-lite test: null/empty/oversized inputs across the surface).
Every public method validates lengths/nulls and throws `ArgumentException`/`ArgumentNullException` with the parameter name before any crypto operation. **AC:** per-primitive negative tests exist (each FR above includes them); no public method can be made to throw `IndexOutOfRangeException`/`NullReferenceException` from bad input (fuzz-lite test: null/empty/oversized inputs across the surface). A wrong-length raw key/scalar handed to a backend (NSec, Nethermind BLS, platform EC import) must surface as a **parameter-named `ArgumentException`**, never a leaked backend type (`System.FormatException`, `Nethermind.Crypto.Bls+BlsException`, or a platform `CryptographicException`); the fuzz-lite suite carries **no** "known deviation" allow-list, and any non-contract exception fails it rather than being pinned.

### NFR-4 — Determinism and thread safety
All `Default*` providers and static classes are stateless/thread-safe. **AC:** a parallel test (≥ 8 threads × 100 ops on one shared provider instance, mixed key types) completes without error and with valid outputs.
Expand Down
76 changes: 75 additions & 1 deletion src/NetCrypto/DefaultCryptoProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ public bool Verify(KeyType keyType, ReadOnlySpan<byte> publicKey, ReadOnlySpan<b
/// <inheritdoc />
public byte[] KeyAgreement(ReadOnlySpan<byte> privateKey, ReadOnlySpan<byte> publicKey)
{
// Validate both raw blobs before NSec (which throws FormatException on a wrong-length
// import) so a malformed input surfaces as a parameter-named ArgumentException (NFR-3).
RawKeyGuard.RequireLength(privateKey, 32, nameof(privateKey), "X25519 private key");
RawKeyGuard.RequireLength(publicKey, 32, nameof(publicKey), "X25519 public key");

var algorithm = NSec.Cryptography.KeyAgreementAlgorithm.X25519;

using var key = Key.Import(algorithm, privateKey, KeyBlobFormat.RawPrivateKey);
Expand Down Expand Up @@ -97,6 +102,9 @@ public byte[] DeriveSharedSecret(KeyType keyType, ReadOnlySpan<byte> privateKey,

private static byte[] DeriveX25519SharedSecret(ReadOnlySpan<byte> privateKey, ReadOnlySpan<byte> publicKey)
{
RawKeyGuard.RequireLength(privateKey, 32, nameof(privateKey), "X25519 private key");
RawKeyGuard.RequireLength(publicKey, 32, nameof(publicKey), "X25519 public key");

var algorithm = NSec.Cryptography.KeyAgreementAlgorithm.X25519;

using var key = Key.Import(algorithm, privateKey, KeyBlobFormat.RawPrivateKey);
Expand Down Expand Up @@ -124,6 +132,10 @@ private static byte[] DeriveNistSharedSecret(ReadOnlySpan<byte> privateKey, Read

private static byte[] SignEd25519(ReadOnlySpan<byte> privateKey, ReadOnlySpan<byte> data)
{
// Validate before NSec, whose Key.Import throws a raw FormatException on a wrong-length
// raw blob; surface a parameter-named ArgumentException instead (NFR-3).
RawKeyGuard.RequireLength(privateKey, 32, nameof(privateKey), "Ed25519 private key");

var algorithm = SignatureAlgorithm.Ed25519;

// Our private key is 32-byte seed. NSec's RawPrivateKey expects the seed.
Expand All @@ -135,6 +147,12 @@ private static byte[] SignEd25519(ReadOnlySpan<byte> privateKey, ReadOnlySpan<by

private static bool VerifyEd25519(ReadOnlySpan<byte> publicKey, ReadOnlySpan<byte> data, ReadOnlySpan<byte> signature)
{
// A wrong-length public key is a malformed caller input, not a verification outcome —
// throw ArgumentException rather than leaking NSec's FormatException (matches the EC
// verify path, which rejects wrong-length/malformed-format keys the same way). An
// attacker-controlled wrong-length *signature* is handled by NSec.Verify returning false.
RawKeyGuard.RequireLength(publicKey, 32, nameof(publicKey), "Ed25519 public key");

var algorithm = SignatureAlgorithm.Ed25519;
var pubKey = NSec.Cryptography.PublicKey.Import(algorithm, publicKey, KeyBlobFormat.RawPublicKey);
return algorithm.Verify(pubKey, data, signature);
Expand Down Expand Up @@ -188,13 +206,57 @@ private static bool VerifyEcDsa(ReadOnlySpan<byte> publicKey, ReadOnlySpan<byte>

internal static ECParameters ImportEcPrivateKey(ReadOnlySpan<byte> privateKey, ECCurve curve)
{
// Validate the scalar length up front: a wrong-length D otherwise fails inside
// ECDsa/ECDiffieHellman.ImportParameters with an opaque, platform-specific
// CryptographicException (e.g. macOS AppleCommonCryptoCryptographicException). A
// parameter-named ArgumentException makes the caller bug unambiguous (NFR-3).
RawKeyGuard.RequireLength(privateKey, EcScalarByteLength(curve), nameof(privateKey), "EC private key");

// Reject an out-of-range scalar up front. A correctly-sized D that is 0 or >= the curve
// order n is not a valid private key; without this it would fail at ImportParameters time
// with the same opaque platform CryptographicException. Normalizing it to a
// parameter-named ArgumentException matches the BLS/secp256k1 paths (NFR-3 consistency).
var d = new BigInteger(privateKey, isUnsigned: true, isBigEndian: true);
if (d <= BigInteger.Zero || d >= EcCurveOrder(curve))
throw new ArgumentException(
"EC private key scalar is out of range (must satisfy 0 < D < n).", nameof(privateKey));

return new ECParameters
{
Curve = curve,
D = privateKey.ToArray()
};
}

// NIST EC private-key scalar length (field byte length) for the supported curves.
private static int EcScalarByteLength(ECCurve curve) => curve.Oid?.Value switch
{
"1.2.840.10045.3.1.7" => 32, // P-256
"1.3.132.0.34" => 48, // P-384
"1.3.132.0.35" => 66, // P-521 (521 bits → 66 bytes)
_ => throw new ArgumentException("Unsupported curve for EC private key import.", nameof(curve))
};

// NIST curve group orders n (verified against the published FIPS 186-4 / SEC 2 values).
// Each is parsed with a leading "0" nibble so NumberStyles.HexNumber yields a positive value.
private static readonly BigInteger P256Order = BigInteger.Parse(
"0FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", NumberStyles.HexNumber);
private static readonly BigInteger P384Order = BigInteger.Parse(
"0FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973",
NumberStyles.HexNumber);
private static readonly BigInteger P521Order = BigInteger.Parse(
"01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFA51868783BF2F966B7FCC0148F709A5D03BB5C9B8899C47AEBB6FB71E91386409",
NumberStyles.HexNumber);

// NIST EC private-key scalar upper bound (the curve order n) for the supported curves.
private static BigInteger EcCurveOrder(ECCurve curve) => curve.Oid?.Value switch
{
"1.2.840.10045.3.1.7" => P256Order,
"1.3.132.0.34" => P384Order,
"1.3.132.0.35" => P521Order,
_ => throw new ArgumentException("Unsupported curve for EC private key import.", nameof(curve))
};

internal static ECParameters ImportEcPublicKey(ReadOnlySpan<byte> publicKey, ECCurve curve)
{
if (publicKey.Length > 0 && (publicKey[0] == 0x02 || publicKey[0] == 0x03))
Expand Down Expand Up @@ -353,8 +415,20 @@ private static bool VerifySecp256k1(ReadOnlySpan<byte> publicKey, ReadOnlySpan<b

private static byte[] SignBls(KeyType keyType, ReadOnlySpan<byte> privateKey, ReadOnlySpan<byte> data)
{
// Nethermind's SecretKey.FromBendian throws BlsException on a wrong-length or otherwise
// invalid (zero / out-of-range) scalar; convert both to a parameter-named
// ArgumentException so bad input never leaks a backend exception type (NFR-3).
RawKeyGuard.RequireLength(privateKey, 32, nameof(privateKey), "BLS12-381 private key");

var sk = new Bls.SecretKey();
sk.FromBendian(privateKey);
try
{
sk.FromBendian(privateKey);
}
catch (Bls.BlsException ex)
{
throw new ArgumentException("Invalid BLS12-381 private key.", nameof(privateKey), ex);
}

if (keyType == KeyType.Bls12381G1)
{
Expand Down
42 changes: 41 additions & 1 deletion src/NetCrypto/DefaultKeyGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,16 @@ public PublicKeyReference DeriveX25519PublicKeyFromEd25519(ReadOnlySpan<byte> ed
var u = numerator * ModInverse(denominator, p) % p;
if (u < 0) u += p;

// Reject low-order results. An Ed25519 public key that is itself a small-order point
// (e.g. the all-zero key, y = 0) maps to a low-order Montgomery u-coordinate; minting a
// PublicKeyReference for it would hand a degenerate, small-subgroup X25519 key to callers
// that key-agree with it. The five canonical Curve25519 low-order u-coordinates (orders
// 1/2/4/8, reduced mod p) are the complete set to exclude (RFC 7748 §6.1 small-subgroup
// guidance). Legitimate prime-order Ed25519 keys never land here.
if (LowOrderX25519U.Contains(u))
throw new ArgumentException(
"Ed25519 public key maps to a low-order X25519 point.", nameof(ed25519PublicKey));

// Encode u as 32 bytes little-endian
var uBytes = u.ToByteArray(isUnsigned: true, isBigEndian: false);
var result = new byte[32];
Expand All @@ -157,6 +167,20 @@ public PublicKeyReference DeriveX25519PublicKeyFromEd25519(ReadOnlySpan<byte> ed
private static System.Numerics.BigInteger ModInverse(System.Numerics.BigInteger a, System.Numerics.BigInteger m)
=> System.Numerics.BigInteger.ModPow(a, m - 2, m);

// The canonical Curve25519 low-order point u-coordinates (points of order 1, 2, 4, and 8),
// reduced mod p = 2^255 − 19. Any derived u in this set is a small-subgroup point and must be
// rejected. (p and p+1 reduce to 0 and 1, already listed.)
private static readonly System.Numerics.BigInteger X25519Prime =
System.Numerics.BigInteger.Pow(2, 255) - 19;
private static readonly HashSet<System.Numerics.BigInteger> LowOrderX25519U =
[
System.Numerics.BigInteger.Zero,
System.Numerics.BigInteger.One,
System.Numerics.BigInteger.Parse("325606250916557431795983626356110631294008115727848805560023387167927233504"),
System.Numerics.BigInteger.Parse("39382357235489614581723060781553021112529911719440698176882885853963445705823"),
X25519Prime - 1,
];

// --- Ed25519 ---

private static KeyPair GenerateEd25519()
Expand All @@ -175,6 +199,9 @@ private static KeyPair GenerateEd25519()

private static KeyPair RestoreEd25519(ReadOnlySpan<byte> privateKey)
{
// Guard before NSec (raw FormatException on a wrong-length blob) → ArgumentException (NFR-3).
RawKeyGuard.RequireLength(privateKey, 32, nameof(privateKey), "Ed25519 private key");

var algorithm = SignatureAlgorithm.Ed25519;
using var key = Key.Import(algorithm, privateKey, KeyBlobFormat.RawPrivateKey,
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
Expand Down Expand Up @@ -205,6 +232,8 @@ private static KeyPair GenerateX25519()

private static KeyPair RestoreX25519(ReadOnlySpan<byte> privateKey)
{
RawKeyGuard.RequireLength(privateKey, 32, nameof(privateKey), "X25519 private key");

var algorithm = NSec.Cryptography.KeyAgreementAlgorithm.X25519;
using var key = Key.Import(algorithm, privateKey, KeyBlobFormat.RawPrivateKey,
new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport });
Expand Down Expand Up @@ -326,8 +355,19 @@ private static KeyPair GenerateBls(KeyType keyType)

private static KeyPair RestoreBls(KeyType keyType, ReadOnlySpan<byte> privateKey)
{
// FromBendian throws BlsException on a wrong-length/invalid scalar; normalize to a
// parameter-named ArgumentException (NFR-3), matching SignBls.
RawKeyGuard.RequireLength(privateKey, 32, nameof(privateKey), "BLS12-381 private key");

var sk = new Bls.SecretKey();
sk.FromBendian(privateKey);
try
{
sk.FromBendian(privateKey);
}
catch (Bls.BlsException ex)
{
throw new ArgumentException("Invalid BLS12-381 private key.", nameof(privateKey), ex);
}

return BuildBlsKeyPair(keyType, sk);
}
Expand Down
23 changes: 23 additions & 0 deletions src/NetCrypto/RawKeyGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace NetCrypto;

/// <summary>
/// Up-front validation for raw key/scalar byte inputs, shared by <see cref="DefaultCryptoProvider"/>
/// and <see cref="DefaultKeyGenerator"/>. Converts a malformed caller input into a parameter-named
/// <see cref="System.ArgumentException"/> <em>before</em> it reaches a cryptographic backend that
/// would otherwise surface a non-contract exception — NSec's <c>FormatException</c>, Nethermind
/// BLS's <c>BlsException</c>, or a platform <c>CryptographicException</c> on EC key import (NFR-3:
/// bad input must produce <c>ArgumentException</c>, never a leaked backend type).
/// </summary>
internal static class RawKeyGuard
{
/// <summary>
/// Throw a parameter-named <see cref="System.ArgumentException"/> if <paramref name="value"/>
/// is not exactly <paramref name="expected"/> bytes long.
/// </summary>
internal static void RequireLength(ReadOnlySpan<byte> value, int expected, string paramName, string label)
{
if (value.Length != expected)
throw new ArgumentException(
$"{label} must be {expected} bytes, but was {value.Length}.", paramName);
}
}
29 changes: 29 additions & 0 deletions tasks/lessons.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,32 @@ launch an adversarial subagent that WRITES AND RUNS exploit code (not just reaso
built artifact, with an explicit "your job is to break this, report every weakness" framing and a
concrete attack list. For binding/commitment properties, always include an *asymmetric* test
(different-length inputs) so a silent argument-order swap can't pass via symmetry.

## L5 — A permissive test contract hides real gaps; "passes the fuzz suite" ≠ "clean errors"

**Context:** A security review (preview.3) flagged that wrong-length raw keys leaked
`System.FormatException` (NSec), `Nethermind.Crypto.Bls+BlsException`, and macOS
`AppleCommonCryptoCryptographicException` from `DefaultCryptoProvider`/`DefaultKeyGenerator` instead
of a parameter-named `ArgumentException`. Two reasons it survived: (a) the fuzz-lite assertion
tolerated `CryptographicException` as "in contract," so the EC private-key path leaking a *platform
crypto exception* on a wrong-length key passed the suite even though NFR-3's normative text demands
a parameter-named `ArgumentException` "before any crypto operation"; (b) the NSec/BLS
`FormatException`/`BlsException` leaks were *pinned* in a `KnownBackendDeviations` allow-list (the
exact red flag L1 named) so the suite stayed green while the gap stayed open. The fix was an
up-front length guard (`RawKeyGuard.RequireLength`) at every backend hand-off, plus a try/catch that
converts BLS's value-validity `BlsException` to `ArgumentException`, plus deleting the allow-list
entirely so any non-contract exception now fails.

**Rule:** When a test's pass condition is broad ("throws any of A/B/C, or is pinned"), it certifies
far less than it appears. A wrong-length input is a *caller bug* and must produce a clear,
parameter-named `ArgumentException` — `CryptographicException` (even a platform subclass) is
reserved for genuine crypto failures and must NOT double as the catch-all for malformed input.
Reflect the strict bar in BOTH the assertion (no allow-list; non-contract exception ⇒ fail) and the
PRD AC, so the contract can't quietly relax again.

**How to apply:** For every public method that forwards caller bytes to a third-party/native/platform
backend, validate length (and other cheap invariants) *before* the hand-off and map any residual
backend exception to `ArgumentException(paramName)`. Never pin a backend-exception deviation to go
green — fix src. Write the assertion as `WithParameterName(...)`, not merely "threw something in a
set." Probe each backend's actual failure type on bad input on every supported OS (the macOS EC
exception differs from Windows/Linux), since "in contract on my machine" can hide a leak elsewhere.
Loading
Loading