Skip to content

feat(crypto): hash to the field and a uniform EcdhMask pad - #913

Open
0xisk wants to merge 17 commits into
refactor/split-ecdh-modulefrom
feat/ecdh-mask-field-kdf
Open

0xisk wants to merge 17 commits into
refactor/split-ecdh-modulefrom
feat/ecdh-mask-field-kdf

Conversation

@0xisk

@0xisk 0xisk commented Sep 17, 2026

Copy link
Copy Markdown
Member

Depends on #866, merge after.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation Update (if none of the other choices apply)

Closes #735
Closes #921
Closes #922
Closes #923

#735 asked for the 248-bit margin to be documented as a caveat. It is not a caveat: ct = m + kdf(S) with kdf(S) < 2^248 never wraps, so a Field plaintext publishes its top bits, which is exactly the note-delivery case. So this adds a uniform mask, fieldKdf, as RFC 9380 hash_to_field over a new crypto/hash interface and a new Fq module, rather than a note on encrypt.

Not visible in the diff:

  • kdf is unchanged on purpose. Its output is a published ciphertext component, and the golden vector pins it. fieldKdf's block preimages extend kdf's by a counter, so both may be used under one (S, domain).
  • The expander is MGF1, not expand_message_xmd: the RFC's step 9 needs strxor, and Compact has no XOR.
  • Every expected value in the Fq, Sha256 and EcdhMask specs comes from an independent Python implementation on hashlib, py_ecc and ECPy, kept with a runner against the compiled mocks at https://github.com/0xisk/midnight-crypto-verification (87 vectors). The pad-width leak is measured in the EcdhMask spec, with kdf and its absence with fieldKdf.
  • The crypto mocks are ledger-backed so @constraints are reproducible. Every value was re-measured.
  • Where to start: Fq.compact, then Sha256.compact, then fieldKdf.

PR Checklist

@0xisk
0xisk requested review from a team as code owners September 17, 2026 06:48
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Essentials

Run ID: ea79df85-0683-444e-980e-32eca5e7a5b8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Essentials

Run ID: e94d396a-f234-4160-b73d-c6e057c5f8ef

📥 Commits

Reviewing files that changed from the base of the PR and between 20c484a and e7732f0.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • contracts/src/crypto/EcdhMask.compact
  • contracts/src/crypto/test/EcdhMask.test.ts
  • contracts/src/crypto/test/mocks/MockEcdhMask.compact

Included review availability: 4 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour.


Walkthrough

EcdhMask now supports whole-field KDF, encryption, and decryption. The change adds ECDH mock wrappers, documents the APIs, and tests field-wide masking, round trips, domain separation, and recipient behavior.

Changes

Field plaintext support

Layer / File(s) Summary
Field KDF contract and implementation
contracts/src/crypto/EcdhMask.compact, CHANGELOG.md
The module documents separate bounded and whole-field KDFs. fieldKdf combines two domain-separated 248-bit halves into a Field mask.
Field encryption and decryption flow
contracts/src/crypto/EcdhMask.compact, contracts/src/crypto/test/mocks/MockEcdhMask.compact
encryptField masks arbitrary Field plaintexts through ECDH and fieldKdf. decryptField reverses the mask. The mock exposes field operations and shared-secret helpers.
Field plaintext validation
contracts/src/crypto/test/EcdhMask.test.ts, contracts/src/crypto/test/mocks/MockEcdhMask.compact
Tests cover KDF construction, encryption round trips, ciphertext distribution, multi-field domain separation, incorrect keys or domains, and identity cases.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant Sender
  participant EcdhMask
  participant Ecdh
  participant Recipient
  Sender->>EcdhMask: encryptField(recipientPk, plaintext, ephemeralScalar, domain)
  EcdhMask->>Ecdh: derive shared point
  EcdhMask->>EcdhMask: derive fieldKdf mask
  EcdhMask-->>Sender: Ciphertext
  Recipient->>EcdhMask: decryptField(ciphertext, recipientScalar, domain)
  EcdhMask->>Ecdh: recover shared point
  EcdhMask->>EcdhMask: subtract fieldKdf mask
  EcdhMask-->>Recipient: Field plaintext
Loading

Suggested reviewers: andrew-fleming

Merge Risk: ⚪ Minimal · up to e7732

