From 1f044437c4f189b018f1c341b086fdba809a2de4 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 17 Sep 2026 08:47:45 +0200 Subject: [PATCH 01/17] feat(crypto): add a Field plaintext pad to EcdhMask `kdf` derives into `[0, 2^248)`, which hides a `Uint<128>` with about 2^-120 slack but leaves the high bits of a full-width `Field` in the clear. Widening `kdf` was not an option: its output is a published ciphertext component, so `fieldKdf` is a second derivation instead. It hashes two halves under distinct half indices and combines them as `k1 + k2 * 2^248 mod p`, landing within 2^-240 of uniform, and its three-element preimage separates it from `kdf` under the same `(sShared, domain)`. `encryptField` / `decryptField` reuse the existing `Ciphertext` struct and reach the weak-input guards through `crypto/Ecdh` exactly as `encrypt` does. Refs: OpenZeppelin/compact-contracts#735 --- contracts/src/crypto/EcdhMask.compact | 174 ++++++++++++++++-- .../crypto/test/mocks/MockEcdhMask.compact | 45 ++++- 2 files changed, 206 insertions(+), 13 deletions(-) diff --git a/contracts/src/crypto/EcdhMask.compact b/contracts/src/crypto/EcdhMask.compact index 254d53b5..b68c1a51 100644 --- a/contracts/src/crypto/EcdhMask.compact +++ b/contracts/src/crypto/EcdhMask.compact @@ -5,13 +5,13 @@ pragma language_version >= 0.26.0; /** * @module EcdhMask - * @description Stateless ECDH hybrid encryption of a single field element to a - * Jubjub public key, built on the key agreement in `crypto/Ecdh`. Delivers a - * value to whoever holds the secret behind a Jubjub public key WITHOUT encoding - * it in the exponent, so recovery needs no discrete-log search and the value is - * unbounded within `Uint<128>`. This is the direct-decrypt counterpart to the - * homomorphic `crypto/ElGamal`: use ElGamal to accumulate ciphertexts, use this - * to hand a recipient the plaintext. + * @description Stateless ECDH hybrid encryption of field elements to a Jubjub + * public key, built on the key agreement in `crypto/Ecdh`. Delivers a value to + * whoever holds the secret behind a Jubjub public key WITHOUT encoding it in the + * exponent, so recovery needs no discrete-log search and the value is unbounded + * within `Uint<128>` (`encrypt`) or over the whole field (`encryptField`). This + * is the direct-decrypt counterpart to the homomorphic `crypto/ElGamal`: use + * ElGamal to accumulate ciphertexts, use this to hand a recipient the plaintext. * * Construction (recipient key `pk = g^ek`, ephemeral scalar `e` fresh per call): * E = g^e (ephemeral public key) @@ -20,6 +20,13 @@ pragma language_version >= 0.26.0; * ct = value + mask (field one-time-pad) * Recipient recovers: S = E^ek, mask = KDF(S), value = ct - mask. * + * @dev Future APIs. + * - `Bytes<32>` plaintexts, carried as two masked fields. + * - `encryptFields<#N>(recipientPk, ms: Vector, e, tags: Vector>)` — one key agreement, N masked fields. Convenience only; tag + * distinctness stays the caller's obligation. + * - No MAC is a decision, not a gap (see Integrity). + * * @dev Key agreement. `deriveShared`, `recoverShared` and the `SharedSecret` * struct live in `crypto/Ecdh` and are not re-exported here; a consumer that * needs them imports that module directly. It owns the weak-input guards every @@ -74,6 +81,24 @@ pragma language_version >= 0.26.0; * `S = pk^e` are both determined by `e`, so `KDF(S)` already varies with `E` and * folding it in buys nothing. * + * @dev Two KDFs, two output ranges. `kdf` derives into `[0, 2^248)`: one + * `degradeToTransient` output, the SEC 1 KDF shape, for keys, nonces, and the + * `Uint<128>` mask of `encrypt` (hidden with about `2^-120` slack). `fieldKdf` + * derives uniformly into the field, the hash-to-field shape: two 248-bit halves + * combined as `k1 + k2 * 2^248 mod p`, each half hashing + * `[pointHash, domain, index]` with `index` the 32-byte encoding of `0` and `1`. + * The sum ranges over about `2^496` values before reduction, which puts the + * output within `2^-240` of uniform, so `ct = m + fieldKdf(S, domain)` hides any + * `m` with no precondition on its range or entropy. Mask a `Field` with + * `fieldKdf`, never with `kdf`; neither authenticates (see Integrity). + * + * @dev Multi-field delivery. A consumer delivering several `Field`s to one + * recipient calls `deriveShared` once and adds `fieldKdf(sShared, tag_i)` per + * field under pairwise-distinct tags. Reusing a tag under one `sShared` reuses + * the pad and publishes the difference of the two plaintexts. `sShared` stays a + * local of the calling circuit; only `ephemeralPk` and the masked fields are + * disclosed. + * * @dev Shared key with `crypto/ElGamal` (in scope). A consumer MAY use the same * recipient Jubjub key for both these memos (hashed ElGamal) and exponential- * ElGamal balance ciphertexts, as the confidential token does. This joint use is @@ -82,9 +107,10 @@ pragma language_version >= 0.26.0; * under CPA / ODH a ciphertext of one scheme does not help attack the other. * * @dev No ledger state, no witnesses. Every circuit is pure and takes its keys, - * value, and randomness as explicit arguments. `decrypt` is exposed as a pure - * circuit for wallet/off-chain use (recovering a received value needs no proof); - * the on-chain path only ever calls `encrypt`. + * value, and randomness as explicit arguments. `decrypt` and `decryptField` are + * exposed as pure circuits for wallet/off-chain use (recovering a received value + * needs no proof); the on-chain path calls only the sender side, `encrypt` / + * `encryptField` / `deriveShared` + `fieldKdf`. */ module EcdhMask { import CompactStandardLibrary; @@ -148,12 +174,25 @@ module EcdhMask { * @return The field mask. */ export pure circuit kdf(sShared: JubjubPoint, domain: Bytes<32>): Field { - const pointHash = persistentHash(sShared); return degradeToTransient( - persistentHash>>([pointHash, domain]) + persistentHash>>([pointDigest(sShared), domain]) ); } + /** + * @description Hashes the shared secret point into the fixed-width `Bytes<32>` + * that opens the preimage of both KDFs. Shared by `kdf` and `fieldKdf` so the + * point is hashed once per `fieldKdf` call and both KDFs agree on its encoding. + * + * @dev Uses `persistentHash` for the same reason as `kdf`. + * + * @param sShared - The ECDH shared secret point. + * @return The point digest. + */ + pure circuit pointDigest(sShared: JubjubPoint): Bytes<32> { + return persistentHash(sShared); + } + /** * @description Recovers the value from a ciphertext using the recipient's * secret scalar. Pure and off-chain (no proof needed to read a received value). @@ -172,4 +211,115 @@ module EcdhMask { const mask = kdf(sShared, domain); return ciphertext.ct - mask; } + + /** + * @description Encrypts an arbitrary `Field` to `recipientPk` under ephemeral + * scalar `e`, masked by the uniform `fieldKdf`. One key agreement, one masked + * field; a consumer delivering several fields calls `deriveShared` and + * `fieldKdf` directly instead, one tag per field. + * + * @notice `e` MUST be fresh per call and secret (see `crypto/Ecdh`). Reuse + * leaks the difference of the two plaintexts. + * + * @constraints k=14, rows=13024 + * + * Requirements: + * + * - `recipientPk` is not the identity point. + * - `e` is non-zero, a valid Jubjub scalar (`< ℓ`), secret, and fresh per call. + * + * @param recipientPk - The recipient's Jubjub public key (`g^ek`). + * @param m - The plaintext. Any `Field`; no range or entropy precondition. + * @param e - A fresh ephemeral scalar. + * @param domain - The consumer's domain-separation tag (must match decrypt). + * @return The ciphertext `{ ephemeralPk = g^e, ct = m + fieldKdf(pk^e) }`. + */ + export pure circuit encryptField( + recipientPk: JubjubPoint, + m: Field, + e: JubjubScalar, + domain: Bytes<32> + ): Ciphertext { + // The weak-input guards come from `deriveShared`, not repeated here. + const shared = Ecdh_deriveShared(recipientPk, e); + const mask = fieldKdf(shared.sShared, domain); + return Ciphertext { ephemeralPk: shared.ephemeralPk, ct: m + mask }; + } + + /** + * @description Derives a mask uniform over the whole field from the ECDH + * shared secret point, domain-separated by a caller-supplied `domain` tag. + * Combines two 248-bit halves as `k1 + k2 * 2^248 mod p`, so + * `m + fieldKdf(S, domain)` hides any `m` with no bound on its range. Use + * this, not `kdf`, to mask a `Field`. + * + * @dev The halves are two separate random-oracle queries, each hashing + * `[pointHash, domain, index]` with `index` the 32-byte encoding of `0` and + * `1`. The three-element preimage also separates both from `kdf`'s + * two-element one, so a consumer may use `kdf` and `fieldKdf` under the same + * `(S, domain)` without one revealing the other. + * + * @dev Uses `persistentHash` for the same reason as `kdf`: the recipient + * reproduces the mask off-chain, possibly across a platform upgrade. + * + * @constraints k=14, rows=11704 + * + * @param sShared - The ECDH shared secret point. + * @param domain - The consumer's domain-separation tag. Pairwise distinct per + * field masked under one `sShared`, and the same value on + * encrypt and decrypt. + * @return The field mask. + */ + export pure circuit fieldKdf(sShared: JubjubPoint, domain: Bytes<32>): Field { + // 2^248 as a decimal literal; Compact has no exponentiation operator. + const twoPow248 = + 452312848583266388373324160190187140051835877600158453279131187530910662656 as Field; + const pointHash = pointDigest(sShared); + const lowHalf = kdfHalf(pointHash, domain, 0 as Field as Bytes<32>); + const highHalf = kdfHalf(pointHash, domain, 1 as Field as Bytes<32>); + return lowHalf + highHalf * twoPow248; + } + + /** + * @description Derives one 248-bit half of a `fieldKdf` mask from the point + * digest, the domain tag, and a half index. `fieldKdf` calls it at `index` 0 + * and 1 and combines the results as `k1 + k2 * 2^248`. + * + * @dev `index` separates the two halves from each other, and the three-element + * preimage separates both from `kdf`'s two-element one, so a consumer may use + * `kdf` and `fieldKdf` under the same `(sShared, domain)`. + * + * @dev Uses `persistentHash` for the same reason as `kdf`. + * + * @param pointHash - The `pointDigest` of the shared secret point. + * @param domain - The consumer's domain-separation tag. + * @param index - The half index, `0` or `1`, as a 32-byte encoding. + * @return One 248-bit half of the field mask. + */ + pure circuit kdfHalf(pointHash: Bytes<32>, domain: Bytes<32>, index: Bytes<32>): Field { + return degradeToTransient( + persistentHash>>([pointHash, domain, index]) + ); + } + + /** + * @description Recovers a `Field` plaintext from an `encryptField` ciphertext. + * Pure and off-chain (reading a received value needs no proof). + * + * @dev Never asserts. A wrong `ekScalar` or `domain` returns an unrelated field + * element rather than aborting, so the circuit is not a key-correctness oracle. + * Nothing here authenticates the ciphertext. + * + * @constraints k=14, rows=12102 + * + * @param ciphertext - The ciphertext to decrypt. + * @param ekScalar - The recipient's secret scalar (`crypto/ElGamal`'s + * `secretToScalar(EK)`). + * @param domain - The consumer's domain-separation tag (must match encrypt). + * @return The recovered plaintext. + */ + export pure circuit decryptField(ciphertext: Ciphertext, ekScalar: JubjubScalar, domain: Bytes<32>): Field { + const sShared = Ecdh_recoverShared(ciphertext.ephemeralPk, ekScalar); + return ciphertext.ct - fieldKdf(sShared, domain); + } } diff --git a/contracts/src/crypto/test/mocks/MockEcdhMask.compact b/contracts/src/crypto/test/mocks/MockEcdhMask.compact index cee1b01c..1464d094 100644 --- a/contracts/src/crypto/test/mocks/MockEcdhMask.compact +++ b/contracts/src/crypto/test/mocks/MockEcdhMask.compact @@ -9,8 +9,11 @@ pragma language_version >= 0.26.0; import CompactStandardLibrary; import "../../EcdhMask" prefix EcdhMask_; +// EcdhMask does not re-export the key agreement, so the multi-field tests reach +// crypto/Ecdh the way a consumer does: a second import. +import "../../Ecdh" prefix Ecdh_; -export { EcdhMask_Ciphertext } +export { EcdhMask_Ciphertext, Ecdh_SharedSecret } export pure circuit kdf(sShared: JubjubPoint, domain: Bytes<32>): Field { return EcdhMask_kdf(sShared, domain); @@ -28,3 +31,43 @@ export pure circuit encrypt( export pure circuit decrypt(ciphertext: EcdhMask_Ciphertext, ekScalar: JubjubScalar, domain: Bytes<32>): Field { return EcdhMask_decrypt(ciphertext, ekScalar, domain); } + +export pure circuit fieldKdf(sShared: JubjubPoint, domain: Bytes<32>): Field { + return EcdhMask_fieldKdf(sShared, domain); +} + +export pure circuit encryptField( + recipientPk: JubjubPoint, + m: Field, + e: JubjubScalar, + domain: Bytes<32> + ): EcdhMask_Ciphertext { + return EcdhMask_encryptField(recipientPk, m, e, domain); +} + +export pure circuit decryptField(ciphertext: EcdhMask_Ciphertext, ekScalar: JubjubScalar, domain: Bytes<32>): Field { + return EcdhMask_decryptField(ciphertext, ekScalar, domain); +} + +export pure circuit deriveShared(recipientPk: JubjubPoint, e: JubjubScalar): Ecdh_SharedSecret { + return Ecdh_deriveShared(recipientPk, e); +} + +export pure circuit recoverShared(ephemeralPk: JubjubPoint, ekScalar: JubjubScalar): JubjubPoint { + return Ecdh_recoverShared(ephemeralPk, ekScalar); +} + +// Test-only, not a module re-export: recomputes the two `fieldKdf` halves +// straight from the stdlib hashes so the tests can pin the pad arithmetic +// against an independent path. +export pure circuit fieldKdfHalves(sShared: JubjubPoint, domain: Bytes<32>): Vector<2, Field> { + const pointHash = persistentHash(sShared); + return [ + degradeToTransient( + persistentHash>>([pointHash, domain, 0 as Field as Bytes<32>]) + ), + degradeToTransient( + persistentHash>>([pointHash, domain, 1 as Field as Bytes<32>]) + ) + ]; +} From 2b835befeea497bd684af8993c1d670a96626f94 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 17 Sep 2026 08:47:55 +0200 Subject: [PATCH 02/17] test(crypto): pin the fieldKdf width and pad discipline * Width: each half stays inside the 248-bit `degradeToTransient` range, the halves are independent hash queries, and the combined mask leaves that range for most shared points. `encryptField` of a fixed plaintext spreads across the whole field, which is the property `kdf` cannot give. * Round-trip at the field boundaries (zero, 2^248, 2^253, p-1) and as a property over arbitrary keys, ephemerals and plaintexts. * Multi-field: two fields under one key agreement and one tag each; the same tag twice publishes their difference. * `kdf` gains a regression pinning its output below 2^248, so a later widening cannot silently change `encrypt`. Refs: OpenZeppelin/compact-contracts#735 --- contracts/src/crypto/test/EcdhMask.test.ts | 257 ++++++++++++++++++++- 1 file changed, 252 insertions(+), 5 deletions(-) diff --git a/contracts/src/crypto/test/EcdhMask.test.ts b/contracts/src/crypto/test/EcdhMask.test.ts index 207e6563..e0624027 100644 --- a/contracts/src/crypto/test/EcdhMask.test.ts +++ b/contracts/src/crypto/test/EcdhMask.test.ts @@ -14,9 +14,16 @@ import { pureCircuits as elgamal } from '../../../artifacts/MockElGamal/contract const L = 6554484396890773809930967563523245729705921265872317281365359162392183254199n; -// Compact `Field` modulus (BLS12-381 scalar field). -const P = - 52435875175126190479447740508185965837690552500527637822603658699938581184513n; +// BLS12-381 scalar field modulus: the modulus of the Compact `Field` type. +const P = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001n; + +// Upper bound of a single degradeToTransient output, so the width of one +// fieldKdf half and of the whole kdf mask. +const TWO_248 = 1n << 248n; + +// Field subtraction. Ciphertext arithmetic wraps, so a raw bigint `-` on two +// field elements is not the field difference. +const sub = (a: bigint, b: bigint): bigint => (a - b + P) % P; // A recipient's secret scalar and their derived public key g^ek. const EK = 111222333444555n; @@ -30,6 +37,10 @@ const domain = (label: string): Uint8Array => { }; const DOMAIN = domain('ecdh_mask_test'); +// Two tags for the multi-field pattern: one key agreement, one fieldKdf per field. +const TAG_VALUE = domain('ecdh_mask_test:value'); +const TAG_NONCE = domain('ecdh_mask_test:nonce'); + // A fixed encrypt input/output pair, recorded before encrypt delegated to // crypto/Ecdh, under its own tag so a change to the tests above cannot move it. const GOLDEN_VALUE = (1n << 127n) + 12345n; @@ -145,6 +156,16 @@ describe('EcdhMask', () => { const c2 = pureCircuits.encrypt(PK, 250n, e, DOMAIN); expect(c1.ct - c2.ct).toBe(1000n - 250n); }); + + it('reusing the ephemeral leaks the difference through encryptField too', () => { + // Uniformity buys nothing once the pad repeats. + const e = 7n; + const m1 = P - 1n; + const m2 = 1n << 200n; + const c1 = pureCircuits.encryptField(PK, m1, e, DOMAIN); + const c2 = pureCircuits.encryptField(PK, m2, e, DOMAIN); + expect(sub(c1.ct, c2.ct)).toBe(sub(m1, m2)); + }); }); describe('weak-input guards', () => { @@ -217,10 +238,236 @@ describe('EcdhMask', () => { // [0, 2^248) (the degradeToTransient range). Pin that stdlib behavior over // several points so a regression surfaces here rather than silently // shrinking the margin. - const bound = 1n << 248n; for (const s of [2n, 5n, 222n, 999999n]) { - expect(pureCircuits.kdf(ecMulGenerator(s), DOMAIN)).toBeLessThan(bound); + expect(pureCircuits.kdf(ecMulGenerator(s), DOMAIN)).toBeLessThan( + TWO_248, + ); + } + }); + }); + + describe('fieldKdf', () => { + const points = [2n, 5n, 222n, 999999n].map((s) => ecMulGenerator(s)); + + it('is deterministic for the same shared point and domain', () => { + expect(pureCircuits.fieldKdf(PK, DOMAIN)).toBe( + pureCircuits.fieldKdf(PK, DOMAIN), + ); + }); + + it('differs for distinct shared points', () => { + expect(pureCircuits.fieldKdf(PK, DOMAIN)).not.toBe( + pureCircuits.fieldKdf(ecMulGenerator(222n), DOMAIN), + ); + }); + + it('differs for distinct domains (domain separation)', () => { + expect(pureCircuits.fieldKdf(PK, domain('a'))).not.toBe( + pureCircuits.fieldKdf(PK, domain('b')), + ); + }); + + it('equals k1 + k2 * 2^248 over the two hashed halves', () => { + // The mock recomputes both halves straight from the stdlib hashes, so the + // pad's arithmetic is pinned against an independent path. + for (const point of points) { + const [low, high] = pureCircuits.fieldKdfHalves(point, DOMAIN); + expect(pureCircuits.fieldKdf(point, DOMAIN)).toBe( + (low + high * TWO_248) % P, + ); + } + }); + + it('draws each half from the 248-bit degradeToTransient range', () => { + for (const point of points) { + const [low, high] = pureCircuits.fieldKdfHalves(point, DOMAIN); + expect(low).toBeLessThan(TWO_248); + expect(high).toBeLessThan(TWO_248); } }); + + it('draws the two halves as independent hash queries', () => { + // Index 0 and index 1 are separate random-oracle queries. + for (const point of points) { + const [low, high] = pureCircuits.fieldKdfHalves(point, DOMAIN); + expect(low).not.toBe(high); + } + }); + + it('differs from kdf under the same point and domain', () => { + // The three-element preimage separates the pad from the kdf, so a + // consumer may use both under one (S, domain). + for (const point of points) { + expect(pureCircuits.fieldKdf(point, DOMAIN)).not.toBe( + pureCircuits.kdf(point, DOMAIN), + ); + } + }); + + it('exceeds the 248-bit kdf range for most shared points', () => { + // The regression that catches a dropped high half: a 248-bit pad can + // never land above 2^248, a field-wide one almost always does. + const wide = Array.from({ length: 64 }, (_, i) => + pureCircuits.fieldKdf(ecMulGenerator(BigInt(i) + 1n), DOMAIN), + ).filter((mask) => mask >= TWO_248); + expect(wide.length).toBeGreaterThan(0); + }); + }); + + describe('encryptField / decryptField round-trip', () => { + // Recovery is exact for every Field, not only the Uint<128> range encrypt + // covers. + const cases: [string, bigint][] = [ + ['zero', 0n], + ['one', 1n], + ['the largest field element', P - 1n], + ['2^248, the top of the kdf range', TWO_248], + ['2^253, above every 248-bit mask', 1n << 253n], + ]; + + for (const [name, m] of cases) { + it(`round-trips ${name}`, () => { + const ciphertext = pureCircuits.encryptField(PK, m, 42n, DOMAIN); + expect(pureCircuits.decryptField(ciphertext, EK, DOMAIN)).toBe(m); + }); + } + + it('round-trips at the maximum valid scalar (L - 1) for key and ephemeral', () => { + const ek = L - 1n; + const pk = ecMulGenerator(ek); + const m = P - 1n; + const ciphertext = pureCircuits.encryptField(pk, m, L - 1n, DOMAIN); + expect(pureCircuits.decryptField(ciphertext, ek, DOMAIN)).toBe(m); + }); + + it('round-trips through the real crypto/ElGamal key derivation', () => { + const ekBytes = new Uint8Array(32).fill(0x11); + const pk = elgamal.derivePk(ekBytes); + const ekScalar = elgamal.secretToScalar(ekBytes); + const m = P - 4242n; + const ciphertext = pureCircuits.encryptField(pk, m, 99n, DOMAIN); + expect(pureCircuits.decryptField(ciphertext, ekScalar, DOMAIN)).toBe(m); + }); + + it('round-trips for arbitrary keys, ephemerals, and field plaintexts (property)', () => { + fc.assert( + fc.property( + fc.bigInt({ min: 1n, max: 1n << 200n }), + fc.bigInt({ min: 1n, max: 1n << 200n }), + fc.bigInt({ min: 0n, max: P - 1n }), + (ek, e, m) => { + const pk = ecMulGenerator(ek); + const ciphertext = pureCircuits.encryptField(pk, m, e, DOMAIN); + expect(pureCircuits.decryptField(ciphertext, ek, DOMAIN)).toBe(m); + }, + ), + ); + }); + + it('masks the plaintext with exactly fieldKdf(pk^e, domain)', () => { + // The ciphertext is a function of (sShared, domain) and m alone. + const m = 1n << 253n; + const shared = pureCircuits.deriveShared(PK, 42n); + const ciphertext = pureCircuits.encryptField(PK, m, 42n, DOMAIN); + expect(ciphertext.ct).toBe( + (m + pureCircuits.fieldKdf(shared.sShared, DOMAIN)) % P, + ); + }); + }); + + describe('encryptField uniformity', () => { + // With a 248-bit pad the ciphertext of a large plaintext stays in a narrow + // band around it, so plaintexts of different magnitudes are distinguishable + // by their ciphertext's range. A field-wide pad scatters every plaintext + // across the whole field, so each of these must land on both sides of the + // midpoint over a fixed set of ephemerals. + const HALF_P = P / 2n; + const plaintexts: [string, bigint][] = [ + ['zero', 0n], + ['2^128', 1n << 128n], + ['2^253', 1n << 253n], + ['the largest field element', P - 1n], + ]; + + for (const [name, m] of plaintexts) { + it(`spreads the ciphertext of ${name} across the whole field`, () => { + const cts = Array.from({ length: 64 }, (_, i) => + pureCircuits.encryptField(PK, m, BigInt(i) + 1n, DOMAIN), + ).map((ciphertext) => ciphertext.ct); + expect(cts.some((ct) => ct < HALF_P)).toBe(true); + expect(cts.some((ct) => ct >= HALF_P)).toBe(true); + }); + } + }); + + describe('multi-field pad discipline', () => { + // `deriveShared` and `recoverShared` come from crypto/Ecdh, imported + // alongside EcdhMask in the mock exactly as a consumer imports both. + it('carries two fields under one key agreement with one tag each', () => { + // The multi-field pattern a consumer builds on. + const value = 1n << 200n; + const nonce = P - 5n; + const shared = pureCircuits.deriveShared(PK, 31337n); + const valueCt = + (value + pureCircuits.fieldKdf(shared.sShared, TAG_VALUE)) % P; + const nonceCt = + (nonce + pureCircuits.fieldKdf(shared.sShared, TAG_NONCE)) % P; + + const recovered = pureCircuits.recoverShared(shared.ephemeralPk, EK); + expect(sub(valueCt, pureCircuits.fieldKdf(recovered, TAG_VALUE))).toBe( + value, + ); + expect(sub(nonceCt, pureCircuits.fieldKdf(recovered, TAG_NONCE))).toBe( + nonce, + ); + }); + + it('leaks the plaintext difference when one tag pads two fields', () => { + // The tag-reuse footgun in executable form: one shared point, one tag, + // two fields is pad reuse. + const m1 = 1n << 200n; + const m2 = 4242n; + const shared = pureCircuits.deriveShared(PK, 31337n); + const mask = pureCircuits.fieldKdf(shared.sShared, TAG_VALUE); + expect(sub((m1 + mask) % P, (m2 + mask) % P)).toBe(sub(m1, m2)); + }); + + it('does not leak the plaintext difference across distinct tags', () => { + const m1 = 1n << 200n; + const m2 = 4242n; + const shared = pureCircuits.deriveShared(PK, 31337n); + const ct1 = (m1 + pureCircuits.fieldKdf(shared.sShared, TAG_VALUE)) % P; + const ct2 = (m2 + pureCircuits.fieldKdf(shared.sShared, TAG_NONCE)) % P; + expect(sub(ct1, ct2)).not.toBe(sub(m1, m2)); + }); + }); + + describe('total recipient-side circuits', () => { + // Nothing on the recipient side asserts, so a scanner cannot tell an + // addressed ciphertext from an unaddressed one by an abort. + const identity = ecMulGenerator(0n); + const WRONG_EK = 999999n; + const ciphertext = pureCircuits.encryptField(PK, 1n << 200n, 42n, DOMAIN); + + it('fieldKdf accepts the identity shared point', () => { + expect(() => pureCircuits.fieldKdf(identity, DOMAIN)).not.toThrow(); + }); + + it('decryptField returns a wrong plaintext under a wrong secret key', () => { + expect(pureCircuits.decryptField(ciphertext, WRONG_EK, DOMAIN)).not.toBe( + 1n << 200n, + ); + }); + + it('decryptField returns a wrong plaintext under a wrong domain', () => { + expect( + pureCircuits.decryptField(ciphertext, EK, domain('other')), + ).not.toBe(1n << 200n); + }); + + it('decryptField resolves an identity ephemeral without aborting', () => { + const forged = { ephemeralPk: identity, ct: ciphertext.ct }; + expect(() => pureCircuits.decryptField(forged, EK, DOMAIN)).not.toThrow(); + }); }); }); From 2c06e4e9cc917fb0cf55fcd037486157684beb2f Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 17 Sep 2026 08:47:59 +0200 Subject: [PATCH 03/17] docs(changelog): add the EcdhMask Field pad --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index db8ca7d0..c5abb277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add the `crypto/Ecdh` module, the Jubjub key agreement that `crypto/EcdhMask` used to carry inline: `deriveShared` (sender side, owns the identity-key and zero-ephemeral guards), `recoverShared` (recipient side, never asserts), and the `SharedSecret` struct. `EcdhMask.encrypt` and `decrypt` delegate to it and produce the same ciphertexts as before; the guard messages now read `Ecdh: identity pk` / `Ecdh: zero ephemeral`. (#866) +- Add `fieldKdf`, `encryptField` and `decryptField` to `crypto/EcdhMask`, a mask uniform over the whole `Field` built from two 248-bit halves combined as `k1 + k2 * 2^248 mod p`. `encryptField` therefore hides a plaintext with no range or entropy precondition, where `encrypt`'s 248-bit `kdf` mask needs `value < 2^128`. `kdf`'s output is unchanged. (#913) ### Changed From 2b7a34772135c09382285993c638af69247a33eb Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 17 Sep 2026 16:26:42 +0200 Subject: [PATCH 04/17] feat(crypto): add the Fq field utilities Group the byte and field conversions under the field they belong to, with names from the literature in place of the standard library's degradeToTransient and upgradeFromTransient, which hide a truncation. * truncatedLEOS2IP and truncatedI2LEOSP wrap the two standard library conversions and document that both drop everything past 248 bits. * fromUniformBytes is the field half of RFC 9380 hash_to_field: LEOS2IP_512(tv) mod q, the same reduction as Fq::from_uniform_bytes in Midnight's curves library. * MockFq keeps every circuit impure so the artifact ships with ZKIR and proving keys. Refs: #735 --- .../src/crypto/curves/bls12_381/Fq.compact | 173 ++++++++++++++++++ .../bls12_381/test/mocks/MockFq.compact | 31 ++++ 2 files changed, 204 insertions(+) create mode 100644 contracts/src/crypto/curves/bls12_381/Fq.compact create mode 100644 contracts/src/crypto/curves/bls12_381/test/mocks/MockFq.compact diff --git a/contracts/src/crypto/curves/bls12_381/Fq.compact b/contracts/src/crypto/curves/bls12_381/Fq.compact new file mode 100644 index 00000000..e2e85bd8 --- /dev/null +++ b/contracts/src/crypto/curves/bls12_381/Fq.compact @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.4.0-alpha.1 (crypto/curves/bls12_381/Fq.compact) + +pragma language_version >= 0.26.0; + +/** + * @module Fq + * @description + * Utilities for `Fq`, the scalar field of BLS12-381 and Compact's native + * `Field`. + * + * @dev The field: + * - Order: `q`, a 255-bit prime. + * - `q = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001`. + * - In Compact: the native `Field`. + * - On Jubjub: the base field. Jubjub's scalar field is `JubjubScalar`. + * - Name: as in Midnight's curves library. Other libraries call it `Fr`. + * + * @dev Notation: + * - Octet string: a byte string. + * - `OS2IP`: Octet-String-to-Integer primitive, big-endian (RFC 8017 §4). + * - `I2OSP`: Integer-to-Octet-String primitive, big-endian (RFC 8017 §4). + * - `LEOS2IP_l(S)`: little-endian `OS2IP` on `l` bits (Zcash spec §5.1). + * - `I2LEOSP_l(x)`: little-endian `I2OSP` on `l` bits (Zcash spec §5.1). + * - `substr(S, 0, n)`: the first `n` bytes of `S` (RFC 9380). + * - `tv`: the uniform bytes that become one field element (RFC 9380). + * - `e_0`: the resulting field element (RFC 9380). + * + * @dev Conversions: + * `truncatedLEOS2IP` and `truncatedI2LEOSP` are the octet-string-to-field-element + * and field-element-to-octet-string conversions of SEC 1 §2.3, little-endian + * and truncated to 248 bits. The standard library names them + * `degradeToTransient` and `upgradeFromTransient`. + * + * @dev Why 248 bits: + * `2^248 < q < 2^256`, so 31 bytes always name a field element and 32 bytes do + * not. Both conversions work on the 31 bytes that fit. + * + * @notice Security: + * - Truncation is silent: neither circuit asserts that its input fits. + * - `truncatedLEOS2IP` ignores byte 31, so two strings that differ only there + * convert to the same element. + * - `truncatedI2LEOSP` drops bits 248 and up, so two elements equal mod + * `2^248` convert to the same string. + * - A converted SHA-256 digest keeps 248 of its 256 bits: `2^124` collision + * resistance against `2^128`. + * + * @dev Round trips: + * - `truncatedLEOS2IP(truncatedI2LEOSP(x)) = x mod 2^248`. + * - `truncatedI2LEOSP(truncatedLEOS2IP(S))` is `S` with byte 31 zeroed. + * - Both are the identity exactly on 248-bit values. + * + * @dev Hashing to the field: + * `fromUniformBytes` is the field half of RFC 9380 §5.2 `hash_to_field`. The + * hash half, `expand_message`, lives in each `crypto/hash` module. + */ +module Fq { + import CompactStandardLibrary; + + /** + * @description 64 uniform bytes, as two 32-byte halves. Read little-endian, + * `low` comes first. + * @type {Bytes<32>} low - Bytes 0 to 31. + * @type {Bytes<32>} high - Bytes 32 to 63. + */ + export struct UniformBytes { + low: Bytes<32>; + high: Bytes<32>; + } + + /** + * @description Converts 64 uniform bytes to a uniform field element. Reads + * `tv` as one little-endian integer and reduces it mod `q`. The last step of + * hashing to the field. + * + * @constraints k=10, rows=797 + * + * @dev Formula: + * `LEOS2IP_512(tv) mod q`. Step 7 of RFC 9380 §5.2 `hash_to_field`, with + * `LEOS2IP` in place of the big-endian `OS2IP`. + * + * @notice Uniformity: + * 512 bits against a 255-bit `q` is the RFC's `k = 257`: for a uniform `tv` + * the result is within `2^-257` of uniform over the field. + * + * @dev Same construction elsewhere: + * `Fq::from_uniform_bytes` in Midnight's curves library, and the shape of + * `ToScalar` in the Zcash protocol specification. + * + * @param {UniformBytes} tv - The 64 uniform bytes. + * @return {Field} - `e_0`, the field element. + */ + export pure circuit fromUniformBytes(tv: UniformBytes): Field { + return reducedLEOS2IP(tv.low) + reducedLEOS2IP(tv.high) * R(); + } + + /** + * @description `LEOS2IP_256(S) mod q`: all 32 bytes of `S`, read + * little-endian and reduced into the field. + * + * @dev Not uniform: + * 256 bits is one bit more than `q`, so a uniform `S` gives a biased + * element. `fromUniformBytes` joins two of these to remove the bias. + * + * @param {Bytes<32>} S - The byte string. + * @return {Field} - The integer mod `q`. + */ + pure circuit reducedLEOS2IP(S: Bytes<32>): Field { + // Byte 31 is the part `truncatedLEOS2IP` drops. + return truncatedLEOS2IP(S) + (slice<1>(S, 31) as Field) * TWO_POW_248(); + } + + /** + * @description Converts a byte string to a field element. Reads the first + * 31 bytes of `S` as a little-endian integer and ignores byte 31. The usual + * way to bring a hash digest into field arithmetic. + * + * @constraints k=7, rows=119 + * + * @dev Formula: + * `LEOS2IP_248(substr(S, 0, 31))`. + * + * @param {Bytes<32>} S - The byte string. Byte 31 is ignored. + * @return {Field} - A value below `2^248`. + */ + export pure circuit truncatedLEOS2IP(S: Bytes<32>): Field { + return degradeToTransient(S); + } + + /** + * @description The constant `2^248 = 2^(8 * 31)`: one past the largest + * 31-byte integer. 31 is the most whole bytes that always fit in `Fq`. + * + * @return {Field} - `2^248`. + */ + pure circuit TWO_POW_248(): Field { + return + 0x100000000000000000000000000000000000000000000000000000000000000 as Field; + } + + /** + * @description The constant `R = 2^256 mod q`: the weight of the high half + * of a 64-byte little-endian integer. + * + * @dev Name: + * `R` is the Montgomery radix of the Handbook of Applied Cryptography + * §14.3.2, `R = b^n`. Midnight's curves library holds an `Fq` element as + * four 64-bit limbs, so `b = 2^64`, `n = 4` and `R = 2^256`. + * + * @return {Field} - `2^256 mod q`. + */ + pure circuit R(): Field { + return + 0x1824b159acc5056f998c4fefecbc4ff55884b7fa0003480200000001fffffffe as Field; + } + + /** + * @description Converts a field element to a byte string. Writes the low + * 248 bits of `x` as 31 little-endian bytes, followed by a zero byte. The + * usual way to pass a field element where a `Bytes<32>` is expected. + * + * @constraints k=9, rows=200 + * + * @dev Formula: + * `I2LEOSP_256(x mod 2^248)`. + * + * @param {Field} x - The field element. Bits 248 and up are dropped. + * @return {Bytes<32>} - The byte string. Byte 31 is zero. + */ + export pure circuit truncatedI2LEOSP(x: Field): Bytes<32> { + return upgradeFromTransient(x); + } +} diff --git a/contracts/src/crypto/curves/bls12_381/test/mocks/MockFq.compact b/contracts/src/crypto/curves/bls12_381/test/mocks/MockFq.compact new file mode 100644 index 00000000..bf395c68 --- /dev/null +++ b/contracts/src/crypto/curves/bls12_381/test/mocks/MockFq.compact @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// Exposes the Fq module's circuits so they can be driven from off-chain tests. +// DO NOT deploy or use this contract in any production application. + +pragma language_version >= 0.26.0; + +import CompactStandardLibrary; +import "../../Fq" prefix Fq_; + +export { Fq_UniformBytes } + +// Keeps every circuit impure. Without a ledger write the compiler promotes +// them to pure, and the artifact ships without ZKIR or proving keys. +ledger _invocations: Counter; + +export circuit fromUniformBytes(tv: Fq_UniformBytes): Field { + _invocations.increment(1); + return Fq_fromUniformBytes(tv); +} + +export circuit truncatedLEOS2IP(S: Bytes<32>): Field { + _invocations.increment(1); + return Fq_truncatedLEOS2IP(S); +} + +export circuit truncatedI2LEOSP(x: Field): Bytes<32> { + _invocations.increment(1); + return Fq_truncatedI2LEOSP(x); +} From f3f69ad94bdc087c054404aea85adea8b720355c Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 17 Sep 2026 16:26:51 +0200 Subject: [PATCH 05/17] feat(crypto): add the Sha256 hash module Give hashing to the field its own module, shaped after RFC 9380 hash_to_field, so the construction can be reviewed against a reference step by step. * hash/Sha256 exports digest and hashToField. hashToField follows the RFC's steps and names; its expander is a counter expander in place of expand_message_xmd, and the deviations are listed on the circuit. * hash/IHasher holds the two signatures as a contract type, so a second hash module can be swapped in by changing an import. * MockSha256 implements the interface, so a drifting signature fails compilation. Refs: #735 --- contracts/src/crypto/hash/IHasher.compact | 34 ++++ contracts/src/crypto/hash/Sha256.compact | 145 ++++++++++++++++++ .../src/crypto/test/mocks/MockSha256.compact | 23 +++ 3 files changed, 202 insertions(+) create mode 100644 contracts/src/crypto/hash/IHasher.compact create mode 100644 contracts/src/crypto/hash/Sha256.compact create mode 100644 contracts/src/crypto/test/mocks/MockSha256.compact diff --git a/contracts/src/crypto/hash/IHasher.compact b/contracts/src/crypto/hash/IHasher.compact new file mode 100644 index 00000000..b6a49130 --- /dev/null +++ b/contracts/src/crypto/hash/IHasher.compact @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.4.0-alpha.1 (crypto/hash/IHasher.compact) + +pragma language_version >= 0.26.0; + +/** + * @module IHasher + * @description + * The signatures every `crypto/hash` module exports, as a contract type. + * + * @dev Fixed types: + * The hash modules are generic over the message type `T`. A contract type + * circuit cannot be, so `Hasher` fixes `T = Bytes<32>`. + * + * @dev Where it is implemented: + * `contract implements` is checked against the exports of the top-level + * contract, including one that reaches the declaring module through another + * import. Each hash module's mock declares it, so a drifting signature fails + * compilation. Declared inside a hash module, it would force every consumer + * to export `digest` and `hashToField`. + */ +module IHasher { + import CompactStandardLibrary; + + /** + * @description The `crypto/hash` interface at `T = Bytes<32>`. `digest` + * returns the hash's byte digest, and `hashToField` maps `msg` to one `Field` + * element, domain-separated by `DST`. + */ + export contract Hasher { + pure circuit digest(value: Bytes<32>): Bytes<32>; + pure circuit hashToField(msg: Bytes<32>, DST: Bytes<32>): Field; + } +} diff --git a/contracts/src/crypto/hash/Sha256.compact b/contracts/src/crypto/hash/Sha256.compact new file mode 100644 index 00000000..49446919 --- /dev/null +++ b/contracts/src/crypto/hash/Sha256.compact @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.4.0-alpha.1 (crypto/hash/Sha256.compact) + +pragma language_version >= 0.26.0; + +/** + * @module Sha256 + * @description + * SHA-256 behind the `crypto/hash` interface: a byte digest and a hash to the + * native field. + * + * @dev Notation: + * - `H(...)`: `digest`, the SHA-256 of its argument as a `Bytes<32>`. + * - `q`: the order of `Fq`, Compact's native `Field`. The RFC's `p`. + * - `msg`: the message to hash (RFC 9380). + * - `DST`: the domain separation tag (RFC 9380). + * - `len_in_bytes`: how many uniform bytes are requested (RFC 9380). + * - `uniform_bytes`: the output of `expand_message` (RFC 9380). + * - `tv`: the slice of `uniform_bytes` that becomes one element (RFC 9380). + * - `e_0`: the resulting field element (RFC 9380). + * - `b_i = H(msg, DST, i)`: expander block `i`. + * - `LEOS2IP`: the little-endian `OS2IP`, as in `crypto/curves/bls12_381/Fq`. + * + * @dev Interface: + * Every `crypto/hash` module exports `digest` and `hashToField` with these + * signatures, so a consumer changes hash by changing its import. `IHasher` + * holds the signatures as a contract type, and `MockSha256` implements it. + * + * @dev Derivations: + * `H` hashes its argument in Compact's encoding. At `T = Bytes<32>` that is + * the raw bytes in field order, so `digest(msg) = SHA-256(msg)` and + * `b_i = SHA-256(msg || DST || i)` with `i` as 32 little-endian bytes. Other + * types encode differently: call the exported circuits instead of + * reimplementing them. + * + * @dev Compatibility: + * Built on `persistentHash`, so outputs survive a platform upgrade. The hash, + * the preimage layout and the reduction are the wire format: changing any of + * them changes every derived value. + * + * @notice Security: + * - Uniformity: with `H` modelled as a random oracle, `hashToField` is within + * `2^-257` of uniform over the field. + * - Domain separation: outputs under distinct `DST`s are independent. A + * consumer picks one `DST` per purpose and never reuses it across protocols. + * + * @dev Performance: + * Cost is SHA-256 compressions, one per 64 bytes of padded preimage, so it + * grows with `T`. The `@constraints` lines are measured at `T = Bytes<32>`. + */ +module Sha256 { + import CompactStandardLibrary; + import "../curves/bls12_381/Fq" prefix Fq_; + + /** + * @description Preimage of expander block `b_i`. + * @type {T} msg - The message being hashed. + * @type {Bytes<32>} DST - The domain separation tag. + * @type {Bytes<32>} i - The block counter. + */ + struct ExpandPreimage { + msg: T; + DST: Bytes<32>; + i: Bytes<32>; + } + + /** + * @description RFC 9380 §5.2 `hash_to_field` with `count = 1`, `m = 1`: maps + * `msg` to one `Field` element, domain-separated by `DST`. + * + * @constraints k=14, rows=8326 + * + * @dev Steps, numbered as in the RFC: + * - 1: `len_in_bytes = count * m * L = 64`. A length is a type here, so + * `Fq.UniformBytes` carries it. + * - 2: `uniform_bytes = expand_message(msg, DST, len_in_bytes)`. + * - 3 to 6: with `count = 1` and `m = 1`, `tv` is all of `uniform_bytes`. + * - 7: `e_0 = LEOS2IP(tv) mod q`, in `Fq.fromUniformBytes`. + * - 8 to 9: return `e_0`. + * + * @dev Deviations from RFC 9380: + * - `L = 64` against the RFC's 48, so `k = 257` against 128. + * - `LEOS2IP` in place of the big-endian `OS2IP`. + * - `expand_message` is `expandMessage` below, in place of + * `expand_message_xmd`. + * + * @param {T} msg - The message to hash. + * @param {Bytes<32>} DST - The domain separation tag. + * @return {Field} - `e_0`, the field element. + */ + export pure circuit hashToField(msg: T, DST: Bytes<32>): Field { + // Steps 1 and 2. + const uniform_bytes = expandMessage(msg, DST); + // Steps 3 to 6. + const tv = uniform_bytes; + // Steps 7 to 9. + return Fq_fromUniformBytes(tv); + } + + /** + * @description A counter expander: `uniform_bytes = b_0 || b_1`. + * + * @dev Deviations from `expand_message_xmd`: + * - The blocks are independent, `b_i = H(msg, DST, i)`. The RFC chains them + * from a seed block. + * - No length prefixes. Every preimage field has a fixed width, so the + * encoding is unambiguous without them. + * + * @param {T} msg - The message to hash. + * @param {Bytes<32>} DST - The domain separation tag. + * @return {Fq_UniformBytes} - `uniform_bytes`. + */ + pure circuit expandMessage(msg: T, DST: Bytes<32>): Fq_UniformBytes { + return Fq_UniformBytes { + low: expandBlock(msg, DST, 0 as Field), + high: expandBlock(msg, DST, 1 as Field) + }; + } + + /** + * @description `b_i = H(msg, DST, i)`: one expander block. + * + * @param {T} msg - The message to hash. + * @param {Bytes<32>} DST - The domain separation tag. + * @param {Field} i - The block counter. + * @return {Bytes<32>} - The block. + */ + pure circuit expandBlock(msg: T, DST: Bytes<32>, i: Field): Bytes<32> { + return digest>( + ExpandPreimage { msg: msg, DST: DST, i: i as Bytes<32> } + ); + } + + /** + * @description `H(value)`: the SHA-256 digest of `value`. + * + * @constraints k=13, rows=2006 + * + * @param {T} value - The value to hash. + * @return {Bytes<32>} - The digest. + */ + export pure circuit digest(value: T): Bytes<32> { + return persistentHash(value); + } +} diff --git a/contracts/src/crypto/test/mocks/MockSha256.compact b/contracts/src/crypto/test/mocks/MockSha256.compact new file mode 100644 index 00000000..5bfdfe21 --- /dev/null +++ b/contracts/src/crypto/test/mocks/MockSha256.compact @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// Exposes the Sha256 module's pure circuits so they can be driven from +// off-chain tests. DO NOT deploy or use this contract in any production +// application. + +pragma language_version >= 0.26.0; + +import CompactStandardLibrary; +import "../../hash/IHasher"; +import "../../hash/Sha256" prefix Sha256_; + +// Fails compilation if Sha256's signatures drift from the shared interface. +contract implements Hasher; + +export pure circuit hashToField(msg: Bytes<32>, DST: Bytes<32>): Field { + return Sha256_hashToField>(msg, DST); +} + +export pure circuit digest(value: Bytes<32>): Bytes<32> { + return Sha256_digest>(value); +} From 47d96a72c64bff7b78dd269bd615eeb963f4c36b Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Thu, 17 Sep 2026 16:27:00 +0200 Subject: [PATCH 06/17] refactor(crypto): route EcdhMask hashing through crypto/hash EcdhMask no longer calls persistentHash or degradeToTransient directly. kdf and pointDigest go through Sha256.digest and Fq.truncatedLEOS2IP, and their outputs are unchanged. fieldKdf now derives through Sha256.hashToField, which reduces all 64 bytes of the two blocks where the inline version reduced 62. Its output changes, the uniformity bound tightens from 2^-241 to 2^-257, and the three Field pad circuits grow by 580 rows at the same k. The fieldKdf tests still pin the old 31-byte halves through MockEcdhMask.fieldKdfHalves and are expected to fail until they are updated. Refs: #735 --- contracts/src/crypto/EcdhMask.compact | 86 +++++++++------------------ 1 file changed, 27 insertions(+), 59 deletions(-) diff --git a/contracts/src/crypto/EcdhMask.compact b/contracts/src/crypto/EcdhMask.compact index b68c1a51..5b6e3048 100644 --- a/contracts/src/crypto/EcdhMask.compact +++ b/contracts/src/crypto/EcdhMask.compact @@ -67,11 +67,11 @@ pragma language_version >= 0.26.0; * * @dev Security. This is hashed ElGamal (ECIES without a MAC) over Jubjub and * inherits its guarantees: IND-CPA under the Oracle Diffie-Hellman assumption in - * the random-oracle model (the `persistentHash` KDF keyed by the secret point + * the random-oracle model (the `Sha256.digest` KDF keyed by the secret point * `S`). It is NOT IND-CCA: the additive pad is malleable (see Integrity), which * is acceptable here only because the on-chain ciphertext is bound in-circuit to * the value it commits to elsewhere. Hiding carries a large margin: `kdf` returns - * a `degradeToTransient` output in `[0, 2^248)` while `value < 2^128`, so even + * a `Fq.truncatedLEOS2IP` output in `[0, 2^248)` while `value < 2^128`, so even * treating the KDF output as uniform the value is hidden with about `2^-120` * statistical slack, leaving only the assumption that the hash of the DH secret * is PRF-like. Confidentiality relies on the runtime constraining every @@ -82,15 +82,13 @@ pragma language_version >= 0.26.0; * folding it in buys nothing. * * @dev Two KDFs, two output ranges. `kdf` derives into `[0, 2^248)`: one - * `degradeToTransient` output, the SEC 1 KDF shape, for keys, nonces, and the + * `Fq.truncatedLEOS2IP` output, the SEC 1 KDF shape, for keys, nonces, and the * `Uint<128>` mask of `encrypt` (hidden with about `2^-120` slack). `fieldKdf` - * derives uniformly into the field, the hash-to-field shape: two 248-bit halves - * combined as `k1 + k2 * 2^248 mod p`, each half hashing - * `[pointHash, domain, index]` with `index` the 32-byte encoding of `0` and `1`. - * The sum ranges over about `2^496` values before reduction, which puts the - * output within `2^-240` of uniform, so `ct = m + fieldKdf(S, domain)` hides any - * `m` with no precondition on its range or entropy. Mask a `Field` with - * `fieldKdf`, never with `kdf`; neither authenticates (see Integrity). + * derives uniformly into the field through `crypto/hash/Sha256.hashToField` + * (RFC 9380 `hash_to_field`), within `2^-257` of uniform, so + * `ct = m + fieldKdf(S, domain)` hides any `m` with no precondition on its range + * or entropy. Mask a `Field` with `fieldKdf`, never with `kdf`; neither + * authenticates (see Integrity). * * @dev Multi-field delivery. A consumer delivering several `Field`s to one * recipient calls `deriveShared` once and adds `fieldKdf(sShared, tag_i)` per @@ -115,6 +113,8 @@ pragma language_version >= 0.26.0; module EcdhMask { import CompactStandardLibrary; import "./Ecdh" prefix Ecdh_; + import "./curves/bls12_381/Fq" prefix Fq_; + import "./hash/Sha256" prefix Sha256_; /** * @description An ECDH one-time-pad ciphertext: the ephemeral public key and @@ -159,12 +159,13 @@ module EcdhMask { * @description Derives the field mask from the ECDH shared secret point, * domain-separated by a caller-supplied `domain` tag. Hashes the point to * `Bytes<32>`, then re-hashes it with the domain tag and truncates into the - * field via `degradeToTransient`. The consumer chooses `domain` (so this + * field via `Fq.truncatedLEOS2IP`. The consumer chooses `domain` (so this * module is not tied to any one protocol) and MUST use the same value on * encrypt and decrypt. * - * @dev Uses `persistentHash` deliberately: the recipient reproduces this mask - * to decrypt, possibly across a platform upgrade, so it must be upgrade-stable. + * @dev Uses `crypto/hash/Sha256` deliberately: the recipient reproduces this + * mask to decrypt, possibly across a platform upgrade, so it must be + * upgrade-stable. * * @constraints k=13, rows=7917 * @@ -174,8 +175,8 @@ module EcdhMask { * @return The field mask. */ export pure circuit kdf(sShared: JubjubPoint, domain: Bytes<32>): Field { - return degradeToTransient( - persistentHash>>([pointDigest(sShared), domain]) + return Fq_truncatedLEOS2IP( + Sha256_digest>>([pointDigest(sShared), domain]) ); } @@ -184,13 +185,13 @@ module EcdhMask { * that opens the preimage of both KDFs. Shared by `kdf` and `fieldKdf` so the * point is hashed once per `fieldKdf` call and both KDFs agree on its encoding. * - * @dev Uses `persistentHash` for the same reason as `kdf`. + * @dev Uses `crypto/hash/Sha256` for the same reason as `kdf`. * * @param sShared - The ECDH shared secret point. * @return The point digest. */ pure circuit pointDigest(sShared: JubjubPoint): Bytes<32> { - return persistentHash(sShared); + return Sha256_digest(sShared); } /** @@ -221,7 +222,7 @@ module EcdhMask { * @notice `e` MUST be fresh per call and secret (see `crypto/Ecdh`). Reuse * leaks the difference of the two plaintexts. * - * @constraints k=14, rows=13024 + * @constraints k=14, rows=13604 * * Requirements: * @@ -248,21 +249,16 @@ module EcdhMask { /** * @description Derives a mask uniform over the whole field from the ECDH - * shared secret point, domain-separated by a caller-supplied `domain` tag. - * Combines two 248-bit halves as `k1 + k2 * 2^248 mod p`, so + * shared secret point, domain-separated by a caller-supplied `domain` tag, so * `m + fieldKdf(S, domain)` hides any `m` with no bound on its range. Use * this, not `kdf`, to mask a `Field`. * - * @dev The halves are two separate random-oracle queries, each hashing - * `[pointHash, domain, index]` with `index` the 32-byte encoding of `0` and - * `1`. The three-element preimage also separates both from `kdf`'s - * two-element one, so a consumer may use `kdf` and `fieldKdf` under the same - * `(S, domain)` without one revealing the other. + * @dev The KDF of the scheme in the SEC 1 sense: `Z = pointDigest(sShared)`, + * `SharedInfo = domain`. `hashToField` hashes a three-element preimage against + * `kdf`'s two-element one, so a consumer may use `kdf` and `fieldKdf` under the + * same `(S, domain)` without one revealing the other. * - * @dev Uses `persistentHash` for the same reason as `kdf`: the recipient - * reproduces the mask off-chain, possibly across a platform upgrade. - * - * @constraints k=14, rows=11704 + * @constraints k=14, rows=12284 * * @param sShared - The ECDH shared secret point. * @param domain - The consumer's domain-separation tag. Pairwise distinct per @@ -271,35 +267,7 @@ module EcdhMask { * @return The field mask. */ export pure circuit fieldKdf(sShared: JubjubPoint, domain: Bytes<32>): Field { - // 2^248 as a decimal literal; Compact has no exponentiation operator. - const twoPow248 = - 452312848583266388373324160190187140051835877600158453279131187530910662656 as Field; - const pointHash = pointDigest(sShared); - const lowHalf = kdfHalf(pointHash, domain, 0 as Field as Bytes<32>); - const highHalf = kdfHalf(pointHash, domain, 1 as Field as Bytes<32>); - return lowHalf + highHalf * twoPow248; - } - - /** - * @description Derives one 248-bit half of a `fieldKdf` mask from the point - * digest, the domain tag, and a half index. `fieldKdf` calls it at `index` 0 - * and 1 and combines the results as `k1 + k2 * 2^248`. - * - * @dev `index` separates the two halves from each other, and the three-element - * preimage separates both from `kdf`'s two-element one, so a consumer may use - * `kdf` and `fieldKdf` under the same `(sShared, domain)`. - * - * @dev Uses `persistentHash` for the same reason as `kdf`. - * - * @param pointHash - The `pointDigest` of the shared secret point. - * @param domain - The consumer's domain-separation tag. - * @param index - The half index, `0` or `1`, as a 32-byte encoding. - * @return One 248-bit half of the field mask. - */ - pure circuit kdfHalf(pointHash: Bytes<32>, domain: Bytes<32>, index: Bytes<32>): Field { - return degradeToTransient( - persistentHash>>([pointHash, domain, index]) - ); + return Sha256_hashToField>(pointDigest(sShared), domain); } /** @@ -310,7 +278,7 @@ module EcdhMask { * element rather than aborting, so the circuit is not a key-correctness oracle. * Nothing here authenticates the ciphertext. * - * @constraints k=14, rows=12102 + * @constraints k=14, rows=12682 * * @param ciphertext - The ciphertext to decrypt. * @param ekScalar - The recipient's secret scalar (`crypto/ElGamal`'s From 23330479b8668243f49cf9c68ddee56c3762c70f Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 12:05:20 +0200 Subject: [PATCH 07/17] test(crypto): move MockSha256 next to the hash module Mocks sit beside the module they expose, as the Fq mock already does under curves/bls12_381/test. Only the two import paths change. --- contracts/src/crypto/{ => hash}/test/mocks/MockSha256.compact | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename contracts/src/crypto/{ => hash}/test/mocks/MockSha256.compact (89%) diff --git a/contracts/src/crypto/test/mocks/MockSha256.compact b/contracts/src/crypto/hash/test/mocks/MockSha256.compact similarity index 89% rename from contracts/src/crypto/test/mocks/MockSha256.compact rename to contracts/src/crypto/hash/test/mocks/MockSha256.compact index 5bfdfe21..4cbbd220 100644 --- a/contracts/src/crypto/test/mocks/MockSha256.compact +++ b/contracts/src/crypto/hash/test/mocks/MockSha256.compact @@ -8,8 +8,8 @@ pragma language_version >= 0.26.0; import CompactStandardLibrary; -import "../../hash/IHasher"; -import "../../hash/Sha256" prefix Sha256_; +import "../../IHasher"; +import "../../Sha256" prefix Sha256_; // Fails compilation if Sha256's signatures drift from the shared interface. contract implements Hasher; From 855f546ab9057f29202445dbfd4d095f3675c2b0 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 12:05:48 +0200 Subject: [PATCH 08/17] refactor(crypto): name the Fq digit split fromUniformBytes and leos2ipModQ both evaluate a two-digit radix representation in the field. fromRadixDigits now holds that one line with its HAC reference, so the mod q is visible in one place instead of implied by two identical comments. No behaviour change; rows unchanged. Docs: verb-first titles with the formula, backend notes on the truncated pair, every notation term linked to its source, sage checks on the three literals, and @constraints re-measured through MockFq. The previous values did not come from the mock. --- .../src/crypto/curves/bls12_381/Fq.compact | 235 +++++++++++++----- 1 file changed, 173 insertions(+), 62 deletions(-) diff --git a/contracts/src/crypto/curves/bls12_381/Fq.compact b/contracts/src/crypto/curves/bls12_381/Fq.compact index e2e85bd8..360020b1 100644 --- a/contracts/src/crypto/curves/bls12_381/Fq.compact +++ b/contracts/src/crypto/curves/bls12_381/Fq.compact @@ -7,7 +7,13 @@ pragma language_version >= 0.26.0; * @module Fq * @description * Utilities for `Fq`, the scalar field of BLS12-381 and Compact's native - * `Field`. + * `Field`. The conversions are the RFC 8017 §4 primitives in the Zcash + * little-endian form. `fromUniformBytes` is the field half of RFC 9380 + * `hash_to_field`. + * + * @see https://www.rfc-editor.org/rfc/rfc8017#section-4 RFC 8017 §4, data conversion primitives + * @see https://zips.z.cash/protocol/protocol.pdf#endian Zcash protocol spec §5.1, endianness + * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.2 RFC 9380 §5.2, `hash_to_field` * * @dev The field: * - Order: `q`, a 255-bit prime. @@ -15,53 +21,81 @@ pragma language_version >= 0.26.0; * - In Compact: the native `Field`. * - On Jubjub: the base field. Jubjub's scalar field is `JubjubScalar`. * - Name: as in Midnight's curves library. Other libraries call it `Fr`. + * - Check: + * ``` + * sage> q = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001 + * sage> is_prime(q), q.nbits(), 2^248 < q < 2^256 + * (True, 255, True) + * ``` * * @dev Notation: - * - Octet string: a byte string. - * - `OS2IP`: Octet-String-to-Integer primitive, big-endian (RFC 8017 §4). - * - `I2OSP`: Integer-to-Octet-String primitive, big-endian (RFC 8017 §4). - * - `LEOS2IP_l(S)`: little-endian `OS2IP` on `l` bits (Zcash spec §5.1). - * - `I2LEOSP_l(x)`: little-endian `I2OSP` on `l` bits (Zcash spec §5.1). - * - `substr(S, 0, n)`: the first `n` bytes of `S` (RFC 9380). - * - `tv`: the uniform bytes that become one field element (RFC 9380). - * - `e_0`: the resulting field element (RFC 9380). - * - * @dev Conversions: - * `truncatedLEOS2IP` and `truncatedI2LEOSP` are the octet-string-to-field-element - * and field-element-to-octet-string conversions of SEC 1 §2.3, little-endian - * and truncated to 248 bits. The standard library names them - * `degradeToTransient` and `upgradeFromTransient`. + * - `OS`: Octet string which is a byte string. + * https://www.rfc-editor.org/rfc/rfc8017#section-4 + * - `I`: Integer, nonnegative. + * https://www.rfc-editor.org/rfc/rfc8017#section-4 + * - `P`: Primitive, a conversion routine. `2` reads as "to". + * https://www.rfc-editor.org/rfc/rfc8017#section-4 + * - `LE`: Little-endian, the Zcash prefix on a primitive. + * https://zips.z.cash/protocol/protocol.pdf#endian + * - `OS2IP`: Octet-String-to-Integer primitive, big-endian. + * https://www.rfc-editor.org/rfc/rfc8017#section-4.2 + * - `I2OSP`: Integer-to-Octet-String primitive, big-endian. + * https://www.rfc-editor.org/rfc/rfc8017#section-4.1 + * - `LEOS2IP_l(S)`: little-endian `OS2IP` on `l` bits. + * https://zips.z.cash/protocol/protocol.pdf#endian + * - `I2LEOSP_l(x)`: little-endian `I2OSP` on `l` bits. + * https://zips.z.cash/protocol/protocol.pdf#endian + * - `substr(S, 0, n)`: the first `n` bytes of `S`. + * https://www.rfc-editor.org/rfc/rfc9380#section-4 + * - `tv`: the uniform bytes that become one field element. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 + * - `e_0`: the resulting field element. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 * - * @dev Why 248 bits: - * `2^248 < q < 2^256`, so 31 bytes always name a field element and 32 bytes do - * not. Both conversions work on the 31 bytes that fit. + * @dev Circuits: + * Two ways to fit an octet string into `Fq`, since `2^248 < q < 2^256`: + * - Truncate: keep the 31 bytes that always fit and drop byte 31. The integer + * is below `2^248 < q`, so no reduction happens, and the dropped byte is + * lost. `truncatedLEOS2IP` and `truncatedI2LEOSP` (`LEOS2IP_248`, + * `I2LEOSP_248`), the standard library's `degradeToTransient` and + * `upgradeFromTransient`. + * - Reduce: read every byte and take the integer mod `q`. Nothing of the input + * is dropped, but the value wraps, and it is uniform only when the input + * has many more bits than `q`. `fromUniformBytes` (`LEOS2IP_512(tv) mod q`), + * the field half of RFC 9380 §5.2 `hash_to_field`. The hash half, + * `expand_message`, lives in each `crypto/hash` module. Compact has no + * integer wider than `q` and no `%`, so the reduction is `Field` `+` and + * `*` on digits that each fit. + * - Round trips of the truncated pair: `truncatedLEOS2IP(truncatedI2LEOSP(x))` + * is `x mod 2^248`, and `truncatedI2LEOSP(truncatedLEOS2IP(S))` is `S` with + * byte 31 zeroed. Both are the identity exactly on 248-bit values. * * @notice Security: * - Truncation is silent: neither circuit asserts that its input fits. - * - `truncatedLEOS2IP` ignores byte 31, so two strings that differ only there - * convert to the same element. - * - `truncatedI2LEOSP` drops bits 248 and up, so two elements equal mod - * `2^248` convert to the same string. - * - A converted SHA-256 digest keeps 248 of its 256 bits: `2^124` collision - * resistance against `2^128`. - * - * @dev Round trips: - * - `truncatedLEOS2IP(truncatedI2LEOSP(x)) = x mod 2^248`. - * - `truncatedI2LEOSP(truncatedLEOS2IP(S))` is `S` with byte 31 zeroed. - * - Both are the identity exactly on 248-bit values. - * - * @dev Hashing to the field: - * `fromUniformBytes` is the field half of RFC 9380 §5.2 `hash_to_field`. The - * hash half, `expand_message`, lives in each `crypto/hash` module. + * - Byte 31 is ignored: two strings that differ only there convert to the + * same element under `truncatedLEOS2IP`. + * - Bits 248 and up are dropped: two elements equal mod `2^248` convert to + * the same string under `truncatedI2LEOSP`. + * - Collision resistance falls to `2^124`: a converted SHA-256 digest keeps + * 248 of its 256 bits, against `2^128` for the digest itself. */ module Fq { import CompactStandardLibrary; /** - * @description 64 uniform bytes, as two 32-byte halves. Read little-endian, - * `low` comes first. - * @type {Bytes<32>} low - Bytes 0 to 31. - * @type {Bytes<32>} high - Bytes 32 to 63. + * @description Holds 64 uniform bytes as two 32-byte halves (`tv = low || high`). + * `tv` is the RFC 9380 `hash_to_field` input to step 7, `L = 64`. The split + * is `Fq::from_uniform_bytes`'s `split_at(32)` on its `[u8; 64]`. + * + * @dev Formula: + * `low = substr(tv, 0, 32)`, `high = substr(tv, 32, 32)`. Read + * little-endian, `low` holds the low 256 bits of `LEOS2IP_512(tv)`. + * + * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.2 `hash_to_field`, steps 5 and 6 + * @see https://github.com/midnightntwrk/midnight-zk/blob/main/curves/src/bls12_381/fq.rs `Fq::from_uniform_bytes` + * + * @type {Bytes<32>} low - Bytes 0 to 31 of `tv`. + * @type {Bytes<32>} high - Bytes 32 to 63 of `tv`. */ export struct UniformBytes { low: Bytes<32>; @@ -69,11 +103,12 @@ module Fq { } /** - * @description Converts 64 uniform bytes to a uniform field element. Reads - * `tv` as one little-endian integer and reduces it mod `q`. The last step of - * hashing to the field. + * @description Converts 64 uniform bytes to a field element (`LEOS2IP_512(tv) mod q`). + * Reads `tv` as one little-endian integer and reduces it mod `q`, so a + * uniform `tv` gives a uniform element. The last step of hashing to the + * field. * - * @constraints k=10, rows=797 + * @constraints k=11, rows=2006 * * @dev Formula: * `LEOS2IP_512(tv) mod q`. Step 7 of RFC 9380 §5.2 `hash_to_field`, with @@ -83,20 +118,54 @@ module Fq { * 512 bits against a 255-bit `q` is the RFC's `k = 257`: for a uniform `tv` * the result is within `2^-257` of uniform over the field. * - * @dev Same construction elsewhere: - * `Fq::from_uniform_bytes` in Midnight's curves library, and the shape of - * `ToScalar` in the Zcash protocol specification. + * @dev Reference: + * `midnight_curves::Fq`'s `ff::FromUniformBytes<64>` impl. The trait asks + * for the element congruent to the little-endian integer, with + * `N * 8 >= NUM_BITS + 128`. The impl splits the 64 bytes at 32 into digits + * `a0`, `a1` and returns `a0 + a1 * 2^256` in the field. Same digits here, + * with `R = 2^256 mod q` as the weight of `a1`. + * + * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.2 `hash_to_field`, step 7 + * @see https://zips.z.cash/protocol/protocol.pdf#endian `LEOS2IP` + * @see https://docs.rs/ff/latest/ff/trait.FromUniformBytes.html `ff::FromUniformBytes` + * @see https://github.com/midnightntwrk/midnight-zk/blob/main/curves/src/bls12_381/fq.rs `Fq::from_uniform_bytes` * * @param {UniformBytes} tv - The 64 uniform bytes. * @return {Field} - `e_0`, the field element. */ export pure circuit fromUniformBytes(tv: UniformBytes): Field { - return reducedLEOS2IP(tv.low) + reducedLEOS2IP(tv.high) * R(); + // Radix 2^256, passed as R = 2^256 mod q. + return fromRadixDigits(leos2ipModQ(tv.low), leos2ipModQ(tv.high), R()); } /** - * @description `LEOS2IP_256(S) mod q`: all 32 bytes of `S`, read - * little-endian and reduced into the field. + * @description Evaluates a two-digit radix `b` representation in the field (`a_0 + a_1 * b mod q`). + * `b` may be given as its residue mod `q`, since mod distributes over `+` + * and `*`. + * + * @dev Formula: + * HAC Fact 14.1 with `n = 1`, then reduced mod `q`. `Field` `+` and `*` + * wrap, so the reduction is the arithmetic itself. + * + * @see https://cacr.uwaterloo.ca/hac/about/chap14.pdf#page=3 HAC §14.2.1, Fact 14.1, radix b representation, p. 592 + * + * @param {Field} a0 - Digit 0. + * @param {Field} a1 - Digit 1. + * @param {Field} b - The radix, or `b mod q` when `b >= q`. + * @return {Field} - `a_0 + a_1 * b mod q`. + */ + pure circuit fromRadixDigits(a0: Field, a1: Field, b: Field): Field { + return a0 + a1 * b; + } + + /** + * @description Converts 32 bytes to a field element (`LEOS2IP_256(S) mod q`). + * `Bytes<32>` does not fit `Field`, so `S` is read as two digits in base + * `2^248`: the low 31 bytes and byte 31. + * + * @dev Formula: + * `LEOS2IP_248(substr(S, 0, 31)) + LEOS2IP_8(substr(S, 31, 1)) * 2^248 mod q`, + * the same digit split as `fromUniformBytes` one level down. * * @dev Not uniform: * 256 bits is one bit more than `q`, so a uniform `S` gives a biased @@ -105,21 +174,33 @@ module Fq { * @param {Bytes<32>} S - The byte string. * @return {Field} - The integer mod `q`. */ - pure circuit reducedLEOS2IP(S: Bytes<32>): Field { - // Byte 31 is the part `truncatedLEOS2IP` drops. - return truncatedLEOS2IP(S) + (slice<1>(S, 31) as Field) * TWO_POW_248(); + pure circuit leos2ipModQ(S: Bytes<32>): Field { + // Radix 2^248. Digit 0 is bytes 0 to 30, digit 1 is byte 31. + return fromRadixDigits( + truncatedLEOS2IP(S), + slice<1>(S, 31) as Field, + TWO_POW_248() + ); } /** - * @description Converts a byte string to a field element. Reads the first - * 31 bytes of `S` as a little-endian integer and ignores byte 31. The usual - * way to bring a hash digest into field arithmetic. + * @description Converts an octet string to an integer, truncated (`LEOS2IP_248(substr(S, 0, 31))`). + * Reads the first 31 bytes of `S` little-endian and ignores byte 31. The + * result is below `2^248 < q`, so it is a field element. The usual way to + * bring a hash digest into field arithmetic. * - * @constraints k=7, rows=119 + * @constraints k=9, rows=302 * * @dev Formula: * `LEOS2IP_248(substr(S, 0, 31))`. * + * @dev Backends: + * - Rust ledger: selects the field element holding bytes 0 to 30. + * - ZKIR: one `copy` of the 31-byte limb of the `Bytes<32>`. No arithmetic. + * + * @see https://www.rfc-editor.org/rfc/rfc8017#section-4.2 `OS2IP` + * @see https://zips.z.cash/protocol/protocol.pdf#endian `LEOS2IP` + * * @param {Bytes<32>} S - The byte string. Byte 31 is ignored. * @return {Field} - A value below `2^248`. */ @@ -128,8 +209,14 @@ module Fq { } /** - * @description The constant `2^248 = 2^(8 * 31)`: one past the largest - * 31-byte integer. 31 is the most whole bytes that always fit in `Fq`. + * @description Returns `2^248`, one past the largest 31-byte integer (`2^(8 * 31)`). + * 31 is the most whole bytes that always fit in `Fq`. + * + * @dev Value: + * ``` + * sage> hex(2^248) + * 0x100000000000000000000000000000000000000000000000000000000000000 + * ``` * * @return {Field} - `2^248`. */ @@ -139,14 +226,30 @@ module Fq { } /** - * @description The constant `R = 2^256 mod q`: the weight of the high half - * of a 64-byte little-endian integer. + * @description Returns the Montgomery radix (`R = 2^256 mod q`). + * The weight of the high half of a 64-byte little-endian integer. * * @dev Name: * `R` is the Montgomery radix of the Handbook of Applied Cryptography * §14.3.2, `R = b^n`. Midnight's curves library holds an `Fq` element as * four 64-bit limbs, so `b = 2^64`, `n = 4` and `R = 2^256`. * + * @dev Value: + * The library's own check, same literal: + * ``` + * sage> mod(2^256, + * 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001) + * sage> 0x1824b159acc5056f998c4fefecbc4ff55884b7fa0003480200000001fffffffe + * ``` + * + * @dev Encoding: + * The library stores every element pre-multiplied by `R`, so its `R` + * constant denotes the element `1`. Compact stores plain values, so the + * same digits here denote `2^256 mod q`. + * + * @see https://cacr.uwaterloo.ca/hac/about/chap14.pdf#page=11 HAC §14.3.2, Montgomery reduction, p. 600 + * @see https://github.com/midnightntwrk/midnight-zk/blob/695351f1cdb3909affd1c89fef0a5eb3e9fa3ab7/curves/src/bls12_381/fq.rs#L90 `R`, the literal + * * @return {Field} - `2^256 mod q`. */ pure circuit R(): Field { @@ -155,15 +258,23 @@ module Fq { } /** - * @description Converts a field element to a byte string. Writes the low - * 248 bits of `x` as 31 little-endian bytes, followed by a zero byte. The - * usual way to pass a field element where a `Bytes<32>` is expected. + * @description Converts an integer to an octet string, truncated (`I2LEOSP_256(x mod 2^248)`). + * Writes the low 248 bits of `x` as 31 little-endian bytes, then a zero + * byte. The usual way to pass a field element where a `Bytes<32>` is + * expected. * - * @constraints k=9, rows=200 + * @constraints k=9, rows=406 * * @dev Formula: * `I2LEOSP_256(x mod 2^248)`. * + * @dev Backends: + * - Rust ledger: copies the low 31 bytes of `x`'s encoding, then a zero byte. + * - ZKIR: `div_mod_power_of_two` by `2^248`, then the top byte set to 0. + * + * @see https://www.rfc-editor.org/rfc/rfc8017#section-4.1 `I2OSP` + * @see https://zips.z.cash/protocol/protocol.pdf#endian `I2LEOSP` + * * @param {Field} x - The field element. Bits 248 and up are dropped. * @return {Bytes<32>} - The byte string. Byte 31 is zero. */ From 4bb42af41875553259d5af580c6424b5d6629de7 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 12:05:48 +0200 Subject: [PATCH 09/17] docs(crypto): reference the RFC 9380 steps in Sha256 Each circuit names its hash_to_field step and links the FIPS 180-4 and RFC 9380 sections it follows. Titles are verb-first with the formula. --- contracts/src/crypto/hash/Sha256.compact | 53 +++++++++++++++++------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/contracts/src/crypto/hash/Sha256.compact b/contracts/src/crypto/hash/Sha256.compact index 49446919..abf944b2 100644 --- a/contracts/src/crypto/hash/Sha256.compact +++ b/contracts/src/crypto/hash/Sha256.compact @@ -7,19 +7,32 @@ pragma language_version >= 0.26.0; * @module Sha256 * @description * SHA-256 behind the `crypto/hash` interface: a byte digest and a hash to the - * native field. + * native field. The hash to the field follows RFC 9380 `hash_to_field`. + * + * @see https://doi.org/10.6028/NIST.FIPS.180-4 FIPS 180-4, SHA-256 + * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.2 RFC 9380 §5.2, `hash_to_field` + * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.3.1 RFC 9380 §5.3.1, `expand_message_xmd` * * @dev Notation: * - `H(...)`: `digest`, the SHA-256 of its argument as a `Bytes<32>`. + * https://doi.org/10.6028/NIST.FIPS.180-4 * - `q`: the order of `Fq`, Compact's native `Field`. The RFC's `p`. - * - `msg`: the message to hash (RFC 9380). - * - `DST`: the domain separation tag (RFC 9380). - * - `len_in_bytes`: how many uniform bytes are requested (RFC 9380). - * - `uniform_bytes`: the output of `expand_message` (RFC 9380). - * - `tv`: the slice of `uniform_bytes` that becomes one element (RFC 9380). - * - `e_0`: the resulting field element (RFC 9380). - * - `b_i = H(msg, DST, i)`: expander block `i`. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 + * - `msg`: the message to hash. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 + * - `DST`: the domain separation tag. + * https://www.rfc-editor.org/rfc/rfc9380#section-2.2.5 + * - `len_in_bytes`: how many uniform bytes are requested. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.3 + * - `uniform_bytes`: the output of `expand_message`. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.3 + * - `tv`: the slice of `uniform_bytes` that becomes one element. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 + * - `e_0`: the resulting field element. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 + * - `b_i = H(msg, DST, i)`: expander block `i`, this module's `expandBlock`. * - `LEOS2IP`: the little-endian `OS2IP`, as in `crypto/curves/bls12_381/Fq`. + * https://zips.z.cash/protocol/protocol.pdf#endian * * @dev Interface: * Every `crypto/hash` module exports `digest` and `hashToField` with these @@ -39,8 +52,8 @@ pragma language_version >= 0.26.0; * them changes every derived value. * * @notice Security: - * - Uniformity: with `H` modelled as a random oracle, `hashToField` is within - * `2^-257` of uniform over the field. + * - Uniformity: with `H` modelled as a random oracle, `hashToField` is + * within `2^-257` of uniform over the field. * - Domain separation: outputs under distinct `DST`s are independent. A * consumer picks one `DST` per purpose and never reuses it across protocols. * @@ -53,7 +66,7 @@ module Sha256 { import "../curves/bls12_381/Fq" prefix Fq_; /** - * @description Preimage of expander block `b_i`. + * @description Holds the preimage of expander block `b_i`. * @type {T} msg - The message being hashed. * @type {Bytes<32>} DST - The domain separation tag. * @type {Bytes<32>} i - The block counter. @@ -65,8 +78,8 @@ module Sha256 { } /** - * @description RFC 9380 §5.2 `hash_to_field` with `count = 1`, `m = 1`: maps - * `msg` to one `Field` element, domain-separated by `DST`. + * @description Hashes a message to one field element (`hash_to_field`, `count = 1`, `m = 1`). + * RFC 9380 §5.2, with `msg` domain-separated by `DST`. * * @constraints k=14, rows=8326 * @@ -84,6 +97,8 @@ module Sha256 { * - `expand_message` is `expandMessage` below, in place of * `expand_message_xmd`. * + * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.2 `hash_to_field` + * * @param {T} msg - The message to hash. * @param {Bytes<32>} DST - The domain separation tag. * @return {Field} - `e_0`, the field element. @@ -98,7 +113,8 @@ module Sha256 { } /** - * @description A counter expander: `uniform_bytes = b_0 || b_1`. + * @description Expands a message to 64 uniform bytes (`uniform_bytes = b_0 || b_1`). + * A counter expander in place of `expand_message_xmd`. * * @dev Deviations from `expand_message_xmd`: * - The blocks are independent, `b_i = H(msg, DST, i)`. The RFC chains them @@ -106,6 +122,8 @@ module Sha256 { * - No length prefixes. Every preimage field has a fixed width, so the * encoding is unambiguous without them. * + * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.3.1 `expand_message_xmd` + * * @param {T} msg - The message to hash. * @param {Bytes<32>} DST - The domain separation tag. * @return {Fq_UniformBytes} - `uniform_bytes`. @@ -118,7 +136,7 @@ module Sha256 { } /** - * @description `b_i = H(msg, DST, i)`: one expander block. + * @description Computes one expander block (`b_i = H(msg, DST, i)`). * * @param {T} msg - The message to hash. * @param {Bytes<32>} DST - The domain separation tag. @@ -132,10 +150,13 @@ module Sha256 { } /** - * @description `H(value)`: the SHA-256 digest of `value`. + * @description Hashes a value with SHA-256 (`H(value)`). + * The hash runs over Compact's encoding of `value`. * * @constraints k=13, rows=2006 * + * @see https://doi.org/10.6028/NIST.FIPS.180-4 FIPS 180-4, SHA-256 + * * @param {T} value - The value to hash. * @return {Bytes<32>} - The digest. */ From 07e8f4618b13022a23d9d505bf1fa48a035c9741 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 12:05:48 +0200 Subject: [PATCH 10/17] test(crypto): add Fq and Sha256 specs Expected values are inlined per test, computed with an independent Python reference (hashlib for SHA-256, py_ecc for the field), plus formula checks done in TypeScript so no vector file is needed. --- .../crypto/curves/bls12_381/test/Fq.test.ts | 225 ++++++++++++++++++ .../bls12_381/test/simulators/FqSimulator.ts | 57 +++++ contracts/src/crypto/hash/test/Sha256.test.ts | 171 +++++++++++++ 3 files changed, 453 insertions(+) create mode 100644 contracts/src/crypto/curves/bls12_381/test/Fq.test.ts create mode 100644 contracts/src/crypto/curves/bls12_381/test/simulators/FqSimulator.ts create mode 100644 contracts/src/crypto/hash/test/Sha256.test.ts diff --git a/contracts/src/crypto/curves/bls12_381/test/Fq.test.ts b/contracts/src/crypto/curves/bls12_381/test/Fq.test.ts new file mode 100644 index 00000000..e660d47a --- /dev/null +++ b/contracts/src/crypto/curves/bls12_381/test/Fq.test.ts @@ -0,0 +1,225 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { FqSimulator } from './simulators/FqSimulator.js'; + +// Expected values come from the Python reference in crypto/test/vectors. + +const Q = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001n; +const TWO_248 = 1n << 248n; + +const hex = (h: string): Uint8Array => Uint8Array.from(Buffer.from(h, 'hex')); +const toHex = (b: Uint8Array): string => Buffer.from(b).toString('hex'); + +const ZERO = new Uint8Array(32); +const MAX = new Uint8Array(32).fill(0xff); +const FULL = Uint8Array.from({ length: 32 }, (_, i) => i); + +describe('Fq', () => { + let fq: FqSimulator; + + beforeAll(async () => { + fq = await FqSimulator.create(); + }); + + describe('fromUniformBytes', () => { + it('should map 64 zero bytes to 0', async () => { + expect( + await fq.fromUniformBytes({ low: ZERO, high: ZERO }), + ).toStrictEqual(0n); + }); + + it('should map 64 0xff bytes to 2^512 - 1 mod q', async () => { + expect(await fq.fromUniformBytes({ low: MAX, high: MAX })).toStrictEqual( + 3294906474794265442129797520630710739278575682199800681788903916070560242796n, + ); + expect(await fq.fromUniformBytes({ low: MAX, high: MAX })).toStrictEqual( + ((1n << 512n) - 1n) % Q, + ); + }); + + it('should map a 0xff low half and a zero high half to 2^256 - 1 mod q', async () => { + expect(await fq.fromUniformBytes({ low: MAX, high: ZERO })).toStrictEqual( + 10920338887063814464675503992315976177888879664585288394250266608035967270909n, + ); + }); + + it('should map a zero low half and a 0xff high half to (2^256 - 1) * 2^256 mod q', async () => { + expect(await fq.fromUniformBytes({ low: ZERO, high: MAX })).toStrictEqual( + 44810442762856641456902034036500700399080248518142150110142296007973174156400n, + ); + }); + + it('should map the sha256("seed", 0, 0) || sha256("seed", 0, 1) sample', async () => { + const low = hex( + '605200169e1f2f47217873e2653be15a9f908339122cfc0e73a7f3e376e852e1', + ); + const high = hex( + '1f961d4472ac98cf60b31eefbc9c883bba2795e9344660ea645bd0f93e5c0dc2', + ); + expect(await fq.fromUniformBytes({ low, high })).toStrictEqual( + 31452218399753120422222562747939223430251044203153823220554251502299720908698n, + ); + }); + + it('should map the sha256("seed", 1, 0) || sha256("seed", 1, 1) sample', async () => { + const low = hex( + '09f2699773536422e82af8f72a22688608a843205bfb32ae9f4140e09896288f', + ); + const high = hex( + '369e1a91fca98365d5adab174a43992cd85b96ed831a4c312bf3d2c080ca7fa9', + ); + expect(await fq.fromUniformBytes({ low, high })).toStrictEqual( + 17968124744794858211683481897468933973143662277017257676723493893263354887101n, + ); + }); + + it('should map the sha256("seed", 2, 0) || sha256("seed", 2, 1) sample', async () => { + const low = hex( + '152a9ca1762c545e9791f6cc0341d41fc096f174726f7cc4806aab6b8e0210d1', + ); + const high = hex( + 'a6afd15b6068cdcc521b2e07ec6071a91f5b3de3510707723eaabdfa5dd249ad', + ); + expect(await fq.fromUniformBytes({ low, high })).toStrictEqual( + 20887356668751830947791920180130609210996405222516537233922531532839350200685n, + ); + }); + + it('should map the sha256("seed", 3, 0) || sha256("seed", 3, 1) sample', async () => { + const low = hex( + '48fff96d4986158b5a2581a55db6e6656a7ee36219e3734591733882805a1a3d', + ); + const high = hex( + 'e6b03328584243fbcdd704e62065b00e62dba75668757959d52f8da5b622e0ce', + ); + expect(await fq.fromUniformBytes({ low, high })).toStrictEqual( + 14228422586471851165696052436850627815199929218761824924046832318003104052378n, + ); + }); + + it('should weight the high half by 2^256 mod q', async () => { + const one = Uint8Array.from(ZERO); + one[0] = 1; + expect(await fq.fromUniformBytes({ low: ZERO, high: one })).toStrictEqual( + 0x1824b159acc5056f998c4fefecbc4ff55884b7fa0003480200000001fffffffen, + ); + }); + + it('should equal LEOS2IP_512(low || high) mod q', async () => { + const low = FULL; + const high = Uint8Array.from({ length: 32 }, (_, i) => 255 - i); + const tv = new Uint8Array([...low, ...high]); + const expected = + tv.reduceRight((acc, b) => (acc << 8n) | BigInt(b), 0n) % Q; + expect(await fq.fromUniformBytes({ low, high })).toStrictEqual(expected); + }); + + it('should not ignore byte 31 of either half', async () => { + const lowOnly = Uint8Array.from(ZERO); + lowOnly[31] = 1; + const highOnly = Uint8Array.from(ZERO); + highOnly[31] = 1; + expect( + await fq.fromUniformBytes({ low: lowOnly, high: ZERO }), + ).toStrictEqual(1n << 248n); + expect( + await fq.fromUniformBytes({ low: ZERO, high: highOnly }), + ).toStrictEqual((1n << 504n) % Q); + }); + }); + + describe('truncatedLEOS2IP', () => { + it('should map 32 zero bytes to 0', async () => { + expect(await fq.truncatedLEOS2IP(ZERO)).toStrictEqual(0n); + }); + + it('should map 32 0xff bytes to 2^248 - 1', async () => { + expect(await fq.truncatedLEOS2IP(MAX)).toStrictEqual( + 452312848583266388373324160190187140051835877600158453279131187530910662655n, + ); + }); + + it('should map the bytes 0x00 to 0x1f little-endian', async () => { + expect(await fq.truncatedLEOS2IP(FULL)).toStrictEqual( + 53206320320083115796502214552783413060461247639578808291150702859268522240n, + ); + }); + + it('should map a string with only byte 31 set to 0', async () => { + expect( + await fq.truncatedLEOS2IP(hex(`${'00'.repeat(31)}01`)), + ).toStrictEqual(0n); + }); + + it('should not distinguish two strings that differ only in byte 31', async () => { + const a = new Uint8Array(32).fill(0xab); + const b = Uint8Array.from(a); + b[31] = 0x00; + expect(await fq.truncatedLEOS2IP(a)).toStrictEqual( + await fq.truncatedLEOS2IP(b), + ); + }); + }); + + describe('truncatedI2LEOSP', () => { + it('should map 0 to 32 zero bytes', async () => { + expect(toHex(await fq.truncatedI2LEOSP(0n))).toStrictEqual( + '00'.repeat(32), + ); + }); + + it('should map 0x0102030405 to its little-endian bytes', async () => { + expect(toHex(await fq.truncatedI2LEOSP(0x0102030405n))).toStrictEqual( + '0504030201000000000000000000000000000000000000000000000000000000', + ); + }); + + it('should map 2^248 - 1 to 31 0xff bytes and a zero byte', async () => { + expect(toHex(await fq.truncatedI2LEOSP(TWO_248 - 1n))).toStrictEqual( + `${'ff'.repeat(31)}00`, + ); + }); + + it('should map 2^248 to 32 zero bytes', async () => { + expect(toHex(await fq.truncatedI2LEOSP(TWO_248))).toStrictEqual( + '00'.repeat(32), + ); + }); + + it('should map q - 1 to its low 248 bits', async () => { + expect(toHex(await fq.truncatedI2LEOSP(Q - 1n))).toStrictEqual( + '00000000fffffffffe5bfeff02a4bd5305d8a10908d83933487d9d2953a7ed00', + ); + }); + + it('should not distinguish two elements equal mod 2^248', async () => { + expect(await fq.truncatedI2LEOSP(0x0102030405n + TWO_248)).toStrictEqual( + await fq.truncatedI2LEOSP(0x0102030405n), + ); + }); + }); + + describe('round trips', () => { + it('should return x mod 2^248 from LEOS2IP(I2LEOSP(x))', async () => { + for (const x of [0n, 0x0102030405n, TWO_248 - 1n, TWO_248, Q - 1n]) { + const back = await fq.truncatedLEOS2IP(await fq.truncatedI2LEOSP(x)); + expect(back).toStrictEqual(x % TWO_248); + } + }); + + it('should return S with byte 31 zeroed from I2LEOSP(LEOS2IP(S))', async () => { + for (const S of [ZERO, MAX, FULL]) { + const back = await fq.truncatedI2LEOSP(await fq.truncatedLEOS2IP(S)); + const expected = Uint8Array.from(S); + expected[31] = 0; + expect(toHex(back)).toStrictEqual(toHex(expected)); + } + }); + + it('should be the identity on 248-bit values', async () => { + const x = TWO_248 - 12345n; + expect( + await fq.truncatedLEOS2IP(await fq.truncatedI2LEOSP(x)), + ).toStrictEqual(x); + }); + }); +}); diff --git a/contracts/src/crypto/curves/bls12_381/test/simulators/FqSimulator.ts b/contracts/src/crypto/curves/bls12_381/test/simulators/FqSimulator.ts new file mode 100644 index 00000000..069047b5 --- /dev/null +++ b/contracts/src/crypto/curves/bls12_381/test/simulators/FqSimulator.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.4.0-alpha.1 (crypto/curves/bls12_381/test/simulators/FqSimulator.ts) + +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockFq, + type Fq_UniformBytes as UniformBytes, +} from '../../../../../../artifacts/MockFq/contract/index.js'; + +export type { UniformBytes }; + +type EmptyPrivateState = Record; +const EmptyPrivateState: EmptyPrivateState = {}; +const emptyWitnesses = () => ({}); + +const FqSimulatorBase = createSimulator< + EmptyPrivateState, + ReturnType, + ReturnType, + MockFq, + readonly [] +>({ + contractFactory: (witnesses) => new MockFq(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => emptyWitnesses(), + artifactName: 'MockFq', +}); + +export class FqSimulator extends FqSimulatorBase { + static async create( + options: SimulatorOptions< + EmptyPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create([], options) as Promise; + } + + public fromUniformBytes(tv: UniformBytes): Promise { + return this.circuits.impure.fromUniformBytes(tv); + } + + public truncatedLEOS2IP(S: Uint8Array): Promise { + return this.circuits.impure.truncatedLEOS2IP(S); + } + + public truncatedI2LEOSP(x: bigint): Promise { + return this.circuits.impure.truncatedI2LEOSP(x); + } +} diff --git a/contracts/src/crypto/hash/test/Sha256.test.ts b/contracts/src/crypto/hash/test/Sha256.test.ts new file mode 100644 index 00000000..792cba65 --- /dev/null +++ b/contracts/src/crypto/hash/test/Sha256.test.ts @@ -0,0 +1,171 @@ +import { createHash } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { pureCircuits } from '../../../../artifacts/MockSha256/contract/index.js'; + +// Expected values come from the Python reference in crypto/test/vectors. +// The circuits are pure, so the artifact's `pureCircuits` are called directly. + +const Q = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001n; + +const hex = (h: string): Uint8Array => Uint8Array.from(Buffer.from(h, 'hex')); +const toHex = (b: Uint8Array): string => Buffer.from(b).toString('hex'); +const label = (text: string): Uint8Array => { + const b = new Uint8Array(32); + b.set(new TextEncoder().encode(text)); + return b; +}; +const sha256 = (...parts: Uint8Array[]): Uint8Array => { + const h = createHash('sha256'); + for (const p of parts) h.update(p); + return new Uint8Array(h.digest()); +}; + +const ZERO = new Uint8Array(32); +const MAX = new Uint8Array(32).fill(0xff); +const ABC = label('abc'); +const FULL = Uint8Array.from({ length: 32 }, (_, i) => i); +const DST_OZ = label('OZ:test:dst'); +const DST_OTHER = label('other'); + +describe('Sha256', () => { + describe('digest', () => { + it('should hash 32 zero bytes', () => { + expect(toHex(pureCircuits.digest(ZERO))).toStrictEqual( + '66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925', + ); + }); + + it('should hash "abc" padded to 32 bytes', () => { + expect(toHex(pureCircuits.digest(ABC))).toStrictEqual( + '26426d7cb06a12643ccfe84107603083d835c37f000a12f734137a0c8df77f26', + ); + }); + + it('should hash the bytes 0x00 to 0x1f', () => { + expect(toHex(pureCircuits.digest(FULL))).toStrictEqual( + '630dcd2966c4336691125448bbb25b4ff412a49c732db2c8abc1b8581bd710dd', + ); + }); + + it('should hash 32 0xff bytes', () => { + expect(toHex(pureCircuits.digest(MAX))).toStrictEqual( + 'af9613760f72635fbdb44a5a0a63c39f12af30f950a6ee5c971be188e89c4051', + ); + }); + + it('should equal plain SHA-256 of the 32 bytes', () => { + const msg = Uint8Array.from({ length: 32 }, (_, i) => 255 - i); + expect(toHex(pureCircuits.digest(msg))).toStrictEqual(toHex(sha256(msg))); + }); + }); + + describe('hashToField', () => { + it('should map 32 zero bytes under "OZ:test:dst"', () => { + expect(pureCircuits.hashToField(ZERO, DST_OZ)).toStrictEqual( + 34210192094318977985183534168987902724601372674013286402051453020672578529427n, + ); + }); + + it('should map 32 zero bytes under "other"', () => { + expect(pureCircuits.hashToField(ZERO, DST_OTHER)).toStrictEqual( + 8252444046083393956013555787964600531038925995538232948302863214155512041927n, + ); + }); + + it('should map 32 zero bytes under a 0xff tag', () => { + expect(pureCircuits.hashToField(ZERO, MAX)).toStrictEqual( + 5783169439825234658278062974923324003958453459859062855722018262150474251593n, + ); + }); + + it('should map "abc" under "OZ:test:dst"', () => { + expect(pureCircuits.hashToField(ABC, DST_OZ)).toStrictEqual( + 4324394735155083904531704740297192940187867625546932967580253364999850513246n, + ); + }); + + it('should map "abc" under "other"', () => { + expect(pureCircuits.hashToField(ABC, DST_OTHER)).toStrictEqual( + 21857836360300176587501939499123296964562523761200186881588315994230546836595n, + ); + }); + + it('should map "abc" under a 0xff tag', () => { + expect(pureCircuits.hashToField(ABC, MAX)).toStrictEqual( + 1226102576975082613384683709326516658236353740412667946641545403440333170714n, + ); + }); + + it('should map the bytes 0x00 to 0x1f under "OZ:test:dst"', () => { + expect(pureCircuits.hashToField(FULL, DST_OZ)).toStrictEqual( + 15556550438748104882361422133592315476793818450331618665969441588440568877440n, + ); + }); + + it('should map the bytes 0x00 to 0x1f under "other"', () => { + expect(pureCircuits.hashToField(FULL, DST_OTHER)).toStrictEqual( + 9992742822580069016050322119799082654165910733055334771929215765417118172064n, + ); + }); + + it('should map the bytes 0x00 to 0x1f under a 0xff tag', () => { + expect(pureCircuits.hashToField(FULL, MAX)).toStrictEqual( + 47774871813993836535546443118418351091640792380679911218978961729634430076400n, + ); + }); + + it('should map 32 0xff bytes under "OZ:test:dst"', () => { + expect(pureCircuits.hashToField(MAX, DST_OZ)).toStrictEqual( + 11885617370090720359587156065762348771197463332995176966052374470152502703310n, + ); + }); + + it('should map 32 0xff bytes under "other"', () => { + expect(pureCircuits.hashToField(MAX, DST_OTHER)).toStrictEqual( + 38666714245396177227157505064478008025562388204734340763101648940949970599424n, + ); + }); + + it('should map 32 0xff bytes under a 0xff tag', () => { + expect(pureCircuits.hashToField(MAX, MAX)).toStrictEqual( + 5376335326590360629005099603710189236197405386625499623432894039735085815136n, + ); + }); + + it('should equal LEOS2IP_512(b_0 || b_1) mod q with b_i = SHA-256(msg || DST || i)', () => { + const msg = Uint8Array.from({ length: 32 }, (_, i) => i * 7); + const DST = Uint8Array.from({ length: 32 }, (_, i) => 100 + i); + const counter = (i: number): Uint8Array => { + const c = new Uint8Array(32); + c[0] = i; + return c; + }; + const tv = new Uint8Array([ + ...sha256(msg, DST, counter(0)), + ...sha256(msg, DST, counter(1)), + ]); + const expected = + tv.reduceRight((acc, b) => (acc << 8n) | BigInt(b), 0n) % Q; + expect(pureCircuits.hashToField(msg, DST)).toStrictEqual(expected); + }); + + it('should stay below q', () => { + expect(pureCircuits.hashToField(MAX, MAX)).toBeLessThan(Q); + expect(pureCircuits.hashToField(hex('80'.repeat(32)), ZERO)).toBeLessThan( + Q, + ); + }); + + it('should not give the same element under two tags', () => { + expect(pureCircuits.hashToField(ABC, DST_OZ)).not.toStrictEqual( + pureCircuits.hashToField(ABC, DST_OTHER), + ); + }); + + it('should not give the same element for two messages', () => { + expect(pureCircuits.hashToField(ABC, DST_OZ)).not.toStrictEqual( + pureCircuits.hashToField(FULL, DST_OZ), + ); + }); + }); +}); From fc0731e1fa241d3e206dde5fae9cb3da1b9e2c50 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 12:05:48 +0200 Subject: [PATCH 11/17] test(crypto): prove the kdf pad leak in EcdhMask kdf pads stay below 2^248, so a ciphertext of a 248-bit or Field value never wraps and its top bits are readable. The spec measures it: a threshold distinguisher wins every time against kdf and is a coin flip against fieldKdf, whose pads cover [0, P). Reference vectors for fieldKdf and encryptField come from the same Python reference as the Fq and Sha256 specs. The mock drops the stale fieldKdfHalves. Refs: #735 --- contracts/src/crypto/test/EcdhMask.test.ts | 250 ++++++++++++++++-- .../crypto/test/mocks/MockEcdhMask.compact | 15 -- 2 files changed, 223 insertions(+), 42 deletions(-) diff --git a/contracts/src/crypto/test/EcdhMask.test.ts b/contracts/src/crypto/test/EcdhMask.test.ts index e0624027..865b4773 100644 --- a/contracts/src/crypto/test/EcdhMask.test.ts +++ b/contracts/src/crypto/test/EcdhMask.test.ts @@ -1,9 +1,11 @@ +import { createHash } from 'node:crypto'; import { ecMulGenerator } from '@midnight-ntwrk/compact-runtime'; import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; import { pureCircuits as ecdh } from '../../../artifacts/MockEcdh/contract/index.js'; import { pureCircuits } from '../../../artifacts/MockEcdhMask/contract/index.js'; import { pureCircuits as elgamal } from '../../../artifacts/MockElGamal/contract/index.js'; +import { pureCircuits as sha } from '../../../artifacts/MockSha256/contract/index.js'; // The EcdhMask circuits are pure, so tests drive them directly via the compiled // artifact's `pureCircuits` (no proof, no simulator needed). @@ -249,54 +251,70 @@ describe('EcdhMask', () => { describe('fieldKdf', () => { const points = [2n, 5n, 222n, 999999n].map((s) => ecMulGenerator(s)); - it('is deterministic for the same shared point and domain', () => { + // persistentHash: SHA-256 of x || y, each as 32 little-endian bytes. + const pointDigest = (point: { x: bigint; y: bigint }): Uint8Array => { + const le = (v: bigint): Uint8Array => + Uint8Array.from({ length: 32 }, (_, i) => + Number((v >> BigInt(8 * i)) & 0xffn), + ); + return new Uint8Array( + createHash('sha256').update(le(point.x)).update(le(point.y)).digest(), + ); + }; + + it('should be deterministic for the same shared point and domain', () => { expect(pureCircuits.fieldKdf(PK, DOMAIN)).toBe( pureCircuits.fieldKdf(PK, DOMAIN), ); }); - it('differs for distinct shared points', () => { + it('should differ for distinct shared points', () => { expect(pureCircuits.fieldKdf(PK, DOMAIN)).not.toBe( pureCircuits.fieldKdf(ecMulGenerator(222n), DOMAIN), ); }); - it('differs for distinct domains (domain separation)', () => { + it('should differ for distinct domains', () => { expect(pureCircuits.fieldKdf(PK, domain('a'))).not.toBe( pureCircuits.fieldKdf(PK, domain('b')), ); }); - it('equals k1 + k2 * 2^248 over the two hashed halves', () => { - // The mock recomputes both halves straight from the stdlib hashes, so the - // pad's arithmetic is pinned against an independent path. + it('should equal Sha256.hashToField(pointDigest(S), domain)', () => { for (const point of points) { - const [low, high] = pureCircuits.fieldKdfHalves(point, DOMAIN); expect(pureCircuits.fieldKdf(point, DOMAIN)).toBe( - (low + high * TWO_248) % P, + sha.hashToField(pointDigest(point), DOMAIN), ); } }); - it('draws each half from the 248-bit degradeToTransient range', () => { - for (const point of points) { - const [low, high] = pureCircuits.fieldKdfHalves(point, DOMAIN); - expect(low).toBeLessThan(TWO_248); - expect(high).toBeLessThan(TWO_248); - } + it('should match the reference for S = 5 * pk(3) under "OZ:test:dst"', () => { + const S = { + x: 34133914351292434048413503276202728289265490189576620060413629725504410538523n, + y: 14331798736465991320125906355460685144102305233516748184833801044822620467723n, + }; + expect(pureCircuits.fieldKdf(S, domain('OZ:test:dst'))).toBe( + 26610806138279577918068632042914450713404457242874797469315820532741471409604n, + ); + expect(pureCircuits.kdf(S, domain('OZ:test:dst'))).toBe( + 69564922259646089911822298958338864030232716070909835277172563539860613021n, + ); }); - it('draws the two halves as independent hash queries', () => { - // Index 0 and index 1 are separate random-oracle queries. - for (const point of points) { - const [low, high] = pureCircuits.fieldKdfHalves(point, DOMAIN); - expect(low).not.toBe(high); - } + it('should match the reference for S = 1307 * pk(42) under "other"', () => { + const S = { + x: 21846731140111779498597767336707429224314113023599758281380925851664428732333n, + y: 16756994231029989709753422113746052839188842806931617304566960550784499484096n, + }; + expect(pureCircuits.fieldKdf(S, domain('other'))).toBe( + 14942334384528480047623944073446731732154425113458333513933056364075658344760n, + ); + expect(pureCircuits.kdf(S, domain('other'))).toBe( + 332242018693607749271965174322648896667187613080348592700581236270042761364n, + ); }); - it('differs from kdf under the same point and domain', () => { - // The three-element preimage separates the pad from the kdf, so a - // consumer may use both under one (S, domain). + it('should not equal kdf under the same point and domain', () => { for (const point of points) { expect(pureCircuits.fieldKdf(point, DOMAIN)).not.toBe( pureCircuits.kdf(point, DOMAIN), @@ -304,13 +322,53 @@ describe('EcdhMask', () => { } }); - it('exceeds the 248-bit kdf range for most shared points', () => { - // The regression that catches a dropped high half: a 248-bit pad can - // never land above 2^248, a field-wide one almost always does. + it('should exceed the 248-bit kdf range for most shared points', () => { const wide = Array.from({ length: 64 }, (_, i) => pureCircuits.fieldKdf(ecMulGenerator(BigInt(i) + 1n), DOMAIN), ).filter((mask) => mask >= TWO_248); - expect(wide.length).toBeGreaterThan(0); + expect(wide.length).toBeGreaterThan(56); + }); + }); + + describe('encryptField reference vectors', () => { + it('should match the reference for sk = 3, e = 5, m = 299973 under "OZ:test:dst"', () => { + const pk = ecMulGenerator(3n); + const ciphertext = pureCircuits.encryptField( + pk, + 299973n, + 5n, + domain('OZ:test:dst'), + ); + expect(ciphertext).toStrictEqual({ + ephemeralPk: { + x: 46037580203438066765405229507649644425780970512522822336637661968249826130047n, + y: 26189429486186784039799689203850934078756791903368248146476421754146336352630n, + }, + ct: 26610806138279577918068632042914450713404457242874797469315820532741471709577n, + }); + expect( + pureCircuits.decryptField(ciphertext, 3n, domain('OZ:test:dst')), + ).toBe(299973n); + }); + + it('should match the reference for sk = 42, e = 1307, m = 4199622 under "other"', () => { + const pk = ecMulGenerator(42n); + const ciphertext = pureCircuits.encryptField( + pk, + 4199622n, + 1307n, + domain('other'), + ); + expect(ciphertext).toStrictEqual({ + ephemeralPk: { + x: 19484914689417196181240116237393434494914401980232007599737218164003740761381n, + y: 6764962076417023830458027630302197924902637758905016901701807876639802414181n, + }, + ct: 14942334384528480047623944073446731732154425113458333513933056364075662544382n, + }); + expect(pureCircuits.decryptField(ciphertext, 42n, domain('other'))).toBe( + 4199622n, + ); }); }); @@ -400,6 +458,144 @@ describe('EcdhMask', () => { } }); + describe('pad width attack', () => { + // Attack vector, one fact per line. + // - A ciphertext is ct = (value + pad) mod P. + // - The kdf pad is LEOS2IP_248 of a digest, so 0 <= pad < 2^248. + // - If value + 2^248 <= P, then value + pad < P and the sum does not wrap. + // - Then ct - pad = value, so value lies in (ct - 2^248, ct]. + // - That window is 2^248 wide in a field of size P > 2^254, so the + // observer learns the top 7 bits of value. + // - A Uint<128> value is below 2^128, so the window covers the whole type + // and the observer learns nothing. + // - The fieldKdf pad is LEOS2IP_512 of two digests reduced mod P, uniform + // on [0, P) to within 2^-257. + // - Then ct is uniform on [0, P) for every value, and no window exists. + // + // Every pad below comes from one of 256 fixed shared points, + // s_i = (7919 i + 1) G, so every count is deterministic. + + const SHARED_POINTS = Array.from({ length: 256 }, (_, i) => + ecMulGenerator(BigInt(i) * 7919n + 1n), + ); + + /** Pads of `kdf` over the fixed shared points, under `DOMAIN`. */ + const padsOf = (kdf: typeof pureCircuits.kdf): bigint[] => + SHARED_POINTS.map((s) => kdf(s, DOMAIN)); + + // The largest value of each plaintext width. The Field one leaves room for + // a 2^248 pad below P, so the kdf sum cannot wrap. + const VALUE_128 = (1n << 128n) - 1n; + const VALUE_248 = TWO_248 - 1n; + const VALUE_FIELD = 1n << 254n; + + /** + * IND-CPA game under `kdf`. Half the pads encrypt 0 and half encrypt + * `value`. The observer guesses `value` whenever ct >= value. Returns the + * win rate: 1 means the ciphertext gives the plaintext away, 0.5 means it + * hides it. + */ + const distinguisherWinRate = ( + kdf: typeof pureCircuits.kdf, + value: bigint, + ): number => { + const pads = padsOf(kdf); + const wins = pads.filter((pad, i) => { + const encryptsValue = i % 2 === 1; + const ct = ((encryptsValue ? value : 0n) + pad) % P; + return ct >= value === encryptsValue; + }).length; + return wins / pads.length; + }; + + /** + * Bit recovery under `kdf`. Encrypts a fixed spread of Field values and + * reports how often the value lies in the window (ct - 2^248, ct]. + * 1 means the observer learns every value to within 2^248. + */ + const windowHitRate = (kdf: typeof pureCircuits.kdf): number => { + const pads = padsOf(kdf); + const hits = pads.filter((pad, i) => { + // Spread over [0, P - 2^248) so the kdf sum never wraps. + const value = + (BigInt(i + 1) * 0x9e3779b97f4a7c15n * (1n << 192n)) % (P - TWO_248); + const ct = (value + pad) % P; + return sub(ct, value) < TWO_248; + }).length; + return hits / pads.length; + }; + + /** A rate a fair coin could produce over 256 trials. */ + const expectCoinFlip = (rate: number): void => { + expect(rate).toBeGreaterThan(0.35); + expect(rate).toBeLessThan(0.65); + }; + + it('should keep every kdf pad below 2^248', () => { + expect(padsOf(pureCircuits.kdf).every((pad) => pad < TWO_248)).toBe(true); + }); + + it('should put most fieldKdf pads at or above 2^248', () => { + const pads = padsOf(pureCircuits.fieldKdf); + const above = pads.filter((pad) => pad >= TWO_248).length; + expect(above).toBeGreaterThan(pads.length * 0.9); + }); + + it('should keep every fieldKdf pad below P', () => { + expect(padsOf(pureCircuits.fieldKdf).every((pad) => pad < P)).toBe(true); + }); + + it('should spread fieldKdf pads over every eighth of the field', () => { + // A pad that stopped at 2^254, or an unreduced 2^256 one, would leave + // the top eighths empty. + const eighths = new Set( + padsOf(pureCircuits.fieldKdf).map((pad) => (pad * 8n) / P), + ); + expect([...eighths].sort()).toStrictEqual([ + 0n, + 1n, + 2n, + 3n, + 4n, + 5n, + 6n, + 7n, + ]); + }); + + it('should let the observer read a 248-bit value off its kdf ciphertext', () => { + expect(distinguisherWinRate(pureCircuits.kdf, VALUE_248)).toBe(1); + }); + + it('should let the observer read a Field value off its kdf ciphertext', () => { + expect(distinguisherWinRate(pureCircuits.kdf, VALUE_FIELD)).toBe(1); + }); + + it('should not let the observer read a Uint<128> value off its kdf ciphertext', () => { + expectCoinFlip(distinguisherWinRate(pureCircuits.kdf, VALUE_128)); + }); + + it('should not let the observer read a 248-bit value off its fieldKdf ciphertext', () => { + expectCoinFlip(distinguisherWinRate(pureCircuits.fieldKdf, VALUE_248)); + }); + + it('should not let the observer read a Field value off its fieldKdf ciphertext', () => { + expectCoinFlip(distinguisherWinRate(pureCircuits.fieldKdf, VALUE_FIELD)); + }); + + it('should not let the observer read a Uint<128> value off its fieldKdf ciphertext', () => { + expectCoinFlip(distinguisherWinRate(pureCircuits.fieldKdf, VALUE_128)); + }); + + it('should leave every Field value within 2^248 of its kdf ciphertext', () => { + expect(windowHitRate(pureCircuits.kdf)).toBe(1); + }); + + it('should not leave a Field value within 2^248 of its fieldKdf ciphertext', () => { + expect(windowHitRate(pureCircuits.fieldKdf)).toBeLessThan(0.05); + }); + }); + describe('multi-field pad discipline', () => { // `deriveShared` and `recoverShared` come from crypto/Ecdh, imported // alongside EcdhMask in the mock exactly as a consumer imports both. diff --git a/contracts/src/crypto/test/mocks/MockEcdhMask.compact b/contracts/src/crypto/test/mocks/MockEcdhMask.compact index 1464d094..547ae3bc 100644 --- a/contracts/src/crypto/test/mocks/MockEcdhMask.compact +++ b/contracts/src/crypto/test/mocks/MockEcdhMask.compact @@ -56,18 +56,3 @@ export pure circuit deriveShared(recipientPk: JubjubPoint, e: JubjubScalar): Ecd export pure circuit recoverShared(ephemeralPk: JubjubPoint, ekScalar: JubjubScalar): JubjubPoint { return Ecdh_recoverShared(ephemeralPk, ekScalar); } - -// Test-only, not a module re-export: recomputes the two `fieldKdf` halves -// straight from the stdlib hashes so the tests can pin the pad arithmetic -// against an independent path. -export pure circuit fieldKdfHalves(sShared: JubjubPoint, domain: Bytes<32>): Vector<2, Field> { - const pointHash = persistentHash(sShared); - return [ - degradeToTransient( - persistentHash>>([pointHash, domain, 0 as Field as Bytes<32>]) - ), - degradeToTransient( - persistentHash>>([pointHash, domain, 1 as Field as Bytes<32>]) - ) - ]; -} From 766a2fd33d4103575bcb87fd31af26e48ad3defe Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 14:02:26 +0200 Subject: [PATCH 12/17] test(crypto): make the Sha256 mock ledger-backed A pure mock ships no ZKIR, so the Sha256 rows could not be measured or reproduced. The mock now carries a counter like MockFq, and the specs reach it through a simulator. The IHasher contract type is declared without pure, since a pure declaration rejects such a mock while a pure export still satisfies the plain one. The @constraints values are the ones measured through the mock. --- contracts/src/crypto/hash/IHasher.compact | 13 ++- contracts/src/crypto/hash/Sha256.compact | 4 +- contracts/src/crypto/hash/test/Sha256.test.ts | 103 +++++++++--------- .../crypto/hash/test/mocks/MockSha256.compact | 16 ++- .../hash/test/simulators/Sha256Simulator.ts | 50 +++++++++ contracts/src/crypto/test/EcdhMask.test.ts | 13 ++- 6 files changed, 134 insertions(+), 65 deletions(-) create mode 100644 contracts/src/crypto/hash/test/simulators/Sha256Simulator.ts diff --git a/contracts/src/crypto/hash/IHasher.compact b/contracts/src/crypto/hash/IHasher.compact index b6a49130..670fd8f3 100644 --- a/contracts/src/crypto/hash/IHasher.compact +++ b/contracts/src/crypto/hash/IHasher.compact @@ -10,7 +10,7 @@ pragma language_version >= 0.26.0; * * @dev Fixed types: * The hash modules are generic over the message type `T`. A contract type - * circuit cannot be, so `Hasher` fixes `T = Bytes<32>`. + * circuit cannot be, so `IHasher` fixes `T = Bytes<32>`. * * @dev Where it is implemented: * `contract implements` is checked against the exports of the top-level @@ -26,9 +26,14 @@ module IHasher { * @description The `crypto/hash` interface at `T = Bytes<32>`. `digest` * returns the hash's byte digest, and `hashToField` maps `msg` to one `Field` * element, domain-separated by `DST`. + * + * @dev Purity: + * Declared without `pure`, which a pure export also satisfies. A pure + * declaration would reject the ledger-backed mocks that exist to ship ZKIR. + * The hash modules themselves export pure circuits. */ - export contract Hasher { - pure circuit digest(value: Bytes<32>): Bytes<32>; - pure circuit hashToField(msg: Bytes<32>, DST: Bytes<32>): Field; + export contract IHasher { + circuit digest(value: Bytes<32>): Bytes<32>; + circuit hashToField(msg: Bytes<32>, DST: Bytes<32>): Field; } } diff --git a/contracts/src/crypto/hash/Sha256.compact b/contracts/src/crypto/hash/Sha256.compact index abf944b2..79459062 100644 --- a/contracts/src/crypto/hash/Sha256.compact +++ b/contracts/src/crypto/hash/Sha256.compact @@ -81,7 +81,7 @@ module Sha256 { * @description Hashes a message to one field element (`hash_to_field`, `count = 1`, `m = 1`). * RFC 9380 §5.2, with `msg` domain-separated by `DST`. * - * @constraints k=14, rows=8326 + * @constraints k=14, rows=9806 * * @dev Steps, numbered as in the RFC: * - 1: `len_in_bytes = count * m * L = 64`. A length is a type here, so @@ -153,7 +153,7 @@ module Sha256 { * @description Hashes a value with SHA-256 (`H(value)`). * The hash runs over Compact's encoding of `value`. * - * @constraints k=13, rows=2006 + * @constraints k=13, rows=2305 * * @see https://doi.org/10.6028/NIST.FIPS.180-4 FIPS 180-4, SHA-256 * diff --git a/contracts/src/crypto/hash/test/Sha256.test.ts b/contracts/src/crypto/hash/test/Sha256.test.ts index 792cba65..af98c675 100644 --- a/contracts/src/crypto/hash/test/Sha256.test.ts +++ b/contracts/src/crypto/hash/test/Sha256.test.ts @@ -1,9 +1,8 @@ import { createHash } from 'node:crypto'; -import { describe, expect, it } from 'vitest'; -import { pureCircuits } from '../../../../artifacts/MockSha256/contract/index.js'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { Sha256Simulator } from './simulators/Sha256Simulator.js'; // Expected values come from the Python reference in crypto/test/vectors. -// The circuits are pure, so the artifact's `pureCircuits` are called directly. const Q = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001n; @@ -28,111 +27,117 @@ const DST_OZ = label('OZ:test:dst'); const DST_OTHER = label('other'); describe('Sha256', () => { + let sha: Sha256Simulator; + + beforeAll(async () => { + sha = await Sha256Simulator.create(); + }); + describe('digest', () => { - it('should hash 32 zero bytes', () => { - expect(toHex(pureCircuits.digest(ZERO))).toStrictEqual( + it('should hash 32 zero bytes', async () => { + expect(toHex(await sha.digest(ZERO))).toStrictEqual( '66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925', ); }); - it('should hash "abc" padded to 32 bytes', () => { - expect(toHex(pureCircuits.digest(ABC))).toStrictEqual( + it('should hash "abc" padded to 32 bytes', async () => { + expect(toHex(await sha.digest(ABC))).toStrictEqual( '26426d7cb06a12643ccfe84107603083d835c37f000a12f734137a0c8df77f26', ); }); - it('should hash the bytes 0x00 to 0x1f', () => { - expect(toHex(pureCircuits.digest(FULL))).toStrictEqual( + it('should hash the bytes 0x00 to 0x1f', async () => { + expect(toHex(await sha.digest(FULL))).toStrictEqual( '630dcd2966c4336691125448bbb25b4ff412a49c732db2c8abc1b8581bd710dd', ); }); - it('should hash 32 0xff bytes', () => { - expect(toHex(pureCircuits.digest(MAX))).toStrictEqual( + it('should hash 32 0xff bytes', async () => { + expect(toHex(await sha.digest(MAX))).toStrictEqual( 'af9613760f72635fbdb44a5a0a63c39f12af30f950a6ee5c971be188e89c4051', ); }); - it('should equal plain SHA-256 of the 32 bytes', () => { + it('should equal plain SHA-256 of the 32 bytes', async () => { const msg = Uint8Array.from({ length: 32 }, (_, i) => 255 - i); - expect(toHex(pureCircuits.digest(msg))).toStrictEqual(toHex(sha256(msg))); + expect(toHex(await sha.digest(msg))).toStrictEqual(toHex(sha256(msg))); }); }); describe('hashToField', () => { - it('should map 32 zero bytes under "OZ:test:dst"', () => { - expect(pureCircuits.hashToField(ZERO, DST_OZ)).toStrictEqual( + it('should map 32 zero bytes under "OZ:test:dst"', async () => { + expect(await sha.hashToField(ZERO, DST_OZ)).toStrictEqual( 34210192094318977985183534168987902724601372674013286402051453020672578529427n, ); }); - it('should map 32 zero bytes under "other"', () => { - expect(pureCircuits.hashToField(ZERO, DST_OTHER)).toStrictEqual( + it('should map 32 zero bytes under "other"', async () => { + expect(await sha.hashToField(ZERO, DST_OTHER)).toStrictEqual( 8252444046083393956013555787964600531038925995538232948302863214155512041927n, ); }); - it('should map 32 zero bytes under a 0xff tag', () => { - expect(pureCircuits.hashToField(ZERO, MAX)).toStrictEqual( + it('should map 32 zero bytes under a 0xff tag', async () => { + expect(await sha.hashToField(ZERO, MAX)).toStrictEqual( 5783169439825234658278062974923324003958453459859062855722018262150474251593n, ); }); - it('should map "abc" under "OZ:test:dst"', () => { - expect(pureCircuits.hashToField(ABC, DST_OZ)).toStrictEqual( + it('should map "abc" under "OZ:test:dst"', async () => { + expect(await sha.hashToField(ABC, DST_OZ)).toStrictEqual( 4324394735155083904531704740297192940187867625546932967580253364999850513246n, ); }); - it('should map "abc" under "other"', () => { - expect(pureCircuits.hashToField(ABC, DST_OTHER)).toStrictEqual( + it('should map "abc" under "other"', async () => { + expect(await sha.hashToField(ABC, DST_OTHER)).toStrictEqual( 21857836360300176587501939499123296964562523761200186881588315994230546836595n, ); }); - it('should map "abc" under a 0xff tag', () => { - expect(pureCircuits.hashToField(ABC, MAX)).toStrictEqual( + it('should map "abc" under a 0xff tag', async () => { + expect(await sha.hashToField(ABC, MAX)).toStrictEqual( 1226102576975082613384683709326516658236353740412667946641545403440333170714n, ); }); - it('should map the bytes 0x00 to 0x1f under "OZ:test:dst"', () => { - expect(pureCircuits.hashToField(FULL, DST_OZ)).toStrictEqual( + it('should map the bytes 0x00 to 0x1f under "OZ:test:dst"', async () => { + expect(await sha.hashToField(FULL, DST_OZ)).toStrictEqual( 15556550438748104882361422133592315476793818450331618665969441588440568877440n, ); }); - it('should map the bytes 0x00 to 0x1f under "other"', () => { - expect(pureCircuits.hashToField(FULL, DST_OTHER)).toStrictEqual( + it('should map the bytes 0x00 to 0x1f under "other"', async () => { + expect(await sha.hashToField(FULL, DST_OTHER)).toStrictEqual( 9992742822580069016050322119799082654165910733055334771929215765417118172064n, ); }); - it('should map the bytes 0x00 to 0x1f under a 0xff tag', () => { - expect(pureCircuits.hashToField(FULL, MAX)).toStrictEqual( + it('should map the bytes 0x00 to 0x1f under a 0xff tag', async () => { + expect(await sha.hashToField(FULL, MAX)).toStrictEqual( 47774871813993836535546443118418351091640792380679911218978961729634430076400n, ); }); - it('should map 32 0xff bytes under "OZ:test:dst"', () => { - expect(pureCircuits.hashToField(MAX, DST_OZ)).toStrictEqual( + it('should map 32 0xff bytes under "OZ:test:dst"', async () => { + expect(await sha.hashToField(MAX, DST_OZ)).toStrictEqual( 11885617370090720359587156065762348771197463332995176966052374470152502703310n, ); }); - it('should map 32 0xff bytes under "other"', () => { - expect(pureCircuits.hashToField(MAX, DST_OTHER)).toStrictEqual( + it('should map 32 0xff bytes under "other"', async () => { + expect(await sha.hashToField(MAX, DST_OTHER)).toStrictEqual( 38666714245396177227157505064478008025562388204734340763101648940949970599424n, ); }); - it('should map 32 0xff bytes under a 0xff tag', () => { - expect(pureCircuits.hashToField(MAX, MAX)).toStrictEqual( + it('should map 32 0xff bytes under a 0xff tag', async () => { + expect(await sha.hashToField(MAX, MAX)).toStrictEqual( 5376335326590360629005099603710189236197405386625499623432894039735085815136n, ); }); - it('should equal LEOS2IP_512(b_0 || b_1) mod q with b_i = SHA-256(msg || DST || i)', () => { + it('should equal LEOS2IP_512(b_0 || b_1) mod q with b_i = SHA-256(msg || DST || i)', async () => { const msg = Uint8Array.from({ length: 32 }, (_, i) => i * 7); const DST = Uint8Array.from({ length: 32 }, (_, i) => 100 + i); const counter = (i: number): Uint8Array => { @@ -146,25 +151,23 @@ describe('Sha256', () => { ]); const expected = tv.reduceRight((acc, b) => (acc << 8n) | BigInt(b), 0n) % Q; - expect(pureCircuits.hashToField(msg, DST)).toStrictEqual(expected); + expect(await sha.hashToField(msg, DST)).toStrictEqual(expected); }); - it('should stay below q', () => { - expect(pureCircuits.hashToField(MAX, MAX)).toBeLessThan(Q); - expect(pureCircuits.hashToField(hex('80'.repeat(32)), ZERO)).toBeLessThan( - Q, - ); + it('should stay below q', async () => { + expect(await sha.hashToField(MAX, MAX)).toBeLessThan(Q); + expect(await sha.hashToField(hex('80'.repeat(32)), ZERO)).toBeLessThan(Q); }); - it('should not give the same element under two tags', () => { - expect(pureCircuits.hashToField(ABC, DST_OZ)).not.toStrictEqual( - pureCircuits.hashToField(ABC, DST_OTHER), + it('should not give the same element under two tags', async () => { + expect(await sha.hashToField(ABC, DST_OZ)).not.toStrictEqual( + await sha.hashToField(ABC, DST_OTHER), ); }); - it('should not give the same element for two messages', () => { - expect(pureCircuits.hashToField(ABC, DST_OZ)).not.toStrictEqual( - pureCircuits.hashToField(FULL, DST_OZ), + it('should not give the same element for two messages', async () => { + expect(await sha.hashToField(ABC, DST_OZ)).not.toStrictEqual( + await sha.hashToField(FULL, DST_OZ), ); }); }); diff --git a/contracts/src/crypto/hash/test/mocks/MockSha256.compact b/contracts/src/crypto/hash/test/mocks/MockSha256.compact index 4cbbd220..75992cc5 100644 --- a/contracts/src/crypto/hash/test/mocks/MockSha256.compact +++ b/contracts/src/crypto/hash/test/mocks/MockSha256.compact @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT // WARNING: FOR TESTING PURPOSES ONLY. -// Exposes the Sha256 module's pure circuits so they can be driven from -// off-chain tests. DO NOT deploy or use this contract in any production +// Exposes the Sha256 module's circuits so they can be driven from off-chain +// tests. DO NOT deploy or use this contract in any production // application. pragma language_version >= 0.26.0; @@ -12,12 +12,18 @@ import "../../IHasher"; import "../../Sha256" prefix Sha256_; // Fails compilation if Sha256's signatures drift from the shared interface. -contract implements Hasher; +contract implements IHasher; -export pure circuit hashToField(msg: Bytes<32>, DST: Bytes<32>): Field { +// Keeps every circuit impure. Without a ledger write the compiler promotes +// them to pure, and the artifact ships without ZKIR or proving keys. +ledger _invocations: Counter; + +export circuit hashToField(msg: Bytes<32>, DST: Bytes<32>): Field { + _invocations.increment(1); return Sha256_hashToField>(msg, DST); } -export pure circuit digest(value: Bytes<32>): Bytes<32> { +export circuit digest(value: Bytes<32>): Bytes<32> { + _invocations.increment(1); return Sha256_digest>(value); } diff --git a/contracts/src/crypto/hash/test/simulators/Sha256Simulator.ts b/contracts/src/crypto/hash/test/simulators/Sha256Simulator.ts new file mode 100644 index 00000000..5e6f2ea1 --- /dev/null +++ b/contracts/src/crypto/hash/test/simulators/Sha256Simulator.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.4.0-alpha.1 (crypto/hash/test/simulators/Sha256Simulator.ts) + +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + ledger, + Contract as MockSha256, +} from '../../../../../artifacts/MockSha256/contract/index.js'; + +type EmptyPrivateState = Record; +const EmptyPrivateState: EmptyPrivateState = {}; +const emptyWitnesses = () => ({}); + +const Sha256SimulatorBase = createSimulator< + EmptyPrivateState, + ReturnType, + ReturnType, + MockSha256, + readonly [] +>({ + contractFactory: (witnesses) => new MockSha256(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => emptyWitnesses(), + artifactName: 'MockSha256', +}); + +export class Sha256Simulator extends Sha256SimulatorBase { + static async create( + options: SimulatorOptions< + EmptyPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create([], options) as Promise; + } + + public digest(value: Uint8Array): Promise { + return this.circuits.impure.digest(value); + } + + public hashToField(msg: Uint8Array, DST: Uint8Array): Promise { + return this.circuits.impure.hashToField(msg, DST); + } +} diff --git a/contracts/src/crypto/test/EcdhMask.test.ts b/contracts/src/crypto/test/EcdhMask.test.ts index 865b4773..02b0225a 100644 --- a/contracts/src/crypto/test/EcdhMask.test.ts +++ b/contracts/src/crypto/test/EcdhMask.test.ts @@ -1,11 +1,11 @@ import { createHash } from 'node:crypto'; import { ecMulGenerator } from '@midnight-ntwrk/compact-runtime'; import fc from 'fast-check'; -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; import { pureCircuits as ecdh } from '../../../artifacts/MockEcdh/contract/index.js'; import { pureCircuits } from '../../../artifacts/MockEcdhMask/contract/index.js'; import { pureCircuits as elgamal } from '../../../artifacts/MockElGamal/contract/index.js'; -import { pureCircuits as sha } from '../../../artifacts/MockSha256/contract/index.js'; +import { Sha256Simulator } from '../hash/test/simulators/Sha256Simulator.js'; // The EcdhMask circuits are pure, so tests drive them directly via the compiled // artifact's `pureCircuits` (no proof, no simulator needed). @@ -250,6 +250,11 @@ describe('EcdhMask', () => { describe('fieldKdf', () => { const points = [2n, 5n, 222n, 999999n].map((s) => ecMulGenerator(s)); + let sha: Sha256Simulator; + + beforeAll(async () => { + sha = await Sha256Simulator.create(); + }); // persistentHash: SHA-256 of x || y, each as 32 little-endian bytes. const pointDigest = (point: { x: bigint; y: bigint }): Uint8Array => { @@ -280,10 +285,10 @@ describe('EcdhMask', () => { ); }); - it('should equal Sha256.hashToField(pointDigest(S), domain)', () => { + it('should equal Sha256.hashToField(pointDigest(S), domain)', async () => { for (const point of points) { expect(pureCircuits.fieldKdf(point, DOMAIN)).toBe( - sha.hashToField(pointDigest(point), DOMAIN), + await sha.hashToField(pointDigest(point), DOMAIN), ); } }); From dac1b0d9265964580bcca249b6c55b28b0b7177d Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 14:02:48 +0200 Subject: [PATCH 13/17] refactor(crypto): rename curves/bls12_381 to bls12-381 Directory names use hyphens; the underscore came from the upstream Rust module path. Only the import strings and header lines change. --- contracts/src/crypto/EcdhMask.compact | 2 +- .../src/crypto/curves/{bls12_381 => bls12-381}/Fq.compact | 2 +- .../crypto/curves/{bls12_381 => bls12-381}/test/Fq.test.ts | 0 .../curves/{bls12_381 => bls12-381}/test/mocks/MockFq.compact | 0 .../{bls12_381 => bls12-381}/test/simulators/FqSimulator.ts | 2 +- contracts/src/crypto/hash/Sha256.compact | 4 ++-- 6 files changed, 5 insertions(+), 5 deletions(-) rename contracts/src/crypto/curves/{bls12_381 => bls12-381}/Fq.compact (99%) rename contracts/src/crypto/curves/{bls12_381 => bls12-381}/test/Fq.test.ts (100%) rename contracts/src/crypto/curves/{bls12_381 => bls12-381}/test/mocks/MockFq.compact (100%) rename contracts/src/crypto/curves/{bls12_381 => bls12-381}/test/simulators/FqSimulator.ts (97%) diff --git a/contracts/src/crypto/EcdhMask.compact b/contracts/src/crypto/EcdhMask.compact index 5b6e3048..9108d93b 100644 --- a/contracts/src/crypto/EcdhMask.compact +++ b/contracts/src/crypto/EcdhMask.compact @@ -113,7 +113,7 @@ pragma language_version >= 0.26.0; module EcdhMask { import CompactStandardLibrary; import "./Ecdh" prefix Ecdh_; - import "./curves/bls12_381/Fq" prefix Fq_; + import "./curves/bls12-381/Fq" prefix Fq_; import "./hash/Sha256" prefix Sha256_; /** diff --git a/contracts/src/crypto/curves/bls12_381/Fq.compact b/contracts/src/crypto/curves/bls12-381/Fq.compact similarity index 99% rename from contracts/src/crypto/curves/bls12_381/Fq.compact rename to contracts/src/crypto/curves/bls12-381/Fq.compact index 360020b1..f9bff0ea 100644 --- a/contracts/src/crypto/curves/bls12_381/Fq.compact +++ b/contracts/src/crypto/curves/bls12-381/Fq.compact @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.4.0-alpha.1 (crypto/curves/bls12_381/Fq.compact) +// OpenZeppelin Compact Contracts v0.4.0-alpha.1 (crypto/curves/bls12-381/Fq.compact) pragma language_version >= 0.26.0; diff --git a/contracts/src/crypto/curves/bls12_381/test/Fq.test.ts b/contracts/src/crypto/curves/bls12-381/test/Fq.test.ts similarity index 100% rename from contracts/src/crypto/curves/bls12_381/test/Fq.test.ts rename to contracts/src/crypto/curves/bls12-381/test/Fq.test.ts diff --git a/contracts/src/crypto/curves/bls12_381/test/mocks/MockFq.compact b/contracts/src/crypto/curves/bls12-381/test/mocks/MockFq.compact similarity index 100% rename from contracts/src/crypto/curves/bls12_381/test/mocks/MockFq.compact rename to contracts/src/crypto/curves/bls12-381/test/mocks/MockFq.compact diff --git a/contracts/src/crypto/curves/bls12_381/test/simulators/FqSimulator.ts b/contracts/src/crypto/curves/bls12-381/test/simulators/FqSimulator.ts similarity index 97% rename from contracts/src/crypto/curves/bls12_381/test/simulators/FqSimulator.ts rename to contracts/src/crypto/curves/bls12-381/test/simulators/FqSimulator.ts index 069047b5..683097b3 100644 --- a/contracts/src/crypto/curves/bls12_381/test/simulators/FqSimulator.ts +++ b/contracts/src/crypto/curves/bls12-381/test/simulators/FqSimulator.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// OpenZeppelin Compact Contracts v0.4.0-alpha.1 (crypto/curves/bls12_381/test/simulators/FqSimulator.ts) +// OpenZeppelin Compact Contracts v0.4.0-alpha.1 (crypto/curves/bls12-381/test/simulators/FqSimulator.ts) import { createSimulator, diff --git a/contracts/src/crypto/hash/Sha256.compact b/contracts/src/crypto/hash/Sha256.compact index 79459062..6b727580 100644 --- a/contracts/src/crypto/hash/Sha256.compact +++ b/contracts/src/crypto/hash/Sha256.compact @@ -31,7 +31,7 @@ pragma language_version >= 0.26.0; * - `e_0`: the resulting field element. * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 * - `b_i = H(msg, DST, i)`: expander block `i`, this module's `expandBlock`. - * - `LEOS2IP`: the little-endian `OS2IP`, as in `crypto/curves/bls12_381/Fq`. + * - `LEOS2IP`: the little-endian `OS2IP`, as in `crypto/curves/bls12-381/Fq`. * https://zips.z.cash/protocol/protocol.pdf#endian * * @dev Interface: @@ -63,7 +63,7 @@ pragma language_version >= 0.26.0; */ module Sha256 { import CompactStandardLibrary; - import "../curves/bls12_381/Fq" prefix Fq_; + import "../curves/bls12-381/Fq" prefix Fq_; /** * @description Holds the preimage of expander block `b_i`. From 31c804819017a4fcbc7c3fbe61bf820c1a5a07d6 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 14:17:11 +0200 Subject: [PATCH 14/17] docs(crypto): name MGF1 as the Sha256 expander expandMessage is RFC 8017 MGF1 with mgfSeed = msg || DST and a 32-byte little-endian counter, not expand_message_xmd, whose strxor step has no Compact operator. The preimage struct and expandBlock use MGF1's names, C and counter, and the struct is exported. Every notation term links to its definition, the hash_to_field steps are spelled out, and the digest backends cite the ledger's persistent_hash and the ZKIR instruction. --- contracts/src/crypto/hash/Sha256.compact | 120 +++++++++++++----- contracts/src/crypto/hash/test/Sha256.test.ts | 2 +- 2 files changed, 90 insertions(+), 32 deletions(-) diff --git a/contracts/src/crypto/hash/Sha256.compact b/contracts/src/crypto/hash/Sha256.compact index 6b727580..f3081822 100644 --- a/contracts/src/crypto/hash/Sha256.compact +++ b/contracts/src/crypto/hash/Sha256.compact @@ -7,22 +7,37 @@ pragma language_version >= 0.26.0; * @module Sha256 * @description * SHA-256 behind the `crypto/hash` interface: a byte digest and a hash to the - * native field. The hash to the field follows RFC 9380 `hash_to_field`. + * native field. `hashToField` is RFC 9380 `hash_to_field` with MGF1 in place + * of `expand_message_xmd`. * * @see https://doi.org/10.6028/NIST.FIPS.180-4 FIPS 180-4, SHA-256 * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.2 RFC 9380 §5.2, `hash_to_field` * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.3.1 RFC 9380 §5.3.1, `expand_message_xmd` + * @see https://www.rfc-editor.org/rfc/rfc8017#appendix-B.2.1 RFC 8017 §B.2.1, MGF1 + * @see https://github.com/midnightntwrk/midnight-ledger/blob/main/base-crypto/src/hash.rs `persistent_hash` * * @dev Notation: - * - `H(...)`: `digest`, the SHA-256 of its argument as a `Bytes<32>`. + * - `H(x)`: `digest(x)`, the SHA-256 of `x` in Compact's encoding. * https://doi.org/10.6028/NIST.FIPS.180-4 + * - `a || b`: concatenation of octet strings. + * https://www.rfc-editor.org/rfc/rfc9380#section-4 + * - `substr(str, sbegin, slen)`: the `slen` bytes of `str` from `sbegin`. + * https://www.rfc-editor.org/rfc/rfc9380#section-4 * - `q`: the order of `Fq`, Compact's native `Field`. The RFC's `p`. * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 * - `msg`: the message to hash. * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 * - `DST`: the domain separation tag. * https://www.rfc-editor.org/rfc/rfc9380#section-2.2.5 - * - `len_in_bytes`: how many uniform bytes are requested. + * - `count`: how many field elements to output. `1` here. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 + * - `m`: the extension degree of the field. `1` for `Fq`. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 + * - `k`: the security parameter, in bits. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 + * - `L`: bytes per element, `ceil((ceil(log2(q)) + k) / 8)`. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 + * - `len_in_bytes`: how many uniform bytes are requested, `count * m * L`. * https://www.rfc-editor.org/rfc/rfc9380#section-5.3 * - `uniform_bytes`: the output of `expand_message`. * https://www.rfc-editor.org/rfc/rfc9380#section-5.3 @@ -30,21 +45,30 @@ pragma language_version >= 0.26.0; * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 * - `e_0`: the resulting field element. * https://www.rfc-editor.org/rfc/rfc9380#section-5.2 - * - `b_i = H(msg, DST, i)`: expander block `i`, this module's `expandBlock`. + * - `I2LEOSP_l(x)`: little-endian `I2OSP` on `l` bits. + * https://zips.z.cash/protocol/protocol.pdf#endian * - `LEOS2IP`: the little-endian `OS2IP`, as in `crypto/curves/bls12-381/Fq`. * https://zips.z.cash/protocol/protocol.pdf#endian + * - `b_i`: expander block `i`, this module's `expandBlock` at + * `counter = i`. Not the RFC's `b_i`, which chains from a seed block. + * https://www.rfc-editor.org/rfc/rfc9380#section-5.3.1 + * - `mgfSeed`, `maskLen`, `hLen`: MGF1's seed, output length and hash + * length. `msg || DST`, `64` and `32` here. + * https://www.rfc-editor.org/rfc/rfc8017#appendix-B.2.1 + * - `counter`, `C`: MGF1's block index and its octet-string encoding. + * https://www.rfc-editor.org/rfc/rfc8017#appendix-B.2.1 * * @dev Interface: * Every `crypto/hash` module exports `digest` and `hashToField` with these * signatures, so a consumer changes hash by changing its import. `IHasher` - * holds the signatures as a contract type, and `MockSha256` implements it. + * holds the signatures as a contract type. * - * @dev Derivations: - * `H` hashes its argument in Compact's encoding. At `T = Bytes<32>` that is - * the raw bytes in field order, so `digest(msg) = SHA-256(msg)` and - * `b_i = SHA-256(msg || DST || i)` with `i` as 32 little-endian bytes. Other - * types encode differently: call the exported circuits instead of - * reimplementing them. + * @dev Encoding: + * `H` hashes Compact's encoding of its argument: `Bytes` raw, `Field` as + * `I2LEOSP_256`, a struct as its fields in order. At `T = Bytes<32>` that + * gives `digest(msg) = SHA-256(msg)` and + * `b_i = SHA-256(msg || DST || I2LEOSP_256(counter))`. Other `T` encode + * differently: call the exported circuits instead of reimplementing them. * * @dev Compatibility: * Built on `persistentHash`, so outputs survive a platform upgrade. The hash, @@ -66,15 +90,25 @@ module Sha256 { import "../curves/bls12-381/Fq" prefix Fq_; /** - * @description Holds the preimage of expander block `b_i`. + * @description Holds the preimage of expander block `b_i` (MGF1's `mgfSeed || C`). + * `mgfSeed = msg || DST` and `C = I2LEOSP_256(counter)`. `persistentHash` + * takes one value, so the parts are a struct, whose encoding is its fields + * in order. + * + * @dev Deviation from MGF1: + * `C` is `I2LEOSP_256(counter)` in place of `I2OSP(counter, 4)`, since + * `counter` arrives as a `Field` and `as Bytes<32>` is its encoding. + * + * @see https://www.rfc-editor.org/rfc/rfc8017#appendix-B.2.1 RFC 8017 §B.2.1, MGF1, step 3 + * * @type {T} msg - The message being hashed. * @type {Bytes<32>} DST - The domain separation tag. - * @type {Bytes<32>} i - The block counter. + * @type {Bytes<32>} C - `I2LEOSP_256(counter)`, the encoded block counter. */ - struct ExpandPreimage { + export struct ExpandPreimage { msg: T; DST: Bytes<32>; - i: Bytes<32>; + C: Bytes<32>; } /** @@ -87,14 +121,17 @@ module Sha256 { * - 1: `len_in_bytes = count * m * L = 64`. A length is a type here, so * `Fq.UniformBytes` carries it. * - 2: `uniform_bytes = expand_message(msg, DST, len_in_bytes)`. - * - 3 to 6: with `count = 1` and `m = 1`, `tv` is all of `uniform_bytes`. + * - 3, 4: one pass each of the loops over `count` and `m`. + * - 5: `elm_offset = L * (j + i * m) = 0`. + * - 6: `tv = substr(uniform_bytes, 0, 64)`, all of it. * - 7: `e_0 = LEOS2IP(tv) mod q`, in `Fq.fromUniformBytes`. - * - 8 to 9: return `e_0`. + * - 8, 9: return `e_0`. * * @dev Deviations from RFC 9380: - * - `L = 64` against the RFC's 48, so `k = 257` against 128. + * - `L = 64` against the RFC's `ceil((255 + 128) / 8) = 48`, so `k = 257` + * against `128`. * - `LEOS2IP` in place of the big-endian `OS2IP`. - * - `expand_message` is `expandMessage` below, in place of + * - `expand_message` is MGF1, `expandMessage` below, in place of * `expand_message_xmd`. * * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.2 `hash_to_field` @@ -105,23 +142,32 @@ module Sha256 { */ export pure circuit hashToField(msg: T, DST: Bytes<32>): Field { // Steps 1 and 2. - const uniform_bytes = expandMessage(msg, DST); + const uniformBytes = expandMessage(msg, DST); // Steps 3 to 6. - const tv = uniform_bytes; + const tv = uniformBytes; // Steps 7 to 9. return Fq_fromUniformBytes(tv); } /** * @description Expands a message to 64 uniform bytes (`uniform_bytes = b_0 || b_1`). - * A counter expander in place of `expand_message_xmd`. + * MGF1 in place of `expand_message_xmd`. * - * @dev Deviations from `expand_message_xmd`: - * - The blocks are independent, `b_i = H(msg, DST, i)`. The RFC chains them - * from a seed block. - * - No length prefixes. Every preimage field has a fixed width, so the - * encoding is unambiguous without them. + * @dev Reference: + * RFC 8017 §B.2.1 MGF1 with `mgfSeed = msg || DST`, `maskLen = 64` and + * `hLen = 32`, so two blocks: + * `T = Hash(mgfSeed || I2OSP(0, 4))` + * ` || Hash(mgfSeed || I2OSP(1, 4))`. + * Here `Hash` is `H` and `C = I2LEOSP_256(counter)`; see + * `ExpandPreimage`. * + * @dev Why not `expand_message_xmd`: + * Its step 9 needs `strxor(b_0, b_(i-1))`, and Compact has no XOR, only + * `+`, `-` and `*`. MGF1 needs concatenation only. The differences that + * follow: blocks are independent instead of chained, and there are no + * length prefixes, since every preimage field has a fixed width. + * + * @see https://www.rfc-editor.org/rfc/rfc8017#appendix-B.2.1 RFC 8017 §B.2.1, MGF1 * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.3.1 `expand_message_xmd` * * @param {T} msg - The message to hash. @@ -136,16 +182,22 @@ module Sha256 { } /** - * @description Computes one expander block (`b_i = H(msg, DST, i)`). + * @description Computes one expander block (`b_i = H(msg || DST || I2LEOSP_256(counter))`). + * `H` runs over the `ExpandPreimage` struct, whose encoding is that + * concatenation. The cast `counter as Bytes<32>` is `I2LEOSP_256(counter)`. * * @param {T} msg - The message to hash. * @param {Bytes<32>} DST - The domain separation tag. - * @param {Field} i - The block counter. + * @param {Field} counter - The block counter, MGF1's `counter`. * @return {Bytes<32>} - The block. */ - pure circuit expandBlock(msg: T, DST: Bytes<32>, i: Field): Bytes<32> { + pure circuit expandBlock( + msg: T, + DST: Bytes<32>, + counter: Field + ): Bytes<32> { return digest>( - ExpandPreimage { msg: msg, DST: DST, i: i as Bytes<32> } + ExpandPreimage { msg: msg, DST: DST, C: counter as Bytes<32> } ); } @@ -155,7 +207,13 @@ module Sha256 { * * @constraints k=13, rows=2305 * + * @dev Backends: + * - Rust ledger: `persistent_hash`, `Sha256::digest` over the encoding. + * - ZKIR: the `persistent_hash` instruction, `sha2_256` over the same bytes. + * * @see https://doi.org/10.6028/NIST.FIPS.180-4 FIPS 180-4, SHA-256 + * @see https://github.com/midnightntwrk/midnight-ledger/blob/main/base-crypto/src/hash.rs `persistent_hash` + * @see https://github.com/midnightntwrk/midnight-ledger/blob/main/zkir/src/ir_vm.rs `PersistentHash`, the ZKIR instruction * * @param {T} value - The value to hash. * @return {Bytes<32>} - The digest. diff --git a/contracts/src/crypto/hash/test/Sha256.test.ts b/contracts/src/crypto/hash/test/Sha256.test.ts index af98c675..824ff0b9 100644 --- a/contracts/src/crypto/hash/test/Sha256.test.ts +++ b/contracts/src/crypto/hash/test/Sha256.test.ts @@ -137,7 +137,7 @@ describe('Sha256', () => { ); }); - it('should equal LEOS2IP_512(b_0 || b_1) mod q with b_i = SHA-256(msg || DST || i)', async () => { + it('should equal LEOS2IP_512(b_0 || b_1) mod q with b_i = SHA-256(msg || DST || I2LEOSP_256(i))', async () => { const msg = Uint8Array.from({ length: 32 }, (_, i) => i * 7); const DST = Uint8Array.from({ length: 32 }, (_, i) => 100 + i); const counter = (i: number): Uint8Array => { From a64d7ed25828fcb6570d75df73bf46227d915222 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 14:27:13 +0200 Subject: [PATCH 15/17] docs(crypto): pin the EcdhMask KDF formulas and references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kdf, pointDigest and fieldKdf state their formulas, fieldKdf cites the ECIES step it plays (SEC 1 §5.1.3) and the hash_to_field it is, and kdf says which SEC 1 KDF it is not. The kdf-versus-fieldKdf separation is stated on the preimages. The @constraints values are re-measured through a ledger-backed mock; the previous ones did not come from one. --- contracts/src/crypto/EcdhMask.compact | 81 +++++++++++++++++---------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/contracts/src/crypto/EcdhMask.compact b/contracts/src/crypto/EcdhMask.compact index 9108d93b..d60480c4 100644 --- a/contracts/src/crypto/EcdhMask.compact +++ b/contracts/src/crypto/EcdhMask.compact @@ -82,13 +82,14 @@ pragma language_version >= 0.26.0; * folding it in buys nothing. * * @dev Two KDFs, two output ranges. `kdf` derives into `[0, 2^248)`: one - * `Fq.truncatedLEOS2IP` output, the SEC 1 KDF shape, for keys, nonces, and the - * `Uint<128>` mask of `encrypt` (hidden with about `2^-120` slack). `fieldKdf` - * derives uniformly into the field through `crypto/hash/Sha256.hashToField` - * (RFC 9380 `hash_to_field`), within `2^-257` of uniform, so - * `ct = m + fieldKdf(S, domain)` hides any `m` with no precondition on its range - * or entropy. Mask a `Field` with `fieldKdf`, never with `kdf`; neither - * authenticates (see Integrity). + * SHA-256 of `H(S) || domain` truncated by `Fq.truncatedLEOS2IP`, for keys, + * nonces, and the `Uint<128>` mask of `encrypt` (hidden with about `2^-120` + * slack). `fieldKdf` derives uniformly into the field through + * `crypto/hash/Sha256.hashToField` (RFC 9380 `hash_to_field`), within `2^-257` + * of uniform, so `ct = m + fieldKdf(S, domain)` hides any `m` with no + * precondition on its range or entropy. Mask a `Field` with `fieldKdf`, never + * with `kdf`: a `kdf` pad never wraps, so `ct` sits within `2^248` above `m` + * and the top bits of `m` show through. Neither authenticates (see Integrity). * * @dev Multi-field delivery. A consumer delivering several `Field`s to one * recipient calls `deriveShared` once and adds `fieldKdf(sShared, tag_i)` per @@ -130,7 +131,7 @@ module EcdhMask { * * @notice `e` MUST be fresh per call (see the module freshness precondition). * - * @constraints k=14, rows=9269 + * @constraints k=14, rows=10521 * * Requirements: * @@ -156,23 +157,28 @@ module EcdhMask { } /** - * @description Derives the field mask from the ECDH shared secret point, - * domain-separated by a caller-supplied `domain` tag. Hashes the point to - * `Bytes<32>`, then re-hashes it with the domain tag and truncates into the - * field via `Fq.truncatedLEOS2IP`. The consumer chooses `domain` (so this - * module is not tied to any one protocol) and MUST use the same value on - * encrypt and decrypt. + * @description Derives the 248-bit mask from the shared secret point (`truncatedLEOS2IP(SHA-256(H(S) || domain))`). + * Domain-separated by a caller-supplied `domain` tag. The consumer chooses + * `domain` (so this module is not tied to any one protocol) and MUST use the + * same value on encrypt and decrypt. + * + * @dev Formula: + * `H(S)` is `pointDigest`. One 64-byte preimage, one SHA-256, then the low + * 31 bytes as an integer. Not the SEC 1 §3.6.1 KDF, which adds a counter + * between `Z` and `SharedInfo`; one block needs none. * * @dev Uses `crypto/hash/Sha256` deliberately: the recipient reproduces this * mask to decrypt, possibly across a platform upgrade, so it must be * upgrade-stable. * - * @constraints k=13, rows=7917 + * @constraints k=14, rows=8700 + * + * @see https://www.secg.org/sec1-v2.pdf#page=38 SEC 1 §3.6.1, the KDF this is not, p. 31 * * @param sShared - The ECDH shared secret point (`pk^e` for the sender, * `E^ek` for the recipient; equal by construction). * @param domain - The consumer's domain-separation tag. - * @return The field mask. + * @return The field mask, below `2^248`. */ export pure circuit kdf(sShared: JubjubPoint, domain: Bytes<32>): Field { return Fq_truncatedLEOS2IP( @@ -181,9 +187,14 @@ module EcdhMask { } /** - * @description Hashes the shared secret point into the fixed-width `Bytes<32>` - * that opens the preimage of both KDFs. Shared by `kdf` and `fieldKdf` so the - * point is hashed once per `fieldKdf` call and both KDFs agree on its encoding. + * @description Hashes the shared secret point to 32 bytes (`H(S) = SHA-256(I2LEOSP_256(S.x) || I2LEOSP_256(S.y))`). + * The fixed-width value that opens the preimage of both KDFs. Shared by `kdf` + * and `fieldKdf` so the point is hashed once per `fieldKdf` call and both + * KDFs agree on its encoding. + * + * @dev Encoding: + * `Sha256.digest` hashes the ledger encoding of the point, its + * two coordinates as 32 little-endian bytes each, `x` first. * * @dev Uses `crypto/hash/Sha256` for the same reason as `kdf`. * @@ -198,7 +209,7 @@ module EcdhMask { * @description Recovers the value from a ciphertext using the recipient's * secret scalar. Pure and off-chain (no proof needed to read a received value). * - * @constraints k=14, rows=8315 + * @constraints k=14, rows=9270 * * @param ciphertext - The ciphertext to decrypt. * @param ekScalar - The recipient's secret scalar (`crypto/ElGamal`'s @@ -222,7 +233,7 @@ module EcdhMask { * @notice `e` MUST be fresh per call and secret (see `crypto/Ecdh`). Reuse * leaks the difference of the two plaintexts. * - * @constraints k=14, rows=13604 + * @constraints k=14, rows=15748 * * Requirements: * @@ -248,23 +259,33 @@ module EcdhMask { } /** - * @description Derives a mask uniform over the whole field from the ECDH - * shared secret point, domain-separated by a caller-supplied `domain` tag, so + * @description Derives a mask uniform over the field from the shared secret point (`hash_to_field(H(S), domain)`). + * Domain-separated by a caller-supplied `domain` tag, so * `m + fieldKdf(S, domain)` hides any `m` with no bound on its range. Use * this, not `kdf`, to mask a `Field`. * - * @dev The KDF of the scheme in the SEC 1 sense: `Z = pointDigest(sShared)`, - * `SharedInfo = domain`. `hashToField` hashes a three-element preimage against - * `kdf`'s two-element one, so a consumer may use `kdf` and `fieldKdf` under the - * same `(S, domain)` without one revealing the other. + * @dev Role: + * The key derivation step of ECIES, SEC 1 §5.1.3 step 5, with + * `Z = H(S)` and `SharedInfo = domain`. The function itself is RFC 9380 + * `hash_to_field` as `crypto/hash/Sha256` implements it, not SEC 1's own + * KDF. + * + * @dev Against `kdf`: + * `kdf`'s 64-byte preimage `H(S) || domain` is a prefix of each MGF1 block + * preimage `H(S) || domain || C`. The 32-byte counter makes them distinct + * inputs, so a consumer may use both under one `(S, domain)` without one + * revealing the other. + * + * @constraints k=14, rows=14055 * - * @constraints k=14, rows=12284 + * @see https://www.secg.org/sec1-v2.pdf#page=58 SEC 1 §5.1.3, ECIES encryption operation, p. 52 + * @see https://www.rfc-editor.org/rfc/rfc9380#section-5.2 RFC 9380 §5.2, `hash_to_field` * * @param sShared - The ECDH shared secret point. * @param domain - The consumer's domain-separation tag. Pairwise distinct per * field masked under one `sShared`, and the same value on * encrypt and decrypt. - * @return The field mask. + * @return `e_0`, the field mask, within `2^-257` of uniform. */ export pure circuit fieldKdf(sShared: JubjubPoint, domain: Bytes<32>): Field { return Sha256_hashToField>(pointDigest(sShared), domain); @@ -278,7 +299,7 @@ module EcdhMask { * element rather than aborting, so the circuit is not a key-correctness oracle. * Nothing here authenticates the ciphertext. * - * @constraints k=14, rows=12682 + * @constraints k=14, rows=14625 * * @param ciphertext - The ciphertext to decrypt. * @param ekScalar - The recipient's secret scalar (`crypto/ElGamal`'s From 405c24ca7462b5b40e57c0fd8bf7bbf1748ccf94 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 14:28:32 +0200 Subject: [PATCH 16/17] docs: changelog for the crypto hash, Fq and fieldKdf additions --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5abb277..a044e0b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add the `crypto/Ecdh` module, the Jubjub key agreement that `crypto/EcdhMask` used to carry inline: `deriveShared` (sender side, owns the identity-key and zero-ephemeral guards), `recoverShared` (recipient side, never asserts), and the `SharedSecret` struct. `EcdhMask.encrypt` and `decrypt` delegate to it and produce the same ciphertexts as before; the guard messages now read `Ecdh: identity pk` / `Ecdh: zero ephemeral`. (#866) -- Add `fieldKdf`, `encryptField` and `decryptField` to `crypto/EcdhMask`, a mask uniform over the whole `Field` built from two 248-bit halves combined as `k1 + k2 * 2^248 mod p`. `encryptField` therefore hides a plaintext with no range or entropy precondition, where `encrypt`'s 248-bit `kdf` mask needs `value < 2^128`. `kdf`'s output is unchanged. (#913) +- Add `crypto/hash/IHasher`, the contract type every hash module satisfies (`digest` and `hashToField` at `T = Bytes<32>`), and `crypto/hash/Sha256` behind it, built on `persistentHash` so outputs survive a platform upgrade. (#921, #913) +- Add `crypto/curves/bls12-381/Fq`, the BLS12-381 scalar field that Compact's `Field` is: `truncatedLEOS2IP` and `truncatedI2LEOSP`, the standard library's `degradeToTransient` and `upgradeFromTransient` under their RFC 8017 names, and `fromUniformBytes`, `LEOS2IP_512(tv) mod q`. (#922, #913) +- Add `Sha256.hashToField`, RFC 9380 `hash_to_field` with `count = 1`, `m = 1`, `L = 64`. The expander is RFC 8017 MGF1, since `expand_message_xmd` needs an XOR Compact lacks. Verified against an independent Python implementation. (#923, #913) +- Add `fieldKdf`, `encryptField` and `decryptField` to `crypto/EcdhMask`: a mask uniform over the whole `Field` through `Sha256.hashToField`, within `2^-257` of uniform, so `encryptField` hides a plaintext with no range or entropy precondition. A `Field` masked with the 248-bit `kdf` leaks its top bits, which the spec now demonstrates. `kdf`'s output is unchanged. (#735, #913) ### Changed From e0a37e92ef5eb1eadab7e0ebd6a0fb85b14be000 Mon Sep 17 00:00:00 2001 From: 0xisk <0xisk@proton.me> Date: Fri, 18 Sep 2026 14:35:45 +0200 Subject: [PATCH 17/17] test(crypto): make the EcdhMask mock ledger-backed A pure mock ships no ZKIR, so the EcdhMask rows could not be measured or reproduced. The mock now carries a counter like MockFq and MockSha256, and the EcdhMask and token specs reach it through a simulator. --- contracts/src/crypto/test/EcdhMask.test.ts | 396 ++++++++++-------- .../crypto/test/mocks/MockEcdhMask.compact | 30 +- .../test/simulators/EcdhMaskSimulator.ts | 104 +++++ .../test/ConfidentialFungibleToken.test.ts | 20 +- 4 files changed, 355 insertions(+), 195 deletions(-) create mode 100644 contracts/src/crypto/test/simulators/EcdhMaskSimulator.ts diff --git a/contracts/src/crypto/test/EcdhMask.test.ts b/contracts/src/crypto/test/EcdhMask.test.ts index 02b0225a..7d411270 100644 --- a/contracts/src/crypto/test/EcdhMask.test.ts +++ b/contracts/src/crypto/test/EcdhMask.test.ts @@ -1,14 +1,20 @@ import { createHash } from 'node:crypto'; -import { ecMulGenerator } from '@midnight-ntwrk/compact-runtime'; +import { + ecMulGenerator, + type JubjubPoint, +} from '@midnight-ntwrk/compact-runtime'; import fc from 'fast-check'; import { beforeAll, describe, expect, it } from 'vitest'; import { pureCircuits as ecdh } from '../../../artifacts/MockEcdh/contract/index.js'; -import { pureCircuits } from '../../../artifacts/MockEcdhMask/contract/index.js'; import { pureCircuits as elgamal } from '../../../artifacts/MockElGamal/contract/index.js'; import { Sha256Simulator } from '../hash/test/simulators/Sha256Simulator.js'; +import { + type Ciphertext, + EcdhMaskSimulator, +} from './simulators/EcdhMaskSimulator.js'; -// The EcdhMask circuits are pure, so tests drive them directly via the compiled -// artifact's `pureCircuits` (no proof, no simulator needed). +// The mock is ledger-backed so the artifact ships ZKIR, so tests reach the +// EcdhMask circuits through a simulator rather than `pureCircuits`. // Jubjub prime-order subgroup order. Valid scalars are [1, L-1]; the runtime // faults ecMul on scalars >= L (see crypto/ElGamal), so L-1 is the largest @@ -27,6 +33,9 @@ const TWO_248 = 1n << 248n; // field elements is not the field difference. const sub = (a: bigint, b: bigint): bigint => (a - b + P) % P; +// A key-derivation circuit under test: `kdf` or `fieldKdf`. +type KdfFn = (sShared: JubjubPoint, domain: Uint8Array) => Promise; + // A recipient's secret scalar and their derived public key g^ek. const EK = 111222333444555n; const PK = ecMulGenerator(EK); @@ -57,56 +66,62 @@ const GOLDEN_CIPHERTEXT = { }; describe('EcdhMask', () => { + let mask: EcdhMaskSimulator; + + beforeAll(async () => { + mask = await EcdhMaskSimulator.create(); + }); + describe('encrypt golden vector', () => { - it('reproduces the pinned ciphertext bit for bit', () => { + it('reproduces the pinned ciphertext bit for bit', async () => { // encrypt's output is part of its API, so an importer that recompiles // still decrypts what it wrote before the split. expect( - pureCircuits.encrypt(PK, GOLDEN_VALUE, GOLDEN_E, GOLDEN_DOMAIN), + await mask.encrypt(PK, GOLDEN_VALUE, GOLDEN_E, GOLDEN_DOMAIN), ).toStrictEqual(GOLDEN_CIPHERTEXT); }); }); describe('composition with crypto/Ecdh', () => { - it('encrypt equals deriveShared then kdf then add', () => { + it('encrypt equals deriveShared then kdf then add', async () => { const shared = ecdh.deriveShared(PK, 42n); - const mask = pureCircuits.kdf(shared.sShared, DOMAIN); - expect(pureCircuits.encrypt(PK, 1000n, 42n, DOMAIN)).toStrictEqual({ + const pad = await mask.kdf(shared.sShared, DOMAIN); + expect(await mask.encrypt(PK, 1000n, 42n, DOMAIN)).toStrictEqual({ ephemeralPk: shared.ephemeralPk, - ct: (1000n + mask) % P, + ct: (1000n + pad) % P, }); }); }); describe('encrypt / decrypt round-trip', () => { - it('recovers the encrypted value', () => { - const ciphertext = pureCircuits.encrypt(PK, 1000n, 42n, DOMAIN); - expect(pureCircuits.decrypt(ciphertext, EK, DOMAIN)).toBe(1000n); + it('recovers the encrypted value', async () => { + const ciphertext = await mask.encrypt(PK, 1000n, 42n, DOMAIN); + expect(await mask.decrypt(ciphertext, EK, DOMAIN)).toBe(1000n); }); - it('round-trips zero', () => { - const ciphertext = pureCircuits.encrypt(PK, 0n, 42n, DOMAIN); - expect(pureCircuits.decrypt(ciphertext, EK, DOMAIN)).toBe(0n); + it('round-trips zero', async () => { + const ciphertext = await mask.encrypt(PK, 0n, 42n, DOMAIN); + expect(await mask.decrypt(ciphertext, EK, DOMAIN)).toBe(0n); }); - it('round-trips a value far above 2^48 (no discrete-log bound)', () => { + it('round-trips a value far above 2^48 (no discrete-log bound)', async () => { // This is the whole point of the ECDH mask: values are delivered // directly, so there is no BSGS table and no 2^48 cap. const big = 1n << 120n; - const ciphertext = pureCircuits.encrypt(PK, big, 42n, DOMAIN); - expect(pureCircuits.decrypt(ciphertext, EK, DOMAIN)).toBe(big); + const ciphertext = await mask.encrypt(PK, big, 42n, DOMAIN); + expect(await mask.decrypt(ciphertext, EK, DOMAIN)).toBe(big); }); - it('round-trips the maximum Uint<128> value', () => { + it('round-trips the maximum Uint<128> value', async () => { // Recovery is field subtraction (ct - mask), which is exact even if // value + mask wrapped the field modulus, so the max value round-trips // regardless of wrap. const max = (1n << 128n) - 1n; - const ciphertext = pureCircuits.encrypt(PK, max, 42n, DOMAIN); - expect(pureCircuits.decrypt(ciphertext, EK, DOMAIN)).toBe(max); + const ciphertext = await mask.encrypt(PK, max, 42n, DOMAIN); + expect(await mask.decrypt(ciphertext, EK, DOMAIN)).toBe(max); }); - it('round-trips at the maximum valid scalar (L - 1) for key and ephemeral', () => { + it('round-trips at the maximum valid scalar (L - 1) for key and ephemeral', async () => { // Exercise the top of the valid scalar range: both the recipient key's // secret and the ephemeral are L - 1, the largest scalar the runtime // accepts (L and above fault ecMul). @@ -114,33 +129,33 @@ describe('EcdhMask', () => { const e = L - 1n; const max = (1n << 128n) - 1n; const pk = ecMulGenerator(ek); - const ciphertext = pureCircuits.encrypt(pk, max, e, DOMAIN); - expect(pureCircuits.decrypt(ciphertext, ek, DOMAIN)).toBe(max); + const ciphertext = await mask.encrypt(pk, max, e, DOMAIN); + expect(await mask.decrypt(ciphertext, ek, DOMAIN)).toBe(max); }); - it('round-trips through the real crypto/ElGamal key derivation', () => { + it('round-trips through the real crypto/ElGamal key derivation', async () => { // The CFT memo path derives the recipient pair from a Bytes<32> EK via // crypto/ElGamal: pk = derivePk(EK), ekScalar = secretToScalar(EK). Pin // that shared-key infrastructure end to end rather than using raw scalars. const ekBytes = new Uint8Array(32).fill(0x11); const pk = elgamal.derivePk(ekBytes); const ekScalar = elgamal.secretToScalar(ekBytes); - const ciphertext = pureCircuits.encrypt(pk, 4242n, 99n, DOMAIN); - expect(pureCircuits.decrypt(ciphertext, ekScalar, DOMAIN)).toBe(4242n); + const ciphertext = await mask.encrypt(pk, 4242n, 99n, DOMAIN); + expect(await mask.decrypt(ciphertext, ekScalar, DOMAIN)).toBe(4242n); }); - it('round-trips for arbitrary keys, ephemerals, and values (property)', () => { - fc.assert( - fc.property( + it('round-trips for arbitrary keys, ephemerals, and values (property)', async () => { + await fc.assert( + fc.asyncProperty( // Keys and ephemerals stay well under the Jubjub subgroup order ℓ // (~2^252) so they are valid scalars; values span the full Uint<128>. fc.bigInt({ min: 1n, max: 1n << 200n }), fc.bigInt({ min: 1n, max: 1n << 200n }), fc.bigInt({ min: 0n, max: (1n << 128n) - 1n }), - (ek, e, value) => { + async (ek, e, value) => { const pk = ecMulGenerator(ek); - const ciphertext = pureCircuits.encrypt(pk, value, e, DOMAIN); - expect(pureCircuits.decrypt(ciphertext, ek, DOMAIN)).toBe(value); + const ciphertext = await mask.encrypt(pk, value, e, DOMAIN); + expect(await mask.decrypt(ciphertext, ek, DOMAIN)).toBe(value); }, ), ); @@ -148,24 +163,24 @@ describe('EcdhMask', () => { }); describe('freshness / pad reuse', () => { - it('reusing the ephemeral to one recipient leaks the value difference', () => { + it('reusing the ephemeral to one recipient leaks the value difference', async () => { // The freshness footgun in executable form: a repeated `e` to the same // recipient reuses the one-time pad, so the ciphertext difference equals // the plaintext difference. This is exactly why `e` MUST be fresh; the // test also pins the pad semantics against a KDF regression. const e = 7n; - const c1 = pureCircuits.encrypt(PK, 1000n, e, DOMAIN); - const c2 = pureCircuits.encrypt(PK, 250n, e, DOMAIN); + const c1 = await mask.encrypt(PK, 1000n, e, DOMAIN); + const c2 = await mask.encrypt(PK, 250n, e, DOMAIN); expect(c1.ct - c2.ct).toBe(1000n - 250n); }); - it('reusing the ephemeral leaks the difference through encryptField too', () => { + it('reusing the ephemeral leaks the difference through encryptField too', async () => { // Uniformity buys nothing once the pad repeats. const e = 7n; const m1 = P - 1n; const m2 = 1n << 200n; - const c1 = pureCircuits.encryptField(PK, m1, e, DOMAIN); - const c2 = pureCircuits.encryptField(PK, m2, e, DOMAIN); + const c1 = await mask.encryptField(PK, m1, e, DOMAIN); + const c2 = await mask.encryptField(PK, m2, e, DOMAIN); expect(sub(c1.ct, c2.ct)).toBe(sub(m1, m2)); }); }); @@ -173,77 +188,73 @@ describe('EcdhMask', () => { describe('weak-input guards', () => { // Both guards now live in crypto/Ecdh, so encrypt raises that module's // messages. The guards themselves are covered in Ecdh.test.ts. - it('rejects encryption to the identity public key', () => { + it('rejects encryption to the identity public key', async () => { const identity = ecMulGenerator(0n); - expect(() => pureCircuits.encrypt(identity, 1000n, 42n, DOMAIN)).toThrow( + await expect(mask.encrypt(identity, 1000n, 42n, DOMAIN)).rejects.toThrow( 'Ecdh: identity pk', ); }); - it('rejects a zero ephemeral', () => { - expect(() => pureCircuits.encrypt(PK, 1000n, 0n, DOMAIN)).toThrow( + it('rejects a zero ephemeral', async () => { + await expect(mask.encrypt(PK, 1000n, 0n, DOMAIN)).rejects.toThrow( 'Ecdh: zero ephemeral', ); }); }); describe('confidentiality / correctness properties', () => { - it('distinct ephemerals yield distinct ciphertexts for the same value', () => { - const c1 = pureCircuits.encrypt(PK, 1000n, 1n, DOMAIN); - const c2 = pureCircuits.encrypt(PK, 1000n, 2n, DOMAIN); + it('distinct ephemerals yield distinct ciphertexts for the same value', async () => { + const c1 = await mask.encrypt(PK, 1000n, 1n, DOMAIN); + const c2 = await mask.encrypt(PK, 1000n, 2n, DOMAIN); expect(c1.ct).not.toBe(c2.ct); expect(c1.ephemeralPk).not.toEqual(c2.ephemeralPk); }); - it('distinct values yield distinct ciphertexts under the same ephemeral', () => { - const c1 = pureCircuits.encrypt(PK, 1000n, 5n, DOMAIN); - const c2 = pureCircuits.encrypt(PK, 2000n, 5n, DOMAIN); + it('distinct values yield distinct ciphertexts under the same ephemeral', async () => { + const c1 = await mask.encrypt(PK, 1000n, 5n, DOMAIN); + const c2 = await mask.encrypt(PK, 2000n, 5n, DOMAIN); expect(c1.ct).not.toBe(c2.ct); }); - it('does not recover the value under the wrong secret key', () => { - const ciphertext = pureCircuits.encrypt(PK, 1000n, 42n, DOMAIN); + it('does not recover the value under the wrong secret key', async () => { + const ciphertext = await mask.encrypt(PK, 1000n, 42n, DOMAIN); const WRONG_EK = 999999n; - expect(pureCircuits.decrypt(ciphertext, WRONG_EK, DOMAIN)).not.toBe( - 1000n, - ); + expect(await mask.decrypt(ciphertext, WRONG_EK, DOMAIN)).not.toBe(1000n); }); - it('does not recover the value under the wrong domain', () => { - const ciphertext = pureCircuits.encrypt(PK, 1000n, 42n, DOMAIN); - expect(pureCircuits.decrypt(ciphertext, EK, domain('other'))).not.toBe( + it('does not recover the value under the wrong domain', async () => { + const ciphertext = await mask.encrypt(PK, 1000n, 42n, DOMAIN); + expect(await mask.decrypt(ciphertext, EK, domain('other'))).not.toBe( 1000n, ); }); }); describe('kdf', () => { - it('is deterministic for the same shared point and domain', () => { - expect(pureCircuits.kdf(PK, DOMAIN)).toBe(pureCircuits.kdf(PK, DOMAIN)); + it('is deterministic for the same shared point and domain', async () => { + expect(await mask.kdf(PK, DOMAIN)).toBe(await mask.kdf(PK, DOMAIN)); }); - it('differs for distinct shared points', () => { + it('differs for distinct shared points', async () => { const other = ecMulGenerator(222n); - expect(pureCircuits.kdf(PK, DOMAIN)).not.toBe( - pureCircuits.kdf(other, DOMAIN), + expect(await mask.kdf(PK, DOMAIN)).not.toBe( + await mask.kdf(other, DOMAIN), ); }); - it('differs for distinct domains (domain separation)', () => { - expect(pureCircuits.kdf(PK, domain('a'))).not.toBe( - pureCircuits.kdf(PK, domain('b')), + it('differs for distinct domains (domain separation)', async () => { + expect(await mask.kdf(PK, domain('a'))).not.toBe( + await mask.kdf(PK, domain('b')), ); }); - it('produces a mask below 2^248 (hiding-margin regression)', () => { + it('produces a mask below 2^248 (hiding-margin regression)', async () => { // The module's ~2^-120 hiding margin rests on the kdf output staying in // [0, 2^248) (the degradeToTransient range). Pin that stdlib behavior over // several points so a regression surfaces here rather than silently // shrinking the margin. for (const s of [2n, 5n, 222n, 999999n]) { - expect(pureCircuits.kdf(ecMulGenerator(s), DOMAIN)).toBeLessThan( - TWO_248, - ); + expect(await mask.kdf(ecMulGenerator(s), DOMAIN)).toBeLessThan(TWO_248); } }); }); @@ -267,78 +278,82 @@ describe('EcdhMask', () => { ); }; - it('should be deterministic for the same shared point and domain', () => { - expect(pureCircuits.fieldKdf(PK, DOMAIN)).toBe( - pureCircuits.fieldKdf(PK, DOMAIN), + it('should be deterministic for the same shared point and domain', async () => { + expect(await mask.fieldKdf(PK, DOMAIN)).toBe( + await mask.fieldKdf(PK, DOMAIN), ); }); - it('should differ for distinct shared points', () => { - expect(pureCircuits.fieldKdf(PK, DOMAIN)).not.toBe( - pureCircuits.fieldKdf(ecMulGenerator(222n), DOMAIN), + it('should differ for distinct shared points', async () => { + expect(await mask.fieldKdf(PK, DOMAIN)).not.toBe( + await mask.fieldKdf(ecMulGenerator(222n), DOMAIN), ); }); - it('should differ for distinct domains', () => { - expect(pureCircuits.fieldKdf(PK, domain('a'))).not.toBe( - pureCircuits.fieldKdf(PK, domain('b')), + it('should differ for distinct domains', async () => { + expect(await mask.fieldKdf(PK, domain('a'))).not.toBe( + await mask.fieldKdf(PK, domain('b')), ); }); it('should equal Sha256.hashToField(pointDigest(S), domain)', async () => { for (const point of points) { - expect(pureCircuits.fieldKdf(point, DOMAIN)).toBe( + expect(await mask.fieldKdf(point, DOMAIN)).toBe( await sha.hashToField(pointDigest(point), DOMAIN), ); } }); - it('should match the reference for S = 5 * pk(3) under "OZ:test:dst"', () => { + it('should match the reference for S = 5 * pk(3) under "OZ:test:dst"', async () => { const S = { x: 34133914351292434048413503276202728289265490189576620060413629725504410538523n, y: 14331798736465991320125906355460685144102305233516748184833801044822620467723n, }; - expect(pureCircuits.fieldKdf(S, domain('OZ:test:dst'))).toBe( + expect(await mask.fieldKdf(S, domain('OZ:test:dst'))).toBe( 26610806138279577918068632042914450713404457242874797469315820532741471409604n, ); - expect(pureCircuits.kdf(S, domain('OZ:test:dst'))).toBe( + expect(await mask.kdf(S, domain('OZ:test:dst'))).toBe( 69564922259646089911822298958338864030232716070909835277172563539860613021n, ); }); - it('should match the reference for S = 1307 * pk(42) under "other"', () => { + it('should match the reference for S = 1307 * pk(42) under "other"', async () => { const S = { x: 21846731140111779498597767336707429224314113023599758281380925851664428732333n, y: 16756994231029989709753422113746052839188842806931617304566960550784499484096n, }; - expect(pureCircuits.fieldKdf(S, domain('other'))).toBe( + expect(await mask.fieldKdf(S, domain('other'))).toBe( 14942334384528480047623944073446731732154425113458333513933056364075658344760n, ); - expect(pureCircuits.kdf(S, domain('other'))).toBe( + expect(await mask.kdf(S, domain('other'))).toBe( 332242018693607749271965174322648896667187613080348592700581236270042761364n, ); }); - it('should not equal kdf under the same point and domain', () => { + it('should not equal kdf under the same point and domain', async () => { for (const point of points) { - expect(pureCircuits.fieldKdf(point, DOMAIN)).not.toBe( - pureCircuits.kdf(point, DOMAIN), + expect(await mask.fieldKdf(point, DOMAIN)).not.toBe( + await mask.kdf(point, DOMAIN), ); } }); - it('should exceed the 248-bit kdf range for most shared points', () => { - const wide = Array.from({ length: 64 }, (_, i) => - pureCircuits.fieldKdf(ecMulGenerator(BigInt(i) + 1n), DOMAIN), - ).filter((mask) => mask >= TWO_248); + it('should exceed the 248-bit kdf range for most shared points', async () => { + const wide: bigint[] = []; + for (let i = 0; i < 64; i++) { + const pad = await mask.fieldKdf(ecMulGenerator(BigInt(i) + 1n), DOMAIN); + if (pad >= TWO_248) { + wide.push(pad); + } + } expect(wide.length).toBeGreaterThan(56); }); }); describe('encryptField reference vectors', () => { - it('should match the reference for sk = 3, e = 5, m = 299973 under "OZ:test:dst"', () => { + it('should match the reference for sk = 3, e = 5, m = 299973 under "OZ:test:dst"', async () => { const pk = ecMulGenerator(3n); - const ciphertext = pureCircuits.encryptField( + const ciphertext = await mask.encryptField( pk, 299973n, 5n, @@ -352,13 +367,13 @@ describe('EcdhMask', () => { ct: 26610806138279577918068632042914450713404457242874797469315820532741471709577n, }); expect( - pureCircuits.decryptField(ciphertext, 3n, domain('OZ:test:dst')), + await mask.decryptField(ciphertext, 3n, domain('OZ:test:dst')), ).toBe(299973n); }); - it('should match the reference for sk = 42, e = 1307, m = 4199622 under "other"', () => { + it('should match the reference for sk = 42, e = 1307, m = 4199622 under "other"', async () => { const pk = ecMulGenerator(42n); - const ciphertext = pureCircuits.encryptField( + const ciphertext = await mask.encryptField( pk, 4199622n, 1307n, @@ -371,7 +386,7 @@ describe('EcdhMask', () => { }, ct: 14942334384528480047623944073446731732154425113458333513933056364075662544382n, }); - expect(pureCircuits.decryptField(ciphertext, 42n, domain('other'))).toBe( + expect(await mask.decryptField(ciphertext, 42n, domain('other'))).toBe( 4199622n, ); }); @@ -389,51 +404,51 @@ describe('EcdhMask', () => { ]; for (const [name, m] of cases) { - it(`round-trips ${name}`, () => { - const ciphertext = pureCircuits.encryptField(PK, m, 42n, DOMAIN); - expect(pureCircuits.decryptField(ciphertext, EK, DOMAIN)).toBe(m); + it(`round-trips ${name}`, async () => { + const ciphertext = await mask.encryptField(PK, m, 42n, DOMAIN); + expect(await mask.decryptField(ciphertext, EK, DOMAIN)).toBe(m); }); } - it('round-trips at the maximum valid scalar (L - 1) for key and ephemeral', () => { + it('round-trips at the maximum valid scalar (L - 1) for key and ephemeral', async () => { const ek = L - 1n; const pk = ecMulGenerator(ek); const m = P - 1n; - const ciphertext = pureCircuits.encryptField(pk, m, L - 1n, DOMAIN); - expect(pureCircuits.decryptField(ciphertext, ek, DOMAIN)).toBe(m); + const ciphertext = await mask.encryptField(pk, m, L - 1n, DOMAIN); + expect(await mask.decryptField(ciphertext, ek, DOMAIN)).toBe(m); }); - it('round-trips through the real crypto/ElGamal key derivation', () => { + it('round-trips through the real crypto/ElGamal key derivation', async () => { const ekBytes = new Uint8Array(32).fill(0x11); const pk = elgamal.derivePk(ekBytes); const ekScalar = elgamal.secretToScalar(ekBytes); const m = P - 4242n; - const ciphertext = pureCircuits.encryptField(pk, m, 99n, DOMAIN); - expect(pureCircuits.decryptField(ciphertext, ekScalar, DOMAIN)).toBe(m); + const ciphertext = await mask.encryptField(pk, m, 99n, DOMAIN); + expect(await mask.decryptField(ciphertext, ekScalar, DOMAIN)).toBe(m); }); - it('round-trips for arbitrary keys, ephemerals, and field plaintexts (property)', () => { - fc.assert( - fc.property( + it('round-trips for arbitrary keys, ephemerals, and field plaintexts (property)', async () => { + await fc.assert( + fc.asyncProperty( fc.bigInt({ min: 1n, max: 1n << 200n }), fc.bigInt({ min: 1n, max: 1n << 200n }), fc.bigInt({ min: 0n, max: P - 1n }), - (ek, e, m) => { + async (ek, e, m) => { const pk = ecMulGenerator(ek); - const ciphertext = pureCircuits.encryptField(pk, m, e, DOMAIN); - expect(pureCircuits.decryptField(ciphertext, ek, DOMAIN)).toBe(m); + const ciphertext = await mask.encryptField(pk, m, e, DOMAIN); + expect(await mask.decryptField(ciphertext, ek, DOMAIN)).toBe(m); }, ), ); }); - it('masks the plaintext with exactly fieldKdf(pk^e, domain)', () => { + it('masks the plaintext with exactly fieldKdf(pk^e, domain)', async () => { // The ciphertext is a function of (sShared, domain) and m alone. const m = 1n << 253n; - const shared = pureCircuits.deriveShared(PK, 42n); - const ciphertext = pureCircuits.encryptField(PK, m, 42n, DOMAIN); + const shared = await mask.deriveShared(PK, 42n); + const ciphertext = await mask.encryptField(PK, m, 42n, DOMAIN); expect(ciphertext.ct).toBe( - (m + pureCircuits.fieldKdf(shared.sShared, DOMAIN)) % P, + (m + (await mask.fieldKdf(shared.sShared, DOMAIN))) % P, ); }); }); @@ -453,10 +468,17 @@ describe('EcdhMask', () => { ]; for (const [name, m] of plaintexts) { - it(`spreads the ciphertext of ${name} across the whole field`, () => { - const cts = Array.from({ length: 64 }, (_, i) => - pureCircuits.encryptField(PK, m, BigInt(i) + 1n, DOMAIN), - ).map((ciphertext) => ciphertext.ct); + it(`spreads the ciphertext of ${name} across the whole field`, async () => { + const cts: bigint[] = []; + for (let i = 0; i < 64; i++) { + const ciphertext = await mask.encryptField( + PK, + m, + BigInt(i) + 1n, + DOMAIN, + ); + cts.push(ciphertext.ct); + } expect(cts.some((ct) => ct < HALF_P)).toBe(true); expect(cts.some((ct) => ct >= HALF_P)).toBe(true); }); @@ -484,9 +506,17 @@ describe('EcdhMask', () => { ecMulGenerator(BigInt(i) * 7919n + 1n), ); + const kdf: KdfFn = (sShared, dst) => mask.kdf(sShared, dst); + const fieldKdf: KdfFn = (sShared, dst) => mask.fieldKdf(sShared, dst); + /** Pads of `kdf` over the fixed shared points, under `DOMAIN`. */ - const padsOf = (kdf: typeof pureCircuits.kdf): bigint[] => - SHARED_POINTS.map((s) => kdf(s, DOMAIN)); + const padsOf = async (kdfFn: KdfFn): Promise => { + const pads: bigint[] = []; + for (const s of SHARED_POINTS) { + pads.push(await kdfFn(s, DOMAIN)); + } + return pads; + }; // The largest value of each plaintext width. The Field one leaves room for // a 2^248 pad below P, so the kdf sum cannot wrap. @@ -500,11 +530,11 @@ describe('EcdhMask', () => { * win rate: 1 means the ciphertext gives the plaintext away, 0.5 means it * hides it. */ - const distinguisherWinRate = ( - kdf: typeof pureCircuits.kdf, + const distinguisherWinRate = async ( + kdfFn: KdfFn, value: bigint, - ): number => { - const pads = padsOf(kdf); + ): Promise => { + const pads = await padsOf(kdfFn); const wins = pads.filter((pad, i) => { const encryptsValue = i % 2 === 1; const ct = ((encryptsValue ? value : 0n) + pad) % P; @@ -518,8 +548,8 @@ describe('EcdhMask', () => { * reports how often the value lies in the window (ct - 2^248, ct]. * 1 means the observer learns every value to within 2^248. */ - const windowHitRate = (kdf: typeof pureCircuits.kdf): number => { - const pads = padsOf(kdf); + const windowHitRate = async (kdfFn: KdfFn): Promise => { + const pads = await padsOf(kdfFn); const hits = pads.filter((pad, i) => { // Spread over [0, P - 2^248) so the kdf sum never wraps. const value = @@ -536,25 +566,25 @@ describe('EcdhMask', () => { expect(rate).toBeLessThan(0.65); }; - it('should keep every kdf pad below 2^248', () => { - expect(padsOf(pureCircuits.kdf).every((pad) => pad < TWO_248)).toBe(true); + it('should keep every kdf pad below 2^248', async () => { + expect((await padsOf(kdf)).every((pad) => pad < TWO_248)).toBe(true); }); - it('should put most fieldKdf pads at or above 2^248', () => { - const pads = padsOf(pureCircuits.fieldKdf); + it('should put most fieldKdf pads at or above 2^248', async () => { + const pads = await padsOf(fieldKdf); const above = pads.filter((pad) => pad >= TWO_248).length; expect(above).toBeGreaterThan(pads.length * 0.9); }); - it('should keep every fieldKdf pad below P', () => { - expect(padsOf(pureCircuits.fieldKdf).every((pad) => pad < P)).toBe(true); + it('should keep every fieldKdf pad below P', async () => { + expect((await padsOf(fieldKdf)).every((pad) => pad < P)).toBe(true); }); - it('should spread fieldKdf pads over every eighth of the field', () => { + it('should spread fieldKdf pads over every eighth of the field', async () => { // A pad that stopped at 2^254, or an unreduced 2^256 one, would leave // the top eighths empty. const eighths = new Set( - padsOf(pureCircuits.fieldKdf).map((pad) => (pad * 8n) / P), + (await padsOf(fieldKdf)).map((pad) => (pad * 8n) / P), ); expect([...eighths].sort()).toStrictEqual([ 0n, @@ -568,77 +598,77 @@ describe('EcdhMask', () => { ]); }); - it('should let the observer read a 248-bit value off its kdf ciphertext', () => { - expect(distinguisherWinRate(pureCircuits.kdf, VALUE_248)).toBe(1); + it('should let the observer read a 248-bit value off its kdf ciphertext', async () => { + expect(await distinguisherWinRate(kdf, VALUE_248)).toBe(1); }); - it('should let the observer read a Field value off its kdf ciphertext', () => { - expect(distinguisherWinRate(pureCircuits.kdf, VALUE_FIELD)).toBe(1); + it('should let the observer read a Field value off its kdf ciphertext', async () => { + expect(await distinguisherWinRate(kdf, VALUE_FIELD)).toBe(1); }); - it('should not let the observer read a Uint<128> value off its kdf ciphertext', () => { - expectCoinFlip(distinguisherWinRate(pureCircuits.kdf, VALUE_128)); + it('should not let the observer read a Uint<128> value off its kdf ciphertext', async () => { + expectCoinFlip(await distinguisherWinRate(kdf, VALUE_128)); }); - it('should not let the observer read a 248-bit value off its fieldKdf ciphertext', () => { - expectCoinFlip(distinguisherWinRate(pureCircuits.fieldKdf, VALUE_248)); + it('should not let the observer read a 248-bit value off its fieldKdf ciphertext', async () => { + expectCoinFlip(await distinguisherWinRate(fieldKdf, VALUE_248)); }); - it('should not let the observer read a Field value off its fieldKdf ciphertext', () => { - expectCoinFlip(distinguisherWinRate(pureCircuits.fieldKdf, VALUE_FIELD)); + it('should not let the observer read a Field value off its fieldKdf ciphertext', async () => { + expectCoinFlip(await distinguisherWinRate(fieldKdf, VALUE_FIELD)); }); - it('should not let the observer read a Uint<128> value off its fieldKdf ciphertext', () => { - expectCoinFlip(distinguisherWinRate(pureCircuits.fieldKdf, VALUE_128)); + it('should not let the observer read a Uint<128> value off its fieldKdf ciphertext', async () => { + expectCoinFlip(await distinguisherWinRate(fieldKdf, VALUE_128)); }); - it('should leave every Field value within 2^248 of its kdf ciphertext', () => { - expect(windowHitRate(pureCircuits.kdf)).toBe(1); + it('should leave every Field value within 2^248 of its kdf ciphertext', async () => { + expect(await windowHitRate(kdf)).toBe(1); }); - it('should not leave a Field value within 2^248 of its fieldKdf ciphertext', () => { - expect(windowHitRate(pureCircuits.fieldKdf)).toBeLessThan(0.05); + it('should not leave a Field value within 2^248 of its fieldKdf ciphertext', async () => { + expect(await windowHitRate(fieldKdf)).toBeLessThan(0.05); }); }); describe('multi-field pad discipline', () => { // `deriveShared` and `recoverShared` come from crypto/Ecdh, imported // alongside EcdhMask in the mock exactly as a consumer imports both. - it('carries two fields under one key agreement with one tag each', () => { + it('carries two fields under one key agreement with one tag each', async () => { // The multi-field pattern a consumer builds on. const value = 1n << 200n; const nonce = P - 5n; - const shared = pureCircuits.deriveShared(PK, 31337n); + const shared = await mask.deriveShared(PK, 31337n); const valueCt = - (value + pureCircuits.fieldKdf(shared.sShared, TAG_VALUE)) % P; + (value + (await mask.fieldKdf(shared.sShared, TAG_VALUE))) % P; const nonceCt = - (nonce + pureCircuits.fieldKdf(shared.sShared, TAG_NONCE)) % P; + (nonce + (await mask.fieldKdf(shared.sShared, TAG_NONCE))) % P; - const recovered = pureCircuits.recoverShared(shared.ephemeralPk, EK); - expect(sub(valueCt, pureCircuits.fieldKdf(recovered, TAG_VALUE))).toBe( + const recovered = await mask.recoverShared(shared.ephemeralPk, EK); + expect(sub(valueCt, await mask.fieldKdf(recovered, TAG_VALUE))).toBe( value, ); - expect(sub(nonceCt, pureCircuits.fieldKdf(recovered, TAG_NONCE))).toBe( + expect(sub(nonceCt, await mask.fieldKdf(recovered, TAG_NONCE))).toBe( nonce, ); }); - it('leaks the plaintext difference when one tag pads two fields', () => { + it('leaks the plaintext difference when one tag pads two fields', async () => { // The tag-reuse footgun in executable form: one shared point, one tag, // two fields is pad reuse. const m1 = 1n << 200n; const m2 = 4242n; - const shared = pureCircuits.deriveShared(PK, 31337n); - const mask = pureCircuits.fieldKdf(shared.sShared, TAG_VALUE); - expect(sub((m1 + mask) % P, (m2 + mask) % P)).toBe(sub(m1, m2)); + const shared = await mask.deriveShared(PK, 31337n); + const pad = await mask.fieldKdf(shared.sShared, TAG_VALUE); + expect(sub((m1 + pad) % P, (m2 + pad) % P)).toBe(sub(m1, m2)); }); - it('does not leak the plaintext difference across distinct tags', () => { + it('does not leak the plaintext difference across distinct tags', async () => { const m1 = 1n << 200n; const m2 = 4242n; - const shared = pureCircuits.deriveShared(PK, 31337n); - const ct1 = (m1 + pureCircuits.fieldKdf(shared.sShared, TAG_VALUE)) % P; - const ct2 = (m2 + pureCircuits.fieldKdf(shared.sShared, TAG_NONCE)) % P; + const shared = await mask.deriveShared(PK, 31337n); + const ct1 = (m1 + (await mask.fieldKdf(shared.sShared, TAG_VALUE))) % P; + const ct2 = (m2 + (await mask.fieldKdf(shared.sShared, TAG_NONCE))) % P; expect(sub(ct1, ct2)).not.toBe(sub(m1, m2)); }); }); @@ -648,27 +678,33 @@ describe('EcdhMask', () => { // addressed ciphertext from an unaddressed one by an abort. const identity = ecMulGenerator(0n); const WRONG_EK = 999999n; - const ciphertext = pureCircuits.encryptField(PK, 1n << 200n, 42n, DOMAIN); + let ciphertext: Ciphertext; + + beforeAll(async () => { + ciphertext = await mask.encryptField(PK, 1n << 200n, 42n, DOMAIN); + }); - it('fieldKdf accepts the identity shared point', () => { - expect(() => pureCircuits.fieldKdf(identity, DOMAIN)).not.toThrow(); + it('fieldKdf accepts the identity shared point', async () => { + await expect(mask.fieldKdf(identity, DOMAIN)).resolves.not.toThrow(); }); - it('decryptField returns a wrong plaintext under a wrong secret key', () => { - expect(pureCircuits.decryptField(ciphertext, WRONG_EK, DOMAIN)).not.toBe( + it('decryptField returns a wrong plaintext under a wrong secret key', async () => { + expect(await mask.decryptField(ciphertext, WRONG_EK, DOMAIN)).not.toBe( 1n << 200n, ); }); - it('decryptField returns a wrong plaintext under a wrong domain', () => { - expect( - pureCircuits.decryptField(ciphertext, EK, domain('other')), - ).not.toBe(1n << 200n); + it('decryptField returns a wrong plaintext under a wrong domain', async () => { + expect(await mask.decryptField(ciphertext, EK, domain('other'))).not.toBe( + 1n << 200n, + ); }); - it('decryptField resolves an identity ephemeral without aborting', () => { + it('decryptField resolves an identity ephemeral without aborting', async () => { const forged = { ephemeralPk: identity, ct: ciphertext.ct }; - expect(() => pureCircuits.decryptField(forged, EK, DOMAIN)).not.toThrow(); + await expect( + mask.decryptField(forged, EK, DOMAIN), + ).resolves.not.toThrow(); }); }); }); diff --git a/contracts/src/crypto/test/mocks/MockEcdhMask.compact b/contracts/src/crypto/test/mocks/MockEcdhMask.compact index 547ae3bc..3bfa6ced 100644 --- a/contracts/src/crypto/test/mocks/MockEcdhMask.compact +++ b/contracts/src/crypto/test/mocks/MockEcdhMask.compact @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // WARNING: FOR TESTING PURPOSES ONLY. -// Exposes the EcdhMask module's pure circuits so they can be driven from +// Exposes the EcdhMask module's circuits so they can be driven from // off-chain tests. DO NOT deploy or use this contract in any production // application. @@ -15,44 +15,56 @@ import "../../Ecdh" prefix Ecdh_; export { EcdhMask_Ciphertext, Ecdh_SharedSecret } -export pure circuit kdf(sShared: JubjubPoint, domain: Bytes<32>): Field { +// Keeps every circuit impure. Without a ledger write the compiler promotes +// them to pure, and the artifact ships without ZKIR or proving keys. +ledger _invocations: Counter; + +export circuit kdf(sShared: JubjubPoint, domain: Bytes<32>): Field { + _invocations.increment(1); return EcdhMask_kdf(sShared, domain); } -export pure circuit encrypt( +export circuit encrypt( recipientPk: JubjubPoint, value: Uint<128>, e: JubjubScalar, domain: Bytes<32> ): EcdhMask_Ciphertext { + _invocations.increment(1); return EcdhMask_encrypt(recipientPk, value, e, domain); } -export pure circuit decrypt(ciphertext: EcdhMask_Ciphertext, ekScalar: JubjubScalar, domain: Bytes<32>): Field { +export circuit decrypt(ciphertext: EcdhMask_Ciphertext, ekScalar: JubjubScalar, domain: Bytes<32>): Field { + _invocations.increment(1); return EcdhMask_decrypt(ciphertext, ekScalar, domain); } -export pure circuit fieldKdf(sShared: JubjubPoint, domain: Bytes<32>): Field { +export circuit fieldKdf(sShared: JubjubPoint, domain: Bytes<32>): Field { + _invocations.increment(1); return EcdhMask_fieldKdf(sShared, domain); } -export pure circuit encryptField( +export circuit encryptField( recipientPk: JubjubPoint, m: Field, e: JubjubScalar, domain: Bytes<32> ): EcdhMask_Ciphertext { + _invocations.increment(1); return EcdhMask_encryptField(recipientPk, m, e, domain); } -export pure circuit decryptField(ciphertext: EcdhMask_Ciphertext, ekScalar: JubjubScalar, domain: Bytes<32>): Field { +export circuit decryptField(ciphertext: EcdhMask_Ciphertext, ekScalar: JubjubScalar, domain: Bytes<32>): Field { + _invocations.increment(1); return EcdhMask_decryptField(ciphertext, ekScalar, domain); } -export pure circuit deriveShared(recipientPk: JubjubPoint, e: JubjubScalar): Ecdh_SharedSecret { +export circuit deriveShared(recipientPk: JubjubPoint, e: JubjubScalar): Ecdh_SharedSecret { + _invocations.increment(1); return Ecdh_deriveShared(recipientPk, e); } -export pure circuit recoverShared(ephemeralPk: JubjubPoint, ekScalar: JubjubScalar): JubjubPoint { +export circuit recoverShared(ephemeralPk: JubjubPoint, ekScalar: JubjubScalar): JubjubPoint { + _invocations.increment(1); return Ecdh_recoverShared(ephemeralPk, ekScalar); } diff --git a/contracts/src/crypto/test/simulators/EcdhMaskSimulator.ts b/contracts/src/crypto/test/simulators/EcdhMaskSimulator.ts new file mode 100644 index 00000000..88662c59 --- /dev/null +++ b/contracts/src/crypto/test/simulators/EcdhMaskSimulator.ts @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Compact Contracts v0.4.0-alpha.1 (crypto/test/simulators/EcdhMaskSimulator.ts) + +import type { JubjubPoint } from '@midnight-ntwrk/compact-runtime'; +import { + createSimulator, + type SimulatorOptions, +} from '@openzeppelin/compact-simulator'; +import { + type EcdhMask_Ciphertext as Ciphertext, + ledger, + Contract as MockEcdhMask, + type Ecdh_SharedSecret as SharedSecret, +} from '../../../../artifacts/MockEcdhMask/contract/index.js'; + +export type { Ciphertext, SharedSecret }; + +type EmptyPrivateState = Record; +const EmptyPrivateState: EmptyPrivateState = {}; +const emptyWitnesses = () => ({}); + +const EcdhMaskSimulatorBase = createSimulator< + EmptyPrivateState, + ReturnType, + ReturnType, + MockEcdhMask, + readonly [] +>({ + contractFactory: (witnesses) => + new MockEcdhMask(witnesses), + defaultPrivateState: () => EmptyPrivateState, + contractArgs: () => [], + ledgerExtractor: (state) => ledger(state), + witnessesFactory: () => emptyWitnesses(), + artifactName: 'MockEcdhMask', +}); + +export class EcdhMaskSimulator extends EcdhMaskSimulatorBase { + static async create( + options: SimulatorOptions< + EmptyPrivateState, + ReturnType + > = {}, + ): Promise { + // biome-ignore lint/complexity/noThisInStatic: super.create must keep the subclass `this` + return super.create([], options) as Promise; + } + + public kdf(sShared: JubjubPoint, domain: Uint8Array): Promise { + return this.circuits.impure.kdf(sShared, domain); + } + + public encrypt( + recipientPk: JubjubPoint, + value: bigint, + e: bigint, + domain: Uint8Array, + ): Promise { + return this.circuits.impure.encrypt(recipientPk, value, e, domain); + } + + public decrypt( + ciphertext: Ciphertext, + ekScalar: bigint, + domain: Uint8Array, + ): Promise { + return this.circuits.impure.decrypt(ciphertext, ekScalar, domain); + } + + public fieldKdf(sShared: JubjubPoint, domain: Uint8Array): Promise { + return this.circuits.impure.fieldKdf(sShared, domain); + } + + public encryptField( + recipientPk: JubjubPoint, + m: bigint, + e: bigint, + domain: Uint8Array, + ): Promise { + return this.circuits.impure.encryptField(recipientPk, m, e, domain); + } + + public decryptField( + ciphertext: Ciphertext, + ekScalar: bigint, + domain: Uint8Array, + ): Promise { + return this.circuits.impure.decryptField(ciphertext, ekScalar, domain); + } + + public deriveShared( + recipientPk: JubjubPoint, + e: bigint, + ): Promise { + return this.circuits.impure.deriveShared(recipientPk, e); + } + + public recoverShared( + ephemeralPk: JubjubPoint, + ekScalar: bigint, + ): Promise { + return this.circuits.impure.recoverShared(ephemeralPk, ekScalar); + } +} diff --git a/contracts/src/token/test/ConfidentialFungibleToken.test.ts b/contracts/src/token/test/ConfidentialFungibleToken.test.ts index 2a951b2f..9627033b 100644 --- a/contracts/src/token/test/ConfidentialFungibleToken.test.ts +++ b/contracts/src/token/test/ConfidentialFungibleToken.test.ts @@ -6,13 +6,13 @@ import { persistentHash, } from '@midnight-ntwrk/compact-runtime'; import { isLiveBackend } from '@openzeppelin/compact-simulator'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { pureCircuits as ecdhMask } from '../../../artifacts/MockEcdhMask/contract/index.js'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; // The ElGamal pure circuits double as an off-circuit "mirror." They let a test // predict a ciphertext the contract will produce internally (e.g. the // post-refund balance in `approve`) so its plaintext can be cached ahead of the // witness query. They are pure (no proof), so this is cheap. import { pureCircuits as elgamal } from '../../../artifacts/MockElGamal/contract/index.js'; +import { EcdhMaskSimulator } from '../../crypto/test/simulators/EcdhMaskSimulator.js'; import { ConfidentialFungibleTokenSimulator } from './simulators/ConfidentialFungibleTokenSimulator.js'; import { ConfidentialFungibleTokenPrivateState } from './witnesses/ConfidentialFungibleTokenWitnesses.js'; @@ -79,6 +79,14 @@ const DECIMALS = 6n; let cft: ConfidentialFungibleTokenSimulator; +// The memo channel's plaintext is only reachable through EcdhMask.decrypt, so +// the specs drive that mock through its simulator. +let ecdhMask: EcdhMaskSimulator; + +beforeAll(async () => { + ecdhMask = await EcdhMaskSimulator.create(); +}); + describe.skipIf(isLiveBackend())( 'ConfidentialFungibleToken: registration', () => { @@ -679,7 +687,7 @@ describe.skipIf(isLiveBackend())( ); const escrow = await cft.allowance(ALICE.accountId, BOB.accountId); const aliceEk = elgamal.secretToScalar(ALICE.encryptionKey); - const remaining = ecdhMask.decrypt( + const remaining = await ecdhMask.decrypt( escrow.ownerMemo, aliceEk, OWNER_MEMO_DOMAIN, @@ -1007,7 +1015,7 @@ describe.skipIf(isLiveBackend())( const bobEk = elgamal.secretToScalar(BOB.encryptionKey); expect( - ecdhMask.decrypt(memos[0], bobEk, padTag('OZ_CFT_ecdh_memo_v1')), + await ecdhMask.decrypt(memos[0], bobEk, padTag('OZ_CFT_ecdh_memo_v1')), ).toBe(250n); }); @@ -1034,7 +1042,7 @@ describe.skipIf(isLiveBackend())( ); const bobEk = elgamal.secretToScalar(BOB.encryptionKey); expect( - ecdhMask.decrypt( + await ecdhMask.decrypt( [...memoList][0], bobEk, padTag('OZ_CFT_ecdh_memo_v1'), @@ -1135,7 +1143,7 @@ describe('ConfidentialFungibleToken: receive-path smoke', () => { ); const aliceEk = elgamal.secretToScalar(ALICE.encryptionKey); expect( - ecdhMask.decrypt( + await ecdhMask.decrypt( [...memoList][0], aliceEk, padTag('OZ_CFT_ecdh_memo_v1'),