diff --git a/CHANGELOG.md b/CHANGELOG.md index db8ca7d0..a044e0b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +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 `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 diff --git a/contracts/src/crypto/EcdhMask.compact b/contracts/src/crypto/EcdhMask.compact index 254d53b5..d60480c4 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 @@ -60,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 @@ -74,6 +81,23 @@ 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 + * 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 + * 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,13 +106,16 @@ 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; 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 @@ -104,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: * @@ -130,35 +157,59 @@ 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 - * 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 `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 + * @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 { - const pointHash = persistentHash(sShared); - return degradeToTransient( - persistentHash>>([pointHash, domain]) + return Fq_truncatedLEOS2IP( + Sha256_digest>>([pointDigest(sShared), domain]) ); } + /** + * @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`. + * + * @param sShared - The ECDH shared secret point. + * @return The point digest. + */ + pure circuit pointDigest(sShared: JubjubPoint): Bytes<32> { + return Sha256_digest(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). * - * @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 @@ -172,4 +223,92 @@ 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=15748 + * + * 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 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 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 + * + * @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 `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); + } + + /** + * @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=14625 + * + * @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/curves/bls12-381/Fq.compact b/contracts/src/crypto/curves/bls12-381/Fq.compact new file mode 100644 index 00000000..f9bff0ea --- /dev/null +++ b/contracts/src/crypto/curves/bls12-381/Fq.compact @@ -0,0 +1,284 @@ +// 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`. 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. + * - `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`. + * - Check: + * ``` + * sage> q = 0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001 + * sage> is_prime(q), q.nbits(), 2^248 < q < 2^256 + * (True, 255, True) + * ``` + * + * @dev Notation: + * - `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 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. + * - 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 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>; + high: Bytes<32>; + } + + /** + * @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=11, rows=2006 + * + * @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 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 { + // Radix 2^256, passed as R = 2^256 mod q. + return fromRadixDigits(leos2ipModQ(tv.low), leos2ipModQ(tv.high), R()); + } + + /** + * @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 + * 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 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 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=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`. + */ + export pure circuit truncatedLEOS2IP(S: Bytes<32>): Field { + return degradeToTransient(S); + } + + /** + * @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`. + */ + pure circuit TWO_POW_248(): Field { + return + 0x100000000000000000000000000000000000000000000000000000000000000 as Field; + } + + /** + * @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 { + return + 0x1824b159acc5056f998c4fefecbc4ff55884b7fa0003480200000001fffffffe as Field; + } + + /** + * @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=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. + */ + export pure circuit truncatedI2LEOSP(x: Field): Bytes<32> { + return upgradeFromTransient(x); + } +} 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/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); +} 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..683097b3 --- /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/IHasher.compact b/contracts/src/crypto/hash/IHasher.compact new file mode 100644 index 00000000..670fd8f3 --- /dev/null +++ b/contracts/src/crypto/hash/IHasher.compact @@ -0,0 +1,39 @@ +// 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 `IHasher` 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`. + * + * @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 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 new file mode 100644 index 00000000..f3081822 --- /dev/null +++ b/contracts/src/crypto/hash/Sha256.compact @@ -0,0 +1,224 @@ +// 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. `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(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 + * - `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 + * - `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 + * - `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. + * + * @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, + * 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 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>} C - `I2LEOSP_256(counter)`, the encoded block counter. + */ + export struct ExpandPreimage { + msg: T; + DST: Bytes<32>; + C: Bytes<32>; + } + + /** + * @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=9806 + * + * @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, 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, 9: return `e_0`. + * + * @dev Deviations from RFC 9380: + * - `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 MGF1, `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. + */ + export pure circuit hashToField(msg: T, DST: Bytes<32>): Field { + // Steps 1 and 2. + const uniformBytes = expandMessage(msg, DST); + // Steps 3 to 6. + 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`). + * MGF1 in place of `expand_message_xmd`. + * + * @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. + * @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 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} counter - The block counter, MGF1's `counter`. + * @return {Bytes<32>} - The block. + */ + pure circuit expandBlock( + msg: T, + DST: Bytes<32>, + counter: Field + ): Bytes<32> { + return digest>( + ExpandPreimage { msg: msg, DST: DST, C: counter as Bytes<32> } + ); + } + + /** + * @description Hashes a value with SHA-256 (`H(value)`). + * The hash runs over Compact's encoding of `value`. + * + * @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. + */ + export pure circuit digest(value: T): Bytes<32> { + return persistentHash(value); + } +} 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..824ff0b9 --- /dev/null +++ b/contracts/src/crypto/hash/test/Sha256.test.ts @@ -0,0 +1,174 @@ +import { createHash } from 'node:crypto'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { Sha256Simulator } from './simulators/Sha256Simulator.js'; + +// Expected values come from the Python reference in crypto/test/vectors. + +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', () => { + let sha: Sha256Simulator; + + beforeAll(async () => { + sha = await Sha256Simulator.create(); + }); + + describe('digest', () => { + it('should hash 32 zero bytes', async () => { + expect(toHex(await sha.digest(ZERO))).toStrictEqual( + '66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925', + ); + }); + + 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', async () => { + expect(toHex(await sha.digest(FULL))).toStrictEqual( + '630dcd2966c4336691125448bbb25b4ff412a49c732db2c8abc1b8581bd710dd', + ); + }); + + 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', async () => { + const msg = Uint8Array.from({ length: 32 }, (_, i) => 255 - i); + expect(toHex(await sha.digest(msg))).toStrictEqual(toHex(sha256(msg))); + }); + }); + + describe('hashToField', () => { + 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"', async () => { + expect(await sha.hashToField(ZERO, DST_OTHER)).toStrictEqual( + 8252444046083393956013555787964600531038925995538232948302863214155512041927n, + ); + }); + + 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"', async () => { + expect(await sha.hashToField(ABC, DST_OZ)).toStrictEqual( + 4324394735155083904531704740297192940187867625546932967580253364999850513246n, + ); + }); + + it('should map "abc" under "other"', async () => { + expect(await sha.hashToField(ABC, DST_OTHER)).toStrictEqual( + 21857836360300176587501939499123296964562523761200186881588315994230546836595n, + ); + }); + + 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"', async () => { + expect(await sha.hashToField(FULL, DST_OZ)).toStrictEqual( + 15556550438748104882361422133592315476793818450331618665969441588440568877440n, + ); + }); + + 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', async () => { + expect(await sha.hashToField(FULL, MAX)).toStrictEqual( + 47774871813993836535546443118418351091640792380679911218978961729634430076400n, + ); + }); + + 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"', async () => { + expect(await sha.hashToField(MAX, DST_OTHER)).toStrictEqual( + 38666714245396177227157505064478008025562388204734340763101648940949970599424n, + ); + }); + + 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 || 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 => { + 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(await sha.hashToField(msg, DST)).toStrictEqual(expected); + }); + + 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', 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', 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 new file mode 100644 index 00000000..75992cc5 --- /dev/null +++ b/contracts/src/crypto/hash/test/mocks/MockSha256.compact @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT + +// WARNING: FOR TESTING PURPOSES ONLY. +// 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; + +import CompactStandardLibrary; +import "../../IHasher"; +import "../../Sha256" prefix Sha256_; + +// Fails compilation if Sha256's signatures drift from the shared interface. +contract implements IHasher; + +// 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 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 207e6563..7d411270 100644 --- a/contracts/src/crypto/test/EcdhMask.test.ts +++ b/contracts/src/crypto/test/EcdhMask.test.ts @@ -1,12 +1,20 @@ -import { ecMulGenerator } from '@midnight-ntwrk/compact-runtime'; +import { createHash } from 'node:crypto'; +import { + ecMulGenerator, + type JubjubPoint, +} 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 { 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 @@ -14,9 +22,19 @@ 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 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; @@ -30,6 +48,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; @@ -44,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). @@ -101,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); }, ), ); @@ -135,92 +163,548 @@ 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', async () => { + // Uniformity buys nothing once the pad repeats. + const e = 7n; + const m1 = P - 1n; + const m2 = 1n << 200n; + 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)); + }); }); 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. - const bound = 1n << 248n; for (const s of [2n, 5n, 222n, 999999n]) { - expect(pureCircuits.kdf(ecMulGenerator(s), DOMAIN)).toBeLessThan(bound); + expect(await mask.kdf(ecMulGenerator(s), DOMAIN)).toBeLessThan(TWO_248); + } + }); + }); + + 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 => { + 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', async () => { + expect(await mask.fieldKdf(PK, DOMAIN)).toBe( + await mask.fieldKdf(PK, 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', 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(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"', async () => { + const S = { + x: 34133914351292434048413503276202728289265490189576620060413629725504410538523n, + y: 14331798736465991320125906355460685144102305233516748184833801044822620467723n, + }; + expect(await mask.fieldKdf(S, domain('OZ:test:dst'))).toBe( + 26610806138279577918068632042914450713404457242874797469315820532741471409604n, + ); + expect(await mask.kdf(S, domain('OZ:test:dst'))).toBe( + 69564922259646089911822298958338864030232716070909835277172563539860613021n, + ); + }); + + it('should match the reference for S = 1307 * pk(42) under "other"', async () => { + const S = { + x: 21846731140111779498597767336707429224314113023599758281380925851664428732333n, + y: 16756994231029989709753422113746052839188842806931617304566960550784499484096n, + }; + expect(await mask.fieldKdf(S, domain('other'))).toBe( + 14942334384528480047623944073446731732154425113458333513933056364075658344760n, + ); + expect(await mask.kdf(S, domain('other'))).toBe( + 332242018693607749271965174322648896667187613080348592700581236270042761364n, + ); + }); + + it('should not equal kdf under the same point and domain', async () => { + for (const point of points) { + 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', 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"', async () => { + const pk = ecMulGenerator(3n); + const ciphertext = await mask.encryptField( + pk, + 299973n, + 5n, + domain('OZ:test:dst'), + ); + expect(ciphertext).toStrictEqual({ + ephemeralPk: { + x: 46037580203438066765405229507649644425780970512522822336637661968249826130047n, + y: 26189429486186784039799689203850934078756791903368248146476421754146336352630n, + }, + ct: 26610806138279577918068632042914450713404457242874797469315820532741471709577n, + }); + expect( + 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"', async () => { + const pk = ecMulGenerator(42n); + const ciphertext = await mask.encryptField( + pk, + 4199622n, + 1307n, + domain('other'), + ); + expect(ciphertext).toStrictEqual({ + ephemeralPk: { + x: 19484914689417196181240116237393434494914401980232007599737218164003740761381n, + y: 6764962076417023830458027630302197924902637758905016901701807876639802414181n, + }, + ct: 14942334384528480047623944073446731732154425113458333513933056364075662544382n, + }); + expect(await mask.decryptField(ciphertext, 42n, domain('other'))).toBe( + 4199622n, + ); + }); + }); + + 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}`, 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', async () => { + const ek = L - 1n; + const pk = ecMulGenerator(ek); + const m = P - 1n; + 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', async () => { + const ekBytes = new Uint8Array(32).fill(0x11); + const pk = elgamal.derivePk(ekBytes); + const ekScalar = elgamal.secretToScalar(ekBytes); + const m = P - 4242n; + 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)', 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 }), + async (ek, e, m) => { + const pk = ecMulGenerator(ek); + 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)', async () => { + // The ciphertext is a function of (sShared, domain) and m alone. + const m = 1n << 253n; + const shared = await mask.deriveShared(PK, 42n); + const ciphertext = await mask.encryptField(PK, m, 42n, DOMAIN); + expect(ciphertext.ct).toBe( + (m + (await mask.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`, 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); + }); + } + }); + + 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), + ); + + 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 = 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. + 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 = async ( + kdfFn: KdfFn, + value: bigint, + ): Promise => { + const pads = await padsOf(kdfFn); + 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 = 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 = + (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', async () => { + expect((await padsOf(kdf)).every((pad) => pad < TWO_248)).toBe(true); + }); + + 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', async () => { + expect((await padsOf(fieldKdf)).every((pad) => pad < P)).toBe(true); + }); + + 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( + (await padsOf(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', async () => { + expect(await distinguisherWinRate(kdf, VALUE_248)).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', async () => { + expectCoinFlip(await distinguisherWinRate(kdf, VALUE_128)); + }); + + 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', async () => { + expectCoinFlip(await distinguisherWinRate(fieldKdf, VALUE_FIELD)); + }); + + 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', async () => { + expect(await windowHitRate(kdf)).toBe(1); + }); + + 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', async () => { + // The multi-field pattern a consumer builds on. + const value = 1n << 200n; + const nonce = P - 5n; + const shared = await mask.deriveShared(PK, 31337n); + const valueCt = + (value + (await mask.fieldKdf(shared.sShared, TAG_VALUE))) % P; + const nonceCt = + (nonce + (await mask.fieldKdf(shared.sShared, TAG_NONCE))) % P; + + const recovered = await mask.recoverShared(shared.ephemeralPk, EK); + expect(sub(valueCt, await mask.fieldKdf(recovered, TAG_VALUE))).toBe( + value, + ); + expect(sub(nonceCt, await mask.fieldKdf(recovered, TAG_NONCE))).toBe( + nonce, + ); + }); + + 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 = 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', async () => { + const m1 = 1n << 200n; + const m2 = 4242n; + 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)); + }); + }); + + 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; + let ciphertext: Ciphertext; + + beforeAll(async () => { + ciphertext = await mask.encryptField(PK, 1n << 200n, 42n, DOMAIN); + }); + + 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', async () => { + expect(await mask.decryptField(ciphertext, WRONG_EK, DOMAIN)).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', async () => { + const forged = { ephemeralPk: identity, ct: ciphertext.ct }; + 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 cee1b01c..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. @@ -9,22 +9,62 @@ 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 { +// 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 circuit fieldKdf(sShared: JubjubPoint, domain: Bytes<32>): Field { + _invocations.increment(1); + return EcdhMask_fieldKdf(sShared, domain); +} + +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 circuit decryptField(ciphertext: EcdhMask_Ciphertext, ekScalar: JubjubScalar, domain: Bytes<32>): Field { + _invocations.increment(1); + return EcdhMask_decryptField(ciphertext, ekScalar, domain); +} + +export circuit deriveShared(recipientPk: JubjubPoint, e: JubjubScalar): Ecdh_SharedSecret { + _invocations.increment(1); + return Ecdh_deriveShared(recipientPk, e); +} + +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'),