The field encryption APIs preserve the existing bounded path and include focused round-trip and domain-separation coverage. No merge-blocking risk is identified.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy the coding requirements in [#735]. EcdhMask adds encryptField, fieldKdf, and decryptField. The field KDF combines two domain-separated 248-bit halves and preserves the exis…
Out of Scope Changes check ✅ Passed The changed files remain within [#735]. Source changes implement field padding in EcdhMask. Tests and mock changes support the required API and existing test patterns. Changelog and documentation ch…
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 1…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding field hashing and a uniform EcdhMask pad for full-width Field encryption.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ecdh-mask-field-kdf

A rabbit hops through fields of light
Two halves make the mask just right
ECDH carries secrets fast
The plaintext returns at last
Domain tags keep pads apart

Comment @coderabbitai help to get the list of available commands.

@0xisk
0xisk force-pushed the feat/ecdh-mask-field-kdf branch from c7fcd25 to e7732f0 Compare September 17, 2026 06:49
@0xisk
0xisk added this pull request to stack #914 September 17, 2026 06:50
`kdf` derives into `[0, 2^248)`, which hides a `Uint<128>` with about
2^-120 slack but leaves the high bits of a full-width `Field` in the
clear. Widening `kdf` was not an option: its output is a published
ciphertext component, so `fieldKdf` is a second derivation instead. It
hashes two halves under distinct half indices and combines them as
`k1 + k2 * 2^248 mod p`, landing within 2^-240 of uniform, and its
three-element preimage separates it from `kdf` under the same
`(sShared, domain)`.

`encryptField` / `decryptField` reuse the existing `Ciphertext` struct
and reach the weak-input guards through `crypto/Ecdh` exactly as
`encrypt` does.

Refs: #735
* Width: each half stays inside the 248-bit `degradeToTransient` range,
  the halves are independent hash queries, and the combined mask leaves
  that range for most shared points. `encryptField` of a fixed plaintext
  spreads across the whole field, which is the property `kdf` cannot give.
* Round-trip at the field boundaries (zero, 2^248, 2^253, p-1) and as a
  property over arbitrary keys, ephemerals and plaintexts.
* Multi-field: two fields under one key agreement and one tag each; the
  same tag twice publishes their difference.
* `kdf` gains a regression pinning its output below 2^248, so a later
  widening cannot silently change `encrypt`.

Refs: #735
Group the byte and field conversions under the field they belong to,
with names from the literature in place of the standard library's
degradeToTransient and upgradeFromTransient, which hide a truncation.

* truncatedLEOS2IP and truncatedI2LEOSP wrap the two standard library
  conversions and document that both drop everything past 248 bits.
* fromUniformBytes is the field half of RFC 9380 hash_to_field:
  LEOS2IP_512(tv) mod q, the same reduction as Fq::from_uniform_bytes
  in Midnight's curves library.
* MockFq keeps every circuit impure so the artifact ships with ZKIR
  and proving keys.

Refs: #735
Give hashing to the field its own module, shaped after RFC 9380
hash_to_field, so the construction can be reviewed against a reference
step by step.

* hash/Sha256 exports digest and hashToField. hashToField follows the
  RFC's steps and names; its expander is a counter expander in place
  of expand_message_xmd, and the deviations are listed on the circuit.
* hash/IHasher holds the two signatures as a contract type, so a second
  hash module can be swapped in by changing an import.
* MockSha256 implements the interface, so a drifting signature fails
  compilation.

Refs: #735
EcdhMask no longer calls persistentHash or degradeToTransient directly.
kdf and pointDigest go through Sha256.digest and Fq.truncatedLEOS2IP,
and their outputs are unchanged.

fieldKdf now derives through Sha256.hashToField, which reduces all 64
bytes of the two blocks where the inline version reduced 62. Its
output changes, the uniformity bound tightens from 2^-241 to 2^-257,
and the three Field pad circuits grow by 580 rows at the same k.

The fieldKdf tests still pin the old 31-byte halves through
MockEcdhMask.fieldKdfHalves and are expected to fail until they are
updated.

Refs: #735
@0xisk
0xisk marked this pull request as draft September 17, 2026 14:27
Mocks sit beside the module they expose, as the Fq mock already does
under curves/bls12_381/test. Only the two import paths change.
fromUniformBytes and leos2ipModQ both evaluate a two-digit radix
representation in the field. fromRadixDigits now holds that one line
with its HAC reference, so the mod q is visible in one place instead of
implied by two identical comments. No behaviour change; rows unchanged.

Docs: verb-first titles with the formula, backend notes on the
truncated pair, every notation term linked to its source, sage checks
on the three literals, and @Constraints re-measured through MockFq.
The previous values did not come from the mock.
Each circuit names its hash_to_field step and links the FIPS 180-4 and
RFC 9380 sections it follows. Titles are verb-first with the formula.
Expected values are inlined per test, computed with an independent
Python reference (hashlib for SHA-256, py_ecc for the field), plus
formula checks done in TypeScript so no vector file is needed.
kdf pads stay below 2^248, so a ciphertext of a 248-bit or Field value
never wraps and its top bits are readable. The spec measures it: a
threshold distinguisher wins every time against kdf and is a coin flip
against fieldKdf, whose pads cover [0, P). Reference vectors for
fieldKdf and encryptField come from the same Python reference as the Fq
and Sha256 specs. The mock drops the stale fieldKdfHalves.

Refs: #735
A pure mock ships no ZKIR, so the Sha256 rows could not be measured or
reproduced. The mock now carries a counter like MockFq, and the specs
reach it through a simulator. The IHasher contract type is declared
without pure, since a pure declaration rejects such a mock while a pure
export still satisfies the plain one. The @Constraints values are the
ones measured through the mock.
Directory names use hyphens; the underscore came from the upstream
Rust module path. Only the import strings and header lines change.
expandMessage is RFC 8017 MGF1 with mgfSeed = msg || DST and a 32-byte
little-endian counter, not expand_message_xmd, whose strxor step has no
Compact operator. The preimage struct and expandBlock use MGF1's names,
C and counter, and the struct is exported. Every notation term links to
its definition, the hash_to_field steps are spelled out, and the digest
backends cite the ledger's persistent_hash and the ZKIR instruction.
kdf, pointDigest and fieldKdf state their formulas, fieldKdf cites the
ECIES step it plays (SEC 1 §5.1.3) and the hash_to_field it is, and
kdf says which SEC 1 KDF it is not. The kdf-versus-fieldKdf separation
is stated on the preimages. The @Constraints values are re-measured
through a ledger-backed mock; the previous ones did not come from one.
@0xisk 0xisk changed the title feat(crypto): add a Field plaintext pad to EcdhMask feat(crypto): hash to the field and a uniform EcdhMask pad Sep 18, 2026
A pure mock ships no ZKIR, so the EcdhMask rows could not be measured
or reproduced. The mock now carries a counter like MockFq and
MockSha256, and the EcdhMask and token specs reach it through a
simulator.
@0xisk
0xisk marked this pull request as ready for review September 18, 2026 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant