Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
193 changes: 166 additions & 27 deletions contracts/src/crypto/EcdhMask.compact
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<N, Field>, e, tags: Vector<N,
* Bytes<32>>)` — 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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
*
Expand All @@ -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<JubjubPoint>(sShared);
return degradeToTransient(
persistentHash<Vector<2, Bytes<32>>>([pointHash, domain])
return Fq_truncatedLEOS2IP(
Sha256_digest<Vector<2, Bytes<32>>>([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<JubjubPoint>` 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<JubjubPoint>(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
Expand All @@ -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<Bytes<32>>(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);
}
}
Loading