From 86cf432d8c6ea9214d143f6ffd3210fdf38dcc08 Mon Sep 17 00:00:00 2001 From: Orie Steele Date: Tue, 28 Jul 2026 14:12:12 -0500 Subject: [PATCH 1/6] feat: implement IHAT under an explicitly-guessed experimental profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: to find out what implementing IHAT actually requires, and to correct a claim in this crate's own documentation that had gone stale. [CRYPTO] exists now. draft-authors-mole-crypto.md landed upstream on 2026-07-15 (PR #38, closing issue #31), nine days after the -00 drafts. The README said it "does not exist yet"; that was checkably wrong, and for a crate whose credibility rests on precisely characterising what the drafts pin down, a reviewer finding the document we said was absent is the worst available failure mode. Corrected in README, SPEC-COVERAGE, lib.rs and crypto.rs. It is still not on the Datatracker (404) and still not interoperably specified, so the conclusion is unchanged — only the reason is now accurate. The construction closes. Deriving it: Z' = xY' = xvY, so Zhat = γv⁻¹Z' = γxY and Xhat = γX = γxG. With r_target = ε⁻¹(t'−ρ), the client's T1 = r_target·Y and T2 = r_target·G; then r = ε⁻¹(r'−ρ) = r_target + γxea, so verification recovers rY − ea·Zhat = r_target·Y and rG − ea·Xhat = r_target·G, and aG + bH = α⁻¹C' − βH = C. The algebra is self-consistent and implementable. So crypto::exp_ihat implements it, behind an off-by-default feature. Unlike insecure_mock this is the real construction: blinding works, the Anchor never learns the nullifier, issuer hiding is a genuine 1-of-n CDS OR proof, and verification needs only public keys. Ten parameters had to be guessed, because the draft is ~50 lines of prose that names no group, no hash, no DSTs, declares TLS notation without defining a single structure, and publishes no test vectors. Choices: ristretto255 (the construction stacks three sigma protocols and curve25519's cofactor 8 makes those unsound); DST base OR13_EXP_MOLE, deliberately naming this repo so it cannot squat a string the spec might want; SHA-256 widened to 64 bytes via expand_message_xmd, because bare 32-byte reduction mod ℓ≈2^252 is biased; Point[33] filled as a 0x00 tag plus 32-byte ristretto, keeping the drafts' wire width intact. Each guess is an open issue; #13 indexes them. Two findings worth reporting upstream: - Anchor nonce reuse recovers the secret key (#9). Reuse of t' and a' across two grants gives r'_1 − r'_2 = (e'_1 − e'_2)a'x, and a' is sent to the Client in the clear. This rules out deterministic nonce derivation, which inverts the usual advice and is a live footgun given IhatAnchor::sign takes &self — the obvious workaround is the dangerous one. No draft says this. - The group is ambiguous across drafts (#1): Point[33] implies SEC1 256-bit while ACT uses 32-byte ristretto255, and upstream #37 folds ACT into the crypto draft. Renamed the package to mole-exp with publish = false. The lib is still `mole` to import, so no call sites move. Nothing here is publishable and the -exp suffix should make that hard to forget. Twelve tests in tests/exp_ihat_profile.rs assert blindness against the actual request bytes, issuer hiding with size invariant to the real branch, challenge binding, public verifiability, and detection of every single-byte flip. They establish self-consistency and nothing more: there are no vectors to check against (#10), the construction has no security proof or public cryptanalysis, and upstream PR #39 is still open to define the proof. 100 tests pass; clippy pedantic, rustdoc, fmt clean; no_std verified on thumbv7em-none-eabi with and without the profile; MSRV 1.75 holds --locked with zeroize pinned below the edition2024 bump (#12). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 10 +- Cargo.lock | 97 +++- Cargo.toml | 24 +- EXP-IHAT-PROFILE.md | 235 +++++++++ README.md | 91 +++- SPEC-COVERAGE.md | 42 +- src/crypto.rs | 17 +- src/crypto/exp_ihat.rs | 915 ++++++++++++++++++++++++++++++++++++ src/crypto/insecure_mock.rs | 10 +- src/error.rs | 14 + src/lib.rs | 25 +- tests/exp_ihat_profile.rs | 269 +++++++++++ 12 files changed, 1702 insertions(+), 47 deletions(-) create mode 100644 EXP-IHAT-PROFILE.md create mode 100644 src/crypto/exp_ihat.rs create mode 100644 tests/exp_ihat_profile.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a0cfb8..1d624fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,12 @@ jobs: - name: Build for a bare-metal target run: cargo build --no-default-features --target thumbv7em-none-eabi + # The experimental IHAT profile pulls in curve25519-dalek, so it gets its + # own no_std check — the point of the profile is not to cost the core + # crate its bare-metal story. + - name: Build the exp-ihat profile for a bare-metal target + run: cargo build --no-default-features --features exp-ihat --target thumbv7em-none-eabi + msrv: name: MSRV (1.75) runs-on: ubuntu-latest @@ -57,4 +63,6 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.75.0 - uses: Swatinem/rust-cache@v2 - - run: cargo build --all-features + # --locked matters here: holding 1.75 depends on the committed lockfile + # pinning zeroize below the version that requires edition2024. See #12. + - run: cargo build --all-features --locked diff --git a/Cargo.lock b/Cargo.lock index 1efc57f..6687255 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,32 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -52,6 +78,12 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "generic-array" version = "0.14.7" @@ -69,13 +101,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "mole" +name = "mole-exp" version = "0.1.0" dependencies = [ "base64", + "curve25519-dalek", "sha2", ] +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "sha2" version = "0.10.9" @@ -87,14 +153,43 @@ dependencies = [ "digest", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/Cargo.toml b/Cargo.toml index 7843d76..ad7e7e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,30 @@ [package] -name = "mole" +name = "mole-exp" version = "0.1.0" edition = "2021" rust-version = "1.75" license = "MIT" -description = "Wire format, HTTP transport, and Moderator state machine for Moderation of unLinkable Endorsements (MoLE), per draft-jms-mole-{architecture,protocols,http-transport}-00" +description = "EXPERIMENTAL. Wire format, HTTP transport, and Moderator state machine for Moderation of unLinkable Endorsements (MoLE), per draft-jms-mole-{architecture,protocols,http-transport}-00, plus an explicitly-guessed IHAT profile that is not interoperable with anything" repository = "https://github.com/OR13/mole" -documentation = "https://docs.rs/mole" -readme = "README.md" +publish = false keywords = ["mole", "privacypass", "unlinkable", "ietf", "tokens"] categories = ["cryptography", "encoding", "network-programming"] exclude = ["/.github"] +[lib] +name = "mole" + [dependencies] sha2 = { version = "0.10", default-features = false } base64 = { version = "0.22", default-features = false, features = ["alloc"] } +# Only for the `exp-ihat` profile. Pinned to the 4.x line because 5.x requires +# Rust 1.85 and this crate's MSRV is 1.75. +curve25519-dalek = { version = "4.1.3", default-features = false, features = [ + "alloc", + "precomputed-tables", +], optional = true } + [features] default = ["std"] std = [] @@ -26,6 +35,13 @@ std = [] # blindness. Never enable this outside tests. See README.md. insecure-mock = [] +# Enables `crypto::exp_ihat`, an implementation of the IHAT construction from the +# upstream `draft-authors-mole-crypto.md` with every unspecified parameter +# GUESSED. It is not interoperable with any other implementation, has no test +# vectors to validate against, and implements a construction that is still under +# active revision by its authors. See `EXP-IHAT-PROFILE.md`. +exp-ihat = ["dep:curve25519-dalek"] + [lints.rust] missing_docs = "warn" unsafe_code = "forbid" diff --git a/EXP-IHAT-PROFILE.md b/EXP-IHAT-PROFILE.md new file mode 100644 index 0000000..c9c1087 --- /dev/null +++ b/EXP-IHAT-PROFILE.md @@ -0,0 +1,235 @@ +# The `exp-ihat` profile + +**This profile is not interoperable with anything, has no test vectors, and +implements a construction with no public cryptanalysis. It exists to find out what +implementing IHAT actually requires. Do not deploy it.** + +`crypto::exp_ihat`, behind the off-by-default `exp-ihat` feature, implements the +IHAT construction from the upstream +[`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md). + +## How this differs from `insecure_mock` + +The crate has two stand-ins for `[CRYPTO]` and they are not the same kind of +thing. `insecure_mock` fakes the *interface* so the encoding and state-machine +layers can be tested. `exp_ihat` implements the *construction* — the algebra is +the draft's algebra, and the security properties are real. + +| Property | `insecure_mock` | `exp_ihat` | +|---|---|---| +| Blind signing — Anchor never learns `nf` | ❌ sent in cleartext | ✅ Anchor sees only `Y' = vY` | +| Issuer hiding | ❌ carries the Anchor's index | ✅ 1-of-n CDS OR proof | +| Public verifiability | ❌ verifier needs every secret key | ✅ public keys only | +| Unlinkability across grants | ❌ byte-identical presentations | ✅ no shared field | +| Challenge binding | ✅ | ✅ | +| Interoperable | ❌ | ❌ | +| Validated against test vectors | n/a | ❌ none exist | + +## The construction + +Ten decisions had to be invented. Each is an open issue; [#13] indexes them. + +### Group: ristretto255 — [#1] + +The draft says only "let hash2curve be the Hash2Curve function from +{{Hash2Curve}}". RFC 9380 defines suites for seven curves and the draft names +none. + +ristretto255 was chosen because the construction stacks three sigma protocols +(DLEQ, the endorsement proof, and the OR proof) and cofactor confusion in a +non-prime-order group makes those unsound in ways that are easy to miss. Raw +curve25519 has cofactor 8; ristretto255 is the prime-order group over the same +field, with canonical encodings. + +**This collides with the protocols draft.** §4.1 pins `opaque Point[33]`, and 33 +bytes is a SEC1 *compressed* point on a 256-bit curve — P-256 or secp256k1, not +ristretto255, which encodes to 32. Meanwhile `draft-schlesinger-cfrg-act-01` uses +ristretto255, by an overlapping author set, and upstream issue #37 folds ACT into +the crypto draft. The two documents point at different groups. + +The profile keeps the draft's wire width: a point is a `0x00` tag byte followed by +the 32-byte ristretto encoding. Anchor keys still round-trip through +`endorsement::ihat::Challenge` untouched. + +### Hashing: SHA-256, widened — [#2], [#3] + +`H` is specified as "a hash function whose output length is sufficiently long". +No hash is named. (ACT uses BLAKE3, a third answer.) + +This profile uses SHA-256 and widens it to 64 bytes with `expand_message_xmd` +(RFC 9380 §5.3.1) before any reduction. The widening is not decoration: ristretto255 +scalars are mod `ℓ ≈ 2^252`, and reducing a bare 32-byte SHA-256 output mod `ℓ` is +measurably biased. Sixty-four bytes wide-reduced is not. This is probably what +"sufficiently long" means, but the draft does not say so. + +The same expansion feeds `hash_to_group`, which makes it exactly the +`hash_to_ristretto255` construction with SHA-256 in place of the suite's SHA-512. + +Base DST is `OR13_EXP_MOLE`, with a suffix per context: + +| Use | DST | +|---|---| +| `Y = Hash2Curve(m)` | `OR13_EXP_MOLE/h2g/m/v1` | +| Pedersen generator | `OR13_EXP_MOLE/h2g/pedersen-H/v1` | +| endorsement challenge | `OR13_EXP_MOLE/chal/endorsement/v1` | +| DLEQ challenge | `OR13_EXP_MOLE/chal/dleq/v1` | +| OR-proof challenge | `OR13_EXP_MOLE/chal/or/v1` | + +The DST names this repository, not MoLE, so it cannot squat a string the eventual +specification might want and nothing here can be mistaken for interoperable. + +### Transcripts — [#4] + +The crypto draft declares TLS notation in its Conventions section and then defines +**zero** structures, while the construction depends on hashing tuples of group +elements. An unspecified Fiat–Shamir transcript is the highest-risk gap in the +document: under-binding one is the classic route to a forgery. + +Every element is its canonical 32-byte ristretto encoding, so ordered +concatenation is unambiguous without length prefixes. Order follows the draft. The +OR transcript additionally commits the branch count as a `uint16`, so proofs over +different-sized accepted sets cannot collide. + +What remains unverified is whether these transcripts bind everything the +construction's security argument needs — which cannot be checked against a +document that does not contain that argument. + +### Pedersen commitment — [#5] + +Step one says only "It computes a commitment", never saying of what. You learn it +is Pedersen from step two's check, `Cprime = aprime G + bprime H`. The profile +assumes `C' = a'G + b'H` on that basis. + +The generator `H` there is a *group element*, and it collides notationally with +`H` the hash function from the same paragraph. Nothing says where it comes from, +and it matters: if `log_G H` were knowable the commitment would not be binding and +the opening check in `finalize` would prove nothing. The profile derives it as +`hash_to_group("pedersen-H")` — nothing up anyone's sleeve. + +This is the one guess flagged `help wanted`. It is defensible but it is a guess +about intent, and the notational collision suggests the draft text needs an +editorial pass. + +### OR proof — [#6] + +The draft, in full: "This is a standard OR sigma proof, and we can use sigma +stacking to compact the proof." That specifies neither the challenge derivation, +the branch ordering, the serialization, nor the stacking scheme. + +The profile uses the textbook Cramer–Damgård–Schoenmakers OR proof: simulate every +branch but the real one, share one challenge, send `(c_i, s_i)` per branch. +Verification recomputes `R_i = s_i·X_i − c_i·Xhat` and checks `Σ c_i` against the +transcript hash. Without stacking the proof is `O(n)` — 64 bytes per Anchor — where +upstream presumably wants compaction, since anonymity-set size is the point. + +Branch `i` corresponds to `keys[i]`, honouring §4.1.3's normative branch ordering. + +### `m` and the nullifier — [#7] + +The crypto draft gives the Endorsement an `m` with `Y = Hash2Curve(m)` and never +defines it. The protocols draft §4.1.3 says verification "exposes `nf` and +`endorsement_context`", neither of which the crypto draft mentions. This is the +seam between the two documents and it is unjoined. + +The profile sets `m = opaque(endorsement_context) || nf[32]`, with `nf` drawn +at random during `Prepare`. Since `m` is revealed at presentation and verification +must expose exactly those two fields, letting `m` *be* them is the least +invention available. It costs no blindness — the Anchor sees only `Y' = vY`. + +A consequence: `m` is fixed at issuance and carries the epoch, so an Endorsement +is epoch-bound. That is consistent with `endorsement_context` naming the epoch and +with `Moderator::redeem` checking epoch before nullifier freshness. + +### Challenge binding — [#8] + +§4.1.3 requires `Present` to bind `challenge_digest` into the proof transcript and +`Verify` to fail on any other digest. The crypto draft's verification procedure +never mentions `challenge_digest`. + +The endorsement proof `(e, a, b, r)` is fixed at issuance, so it cannot bind a +value known only at presentation. The OR proof is the only part computed at +presentation time, so that is where the digest is committed. This is more forced +than chosen, but the draft does not say it. + +## A security property no draft states — [#9] + +Found while implementing. + +The Anchor must draw `a'`, `b'`, `t'` freshly per session. Given reuse of `t'` and +`a'` across two sessions with chosen `e'_1 ≠ e'_2`: + +``` +r'_1 = t' + e'_1 a' x +r'_2 = t' + e'_2 a' x +``` + +Subtracting gives `(e'_1 − e'_2) a' x`, which solves for `a'x` — and `a'` is sent +to the Client in the clear in exchange two. The Anchor's long-term secret key +falls out with one division. + +Two consequences: + +1. **Nonces must not be derived deterministically from the request.** This inverts + the usual advice (RFC 6979, Ed25519), which makes it a live footgun — a careful + implementer reaches for deterministic nonces by default. `IhatAnchor::sign` + takes `&self`, so the obvious workaround *is* the dangerous one. `ExpAnchor` + holds an RNG behind a `RefCell` instead. +2. **Anchor state must be single-use.** `IhatAnchor::prove` consuming + `Self::State` by value enforces it at the type level. + +The protocols draft tells the *Client* "It MUST NOT retry with the same state" and +says nothing to the Anchor. The crypto draft says nothing about nonce generation. +Both should. + +## What the tests establish, and what they do not + +`tests/exp_ihat_profile.rs` — twelve tests: + +- the full two-exchange grant, then a presentation a verifier accepts +- blindness, asserted against the actual request bytes: neither the nullifier nor + the epoch appears in anything the Anchor receives, and the two request bodies + are exactly 33 and 32 bytes +- issuer hiding across a three-Anchor set, with presentation size invariant to + which branch is real +- unlinkability: two grants share no field +- challenge binding: a different digest fails +- public verifiability: the verifier is constructed from nothing +- an Anchor outside the accepted set is rejected; so is substituting the set +- every single-byte flip in a presentation is detected +- a corrupted DLEQ proof is caught by the Client +- malformed and truncated input is rejected without panicking + +All twelve passing means the algebra closes and the profile is self-consistent. + +It does **not** mean the profile agrees with the eventual specification — no test +vectors exist ([#10]) — and it is not cryptanalysis. The construction has no +security proof and no public review, and it is still moving: upstream PR #38 +patched a "PQ tagging attack" and PR #39, "Add more text to try to define proof", +is open. The two grant exchanges still have no correlation mechanism ([#11]); in +this profile correlation is implicit in the Rust call sequence, which is exactly +what a network protocol cannot rely on. + +## Building + +``` +cargo test --features exp-ihat +cargo build --no-default-features --features exp-ihat --target thumbv7em-none-eabi +``` + +The profile is `no_std`-clean. It adds one dependency, `curve25519-dalek`, held at +the `4.x` line because `5.x` requires Rust 1.85 against this crate's 1.75 MSRV; +`zeroize` is pinned in `Cargo.lock` for the same reason ([#12]). + +[#1]: https://github.com/OR13/mole/issues/1 +[#2]: https://github.com/OR13/mole/issues/2 +[#3]: https://github.com/OR13/mole/issues/3 +[#4]: https://github.com/OR13/mole/issues/4 +[#5]: https://github.com/OR13/mole/issues/5 +[#6]: https://github.com/OR13/mole/issues/6 +[#7]: https://github.com/OR13/mole/issues/7 +[#8]: https://github.com/OR13/mole/issues/8 +[#9]: https://github.com/OR13/mole/issues/9 +[#10]: https://github.com/OR13/mole/issues/10 +[#11]: https://github.com/OR13/mole/issues/11 +[#12]: https://github.com/OR13/mole/issues/12 +[#13]: https://github.com/OR13/mole/issues/13 diff --git a/README.md b/README.md index 6f7107a..9cc34b8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,11 @@ -# mole +# mole-exp -A Rust implementation of **MoLE** — Moderation of unLinkable Endorsements — covering -the parts that the IETF drafts actually specify. +An experimental Rust implementation of **MoLE** — Moderation of unLinkable +Endorsements — covering the parts that the IETF drafts actually specify, plus an +explicitly-guessed IHAT profile that is not interoperable with anything. + +Not published to crates.io, and `publish = false` in the manifest. The `-exp` +suffix is load-bearing. [![CI](https://github.com/OR13/mole/actions/workflows/ci.yml/badge.svg)](https://github.com/OR13/mole/actions/workflows/ci.yml) @@ -14,10 +18,10 @@ from Google, Mozilla, and Cloudflare. ## ⚠️ Read this before you use it -**This crate contains no working cryptography, and that is not an oversight.** +**The core of this crate contains no cryptography, and that is not an oversight.** The protocols draft specifies IHAT's message *structures* and then defers every -cryptographic operation to a companion document that does not exist yet: +cryptographic operation to a companion document: > The cryptographic operations, and the contents of every message body, are > defined in [CRYPTO]. Until that document is complete, bodies in this section are @@ -25,21 +29,51 @@ cryptographic operation to a companion document that does not exist yet: > > — [§4.1, `draft-jms-mole-protocols-00`](https://datatracker.ietf.org/doc/draft-jms-mole-protocols/) -`[CRYPTO]` ("MoLE Cryptography") is cited with no date and is not on the -Datatracker. The same draft notes that "IHAT is a placeholder name," that the two +### Where `[CRYPTO]` actually stands + +`[CRYPTO]` ("MoLE Cryptography") is cited in `-protocols-00` with no date. Since +that draft was published it **has been written**: +[`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md) +landed upstream on 15 July 2026 (PR #38, closing issue #31). It is **not** on the +IETF Datatracker — `draft-authors-mole-crypto` returns 404 — and it is not +submitted. + +It is also not implementable as an interoperable specification. It runs about fifty +lines of cryptographic content in prose, and it: + +- names no group (the protocols draft's `Point[33]` implies a SEC1 256-bit curve; + the ACT draft, overlapping authors, uses ristretto255 at 32 bytes) +- names no hash — `H` is "a hash function whose output length is sufficiently long" +- gives no hash-to-curve suite and no domain separation tag +- declares TLS notation in its Conventions section and then defines **zero** structures +- uses `C'` before defining it, and leaves the Pedersen generator undefined +- defers the OR proof to "sigma stacking" — PR #39, "Add more text to try to define + proof", is still open +- publishes no test vectors + +The same protocols draft notes that "IHAT is a placeholder name," that the two grant exchanges still need a correlation mechanism, and that the protocols "are not final. Some may be removed, others added." The registry values are labelled candidates, not assignments. -So what you get here is everything the drafts *do* pin down — the wire format, the -HTTP carriage, challenge binding, the registries and greasing rules, and the +So the core of this crate is everything the drafts *do* pin down — the wire format, +the HTTP carriage, challenge binding, the registries and greasing rules, and the Moderator's replay state machine — with the cryptography expressed as the trait -boundary a future `[CRYPTO]` implementation plugs into. Nothing in this crate -invents a scheme to fill that gap. +boundary in `src/crypto.rs`. Nothing in the default build invents a scheme. + +### Two off-by-default stand-ins + +| Feature | What it is | +|---|---| +| `insecure-mock` | Fakes the *interface* so the encoding and state-machine layers can be tested end to end. No blindness, no issuer hiding, no public verifiability, no unlinkability. A test fixture. | +| `exp-ihat` | Implements the *construction* from the upstream crypto draft, with all ten unspecified parameters guessed. Blindness, issuer hiding, public verifiability and unlinkability are real. Interoperability is not. | -For running the flow end to end there is `crypto::insecure_mock`, behind an -off-by-default feature. It provides no blindness, no issuer hiding, no public -verifiability, and no unlinkability. It is a test fixture. Do not deploy it. +Neither is deployable. `exp-ihat` is the more dangerous of the two precisely +because it works: it produces valid-looking endorsements under parameters this +crate invented, against a construction with no security proof and no public +cryptanalysis. Every guess is inventoried in +[`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md) and filed as an open issue +([#13](https://github.com/OR13/mole/issues/13) indexes them). ## What is implemented @@ -55,7 +89,7 @@ verifiability, and no unlinkability. It is a test fixture. Do not deploy it. | Greasing: value selection, and grease-as-unknown on the Moderator | ✅ | | Epoch nullifier store, single-use enforcement, token nonce replay | ✅ | | 200/401/403 challenge semantics | ✅ | -| IHAT cryptography | ❌ `[CRYPTO]` unpublished — trait only | +| IHAT cryptography | 🟡 trait boundary; `exp-ihat` implements it under guessed parameters | | Longfellow proving/verifying | ❌ needs `draft-google-cfrg-libzk` + circuit | | ACT spend/refund | ❌ needs `draft-schlesinger-cfrg-act` | | Privacy Pass issuance (VOPRF, batched tokens) | ❌ needs RFC 9578 impl | @@ -66,12 +100,16 @@ does with it, including the drafts' own open questions. ## Usage +Not on crates.io. Take it by path or git if you want to experiment: + ```toml [dependencies] -mole = "0.1" +mole-exp = { git = "https://github.com/OR13/mole" } ``` -Two dependencies: `sha2` and `base64`. `#![no_std]` with `alloc`; `forbid(unsafe_code)`. +The library is still `mole` to import. Two dependencies in the default build, +`sha2` and `base64`; `exp-ihat` adds `curve25519-dalek`. `#![no_std]` with `alloc` +throughout, including the profile; `forbid(unsafe_code)`. ```rust use mole::binding::ChallengeDigest; @@ -131,15 +169,22 @@ That is `moderator::NonceStore`, kept separate from the epoch `NullifierStore`. ## Tests ``` -cargo test --all-features # 88 tests +cargo test --all-features # 100 tests cargo clippy --all-features --all-targets -cargo build --no-default-features # no_std +RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps +cargo build --no-default-features # no_std ``` +`cargo doc` is part of the check, not an afterthought: the docs quote the drafts +constantly, and a bracketed citation like `[ACT]` is a broken intra-doc link that +only rustdoc will catch. + `tests/wire_vectors.rs` holds byte-exact hex vectors — the drafts publish none, so these are this crate's, offered for cross-implementation checking. `tests/appendix_a_flow.rs` drives the complete exchange from Appendix A of the -protocols draft. +protocols draft. `tests/exp_ihat_profile.rs` exercises the experimental profile; +there are deliberately no wire vectors for it, because any vectors would encode +this crate's guesses as though they were the specification. ## Drafts tracked @@ -150,6 +195,12 @@ Jackson (Mozilla), and Thibault Meunier (Cloudflare): - [`draft-jms-mole-protocols-00`](https://datatracker.ietf.org/doc/draft-jms-mole-protocols/) - [`draft-jms-mole-http-transport-00`](https://datatracker.ietf.org/doc/draft-jms-mole-http-transport/) +Plus, for the `exp-ihat` profile only, +[`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md) +— repository `main` as of 28 July 2026. It has no Datatracker entry and no revision +number, so there is no stable identifier to track it by; the profile is pinned to a +reading of the file, not to a published draft. + Upstream source: [Moderation-of-unLinkable-Endorsements/internet-drafts](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts). This is an independent implementation. It is not affiliated with or endorsed by the diff --git a/SPEC-COVERAGE.md b/SPEC-COVERAGE.md index 868bd55..1f44035 100644 --- a/SPEC-COVERAGE.md +++ b/SPEC-COVERAGE.md @@ -18,10 +18,10 @@ Legend: ✅ implemented · 🟡 partial, see note · ⬜ structures only, semant | 3.2 | Greasing | ✅ | `client::grease_presentation` picks from the 16 reserved values with a random body; `client::choose_presentation` also declines optional challenges. `moderator::dispatch` cannot special-case grease. | | 3.3 | Challenge Binding | ✅ | `binding::ChallengeDigest`. SHA-256 over the TLS-presentation octets; `from_base64url` decodes before hashing. | | 4 | Endorsement Protocols | ✅ | `endorsement::{EndorsementRequest, EndorsementResponse}` + both media type constants. | -| 4.1 | IHAT | ⬜ | `endorsement::ihat` has `Challenge` (ordered `Point keys`) and `Presentation`. All crypto is `crypto::{IhatAnchor, IhatClient, IhatVerifier}` — `[CRYPTO]` is unpublished. | +| 4.1 | IHAT | 🟡 | `endorsement::ihat` has `Challenge` (ordered `Point keys`) and `Presentation`. Crypto is the trait boundary `crypto::{IhatAnchor, IhatClient, IhatVerifier}`; `crypto::exp_ihat` implements it under guessed parameters (`EXP-IHAT-PROFILE.md`). `[CRYPTO]` exists upstream but is unsubmitted and not interoperably specified. | | 4.1.1 | Configuration | 🟡 | `pkA` and `endorsement_context` are trait/API parameters. There is no config-document parser because §6 does not define one. | -| 4.1.2 | Grant | ⬜ | Two exchanges, `Prepare`/`Sign`/`RequestProof`/`Prove`/`Finalize` as trait methods. `finalize` takes state **by value**, enforcing "MUST NOT retry with the same state." The draft's open TODO on correlating the two exchanges is unresolved upstream. | -| 4.1.3 | Redemption | ✅ | `moderator::Moderator::redeem` runs the checks in order: verify, epoch, nullifier freshness. Key order is preserved by `ihat::Challenge`. | +| 4.1.2 | Grant | 🟡 | Two exchanges, `Prepare`/`Sign`/`RequestProof`/`Prove`/`Finalize` as trait methods, implemented by `exp_ihat`. `finalize` takes state **by value**, enforcing "MUST NOT retry with the same state." The draft's open TODO on correlating the two exchanges is unresolved upstream ([#11]). The draft also fails to require the *Anchor* to use fresh per-session nonces, which key recovery depends on ([#9]). | +| 4.1.3 | Redemption | ✅ | `moderator::Moderator::redeem` runs the checks in order: verify, epoch, nullifier freshness. Key order is preserved by `ihat::Challenge`, and `exp_ihat`'s OR proof matches branches to keys by position as required. | | 4.2 | Longfellow | ⬜ | `endorsement::longfellow` has the empty `Challenge` and the `Presentation` (`circuit_id`, `nullifier`, `proof`). Proving/verifying is `crypto::LongfellowVerifier`; needs `draft-google-cfrg-libzk` and an out-of-band circuit. | | 4.2.3 | Differences from IHAT | 📄 | Scarcity rationale. | | 5 | Credential Protocols | ✅ | `credential::{CredentialRequest, CredentialResponse}`, including the rule that type `0x0001` carries an empty `endorsement_presentation`. | @@ -72,23 +72,44 @@ protocols draft, which is unwritten. ## Deliberate deviations -None. Where the drafts are silent this crate stops rather than guessing, and every -such stop is a row above. Two choices are this crate's own and are not -specification requirements: +In the default build, none. Where the drafts are silent the core crate stops rather +than guessing, and every such stop is a row above. Two choices are this crate's own +and are not specification requirements: 1. **`GreasePolicy` default rates** (5%). The draft says "some non-trivial probability" without fixing a value. 2. **The wire vectors** in `tests/wire_vectors.rs`. The drafts publish none. +**The `exp-ihat` feature is the exception, and it is a large one.** It guesses ten +parameters the crypto draft leaves open — group, hash, DSTs, transcript encodings, +the Pedersen generator, the OR-proof construction, the definition of `m`, and where +`challenge_digest` binds. It is off by default, `publish = false`, and every guess +is an open issue indexed by [#13]. See `EXP-IHAT-PROFILE.md`. Nothing in that +profile should be read as a claim about what MoLE specifies. + ## Things the drafts leave open Collected here because they are the interesting parts, and an implementer will hit them: -- `[CRYPTO]` does not exist, so IHAT cannot be implemented at all. +- `[CRYPTO]` now exists upstream as `draft-authors-mole-crypto.md` (merged 15 July + 2026, PR #38) but is **not** on the Datatracker and is not an interoperable + specification: no group, no hash, no DSTs, no wire structures, no test vectors, + and PR #39 is still open to "try to define proof". IHAT can be *implemented* — + `crypto::exp_ihat` does — but not interoperably, and not without inventing ten + parameters ([#13]). - IHAT's two grant exchanges have no defined correlation mechanism — the draft offers "either `[CRYPTO]` adds a session identifier … or this document mandates - connection reuse" and picks neither. + connection reuse" and picks neither ([#11]). This is the sharpest gap, and it is + now time-sensitive: the crypto draft's two-round issuance is exactly where a + session identifier would live, and it is being edited. +- The drafts never require the **Anchor** to use fresh per-session nonces. Reuse of + `t'` and `a'` across two grants recovers the Anchor's secret key by subtraction, + since `a'` is sent to the Client in the clear ([#9]). The protocols draft + constrains only the Client's state reuse. +- The group is ambiguous across drafts: `Point[33]` implies a SEC1 256-bit curve, + while `draft-schlesinger-cfrg-act-01` uses 32-byte ristretto255 and upstream + issue #37 folds ACT into the crypto draft ([#1]). - ACT's `Challenge` must express a predicate and a charged amount; its contents are undefined. - Reverse flow has no device binding, so an update is not provably bound to the @@ -98,3 +119,8 @@ them: - Anchor-set verification — how a Client confirms the accepted Anchors are real rather than padding invented by the Moderator to shrink the anonymity set — is listed as a privacy consideration to be written. + +[#1]: https://github.com/OR13/mole/issues/1 +[#9]: https://github.com/OR13/mole/issues/9 +[#11]: https://github.com/OR13/mole/issues/11 +[#13]: https://github.com/OR13/mole/issues/13 diff --git a/src/crypto.rs b/src/crypto.rs index 1ec736f..9e27fba 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -11,10 +11,17 @@ //! > below. //! //! `[CRYPTO]` ("MoLE Cryptography", `draft-authors-mole-crypto`) is cited with no -//! date and is not published on the IETF Datatracker as of -//! `draft-jms-mole-protocols-00`. The draft additionally notes that "IHAT is a -//! placeholder name" and that the two grant exchanges still need a correlation -//! mechanism. +//! date in `draft-jms-mole-protocols-00`. It has since been written — the file +//! landed in the upstream repository on 15 July 2026 — but it is not on the IETF +//! Datatracker, is not submitted, and does not specify enough to implement +//! interoperably: no group, no hash, no domain separation tags, no wire structures, +//! no test vectors, and an open PR to "try to define proof". The draft additionally +//! notes that "IHAT is a placeholder name" and that the two grant exchanges still +//! need a correlation mechanism. +//! +//! `crypto::exp_ihat` (feature `exp-ihat`) implements the construction anyway, by +//! guessing all of the above. It is not interoperable with anything and exists to +//! find out what implementing IHAT requires. //! //! Longfellow (Section 4.2) similarly requires the proving system of //! `draft-google-cfrg-libzk` plus an out-of-band circuit, and ACT (Section 5.1) @@ -149,5 +156,7 @@ pub trait LongfellowVerifier { ) -> Result; } +#[cfg(feature = "exp-ihat")] +pub mod exp_ihat; #[cfg(feature = "insecure-mock")] pub mod insecure_mock; diff --git a/src/crypto/exp_ihat.rs b/src/crypto/exp_ihat.rs new file mode 100644 index 0000000..9b708da --- /dev/null +++ b/src/crypto/exp_ihat.rs @@ -0,0 +1,915 @@ +//! **EXPERIMENTAL AND NOT INTEROPERABLE.** The IHAT construction from the +//! upstream `draft-authors-mole-crypto.md`, with every unspecified parameter +//! guessed by this crate. +//! +//! # What this is +//! +//! Unlike [`super::insecure_mock`], this is a real implementation of the real +//! construction. The blinding works, the endorsement is publicly verifiable, the +//! Anchor never learns the nullifier, and issuer hiding is a genuine 1-of-n OR +//! proof. The algebra is the draft's algebra. +//! +//! # Why you still must not deploy it +//! +//! The crypto draft is roughly fifty lines of prose. It names no group, no hash, +//! no domain separation tags, no wire encodings, and publishes no test vectors. +//! Turning it into code required inventing all of that. Every invention below is a +//! place where this implementation will disagree with the eventual specification +//! and with every other implementation: +//! +//! | What the draft leaves open | What this profile does | Issue | +//! |---|---|---| +//! | Group is unnamed; `Point[33]` implies SEC1, ristretto255 encodes to 32 | ristretto255, with a `0x00` tag byte filling the 33rd | [#1] | +//! | No hash-to-curve suite or DST | `expand_message_xmd(SHA-256)`, DSTs below | [#2] | +//! | `H` is only "sufficiently long" | SHA-256, widened to 64 bytes before reduction | [#3] | +//! | Fiat–Shamir transcripts unspecified | canonical fixed-width encodings, ordered as written | [#4] | +//! | Pedersen generator undefined; `C'` used before it is defined | hash-to-group of a fixed label; assumed `a'G + b'H` | [#5] | +//! | OR proof "sigma stacking" unspecified | textbook CDS OR proof, `O(n)`, not compacted | [#6] | +//! | `m` never defined; relation to `nf` unstated | `m = opaque(endorsement_context) \|\| nf[32]` | [#7] | +//! | Where `challenge_digest` binds | into the OR-proof transcript | [#8] | +//! +//! Two further gaps are tracked but not closeable here: there are no test vectors +//! to validate against ([#10]) and the two grant exchanges still have no +//! correlation mechanism ([#11]). [#13] indexes all of it. +//! +//! [#1]: https://github.com/OR13/mole/issues/1 +//! [#2]: https://github.com/OR13/mole/issues/2 +//! [#3]: https://github.com/OR13/mole/issues/3 +//! [#4]: https://github.com/OR13/mole/issues/4 +//! [#5]: https://github.com/OR13/mole/issues/5 +//! [#6]: https://github.com/OR13/mole/issues/6 +//! [#7]: https://github.com/OR13/mole/issues/7 +//! [#8]: https://github.com/OR13/mole/issues/8 +//! [#9]: https://github.com/OR13/mole/issues/9 +//! [#10]: https://github.com/OR13/mole/issues/10 +//! [#11]: https://github.com/OR13/mole/issues/11 +//! [#13]: https://github.com/OR13/mole/issues/13 +//! +//! Beyond the guesses, the construction itself is still moving: upstream PR #38 +//! patched a "PQ tagging attack" and PR #39 ("Add more text to try to define +//! proof") is open at the time of writing. It has had no public cryptanalysis and +//! no security proof. `EXP-IHAT-PROFILE.md` records all of this in full. +//! +//! # A security property the draft does not state +//! +//! The Anchor MUST draw `a'`, `b'`, `t'` freshly per session and MUST NOT reuse +//! them. If a Client can induce the Anchor to reuse `t'` and `a'` across two +//! sessions, it obtains `r'_1 = t' + e'_1 a' x` and `r'_2 = t' + e'_2 a' x` for +//! chosen `e'_1 != e'_2`; subtracting yields `a'x`, and `a'` is sent in the clear, +//! so the Anchor's secret key `x` falls out. This rules out deriving the nonces +//! deterministically from the request, which is why [`ExpAnchor`] holds an RNG. +//! The draft says nothing about it — see [#9], which is worth reporting upstream. + +// This module's identifiers are the draft's identifiers. `x`, `G`, `k`, `a'`, +// `b'`, `t'`, `T1'`, `T2'` are the names the construction is written in, and the +// whole point of a reference implementation is that a reader can check it against +// the document line by line. Renaming them to satisfy a similarity heuristic +// would cost exactly the property that makes this code auditable. +#![allow( + clippy::many_single_char_names, + clippy::similar_names, + clippy::struct_field_names +)] + +use core::cell::RefCell; + +use alloc::vec::Vec; + +use curve25519_dalek::constants::RISTRETTO_BASEPOINT_POINT; +use curve25519_dalek::ristretto::{CompressedRistretto, RistrettoPoint}; +use curve25519_dalek::scalar::Scalar; +use curve25519_dalek::traits::Identity; +use sha2::{Digest, Sha256}; + +use crate::binding::ChallengeDigest; +use crate::codec::{encode_varint, write_opaque_v, Reader}; +use crate::crypto::{IhatAnchor, IhatClient, IhatVerifier, RandomSource, VerifiedEndorsement}; +use crate::endorsement::Point; +use crate::error::{Error, Result}; + +/// The base domain separation string for this profile. +/// +/// Chosen by this crate. The drafts specify no DST, and RFC 9380 requires one to +/// be protocol-unique — so this value deliberately names the implementation +/// rather than MoLE, because it is not MoLE's. +pub const DST_BASE: &[u8] = b"OR13_EXP_MOLE"; + +const DST_H2G_M: &[u8] = b"OR13_EXP_MOLE/h2g/m/v1"; +const DST_H2G_PEDERSEN: &[u8] = b"OR13_EXP_MOLE/h2g/pedersen-H/v1"; +const DST_CHAL_ENDORSEMENT: &[u8] = b"OR13_EXP_MOLE/chal/endorsement/v1"; +const DST_CHAL_DLEQ: &[u8] = b"OR13_EXP_MOLE/chal/dleq/v1"; +const DST_CHAL_OR: &[u8] = b"OR13_EXP_MOLE/chal/or/v1"; + +/// Tag byte prefixed to a 32-byte ristretto encoding to fill `Point[33]`. +const POINT_TAG: u8 = 0x00; + +// --------------------------------------------------------------------------- +// Hashing (guesses 3, 4, 5) +// --------------------------------------------------------------------------- + +/// `expand_message_xmd` with SHA-256, per Section 5.3.1 of RFC 9380. +/// +/// The draft calls for a hash "whose output length is sufficiently long" without +/// naming one. This profile uses SHA-256 and widens it here, so that reduction +/// into the ristretto255 scalar field is unbiased: a bare 32-byte SHA-256 output +/// reduced mod the group order is measurably skewed, and 64 bytes is not. +fn expand_message_xmd_sha256(msg: &[&[u8]], dst: &[u8]) -> [u8; 64] { + const B_IN_BYTES: usize = 32; + const S_IN_BYTES: usize = 64; + const LEN_IN_BYTES: usize = 64; + // ell = ceil(64 / 32) = 2, comfortably inside the 255 limit, and the DSTs in + // this module are all far shorter than 255 bytes, so RFC 9380's abort + // conditions are unreachable here. + + // dst_prime = DST || I2OSP(len(DST), 1) + let mut dst_prime = Vec::with_capacity(dst.len() + 1); + dst_prime.extend_from_slice(dst); + dst_prime.push(u8::try_from(dst.len()).expect("DST shorter than 256 bytes")); + + // b_0 = H(Z_pad || msg || I2OSP(len_in_bytes, 2) || I2OSP(0, 1) || dst_prime) + let mut h = Sha256::new(); + h.update([0u8; S_IN_BYTES]); + for part in msg { + h.update(part); + } + h.update( + u16::try_from(LEN_IN_BYTES) + .expect("64 fits u16") + .to_be_bytes(), + ); + h.update([0u8]); + h.update(&dst_prime); + let b_0: [u8; B_IN_BYTES] = h.finalize().into(); + + // b_1 = H(b_0 || I2OSP(1, 1) || dst_prime) + let mut h = Sha256::new(); + h.update(b_0); + h.update([1u8]); + h.update(&dst_prime); + let b_1: [u8; B_IN_BYTES] = h.finalize().into(); + + // b_2 = H(strxor(b_0, b_1) || I2OSP(2, 1) || dst_prime) + let mut xored = [0u8; B_IN_BYTES]; + for (i, byte) in xored.iter_mut().enumerate() { + *byte = b_0[i] ^ b_1[i]; + } + let mut h = Sha256::new(); + h.update(xored); + h.update([2u8]); + h.update(&dst_prime); + let b_2: [u8; B_IN_BYTES] = h.finalize().into(); + + let mut out = [0u8; LEN_IN_BYTES]; + out[..B_IN_BYTES].copy_from_slice(&b_1); + out[B_IN_BYTES..].copy_from_slice(&b_2); + out +} + +/// Hashes to the ristretto255 group. +/// +/// This is the `hash_to_ristretto255` construction — `expand_message_xmd` to 64 +/// bytes, then the one-way map — with SHA-256 substituted for the suite's SHA-512. +fn hash_to_group(msg: &[&[u8]], dst: &[u8]) -> RistrettoPoint { + RistrettoPoint::from_uniform_bytes(&expand_message_xmd_sha256(msg, dst)) +} + +/// Hashes to a scalar, reducing 64 wide bytes so the result is unbiased. +fn hash_to_scalar(msg: &[&[u8]], dst: &[u8]) -> Scalar { + Scalar::from_bytes_mod_order_wide(&expand_message_xmd_sha256(msg, dst)) +} + +/// The Pedersen commitment's second generator (guess 6). +/// +/// The draft writes `C' = a'G + b'H` without saying where `H` comes from. If its +/// discrete log with respect to `G` were known, the commitment would not be +/// binding, so this derives it by hashing a fixed label — nothing up anyone's +/// sleeve. +fn pedersen_h() -> RistrettoPoint { + hash_to_group(&[b"pedersen-H"], DST_H2G_PEDERSEN) +} + +// --------------------------------------------------------------------------- +// Encoding (guess 2) +// --------------------------------------------------------------------------- + +fn encode_point(p: &RistrettoPoint) -> Point { + let mut out = [0u8; 33]; + out[0] = POINT_TAG; + out[1..].copy_from_slice(&p.compress().to_bytes()); + Point(out) +} + +fn decode_point(p: &Point) -> Result { + if p.0[0] != POINT_TAG { + return Err(Error::InvalidGroupElement); + } + let mut b = [0u8; 32]; + b.copy_from_slice(&p.0[1..]); + CompressedRistretto(b) + .decompress() + .ok_or(Error::InvalidGroupElement) +} + +fn read_point(r: &mut Reader<'_>) -> Result { + decode_point(&Point(r.array::<33>()?)) +} + +fn write_point(p: &RistrettoPoint, out: &mut Vec) { + out.extend_from_slice(&encode_point(p).0); +} + +fn read_scalar(r: &mut Reader<'_>) -> Result { + Option::::from(Scalar::from_canonical_bytes(r.array::<32>()?)) + .ok_or(Error::InvalidGroupElement) +} + +fn write_scalar(s: &Scalar, out: &mut Vec) { + out.extend_from_slice(s.as_bytes()); +} + +fn random_scalar(rng: &mut R) -> Scalar { + let mut b = [0u8; 64]; + rng.fill_bytes(&mut b); + Scalar::from_bytes_mod_order_wide(&b) +} + +/// Draws a scalar that is invertible. Retries a bounded number of times so a +/// pathological [`RandomSource`] cannot spin forever. +fn random_nonzero_scalar(rng: &mut R) -> Result { + for _ in 0..8 { + let s = random_scalar(rng); + if s != Scalar::ZERO { + return Ok(s); + } + } + Err(Error::VerificationFailed) +} + +// --------------------------------------------------------------------------- +// `m` (guess 9) +// --------------------------------------------------------------------------- + +/// Builds `m` so that verification can expose exactly what Section 4.1.3 says it +/// exposes. +/// +/// The crypto draft gives the Endorsement an `m` with `Y = Hash2Curve(m)` and +/// never defines it. Section 4.1.3 of the protocols draft says verification +/// "exposes `nf` and `endorsement_context`". Since `m` is revealed at +/// presentation, the least-invention option is to let `m` *be* those two fields, +/// so no separate derivation is needed. The Anchor never sees `m` — it receives +/// only `Y' = vY` — so this does not cost blindness. +fn encode_m(endorsement_context: &[u8], nullifier: &[u8; 32]) -> Vec { + let mut m = Vec::new(); + write_opaque_v(endorsement_context, &mut m); + m.extend_from_slice(nullifier); + m +} + +fn decode_m(m: &[u8]) -> Result<(Vec, [u8; 32])> { + let mut r = Reader::new(m); + let context = r.opaque_v_vec()?; + let nullifier = r.array::<32>()?; + r.expect_end()?; + Ok((context, nullifier)) +} + +// --------------------------------------------------------------------------- +// DLEQ (Section "Issuance Step One") +// --------------------------------------------------------------------------- + +/// Proves `log_G(X) == log_Yprime(Zprime)`, as the draft's reference to DLEQ +/// requires of the Anchor in exchange one. +fn dleq_prove( + rng: &mut R, + x: &Scalar, + y_prime: &RistrettoPoint, + x_pub: &RistrettoPoint, + z_prime: &RistrettoPoint, +) -> (Scalar, Scalar) { + let g = RISTRETTO_BASEPOINT_POINT; + let k = random_scalar(rng); + let a = k * g; + let b = k * y_prime; + let c = dleq_challenge(y_prime, x_pub, z_prime, &a, &b); + (c, k + c * x) +} + +fn dleq_challenge( + y_prime: &RistrettoPoint, + x_pub: &RistrettoPoint, + z_prime: &RistrettoPoint, + a: &RistrettoPoint, + b: &RistrettoPoint, +) -> Scalar { + let g = RISTRETTO_BASEPOINT_POINT; + // Every element is a canonical fixed-width ristretto encoding, so ordered + // concatenation is unambiguous without length prefixes (guess 5). + hash_to_scalar( + &[ + &g.compress().to_bytes(), + &y_prime.compress().to_bytes(), + &x_pub.compress().to_bytes(), + &z_prime.compress().to_bytes(), + &a.compress().to_bytes(), + &b.compress().to_bytes(), + ], + DST_CHAL_DLEQ, + ) +} + +fn dleq_verify( + y_prime: &RistrettoPoint, + x_pub: &RistrettoPoint, + z_prime: &RistrettoPoint, + c: &Scalar, + s: &Scalar, +) -> Result<()> { + let g = RISTRETTO_BASEPOINT_POINT; + let a = s * g - c * x_pub; + let b = s * y_prime - c * z_prime; + if dleq_challenge(y_prime, x_pub, z_prime, &a, &b) == *c { + Ok(()) + } else { + Err(Error::VerificationFailed) + } +} + +// --------------------------------------------------------------------------- +// OR proof (guesses 8, 10) +// --------------------------------------------------------------------------- + +/// Challenge for the 1-of-n OR proof. +/// +/// `challenge_digest` enters here, and only here. The endorsement proof +/// `(e, a, b, r)` is fixed at issuance and so cannot bind a per-presentation +/// value; the OR proof is computed at presentation time, which makes it the only +/// place Section 4.1.3's "`Present` MUST bind `challenge_digest` into the proof +/// transcript" can be satisfied (guess 10). +/// +/// `n` is committed as a `uint16` so that proofs over different accepted sets +/// cannot collide. +fn or_challenge( + x_hat: &RistrettoPoint, + keys: &[RistrettoPoint], + commitments: &[RistrettoPoint], + challenge_digest: &ChallengeDigest, +) -> Result { + let mut transcript = Vec::new(); + transcript.extend_from_slice(&x_hat.compress().to_bytes()); + transcript.extend_from_slice( + &u16::try_from(keys.len()) + .map_err(|_| Error::VerificationFailed)? + .to_be_bytes(), + ); + for k in keys { + transcript.extend_from_slice(&k.compress().to_bytes()); + } + for r in commitments { + transcript.extend_from_slice(&r.compress().to_bytes()); + } + transcript.extend_from_slice(challenge_digest.as_bytes()); + Ok(hash_to_scalar(&[&transcript], DST_CHAL_OR)) +} + +/// A textbook Cramer–Damgård–Schoenmakers OR proof of `∃i, γ: Xhat = γ·X_i`. +/// +/// The draft says "This is a standard OR sigma proof, and we can use sigma +/// stacking to compact the proof" and specifies neither. This is the standard +/// construction without the stacking optimisation, so the proof is `O(n)` in the +/// size of the accepted Anchor set rather than compacted. +fn or_prove( + rng: &mut R, + gamma: &Scalar, + x_hat: &RistrettoPoint, + keys: &[RistrettoPoint], + real_index: usize, + challenge_digest: &ChallengeDigest, +) -> Result> { + let n = keys.len(); + let mut challenges = alloc::vec![Scalar::ZERO; n]; + let mut responses = alloc::vec![Scalar::ZERO; n]; + let mut commitments = alloc::vec![RistrettoPoint::identity(); n]; + + // Simulate every branch but the real one. + for i in 0..n { + if i == real_index { + continue; + } + challenges[i] = random_scalar(rng); + responses[i] = random_scalar(rng); + commitments[i] = responses[i] * keys[i] - challenges[i] * x_hat; + } + + // Commit honestly on the real branch. + let k = random_scalar(rng); + commitments[real_index] = k * keys[real_index]; + + let c = or_challenge(x_hat, keys, &commitments, challenge_digest)?; + let simulated_sum: Scalar = challenges + .iter() + .enumerate() + .filter(|(i, _)| *i != real_index) + .map(|(_, s)| *s) + .sum(); + challenges[real_index] = c - simulated_sum; + responses[real_index] = k + challenges[real_index] * gamma; + + let mut out = Vec::new(); + encode_varint(n as u64, &mut out)?; + for i in 0..n { + write_scalar(&challenges[i], &mut out); + write_scalar(&responses[i], &mut out); + } + Ok(out) +} + +fn or_verify( + r: &mut Reader<'_>, + x_hat: &RistrettoPoint, + keys: &[RistrettoPoint], + challenge_digest: &ChallengeDigest, +) -> Result<()> { + let n = usize::try_from(r.varint()?).map_err(|_| Error::VerificationFailed)?; + if n != keys.len() { + return Err(Error::VerificationFailed); + } + let mut challenges = Vec::with_capacity(n); + let mut commitments = Vec::with_capacity(n); + for key in keys { + let c_i = read_scalar(r)?; + let s_i = read_scalar(r)?; + commitments.push(s_i * key - c_i * x_hat); + challenges.push(c_i); + } + let expected = or_challenge(x_hat, keys, &commitments, challenge_digest)?; + let sum: Scalar = challenges.iter().copied().sum(); + if sum == expected { + Ok(()) + } else { + Err(Error::VerificationFailed) + } +} + +// --------------------------------------------------------------------------- +// Anchor +// --------------------------------------------------------------------------- + +/// An Anchor running this profile. +/// +/// Holds an RNG behind a [`RefCell`] because [`IhatAnchor::sign`] takes `&self` +/// while the per-session nonces `a'`, `b'`, `t'` must be fresh — see the module +/// docs for why deriving them deterministically would leak the secret key. +#[derive(Debug)] +pub struct ExpAnchor { + secret: Scalar, + rng: RefCell, +} + +impl ExpAnchor { + /// Creates an Anchor from a secret scalar and a source of randomness. + /// + /// # Errors + /// Returns [`Error::InvalidGroupElement`] if `secret` is not a canonical + /// scalar, and [`Error::VerificationFailed`] if it is zero. + pub fn new(secret: [u8; 32], rng: R) -> Result { + let secret = Option::::from(Scalar::from_canonical_bytes(secret)) + .ok_or(Error::InvalidGroupElement)?; + if secret == Scalar::ZERO { + return Err(Error::VerificationFailed); + } + Ok(Self { + secret, + rng: RefCell::new(rng), + }) + } +} + +/// Anchor state between the two grant exchanges. +/// +/// Consumed by value in [`IhatAnchor::prove`], which is what prevents the nonce +/// reuse described in the module docs. +#[derive(Debug, Clone)] +pub struct AnchorState { + a_prime: Scalar, + b_prime: Scalar, + t_prime: Scalar, +} + +impl IhatAnchor for ExpAnchor { + type State = AnchorState; + + fn sign(&self, request_body: &[u8]) -> Result<(Vec, Self::State)> { + let g = RISTRETTO_BASEPOINT_POINT; + let mut r = Reader::new(request_body); + let y_prime = read_point(&mut r)?; + r.expect_end()?; + if y_prime == RistrettoPoint::identity() { + return Err(Error::InvalidGroupElement); + } + + let mut rng = self.rng.borrow_mut(); + let z_prime = self.secret * y_prime; + let a_prime = random_nonzero_scalar(&mut *rng)?; + let b_prime = random_scalar(&mut *rng); + let t_prime = random_scalar(&mut *rng); + + let c_prime = a_prime * g + b_prime * pedersen_h(); + let t1_prime = t_prime * y_prime; + let t2_prime = t_prime * g; + + let x_pub = self.secret * g; + let (dleq_c, dleq_s) = dleq_prove(&mut *rng, &self.secret, &y_prime, &x_pub, &z_prime); + + let mut body = Vec::new(); + write_point(&z_prime, &mut body); + write_point(&c_prime, &mut body); + write_point(&t1_prime, &mut body); + write_point(&t2_prime, &mut body); + write_scalar(&dleq_c, &mut body); + write_scalar(&dleq_s, &mut body); + + Ok(( + body, + AnchorState { + a_prime, + b_prime, + t_prime, + }, + )) + } + + fn prove(&self, state: Self::State, request_body: &[u8]) -> Result> { + let mut r = Reader::new(request_body); + let e_prime = read_scalar(&mut r)?; + r.expect_end()?; + + // r' = t' + e' a' x + let r_prime = state.t_prime + e_prime * state.a_prime * self.secret; + + let mut body = Vec::new(); + write_scalar(&r_prime, &mut body); + write_scalar(&state.a_prime, &mut body); + write_scalar(&state.b_prime, &mut body); + Ok(body) + } + + fn public_key(&self) -> Point { + encode_point(&(self.secret * RISTRETTO_BASEPOINT_POINT)) + } +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// A Client running this profile. +#[derive(Debug, Clone)] +pub struct ExpClient { + rng: R, +} + +impl ExpClient { + /// Creates a Client drawing all blinding factors from `rng`. + pub const fn new(rng: R) -> Self { + Self { rng } + } +} + +/// Client state after `Prepare`. +#[derive(Debug, Clone)] +pub struct ClientState { + anchor_public_key: Point, + x_pub: RistrettoPoint, + m: Vec, + y: RistrettoPoint, + y_prime: RistrettoPoint, + v: Scalar, + gamma: Scalar, + /// Present only after `RequestProof`. + stage_two: Option, +} + +#[derive(Debug, Clone)] +struct StageTwo { + x_hat: RistrettoPoint, + z_hat: RistrettoPoint, + c_prime: RistrettoPoint, + e: Scalar, + alpha: Scalar, + beta: Scalar, + epsilon: Scalar, + rho: Scalar, +} + +/// A finalized Endorsement under this profile. +/// +/// `gamma` and `anchor_public_key` are private Client data and are never sent; +/// the rest is exactly what a presentation reveals. +#[derive(Debug, Clone)] +pub struct ExpEndorsement { + /// `Xhat = γX`, the randomized Anchor key that issuer hiding rests on. + pub x_hat: RistrettoPoint, + /// `Zhat = γxY`. + pub z_hat: RistrettoPoint, + /// `m`, carrying `endorsement_context` and the nullifier. + pub m: Vec, + /// Fiat–Shamir challenge. + pub e: Scalar, + /// Opened commitment scalar `a`. + pub a: Scalar, + /// Opened commitment scalar `b`. + pub b: Scalar, + /// Response scalar `r`. + pub r: Scalar, + /// The witness for the OR proof. Private. + gamma: Scalar, + /// Locates this Anchor's branch in the accepted set. Private. + anchor_public_key: Point, +} + +impl ExpClient { + /// Runs both grant exchanges against `anchor` and returns a presentation + /// bound to `challenge_digest`. + pub fn grant_and_present( + &mut self, + anchor: &ExpAnchor, + endorsement_context: &[u8], + keys: &[Point], + challenge_digest: &ChallengeDigest, + ) -> Result> { + let pk = anchor.public_key(); + let (req1, state) = self.prepare(&pk, endorsement_context)?; + let (resp1, anchor_state) = anchor.sign(&req1)?; + let (req2, state) = self.request_proof(state, &resp1)?; + let resp2 = anchor.prove(anchor_state, &req2)?; + let endorsement = self.finalize(state, &resp2)?; + self.present(&endorsement, keys, challenge_digest) + } +} + +impl IhatClient for ExpClient { + type State = ClientState; + type Endorsement = ExpEndorsement; + + fn prepare( + &mut self, + anchor_public_key: &Point, + endorsement_context: &[u8], + ) -> Result<(Vec, Self::State)> { + let x_pub = decode_point(anchor_public_key)?; + + let mut nullifier = [0u8; 32]; + self.rng.fill_bytes(&mut nullifier); + let m = encode_m(endorsement_context, &nullifier); + let y = hash_to_group(&[&m], DST_H2G_M); + if y == RistrettoPoint::identity() { + return Err(Error::InvalidGroupElement); + } + + let v = random_nonzero_scalar(&mut self.rng)?; + let gamma = random_nonzero_scalar(&mut self.rng)?; + let y_prime = v * y; + + let mut body = Vec::new(); + write_point(&y_prime, &mut body); + + Ok(( + body, + ClientState { + anchor_public_key: *anchor_public_key, + x_pub, + m, + y, + y_prime, + v, + gamma, + stage_two: None, + }, + )) + } + + fn request_proof( + &mut self, + mut state: Self::State, + response_body: &[u8], + ) -> Result<(Vec, Self::State)> { + let g = RISTRETTO_BASEPOINT_POINT; + let mut r = Reader::new(response_body); + let z_prime = read_point(&mut r)?; + let c_prime = read_point(&mut r)?; + let t1_prime = read_point(&mut r)?; + let t2_prime = read_point(&mut r)?; + let dleq_c = read_scalar(&mut r)?; + let dleq_s = read_scalar(&mut r)?; + r.expect_end()?; + + // The Anchor must prove it applied its real key to the blinded point. + dleq_verify(&state.y_prime, &state.x_pub, &z_prime, &dleq_c, &dleq_s)?; + + let v_inv = state.v.invert(); + let z_hat = state.gamma * v_inv * z_prime; + let x_hat = state.gamma * state.x_pub; + + let alpha = random_nonzero_scalar(&mut self.rng)?; + let beta = random_scalar(&mut self.rng); + let epsilon = random_nonzero_scalar(&mut self.rng)?; + let rho = random_scalar(&mut self.rng); + + let alpha_inv = alpha.invert(); + let epsilon_inv = epsilon.invert(); + + let c = alpha_inv * c_prime - beta * pedersen_h(); + let t1 = epsilon_inv * v_inv * (t1_prime - rho * state.y_prime); + let t2 = epsilon_inv * (t2_prime - rho * g); + + let e = endorsement_challenge(&state.y, &z_hat, &t1, &t2, &c); + let e_prime = epsilon * alpha_inv * state.gamma * e; + + let mut body = Vec::new(); + write_scalar(&e_prime, &mut body); + + state.stage_two = Some(StageTwo { + x_hat, + z_hat, + c_prime, + e, + alpha, + beta, + epsilon, + rho, + }); + Ok((body, state)) + } + + fn finalize(&mut self, state: Self::State, response_body: &[u8]) -> Result { + let g = RISTRETTO_BASEPOINT_POINT; + let two = state.stage_two.ok_or(Error::VerificationFailed)?; + + let mut r = Reader::new(response_body); + let r_prime = read_scalar(&mut r)?; + let a_prime = read_scalar(&mut r)?; + let b_prime = read_scalar(&mut r)?; + r.expect_end()?; + + // "The client then checks Cprime = aprime G + bprime H, that aprime is + // invertable" — the commitment opening. + if a_prime == Scalar::ZERO { + return Err(Error::VerificationFailed); + } + if a_prime * g + b_prime * pedersen_h() != two.c_prime { + return Err(Error::VerificationFailed); + } + + let alpha_inv = two.alpha.invert(); + let a = alpha_inv * a_prime; + let b = alpha_inv * b_prime - two.beta; + let r_final = two.epsilon.invert() * (r_prime - two.rho); + + let endorsement = ExpEndorsement { + x_hat: two.x_hat, + z_hat: two.z_hat, + m: state.m, + e: two.e, + a, + b, + r: r_final, + gamma: state.gamma, + anchor_public_key: state.anchor_public_key, + }; + + // "The client then verifies Xhat, Zhat, m, e, a, b, r are a valid + // endorsement as in the section below." + verify_endorsement_proof(&endorsement)?; + Ok(endorsement) + } + + fn present( + &mut self, + endorsement: &Self::Endorsement, + keys: &[Point], + challenge_digest: &ChallengeDigest, + ) -> Result> { + let real_index = keys + .iter() + .position(|k| *k == endorsement.anchor_public_key) + .ok_or(Error::UnknownAnchorKey)?; + let decoded: Result> = keys.iter().map(decode_point).collect(); + let decoded = decoded?; + + let or_proof = or_prove( + &mut self.rng, + &endorsement.gamma, + &endorsement.x_hat, + &decoded, + real_index, + challenge_digest, + )?; + + let mut out = Vec::new(); + write_point(&endorsement.x_hat, &mut out); + write_point(&endorsement.z_hat, &mut out); + write_opaque_v(&endorsement.m, &mut out); + write_scalar(&endorsement.e, &mut out); + write_scalar(&endorsement.a, &mut out); + write_scalar(&endorsement.b, &mut out); + write_scalar(&endorsement.r, &mut out); + out.extend_from_slice(&or_proof); + Ok(out) + } +} + +// --------------------------------------------------------------------------- +// Verification +// --------------------------------------------------------------------------- + +fn endorsement_challenge( + y: &RistrettoPoint, + z_hat: &RistrettoPoint, + t1: &RistrettoPoint, + t2: &RistrettoPoint, + c: &RistrettoPoint, +) -> Scalar { + // `e = H(Y, Zhat, T1, T2, C)`, in the draft's order (guess 5). + hash_to_scalar( + &[ + &y.compress().to_bytes(), + &z_hat.compress().to_bytes(), + &t1.compress().to_bytes(), + &t2.compress().to_bytes(), + &c.compress().to_bytes(), + ], + DST_CHAL_ENDORSEMENT, + ) +} + +/// The draft's "Verifying such an endorsement", without the OR proof. +fn verify_endorsement_proof(e: &ExpEndorsement) -> Result<()> { + let g = RISTRETTO_BASEPOINT_POINT; + let y = hash_to_group(&[&e.m], DST_H2G_M); + // "It checks a is not zero and Y is not the identity". + if e.a == Scalar::ZERO || y == RistrettoPoint::identity() { + return Err(Error::VerificationFailed); + } + let t1 = e.r * y - e.e * e.a * e.z_hat; + let t2 = e.r * g - e.e * e.a * e.x_hat; + let c = e.a * g + e.b * pedersen_h(); + if endorsement_challenge(&y, &e.z_hat, &t1, &t2, &c) == e.e { + Ok(()) + } else { + Err(Error::VerificationFailed) + } +} + +/// The Moderator's verifier for this profile. +/// +/// Publicly verifiable: it needs only the accepted Anchor public keys, never a +/// secret. That is the property [`super::insecure_mock`] cannot provide. +#[derive(Debug, Clone, Copy, Default)] +pub struct ExpVerifier; + +impl IhatVerifier for ExpVerifier { + fn verify( + &self, + presentation: &[u8], + keys: &[Point], + challenge_digest: &ChallengeDigest, + ) -> Result { + if keys.is_empty() { + return Err(Error::VerificationFailed); + } + let mut r = Reader::new(presentation); + let x_hat = read_point(&mut r)?; + let z_hat = read_point(&mut r)?; + let m = r.opaque_v_vec()?; + let e = read_scalar(&mut r)?; + let a = read_scalar(&mut r)?; + let b = read_scalar(&mut r)?; + let r_scalar = read_scalar(&mut r)?; + + let decoded: Result> = keys.iter().map(decode_point).collect(); + let decoded = decoded?; + or_verify(&mut r, &x_hat, &decoded, challenge_digest)?; + r.expect_end()?; + + // Reconstruct just enough of the Endorsement to run the proof check. The + // private fields are unused by verification, which is the point. + let candidate = ExpEndorsement { + x_hat, + z_hat, + m, + e, + a, + b, + r: r_scalar, + gamma: Scalar::ZERO, + anchor_public_key: Point([0u8; 33]), + }; + verify_endorsement_proof(&candidate)?; + + let (endorsement_context, nullifier) = decode_m(&candidate.m)?; + Ok(VerifiedEndorsement { + nullifier: nullifier.to_vec(), + endorsement_context, + }) + } +} diff --git a/src/crypto/insecure_mock.rs b/src/crypto/insecure_mock.rs index 3401bcc..f9f7e35 100644 --- a/src/crypto/insecure_mock.rs +++ b/src/crypto/insecure_mock.rs @@ -1,5 +1,11 @@ -//! **INSECURE.** A stand-in for the unpublished `[CRYPTO]` document, so that the -//! message flow can be exercised end to end. +//! **INSECURE.** A stand-in for `[CRYPTO]`, so that the message flow can be +//! exercised end to end. +//! +//! For an implementation of the actual construction — real blinding, real issuer +//! hiding, real public verifiability, but with every unspecified parameter guessed +//! — see `crypto::exp_ihat` behind the `exp-ihat` feature. This module is the +//! cheaper fixture and remains the right one for testing the encoding and +//! state-machine layers, because it has no cryptographic cost. //! //! # This is not IHAT and provides no security //! diff --git a/src/error.rs b/src/error.rs index af491a5..c747330 100644 --- a/src/error.rs +++ b/src/error.rs @@ -72,6 +72,16 @@ pub enum Error { WrongEpoch, /// Verification of a proof, signature, or MAC failed. VerificationFailed, + /// A field that must encode a group element or scalar did not. + /// + /// Only produced by `crypto::exp_ihat`. The drafts define no group, so this + /// variant is about that profile's guessed encoding, not a spec requirement. + InvalidGroupElement, + /// A presented Endorsement names an Anchor outside the accepted set. + /// + /// Only produced by `crypto::exp_ihat`, where the Client must locate its own + /// Anchor's position to build the OR proof. + UnknownAnchorKey, } impl fmt::Display for Error { @@ -106,6 +116,10 @@ impl fmt::Display for Error { Self::NullifierAlreadySpent => f.write_str("nullifier already spent in this epoch"), Self::WrongEpoch => f.write_str("endorsement is not valid in the current epoch"), Self::VerificationFailed => f.write_str("verification failed"), + Self::InvalidGroupElement => f.write_str("not a valid group element or scalar"), + Self::UnknownAnchorKey => { + f.write_str("endorsement names an Anchor outside the accepted set") + } } } } diff --git a/src/lib.rs b/src/lib.rs index d50ca1b..363399b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,24 +25,35 @@ //! //! ## What is NOT implemented, and why //! -//! **No cryptography.** This is the load-bearing caveat. Section 4.1 of the -//! protocols draft specifies IHAT's message structures and then defers every -//! cryptographic operation to a `[CRYPTO]` document that does not exist yet: +//! **No cryptography in the default build.** This is the load-bearing caveat. +//! Section 4.1 of the protocols draft specifies IHAT's message structures and then +//! defers every cryptographic operation to a `[CRYPTO]` document: //! //! > The cryptographic operations, and the contents of every message body, are //! > defined in \[CRYPTO\]. Until that document is complete, bodies in this //! > section are opaque byte strings produced and consumed by the functions named //! > below. //! +//! `[CRYPTO]` has since been written — `draft-authors-mole-crypto.md` landed in the +//! upstream repository on 15 July 2026 — but it is not on the IETF Datatracker and +//! is not an interoperable specification: it names no group, no hash, and no domain +//! separation tags, declares TLS notation without defining a single structure, and +//! publishes no test vectors. +//! //! The other schemes are likewise external: Longfellow needs //! `draft-google-cfrg-libzk` plus an out-of-band circuit, ACT needs //! `draft-schlesinger-cfrg-act`, and the Privacy Pass credential types need //! RFC 9578 issuance. So the cryptographic surface here is the trait boundary in -//! [`crypto`], and nothing in this crate invents a scheme to fill it. +//! [`crypto`], and the default build invents no scheme to fill it. +//! +//! Two off-by-default features do fill it, neither deployable: //! -//! For running the flow end to end there is [`crypto::insecure_mock`], behind the -//! off-by-default `insecure-mock` feature. It is deliberately and thoroughly -//! insecure; read its module documentation before touching it. +//! * `insecure-mock` — [`crypto::insecure_mock`] fakes the *interface* so the +//! encoding and state-machine layers can be driven end to end. Deliberately and +//! thoroughly insecure; read its module documentation before touching it. +//! * `exp-ihat` — `crypto::exp_ihat` implements the *construction* from the upstream +//! crypto draft with all ten unspecified parameters guessed. The security +//! properties are real; interoperability is not. See `EXP-IHAT-PROFILE.md`. //! //! ## Example: a Moderator challenge, and a Client answering it //! diff --git a/tests/exp_ihat_profile.rs b/tests/exp_ihat_profile.rs new file mode 100644 index 0000000..be80724 --- /dev/null +++ b/tests/exp_ihat_profile.rs @@ -0,0 +1,269 @@ +//! Tests for the experimental IHAT profile (`crypto::exp_ihat`). +//! +//! These assert the properties the construction is *supposed* to have, which is +//! the only validation available: the crypto draft publishes no test vectors, so +//! there is nothing to check byte-exact agreement against. Passing these tests +//! says the algebra closes and the profile is self-consistent. It says nothing +//! about whether the profile agrees with the eventual specification. + +#![cfg(feature = "exp-ihat")] + +use mole::binding::ChallengeDigest; +use mole::client::DeterministicRng; +use mole::crypto::exp_ihat::{ExpAnchor, ExpClient, ExpVerifier}; +use mole::crypto::{IhatAnchor, IhatClient, IhatVerifier}; +use mole::endorsement::Point; +use mole::error::Error; + +const EPOCH: &[u8] = b"2026-07-28T00:00:00Z/PT24H"; + +fn anchor(seed: u8, rng_seed: u64) -> ExpAnchor { + ExpAnchor::new([seed; 32], DeterministicRng::new(rng_seed)).unwrap() +} + +fn digest(label: &[u8]) -> ChallengeDigest { + ChallengeDigest::new(label) +} + +/// The whole flow: two grant exchanges, then a presentation a verifier accepts +/// holding nothing but public keys. +#[test] +fn grant_then_present_then_verify() { + let a = anchor(1, 42); + let mut client = ExpClient::new(DeterministicRng::new(7)); + let keys = vec![a.public_key()]; + let cd = digest(b"challenge-one"); + + let presentation = client.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + let verified = ExpVerifier.verify(&presentation, &keys, &cd).unwrap(); + + assert_eq!(verified.endorsement_context, EPOCH); + assert_eq!(verified.nullifier.len(), 32); +} + +/// Issuer hiding: the verifier accepts against the whole accepted set, and the +/// presentation is identical in size and structure regardless of which Anchor in +/// the set actually issued. +#[test] +fn issuer_hiding_across_an_accepted_set() { + let a0 = anchor(1, 42); + let a1 = anchor(2, 43); + let a2 = anchor(3, 44); + let keys = vec![a0.public_key(), a1.public_key(), a2.public_key()]; + let cd = digest(b"challenge-set"); + + let mut sizes = Vec::new(); + for (i, a) in [&a0, &a1, &a2].into_iter().enumerate() { + let mut client = ExpClient::new(DeterministicRng::new(100 + i as u64)); + let p = client.grant_and_present(a, EPOCH, &keys, &cd).unwrap(); + // Every branch verifies against the same public set. + ExpVerifier.verify(&p, &keys, &cd).unwrap(); + sizes.push(p.len()); + } + // A presentation's length must not leak the issuing branch. + assert!( + sizes.windows(2).all(|w| w[0] == w[1]), + "presentation size varies by issuing Anchor: {sizes:?}" + ); +} + +/// The Anchor never sees the nullifier. This is the property +/// `crypto::insecure_mock` cannot provide, so it is worth asserting directly on +/// the bytes that cross the wire. +#[test] +fn anchor_never_receives_the_nullifier() { + let a = anchor(1, 42); + let mut client = ExpClient::new(DeterministicRng::new(7)); + let keys = vec![a.public_key()]; + let cd = digest(b"blindness"); + + let (req1, state) = client.prepare(&a.public_key(), EPOCH).unwrap(); + let (resp1, astate) = a.sign(&req1).unwrap(); + let (req2, state) = client.request_proof(state, &resp1).unwrap(); + let resp2 = a.prove(astate, &req2).unwrap(); + let endorsement = client.finalize(state, &resp2).unwrap(); + let presentation = client.present(&endorsement, &keys, &cd).unwrap(); + + let verified = ExpVerifier.verify(&presentation, &keys, &cd).unwrap(); + let nf = verified.nullifier; + assert_eq!(nf.len(), 32); + + // Neither request body the Anchor received contains the nullifier, and + // neither contains the epoch either: the Anchor sees only `Y' = vY` and a + // blinded scalar. + for body in [&req1, &req2] { + assert!( + !body.windows(nf.len()).any(|w| w == nf.as_slice()), + "nullifier leaked to the Anchor" + ); + assert!( + !body.windows(EPOCH.len()).any(|w| w == EPOCH), + "endorsement_context leaked to the Anchor" + ); + } + // Exchange one is a single Point[33]; exchange two is a single scalar. + assert_eq!(req1.len(), 33); + assert_eq!(req2.len(), 32); +} + +/// Section 4.1.3: "`Verify` MUST fail when given any other `challenge_digest`." +#[test] +fn verify_fails_against_a_different_challenge_digest() { + let a = anchor(1, 42); + let mut client = ExpClient::new(DeterministicRng::new(7)); + let keys = vec![a.public_key()]; + + let presentation = client + .grant_and_present(&a, EPOCH, &keys, &digest(b"bound-to-this")) + .unwrap(); + + assert_eq!( + ExpVerifier + .verify(&presentation, &keys, &digest(b"but-not-this")) + .unwrap_err(), + Error::VerificationFailed + ); +} + +/// Two separate grants produce unlinkable endorsements: no field is shared. +#[test] +fn separate_grants_share_no_field() { + let a = anchor(1, 42); + let keys = vec![a.public_key()]; + let cd = digest(b"unlinkability"); + + let mut c1 = ExpClient::new(DeterministicRng::new(7)); + let mut c2 = ExpClient::new(DeterministicRng::new(8)); + let p1 = c1.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + let p2 = c2.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + + assert_ne!(p1, p2); + // Xhat is the randomized Anchor key; distinct gamma means distinct Xhat, so + // presentations cannot be linked to each other by it. + assert_ne!(p1[..33], p2[..33]); + let v1 = ExpVerifier.verify(&p1, &keys, &cd).unwrap(); + let v2 = ExpVerifier.verify(&p2, &keys, &cd).unwrap(); + assert_ne!(v1.nullifier, v2.nullifier); +} + +/// An Anchor outside the accepted set cannot produce an acceptable presentation. +#[test] +fn anchor_outside_the_accepted_set_is_rejected() { + let trusted = anchor(1, 42); + let rogue = anchor(9, 99); + let keys = vec![trusted.public_key()]; + let cd = digest(b"rogue"); + + let mut client = ExpClient::new(DeterministicRng::new(7)); + // `present` cannot even locate a branch for an untrusted Anchor. + assert_eq!( + client + .grant_and_present(&rogue, EPOCH, &keys, &cd) + .unwrap_err(), + Error::UnknownAnchorKey + ); +} + +/// A presentation verified against a set the Client did not prove over fails. +#[test] +fn substituting_the_accepted_set_fails() { + let a0 = anchor(1, 42); + let a1 = anchor(2, 43); + let issued_over = vec![a0.public_key(), a1.public_key()]; + let cd = digest(b"set-substitution"); + + let mut client = ExpClient::new(DeterministicRng::new(7)); + let p = client + .grant_and_present(&a0, EPOCH, &issued_over, &cd) + .unwrap(); + + // Same size, different membership: the OR transcript commits to the keys. + let other = vec![a1.public_key(), anchor(3, 44).public_key()]; + assert!(ExpVerifier.verify(&p, &other, &cd).is_err()); + + // Different size: the proof's branch count is checked against the set. + let shorter = vec![a0.public_key()]; + assert!(ExpVerifier.verify(&p, &shorter, &cd).is_err()); +} + +/// Flipping any single byte of a presentation makes it unverifiable. +#[test] +fn tampering_is_detected() { + let a = anchor(1, 42); + let mut client = ExpClient::new(DeterministicRng::new(7)); + let keys = vec![a.public_key()]; + let cd = digest(b"tamper"); + let good = client.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + + ExpVerifier.verify(&good, &keys, &cd).unwrap(); + + for i in 0..good.len() { + let mut bad = good.clone(); + bad[i] ^= 0x01; + assert!( + ExpVerifier.verify(&bad, &keys, &cd).is_err(), + "byte {i} could be flipped without detection" + ); + } +} + +/// The Client rejects an Anchor that does not apply its real key, which is what +/// the DLEQ proof in exchange one is for. +#[test] +fn client_rejects_a_bad_dleq_proof() { + let a = anchor(1, 42); + let mut client = ExpClient::new(DeterministicRng::new(7)); + + let (req1, state) = client.prepare(&a.public_key(), EPOCH).unwrap(); + let (mut resp1, _) = a.sign(&req1).unwrap(); + + // Corrupt Z' — the DLEQ proof no longer matches it. + resp1[33 * 4] ^= 0x01; + assert!(client.request_proof(state, &resp1).is_err()); +} + +/// Verification needs only public keys. Constructing a verifier takes no secret, +/// which is the public-verifiability requirement of Section 9.1.1. +#[test] +fn verification_requires_no_secret() { + let a = anchor(1, 42); + let mut client = ExpClient::new(DeterministicRng::new(7)); + let keys = vec![a.public_key()]; + let cd = digest(b"public"); + let p = client.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + + // A verifier built from nothing at all, with only the published keys passed + // in, accepts. Contrast `insecure_mock::InsecureVerifier`, which is + // constructed from every Anchor's secret. + let verifier = ExpVerifier; + assert!(verifier.verify(&p, &keys, &cd).is_ok()); +} + +/// Garbage input is rejected rather than panicking. +#[test] +fn malformed_presentations_are_rejected() { + let keys = vec![anchor(1, 42).public_key()]; + let cd = digest(b"garbage"); + + for len in 0usize..80 { + let junk = vec![0xABu8; len]; + assert!(ExpVerifier.verify(&junk, &keys, &cd).is_err()); + } + // A non-canonical point tag is refused. + let mut bad_tag = vec![0u8; 200]; + bad_tag[0] = 0xFF; + assert!(ExpVerifier.verify(&bad_tag, &keys, &cd).is_err()); + // An empty accepted set has no branch to prove. + assert!(ExpVerifier.verify(&[], &[], &cd).is_err()); +} + +/// The `Point[33]` encoding this profile invents keeps the draft's width, so +/// Anchor keys still round-trip through the spec's `ihat::Challenge`. +#[test] +fn public_keys_fit_the_drafts_point_width() { + let a = anchor(1, 42); + let pk: Point = a.public_key(); + assert_eq!(pk.0.len(), 33); + // Tag byte marks it as this profile's ristretto encoding, not SEC1. + assert_eq!(pk.0[0], 0x00); +} From 1ddda7b7252961c9235b77233cc697d2765c6481 Mon Sep 17 00:00:00 2001 From: Orie Steele Date: Tue, 28 Jul 2026 14:42:37 -0500 Subject: [PATCH 2/6] feat: move the exp-ihat profile to P-256, aligning with the drafts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: P-256 is the group both MoLE drafts point at, and switching to it *removes* inventions rather than adding them. Four things that were this crate's choices are now the specification: - Point[33] is a genuine SEC1 compressed P-256 point. The 0x00 tag byte that padded a 32-byte ristretto encoding out to the drafts' width is gone. This was the only place the profile had to invent an encoding. - Hash-to-curve is RFC 9380 P256_XMD:SHA-256_SSWU_RO_, which is what the crypto draft's "RO based Hash2Curve function" resolves to over P-256. The hand-rolled expand_message_xmd is deleted in favour of p256's hash2curve. - Scalars come from RFC 9380 hash_to_field, expanding to 48 bytes for P-256 before reduction, so the draft's "output length sufficiently long" is met by construction rather than by this crate choosing a width. - The Pedersen second generator is Hash2Curve("IHAT second generator"), the literal string upstream PR #39 defines. SHA-256 therefore stops being a preference and becomes the specification: P256_XMD:SHA-256_SSWU_RO_ expands with SHA-256 internally. The earlier argument for ristretto255 — curve25519's cofactor 8 against three stacked sigma protocols — turned out to be moot, because P-256 has cofactor 1. Also corrects an error in this crate's earlier analysis, recorded on #4: the crypto draft's {{DLEQ}} reference has always been I-D.draft-irtf-cfrg-sigma-protocols, not RFC 9497. It never reached an issue body or the code, but it understated how much specified machinery the draft already pointed at. Six items remain open, each an issue, and two of them are now deliberate deviations from things PR #39 does specify rather than gaps: - #1 the two MoLE drafts disagree on point compression, Point[33] vs bytes[65]; and the group is still only named by a forward reference the architecture draft does not satisfy - #2 no DST upstream; ours now name the suite per RFC 9380 3.1 convention - #3/#4 e = H(Y, Zhat, T1, T2, C) is the one hash left outside any framework; the profile does not yet use the SHAKE128 sponge and P-256 codec of draft-irtf-cfrg-fiat-shamir - #5 C' is still used before it is defined - #6 textbook CDS OR proof rather than PR #39's one-of-N trapdoor commitment tree, held because that PR is unmerged and carries its own TODO saying the construction may be replaced - #7/#8 m typed as Scalar cannot carry endorsement_context, and challenge_digest appears in no upstream structure or challenge, so following the crypto draft here would produce something that cannot satisfy protocols 4.1.3 at all 102 tests pass. The profile gains two: Anchor keys are SEC1 compressed with both parities occurring, and the identity is refused rather than truncated into a 33-byte lookalike. Clippy pedantic, rustdoc and fmt clean; no_std verified on thumbv7em-none-eabi with and without the profile; MSRV 1.75 holds --locked, with zeroize still pinned because p256 reaches it via elliptic-curve (#12). Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 6 +- Cargo.lock | 146 ++++++---- Cargo.toml | 23 +- EXP-IHAT-PROFILE.md | 288 +++++++++++--------- README.md | 67 +++-- SPEC-COVERAGE.md | 39 ++- src/crypto.rs | 12 +- src/crypto/exp_ihat.rs | 541 ++++++++++++++++++++------------------ src/lib.rs | 14 +- tests/exp_ihat_profile.rs | 51 +++- 10 files changed, 686 insertions(+), 501 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d624fe..24013cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,9 +50,9 @@ jobs: - name: Build for a bare-metal target run: cargo build --no-default-features --target thumbv7em-none-eabi - # The experimental IHAT profile pulls in curve25519-dalek, so it gets its - # own no_std check — the point of the profile is not to cost the core - # crate its bare-metal story. + # The experimental IHAT profile pulls in p256, so it gets its own no_std + # check — the point of the profile is not to cost the core crate its + # bare-metal story. - name: Build the exp-ihat profile for a bare-metal target run: cargo build --no-default-features --features exp-ihat --target thumbv7em-none-eabi diff --git a/Cargo.lock b/Cargo.lock index 6687255..ab5c426 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -23,6 +29,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -33,39 +45,35 @@ dependencies = [ ] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "crypto-bigint" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "typenum", + "rand_core", + "subtle", + "zeroize", ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", + "generic-array", + "typenum", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "proc-macro2", - "quote", - "syn", + "const-oid", + "zeroize", ] [[package]] @@ -79,10 +87,32 @@ dependencies = [ ] [[package]] -name = "fiat-crypto" -version = "0.2.9" +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] [[package]] name = "generic-array" @@ -92,6 +122,18 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", ] [[package]] @@ -105,42 +147,47 @@ name = "mole-exp" version = "0.1.0" dependencies = [ "base64", - "curve25519-dalek", + "p256", "sha2", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "p256" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "unicode-ident", + "elliptic-curve", + "primeorder", ] [[package]] -name = "quote" -version = "1.0.47" +name = "primeorder" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "proc-macro2", + "elliptic-curve", ] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "rand_core" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] -name = "semver" -version = "1.0.28" +name = "sec1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] [[package]] name = "sha2" @@ -159,29 +206,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index ad7e7e5..30906a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,11 +18,13 @@ name = "mole" sha2 = { version = "0.10", default-features = false } base64 = { version = "0.22", default-features = false, features = ["alloc"] } -# Only for the `exp-ihat` profile. Pinned to the 4.x line because 5.x requires -# Rust 1.85 and this crate's MSRV is 1.75. -curve25519-dalek = { version = "4.1.3", default-features = false, features = [ - "alloc", - "precomputed-tables", +# Only for the `exp-ihat` profile. P-256 is the group the drafts point at, and +# `hash2curve` gives the RFC 9380 P256_XMD:SHA-256_SSWU_RO_ suite the crypto +# draft's "RO based Hash2Curve" calls for. Held at the 0.13 line because 0.14 +# requires Rust 1.85 and this crate's MSRV is 1.75. +p256 = { version = "0.13.2", default-features = false, features = [ + "arithmetic", + "hash2curve", ], optional = true } [features] @@ -36,11 +38,12 @@ std = [] insecure-mock = [] # Enables `crypto::exp_ihat`, an implementation of the IHAT construction from the -# upstream `draft-authors-mole-crypto.md` with every unspecified parameter -# GUESSED. It is not interoperable with any other implementation, has no test -# vectors to validate against, and implements a construction that is still under -# active revision by its authors. See `EXP-IHAT-PROFILE.md`. -exp-ihat = ["dep:curve25519-dalek"] +# upstream `draft-authors-mole-crypto.md` over P-256. The group, hash-to-curve +# suite and Pedersen generator follow the draft; six further parameters it does +# not specify are GUESSED. It is not interoperable with any other implementation, +# has no test vectors to validate against, and implements a construction still +# under active revision by its authors. See `EXP-IHAT-PROFILE.md`. +exp-ihat = ["dep:p256"] [lints.rust] missing_docs = "warn" diff --git a/EXP-IHAT-PROFILE.md b/EXP-IHAT-PROFILE.md index c9c1087..ad8890c 100644 --- a/EXP-IHAT-PROFILE.md +++ b/EXP-IHAT-PROFILE.md @@ -6,150 +6,198 @@ implementing IHAT actually requires. Do not deploy it.** `crypto::exp_ihat`, behind the off-by-default `exp-ihat` feature, implements the IHAT construction from the upstream -[`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md). +[`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md), +over **P-256**. ## How this differs from `insecure_mock` The crate has two stand-ins for `[CRYPTO]` and they are not the same kind of thing. `insecure_mock` fakes the *interface* so the encoding and state-machine layers can be tested. `exp_ihat` implements the *construction* — the algebra is -the draft's algebra, and the security properties are real. +the draft's algebra, over the curve the drafts point at, and the security +properties are real. | Property | `insecure_mock` | `exp_ihat` | |---|---|---| | Blind signing — Anchor never learns `nf` | ❌ sent in cleartext | ✅ Anchor sees only `Y' = vY` | | Issuer hiding | ❌ carries the Anchor's index | ✅ 1-of-n CDS OR proof | -| Public verifiability | ❌ verifier needs every secret key | ✅ public keys only | +| Public verifiability | ❌ verifier needs every secret | ✅ public keys only | | Unlinkability across grants | ❌ byte-identical presentations | ✅ no shared field | | Challenge binding | ✅ | ✅ | +| Real group | ❌ not curve points at all | ✅ P-256 | | Interoperable | ❌ | ❌ | | Validated against test vectors | n/a | ❌ none exist | -## The construction +## What is now specified rather than guessed -Ten decisions had to be invented. Each is an open issue; [#13] indexes them. +The profile originally used ristretto255 and invented four things that P-256 makes +unnecessary. Aligning on the drafts' group **removed** inventions: -### Group: ristretto255 — [#1] - -The draft says only "let hash2curve be the Hash2Curve function from -{{Hash2Curve}}". RFC 9380 defines suites for seven curves and the draft names -none. - -ristretto255 was chosen because the construction stacks three sigma protocols -(DLEQ, the endorsement proof, and the OR proof) and cofactor confusion in a -non-prime-order group makes those unsound in ways that are easy to miss. Raw -curve25519 has cofactor 8; ristretto255 is the prime-order group over the same -field, with canonical encodings. - -**This collides with the protocols draft.** §4.1 pins `opaque Point[33]`, and 33 -bytes is a SEC1 *compressed* point on a 256-bit curve — P-256 or secp256k1, not -ristretto255, which encodes to 32. Meanwhile `draft-schlesinger-cfrg-act-01` uses -ristretto255, by an overlapping author set, and upstream issue #37 folds ACT into -the crypto draft. The two documents point at different groups. - -The profile keeps the draft's wire width: a point is a `0x00` tag byte followed by -the 32-byte ristretto encoding. Anchor keys still round-trip through -`endorsement::ihat::Challenge` untouched. - -### Hashing: SHA-256, widened — [#2], [#3] - -`H` is specified as "a hash function whose output length is sufficiently long". -No hash is named. (ACT uses BLAKE3, a third answer.) - -This profile uses SHA-256 and widens it to 64 bytes with `expand_message_xmd` -(RFC 9380 §5.3.1) before any reduction. The widening is not decoration: ristretto255 -scalars are mod `ℓ ≈ 2^252`, and reducing a bare 32-byte SHA-256 output mod `ℓ` is -measurably biased. Sixty-four bytes wide-reduced is not. This is probably what -"sufficiently long" means, but the draft does not say so. - -The same expansion feeds `hash_to_group`, which makes it exactly the -`hash_to_ristretto255` construction with SHA-256 in place of the suite's SHA-512. +| | before | now | +|---|---|---| +| Group | ristretto255 (our choice) | **P-256**, per the crypto draft | +| `Point[33]` | 32-byte ristretto plus a `0x00` tag byte | **genuine SEC1 compressed point** | +| Hash-to-curve | `expand_message_xmd(SHA-256)` + ristretto map, our assembly | RFC 9380 **`P256_XMD:SHA-256_SSWU_RO_`**, the suite the draft's "RO based Hash2Curve" names | +| Scalar derivation | SHA-256 widened to 64 bytes, our choice of width | RFC 9380 **`hash_to_field`**, 48 bytes for P-256 by the spec | +| Pedersen generator | hash of our own label | **`Hash2Curve("IHAT second generator")`**, the literal string from PR #39 | -Base DST is `OR13_EXP_MOLE`, with a suffix per context: +Two notes on that. -| Use | DST | -|---|---| -| `Y = Hash2Curve(m)` | `OR13_EXP_MOLE/h2g/m/v1` | -| Pedersen generator | `OR13_EXP_MOLE/h2g/pedersen-H/v1` | -| endorsement challenge | `OR13_EXP_MOLE/chal/endorsement/v1` | -| DLEQ challenge | `OR13_EXP_MOLE/chal/dleq/v1` | -| OR-proof challenge | `OR13_EXP_MOLE/chal/or/v1` | +**SHA-256 is now the specified hash, not a preference.** `P256_XMD:SHA-256_SSWU_RO_` +expands with SHA-256 internally, so the hash comes from the suite. The draft's `H` +"whose output length is sufficiently long" is likewise satisfied by construction — +`hash_to_field` for P-256 expands to 48 bytes per element before reduction, which +is what keeps scalars unbiased. -The DST names this repository, not MoLE, so it cannot squat a string the eventual -specification might want and nothing here can be mistaken for interoperable. +**The `Point[33]` hack is gone.** This is the clearest win. A compressed SEC1 P-256 +point is exactly 33 bytes, so `opaque Point[33]` from +`draft-jms-mole-protocols-00` §4.1 is the encoding rather than something we pad to +fit. `tests/exp_ihat_profile.rs` asserts the leading byte is `0x02` or `0x03` and +that both parities actually occur. -### Transcripts — [#4] +## What is still guessed -The crypto draft declares TLS notation in its Conventions section and then defines -**zero** structures, while the construction depends on hashing tuples of group -elements. An unspecified Fiat–Shamir transcript is the highest-risk gap in the -document: under-binding one is the classic route to a forgery. +Six things, each an open issue; [#13] indexes them. -Every element is its canonical 32-byte ristretto encoding, so ordered -concatenation is unambiguous without length prefixes. Order follows the draft. The -OR transcript additionally commits the branch count as a `uint16`, so proofs over -different-sized accepted sets cannot collide. +### Point compression — [#1] -What remains unverified is whether these transcripts bind everything the -construction's security argument needs — which cannot be checked against a -document that does not contain that argument. +Both MoLE drafts now imply P-256 and **disagree on compression**: -### Pedersen commitment — [#5] +| Document | Width | Encoding | +|---|---|---| +| `draft-jms-mole-protocols-00` §4.1 | `opaque Point[33]` | SEC1 compressed | +| `draft-authors-mole-crypto` (PR #39) | `uncompressed bytes[65]` | SEC1 uncompressed | +| `draft-schlesinger-cfrg-act-01` | 32 | ristretto255 | -Step one says only "It computes a commitment", never saying of what. You learn it -is Pedersen from step two's check, `Cprime = aprime G + bprime H`. The profile -assumes `C' = a'G + b'H` on that basis. +This profile uses **compressed**, matching the protocols draft, because that is the +width the rest of this crate already implements and `ihat::Challenge` already +parses. One of the two MoLE drafts needs to change. -The generator `H` there is a *group element*, and it collides notationally with -`H` the hash function from the same paragraph. Nothing says where it comes from, -and it matters: if `log_G H` were knowable the commitment would not be binding and -the opening check in `finalize` would prove nothing. The profile derives it as -`hash_to_group("pedersen-H")` — nothing up anyone's sleeve. +Also worth noting: the crypto draft's only statement about which group to use is +"`{{ARCH}}` currently instantiates this protocol with P-256" — and the architecture +draft contains **no group instantiation at all**. So P-256 is inferred from a +forward reference to content that does not exist, corroborated by `Point[33]` and +`bytes[65]` both being P-256 SEC1 widths. That inference is why this profile is on +P-256, and it is not the same thing as the draft specifying it. -This is the one guess flagged `help wanted`. It is defensible but it is a guess -about intent, and the notational collision suggests the draft text needs an -editorial pass. +### Domain separation tags — [#2] -### OR proof — [#6] +RFC 9380 §3.1 requires a DST unique to the protocol. The crypto draft supplies +none, even though PR #39 introduces two hash-to-curve *inputs* +(`"IHAT second generator"`, `"IHAT unknown discrete logarithm point"`) — those are +messages, not DSTs. -The draft, in full: "This is a standard OR sigma proof, and we can use sigma -stacking to compact the proof." That specifies neither the challenge derivation, -the branch ordering, the serialization, nor the stacking scheme. +This profile uses, following the convention of RFC 9380's own examples: -The profile uses the textbook Cramer–Damgård–Schoenmakers OR proof: simulate every -branch but the real one, share one challenge, send `(c_i, s_i)` per branch. +| Use | DST | +|---|---| +| `Y = Hash2Curve(m)` | `OR13_EXP_MOLE-V01-h2g-m-with-P256_XMD:SHA-256_SSWU_RO_` | +| second generator | `OR13_EXP_MOLE-V01-h2g-gen2-with-P256_XMD:SHA-256_SSWU_RO_` | +| endorsement challenge | `OR13_EXP_MOLE-V01-chal-endorsement-with-P256_XMD:SHA-256_SSWU_RO_` | +| DLEQ challenge | `OR13_EXP_MOLE-V01-chal-dleq-with-P256_XMD:SHA-256_SSWU_RO_` | +| OR-proof challenge | `OR13_EXP_MOLE-V01-chal-or-with-P256_XMD:SHA-256_SSWU_RO_` | +| scalar sampling | `OR13_EXP_MOLE-V01-rand-with-P256_XMD:SHA-256_SSWU_RO_` | + +Each names this repository, so it cannot squat a string the specification might +want, and each names the suite, so changing the group necessarily changes the +domain. + +### `e = H(Y, Zhat, T1, T2, C)` sits outside the sigma framework — [#3], [#4] + +PR #39 specifies that the DLEQ and OR proofs be "instantiated using the DLEQ proof +of `{{DLEQ}}`, the appropriate group Codec for the group used, and SHAKE128 sponge +in `{{FIAT}}`" — that is `draft-irtf-cfrg-sigma-protocols-02` and +`draft-irtf-cfrg-fiat-shamir-02`, which together define a SHAKE128 duplex sponge +and a concrete P-256 codec. + +That is a real specified mechanism and it is better than what this profile does. +`exp_ihat` instead concatenates canonical SEC1 encodings in the draft's stated +order and hashes with `hash_to_field`. Since every element is fixed-width and +canonical, the concatenation is unambiguous — but it is not the specified +construction. + +**Held deliberately.** Adopting it means implementing a Keccak duplex sponge and +the sigma-protocol codec framework, against a PR that has not merged. Tracked +rather than done. + +Note also that `e = H(...)` in Issuance Step Two is *not* covered by that framework +even in PR #39 — it remains a bare hash over a tuple, the only such hash left in +the design. Either it should move inside, or the draft should say why it differs. + +### `C'` is used before it is defined — [#5] + +Issuance Step One says only "It computes a commitment." PR #39 does not touch that +sentence, so the reader still infers `C' = a'G + b'H` from Step Two's check. This +profile assumes exactly that. + +The generator half of this issue is resolved: PR #39 defines `H` as +`Hash2Curve("IHAT second generator")`, which is the same nothing-up-my-sleeve +construction this crate had guessed, and the profile now uses that literal string. + +### The OR proof — [#6] + +PR #39 replaces "we can use sigma stacking" with a full construction: one-of-two +binding commitments with trapdoor equivocation, composed into a tree for one-of-N +with `n = ceil(log2 N)` levels, plus `P = Hash2Curve("IHAT unknown discrete +logarithm point")`. + +This profile uses the textbook Cramer–Damgård–Schoenmakers OR proof — simulate +every branch but the real one, share one challenge, send `(c_i, s_i)` per branch. Verification recomputes `R_i = s_i·X_i − c_i·Xhat` and checks `Σ c_i` against the -transcript hash. Without stacking the proof is `O(n)` — 64 bytes per Anchor — where -upstream presumably wants compaction, since anonymity-set size is the point. +transcript hash. Branch `i` corresponds to `keys[i]`, honouring §4.1.3's normative +branch ordering. -Branch `i` corresponds to `keys[i]`, honouring §4.1.3's normative branch ordering. - -### `m` and the nullifier — [#7] - -The crypto draft gives the Endorsement an `m` with `Y = Hash2Curve(m)` and never -defines it. The protocols draft §4.1.3 says verification "exposes `nf` and -`endorsement_context`", neither of which the crypto draft mentions. This is the -seam between the two documents and it is unjoined. - -The profile sets `m = opaque(endorsement_context) || nf[32]`, with `nf` drawn +| | this profile | PR #39 | +|---|---|---| +| proof size | `2n` scalars, `O(n)` | opening plus one shared third message | +| parameters | none | `n = O(log N)` points | +| technique | simulate all-but-one, split challenge | trapdoor equivocation over a commitment tree | + +**Held deliberately**, for two reasons: the PR is unmerged, and it carries its own +`TODO: consider more efficient, but harder to describe (and maybe more susceptible +to side channel) constructions`. Implementing a construction its authors say they +may replace is poor value. + +One question worth putting to upstream: the tree specifies that "the same +randomness is used at each level". Reusing a single `s` across every level of a +Pedersen commitment tree is stated without justification. It is clearly deliberate +— equivocation has to recompute consistently — but it wants an explicit argument. +Flagged as a question; no analysis claimed. + +### `m` — [#7] + +The crypto draft gives the Endorsement an `m` with `Y = Hash2Curve(m)`. The +protocols draft §4.1.3 says verification "exposes `nf` and `endorsement_context`", +neither of which the crypto draft mentions. + +PR #39 makes this a **contradiction rather than a silence**: its +`EndorsementPresentation` types `m` as `Scalar`, which is `bytes[32]`. A single +32-byte scalar cannot carry a variable-length `endorsement_context` *and* a +nullifier, so §4.1.3 is unsatisfiable against that structure. It also looks like a +plain typing error — `Hash2Curve` consumes octets, not a field element. + +This profile sets `m = opaque(endorsement_context) || nf[32]`, with `nf` drawn at random during `Prepare`. Since `m` is revealed at presentation and verification -must expose exactly those two fields, letting `m` *be* them is the least -invention available. It costs no blindness — the Anchor sees only `Y' = vY`. +must expose exactly those two fields, letting `m` *be* them is the least invention +available, and it costs no blindness — the Anchor sees only `Y' = vY`. -A consequence: `m` is fixed at issuance and carries the epoch, so an Endorsement -is epoch-bound. That is consistent with `endorsement_context` naming the epoch and +A consequence: `m` is fixed at issuance and carries the epoch, so an Endorsement is +epoch-bound. That is consistent with `endorsement_context` naming the epoch and with `Moderator::redeem` checking epoch before nullifier freshness. -### Challenge binding — [#8] +### `challenge_digest` — [#8] §4.1.3 requires `Present` to bind `challenge_digest` into the proof transcript and -`Verify` to fail on any other digest. The crypto draft's verification procedure -never mentions `challenge_digest`. +`Verify` to fail on any other digest. In the crypto draft it appears **nowhere**: +not in `EndorsementPresentation`, not in `e = H(Y, Zhat, T1, T2, C)`, not in the OR +challenge `H(params, Xi, C)`, not in the verification procedure. Both MUSTs are +unsatisfiable as specified, which would make presentations replayable across +challenges. -The endorsement proof `(e, a, b, r)` is fixed at issuance, so it cannot bind a -value known only at presentation. The OR proof is the only part computed at -presentation time, so that is where the digest is committed. This is more forced -than chosen, but the draft does not say it. +This profile commits the digest in the OR-proof transcript. That is the only place +it can go: the partial endorsement `(e, a, b, r)` is fixed at issuance, and PR #39 +confirms the division — every field of its presentation except `proof` is +issuance-time. Upstream needs to put it there. ## A security property no draft states — [#9] @@ -164,8 +212,8 @@ r'_2 = t' + e'_2 a' x ``` Subtracting gives `(e'_1 − e'_2) a' x`, which solves for `a'x` — and `a'` is sent -to the Client in the clear in exchange two. The Anchor's long-term secret key -falls out with one division. +to the Client in the clear in exchange two, which PR #39's `AnchorSecondMessage` +confirms. The Anchor's long-term secret key falls out with one division. Two consequences: @@ -178,12 +226,13 @@ Two consequences: `Self::State` by value enforces it at the type level. The protocols draft tells the *Client* "It MUST NOT retry with the same state" and -says nothing to the Anchor. The crypto draft says nothing about nonce generation. -Both should. +says nothing to the Anchor. PR #39 adds a Security Considerations section covering +only single-show unlinkability and a PQ public-key caveat — so there is now an +obvious place to put this. ## What the tests establish, and what they do not -`tests/exp_ihat_profile.rs` — twelve tests: +`tests/exp_ihat_profile.rs` — fourteen tests: - the full two-exchange grant, then a presentation a verifier accepts - blindness, asserted against the actual request bytes: neither the nullifier nor @@ -194,20 +243,24 @@ Both should. - unlinkability: two grants share no field - challenge binding: a different digest fails - public verifiability: the verifier is constructed from nothing +- Anchor keys are genuine SEC1 compressed P-256 points, both parities occurring +- the identity is refused rather than truncated into a 33-byte lookalike - an Anchor outside the accepted set is rejected; so is substituting the set - every single-byte flip in a presentation is detected - a corrupted DLEQ proof is caught by the Client - malformed and truncated input is rejected without panicking -All twelve passing means the algebra closes and the profile is self-consistent. +All fourteen passing means the algebra closes over P-256 and the profile is +self-consistent. It does **not** mean the profile agrees with the eventual specification — no test vectors exist ([#10]) — and it is not cryptanalysis. The construction has no -security proof and no public review, and it is still moving: upstream PR #38 -patched a "PQ tagging attack" and PR #39, "Add more text to try to define proof", -is open. The two grant exchanges still have no correlation mechanism ([#11]); in -this profile correlation is implicit in the Rust call sequence, which is exactly -what a network protocol cannot rely on. +security proof and no public review, and it is still moving: PR #38 patched a "PQ +tagging attack" and PR #39 is open. The two grant exchanges still have no +correlation mechanism ([#11]); PR #39 now defines all four grant messages and none +carries a session identifier, which forecloses that option by omission. In this +profile correlation is implicit in the Rust call sequence, which is exactly what a +network protocol cannot rely on. ## Building @@ -216,9 +269,10 @@ cargo test --features exp-ihat cargo build --no-default-features --features exp-ihat --target thumbv7em-none-eabi ``` -The profile is `no_std`-clean. It adds one dependency, `curve25519-dalek`, held at -the `4.x` line because `5.x` requires Rust 1.85 against this crate's 1.75 MSRV; -`zeroize` is pinned in `Cargo.lock` for the same reason ([#12]). +The profile is `no_std`-clean. It adds one dependency, `p256` with the `hash2curve` +feature, held at the `0.13` line because `0.14` requires Rust 1.85 against this +crate's 1.75 MSRV; `zeroize` is pinned in `Cargo.lock` for the same reason +([#12]). [#1]: https://github.com/OR13/mole/issues/1 [#2]: https://github.com/OR13/mole/issues/2 diff --git a/README.md b/README.md index 9cc34b8..e5ea114 100644 --- a/README.md +++ b/README.md @@ -38,18 +38,32 @@ landed upstream on 15 July 2026 (PR #38, closing issue #31). It is **not** on th IETF Datatracker — `draft-authors-mole-crypto` returns 404 — and it is not submitted. -It is also not implementable as an interoperable specification. It runs about fifty -lines of cryptographic content in prose, and it: - -- names no group (the protocols draft's `Point[33]` implies a SEC1 256-bit curve; - the ACT draft, overlapping authors, uses ristretto255 at 32 bytes) -- names no hash — `H` is "a hash function whose output length is sufficiently long" -- gives no hash-to-curve suite and no domain separation tag -- declares TLS notation in its Conventions section and then defines **zero** structures -- uses `C'` before defining it, and leaves the Pedersen generator undefined -- defers the OR proof to "sigma stacking" — PR #39, "Add more text to try to define - proof", is still open -- publishes no test vectors +It is also not yet an interoperable specification. On `main` it runs about fifty +lines of cryptographic content in prose: it names no group, no hash, and no domain +separation tag, declares TLS notation in its Conventions section and then defines +**zero** structures, uses `C'` before defining it, leaves the Pedersen generator +undefined, defers the OR proof to "sigma stacking", and publishes no test vectors. + +Open [PR #39](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/pull/39) +closes a good deal of that — it adds message structures, defines the second +generator, specifies a full OR-proof construction, and routes the sigma proofs +through `draft-irtf-cfrg-sigma-protocols` and `draft-irtf-cfrg-fiat-shamir`. What +remains open, or newly broken, after it: + +- The group is named only by forward reference: "`{{ARCH}}` currently instantiates + this protocol with P-256" — and the architecture draft contains **no group + instantiation at all**. +- The two MoLE drafts disagree on point encoding: `Point[33]` (compressed) versus + PR #39's `bytes[65]` (uncompressed). ACT, with overlapping authors, uses 32-byte + ristretto255. +- No domain separation tag, which RFC 9380 requires. +- `m` is typed as a 32-byte `Scalar`, which **cannot** carry the + `endorsement_context` that protocols §4.1.3 requires verification to expose. +- `challenge_digest` appears in no structure and no challenge, so §4.1.3's two + MUSTs about binding it are unsatisfiable as written. +- Still no test vectors, and still no correlation mechanism for the two grant + exchanges — PR #39 defines all four grant messages and none carries a session + identifier. The same protocols draft notes that "IHAT is a placeholder name," that the two grant exchanges still need a correlation mechanism, and that the protocols "are not @@ -66,12 +80,12 @@ boundary in `src/crypto.rs`. Nothing in the default build invents a scheme. | Feature | What it is | |---|---| | `insecure-mock` | Fakes the *interface* so the encoding and state-machine layers can be tested end to end. No blindness, no issuer hiding, no public verifiability, no unlinkability. A test fixture. | -| `exp-ihat` | Implements the *construction* from the upstream crypto draft, with all ten unspecified parameters guessed. Blindness, issuer hiding, public verifiability and unlinkability are real. Interoperability is not. | +| `exp-ihat` | Implements the *construction* from the upstream crypto draft over **P-256**, with the six parameters it still leaves open guessed. Blindness, issuer hiding, public verifiability and unlinkability are real. Interoperability is not. | Neither is deployable. `exp-ihat` is the more dangerous of the two precisely -because it works: it produces valid-looking endorsements under parameters this -crate invented, against a construction with no security proof and no public -cryptanalysis. Every guess is inventoried in +because it works: it produces valid-looking endorsements on a real curve, under +parameters this crate had to fill in, against a construction with no security proof +and no public cryptanalysis. Every guess is inventoried in [`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md) and filed as an open issue ([#13](https://github.com/OR13/mole/issues/13) indexes them). @@ -89,7 +103,7 @@ cryptanalysis. Every guess is inventoried in | Greasing: value selection, and grease-as-unknown on the Moderator | ✅ | | Epoch nullifier store, single-use enforcement, token nonce replay | ✅ | | 200/401/403 challenge semantics | ✅ | -| IHAT cryptography | 🟡 trait boundary; `exp-ihat` implements it under guessed parameters | +| IHAT cryptography | 🟡 trait boundary; `exp-ihat` implements it over P-256 under partly guessed parameters | | Longfellow proving/verifying | ❌ needs `draft-google-cfrg-libzk` + circuit | | ACT spend/refund | ❌ needs `draft-schlesinger-cfrg-act` | | Privacy Pass issuance (VOPRF, batched tokens) | ❌ needs RFC 9578 impl | @@ -108,8 +122,8 @@ mole-exp = { git = "https://github.com/OR13/mole" } ``` The library is still `mole` to import. Two dependencies in the default build, -`sha2` and `base64`; `exp-ihat` adds `curve25519-dalek`. `#![no_std]` with `alloc` -throughout, including the profile; `forbid(unsafe_code)`. +`sha2` and `base64`; `exp-ihat` adds `p256`. `#![no_std]` with `alloc` throughout, +including the profile; `forbid(unsafe_code)`. ```rust use mole::binding::ChallengeDigest; @@ -169,7 +183,7 @@ That is `moderator::NonceStore`, kept separate from the epoch `NullifierStore`. ## Tests ``` -cargo test --all-features # 100 tests +cargo test --all-features # 102 tests cargo clippy --all-features --all-targets RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps cargo build --no-default-features # no_std @@ -197,9 +211,16 @@ Jackson (Mozilla), and Thibault Meunier (Cloudflare): Plus, for the `exp-ihat` profile only, [`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md) -— repository `main` as of 28 July 2026. It has no Datatracker entry and no revision -number, so there is no stable identifier to track it by; the profile is pinned to a -reading of the file, not to a published draft. +— repository `main` as of 28 July 2026, with the group and second generator taken +from open [PR #39](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/pull/39). +It has no Datatracker entry and no revision number, so there is no stable +identifier to track it by; the profile is pinned to a reading of the file, not to a +published draft. + +Transitively, `exp-ihat` also relies on RFC 9380 for hash-to-curve. PR #39 further +points the sigma proofs at `draft-irtf-cfrg-sigma-protocols-02` and +`draft-irtf-cfrg-fiat-shamir-02`, which this profile does **not** yet follow — see +[`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md). Upstream source: [Moderation-of-unLinkable-Endorsements/internet-drafts](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts). diff --git a/SPEC-COVERAGE.md b/SPEC-COVERAGE.md index 1f44035..71988a6 100644 --- a/SPEC-COVERAGE.md +++ b/SPEC-COVERAGE.md @@ -80,12 +80,19 @@ and are not specification requirements: probability" without fixing a value. 2. **The wire vectors** in `tests/wire_vectors.rs`. The drafts publish none. -**The `exp-ihat` feature is the exception, and it is a large one.** It guesses ten -parameters the crypto draft leaves open — group, hash, DSTs, transcript encodings, -the Pedersen generator, the OR-proof construction, the definition of `m`, and where -`challenge_digest` binds. It is off by default, `publish = false`, and every guess -is an open issue indexed by [#13]. See `EXP-IHAT-PROFILE.md`. Nothing in that -profile should be read as a claim about what MoLE specifies. +**The `exp-ihat` feature is the exception.** It implements IHAT over **P-256** — +the group both MoLE drafts point at — using RFC 9380 `P256_XMD:SHA-256_SSWU_RO_` +for hash-to-curve and `Hash2Curve("IHAT second generator")` for the Pedersen +generator, both as specified. Six things remain guessed or deviated: point +compression ([#1], the two drafts disagree), the DST ([#2], none is specified), the +`e = H(...)` transcript ([#3], [#4], where the profile does not yet use the SHAKE128 +sponge the crypto draft now points at), `C'` ([#5], still undefined upstream), the +OR proof ([#6], the profile keeps a textbook CDS rather than PR #39's unmerged +commitment tree), `m` ([#7]) and `challenge_digest` ([#8]) — the last two being +places where following the crypto draft would produce something that cannot satisfy +§4.1.3 at all. It is off by default, `publish = false`, and every item is an open +issue indexed by [#13]. See `EXP-IHAT-PROFILE.md`. Nothing in that profile should be +read as a claim about what MoLE specifies. ## Things the drafts leave open @@ -96,8 +103,8 @@ them: 2026, PR #38) but is **not** on the Datatracker and is not an interoperable specification: no group, no hash, no DSTs, no wire structures, no test vectors, and PR #39 is still open to "try to define proof". IHAT can be *implemented* — - `crypto::exp_ihat` does — but not interoperably, and not without inventing ten - parameters ([#13]). + `crypto::exp_ihat` does over P-256 — but not interoperably, and not without + filling in six parameters it still does not supply ([#13]). - IHAT's two grant exchanges have no defined correlation mechanism — the draft offers "either `[CRYPTO]` adds a session identifier … or this document mandates connection reuse" and picks neither ([#11]). This is the sharpest gap, and it is @@ -107,9 +114,12 @@ them: `t'` and `a'` across two grants recovers the Anchor's secret key by subtraction, since `a'` is sent to the Client in the clear ([#9]). The protocols draft constrains only the Client's state reuse. -- The group is ambiguous across drafts: `Point[33]` implies a SEC1 256-bit curve, - while `draft-schlesinger-cfrg-act-01` uses 32-byte ristretto255 and upstream - issue #37 folds ACT into the crypto draft ([#1]). +- The group is P-256 by inference, not statement: the crypto draft says + "`{{ARCH}}` currently instantiates this protocol with P-256" and the architecture + draft contains no group instantiation at all. It is corroborated only by + `Point[33]` and PR #39's `bytes[65]` both being P-256 SEC1 widths — which is also + the defect, since those two are the compressed and uncompressed encodings and the + drafts must pick one ([#1]). - ACT's `Challenge` must express a predicate and a charged amount; its contents are undefined. - Reverse flow has no device binding, so an update is not provably bound to the @@ -121,6 +131,13 @@ them: listed as a privacy consideration to be written. [#1]: https://github.com/OR13/mole/issues/1 +[#2]: https://github.com/OR13/mole/issues/2 +[#3]: https://github.com/OR13/mole/issues/3 +[#4]: https://github.com/OR13/mole/issues/4 +[#5]: https://github.com/OR13/mole/issues/5 +[#6]: https://github.com/OR13/mole/issues/6 +[#7]: https://github.com/OR13/mole/issues/7 +[#8]: https://github.com/OR13/mole/issues/8 [#9]: https://github.com/OR13/mole/issues/9 [#11]: https://github.com/OR13/mole/issues/11 [#13]: https://github.com/OR13/mole/issues/13 diff --git a/src/crypto.rs b/src/crypto.rs index 9e27fba..3496a2c 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -14,14 +14,16 @@ //! date in `draft-jms-mole-protocols-00`. It has since been written — the file //! landed in the upstream repository on 15 July 2026 — but it is not on the IETF //! Datatracker, is not submitted, and does not specify enough to implement -//! interoperably: no group, no hash, no domain separation tags, no wire structures, -//! no test vectors, and an open PR to "try to define proof". The draft additionally +//! interoperably: the group is named only by a forward reference the architecture +//! draft does not satisfy, there are no domain separation tags, and there are no +//! test vectors. The draft additionally //! notes that "IHAT is a placeholder name" and that the two grant exchanges still //! need a correlation mechanism. //! -//! `crypto::exp_ihat` (feature `exp-ihat`) implements the construction anyway, by -//! guessing all of the above. It is not interoperable with anything and exists to -//! find out what implementing IHAT requires. +//! `crypto::exp_ihat` (feature `exp-ihat`) implements the construction anyway, over +//! P-256 — the group the drafts point at — filling in the parameters they still do +//! not supply. It is not interoperable with anything and exists to find out what +//! implementing IHAT requires. //! //! Longfellow (Section 4.2) similarly requires the proving system of //! `draft-google-cfrg-libzk` plus an out-of-band circuit, and ACT (Section 5.1) diff --git a/src/crypto/exp_ihat.rs b/src/crypto/exp_ihat.rs index 9b708da..378e24d 100644 --- a/src/crypto/exp_ihat.rs +++ b/src/crypto/exp_ihat.rs @@ -1,37 +1,54 @@ //! **EXPERIMENTAL AND NOT INTEROPERABLE.** The IHAT construction from the -//! upstream `draft-authors-mole-crypto.md`, with every unspecified parameter -//! guessed by this crate. +//! upstream `draft-authors-mole-crypto.md`, over P-256, with the parameters the +//! draft still leaves open guessed by this crate. //! //! # What this is //! //! Unlike [`super::insecure_mock`], this is a real implementation of the real -//! construction. The blinding works, the endorsement is publicly verifiable, the -//! Anchor never learns the nullifier, and issuer hiding is a genuine 1-of-n OR -//! proof. The algebra is the draft's algebra. +//! construction over a real curve. The blinding works, the endorsement is publicly +//! verifiable, the Anchor never learns the nullifier, and issuer hiding is a +//! genuine 1-of-n OR proof. The algebra is the draft's algebra. +//! +//! # What it aligns with +//! +//! The group is **P-256**, which is what the drafts point at, so the parts of this +//! profile that used to be inventions are now the specification: +//! +//! * A point is SEC1 **compressed**, 33 bytes — exactly the `opaque Point[33]` of +//! `draft-jms-mole-protocols-00` §4.1. There is no padding or tagging. +//! * Hash-to-curve is RFC 9380 `P256_XMD:SHA-256_SSWU_RO_`, which is what the +//! crypto draft's "RO based Hash2Curve function" resolves to over P-256 — and it +//! hashes with SHA-256, so the hash is the suite's, not a choice. +//! * Scalars come from RFC 9380 `hash_to_field`, expanding to 48 bytes for P-256 +//! before reduction, so the draft's `H` "whose output length is sufficiently +//! long" is satisfied by construction. +//! * The Pedersen second generator is `Hash2Curve("IHAT second generator")`, the +//! literal definition added by upstream PR #39. //! //! # Why you still must not deploy it //! -//! The crypto draft is roughly fifty lines of prose. It names no group, no hash, -//! no domain separation tags, no wire encodings, and publishes no test vectors. -//! Turning it into code required inventing all of that. Every invention below is a -//! place where this implementation will disagree with the eventual specification -//! and with every other implementation: +//! Six things remain guessed, because the crypto draft still does not supply them: //! -//! | What the draft leaves open | What this profile does | Issue | +//! | What the drafts leave open | What this profile does | Issue | //! |---|---|---| -//! | Group is unnamed; `Point[33]` implies SEC1, ristretto255 encodes to 32 | ristretto255, with a `0x00` tag byte filling the 33rd | [#1] | -//! | No hash-to-curve suite or DST | `expand_message_xmd(SHA-256)`, DSTs below | [#2] | -//! | `H` is only "sufficiently long" | SHA-256, widened to 64 bytes before reduction | [#3] | -//! | Fiat–Shamir transcripts unspecified | canonical fixed-width encodings, ordered as written | [#4] | -//! | Pedersen generator undefined; `C'` used before it is defined | hash-to-group of a fixed label; assumed `a'G + b'H` | [#5] | -//! | OR proof "sigma stacking" unspecified | textbook CDS OR proof, `O(n)`, not compacted | [#6] | -//! | `m` never defined; relation to `nf` unstated | `m = opaque(endorsement_context) \|\| nf[32]` | [#7] | -//! | Where `challenge_digest` binds | into the OR-proof transcript | [#8] | +//! | The two MoLE drafts disagree on point compression: `Point[33]` vs PR #39's `bytes[65]` | compressed, matching the protocols draft the rest of this crate implements | [#1] | +//! | No domain separation tag, though RFC 9380 requires one | `OR13_EXP_MOLE-V01-…-with-P256_XMD:SHA-256_SSWU_RO_` | [#2] | +//! | `e = H(Y, Zhat, T1, T2, C)` sits outside the `draft-irtf-cfrg-fiat-shamir` framework | `hash_to_field` under its own DST | [#3] | +//! | Sigma transcripts should use the SHAKE128 sponge and group codec of `draft-irtf-cfrg-fiat-shamir` | canonical SEC1 concatenation, ordered as the draft writes it | [#4] | +//! | `C'` is used before it is defined | assumed Pedersen `a'G + b'H` | [#5] | +//! | PR #39 specifies a one-of-N trapdoor commitment tree at `O(log n)` | textbook CDS OR proof, `O(n)` | [#6] | +//! | `m` is typed `Scalar`, which cannot carry `endorsement_context` | `m = opaque(endorsement_context) \|\| nf[32]` | [#7] | +//! | `challenge_digest` appears in no structure or challenge upstream | bound into the OR-proof transcript | [#8] | //! //! Two further gaps are tracked but not closeable here: there are no test vectors //! to validate against ([#10]) and the two grant exchanges still have no //! correlation mechanism ([#11]). [#13] indexes all of it. //! +//! [#4] and [#6] are deliberate deviations from things PR #39 *does* specify, held +//! because that PR is unmerged, carries its own `TODO` on the OR proof, and would +//! pull in two further CFRG drafts. [#7] and [#8] are places where following the +//! draft would produce something that cannot satisfy the protocols draft at all. +//! //! [#1]: https://github.com/OR13/mole/issues/1 //! [#2]: https://github.com/OR13/mole/issues/2 //! [#3]: https://github.com/OR13/mole/issues/3 @@ -75,11 +92,11 @@ use core::cell::RefCell; use alloc::vec::Vec; -use curve25519_dalek::constants::RISTRETTO_BASEPOINT_POINT; -use curve25519_dalek::ristretto::{CompressedRistretto, RistrettoPoint}; -use curve25519_dalek::scalar::Scalar; -use curve25519_dalek::traits::Identity; -use sha2::{Digest, Sha256}; +use p256::elliptic_curve::hash2curve::{ExpandMsgXmd, GroupDigest}; +use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}; +use p256::elliptic_curve::{Field, PrimeField}; +use p256::{EncodedPoint, NistP256, ProjectivePoint, Scalar}; +use sha2::Sha256; use crate::binding::ChallengeDigest; use crate::codec::{encode_varint, write_opaque_v, Reader}; @@ -94,159 +111,155 @@ use crate::error::{Error, Result}; /// rather than MoLE, because it is not MoLE's. pub const DST_BASE: &[u8] = b"OR13_EXP_MOLE"; -const DST_H2G_M: &[u8] = b"OR13_EXP_MOLE/h2g/m/v1"; -const DST_H2G_PEDERSEN: &[u8] = b"OR13_EXP_MOLE/h2g/pedersen-H/v1"; -const DST_CHAL_ENDORSEMENT: &[u8] = b"OR13_EXP_MOLE/chal/endorsement/v1"; -const DST_CHAL_DLEQ: &[u8] = b"OR13_EXP_MOLE/chal/dleq/v1"; -const DST_CHAL_OR: &[u8] = b"OR13_EXP_MOLE/chal/or/v1"; - -/// Tag byte prefixed to a 32-byte ristretto encoding to fill `Point[33]`. -const POINT_TAG: u8 = 0x00; +/// The RFC 9380 suite this profile uses, named in every DST. +/// +/// The crypto draft asks for "the RO based Hash2Curve function" over a group the +/// architecture draft is supposed to fix as P-256. That combination is exactly +/// this suite, and it hashes with SHA-256. +pub const SUITE_ID: &[u8] = b"P256_XMD:SHA-256_SSWU_RO_"; + +// Per RFC 9380 Section 3.1 a DST is unique to the protocol *and* names the suite, +// so that changing the group forces a different domain. These follow the +// convention of that section's examples. +const DST_H2G_M: &[u8] = b"OR13_EXP_MOLE-V01-h2g-m-with-P256_XMD:SHA-256_SSWU_RO_"; +const DST_H2G_GEN2: &[u8] = b"OR13_EXP_MOLE-V01-h2g-gen2-with-P256_XMD:SHA-256_SSWU_RO_"; +const DST_CHAL_ENDORSEMENT: &[u8] = + b"OR13_EXP_MOLE-V01-chal-endorsement-with-P256_XMD:SHA-256_SSWU_RO_"; +const DST_CHAL_DLEQ: &[u8] = b"OR13_EXP_MOLE-V01-chal-dleq-with-P256_XMD:SHA-256_SSWU_RO_"; +const DST_CHAL_OR: &[u8] = b"OR13_EXP_MOLE-V01-chal-or-with-P256_XMD:SHA-256_SSWU_RO_"; +const DST_RANDOM: &[u8] = b"OR13_EXP_MOLE-V01-rand-with-P256_XMD:SHA-256_SSWU_RO_"; + +/// Compressed SEC1 encoding width for P-256 — and exactly the `Point[33]` the +/// protocols draft specifies. +const POINT_LEN: usize = 33; // --------------------------------------------------------------------------- -// Hashing (guesses 3, 4, 5) +// Hashing // --------------------------------------------------------------------------- -/// `expand_message_xmd` with SHA-256, per Section 5.3.1 of RFC 9380. +/// Hashes to the group, using the RFC 9380 `P256_XMD:SHA-256_SSWU_RO_` suite. /// -/// The draft calls for a hash "whose output length is sufficiently long" without -/// naming one. This profile uses SHA-256 and widens it here, so that reduction -/// into the ristretto255 scalar field is unbiased: a bare 32-byte SHA-256 output -/// reduced mod the group order is measurably skewed, and 64 bytes is not. -fn expand_message_xmd_sha256(msg: &[&[u8]], dst: &[u8]) -> [u8; 64] { - const B_IN_BYTES: usize = 32; - const S_IN_BYTES: usize = 64; - const LEN_IN_BYTES: usize = 64; - // ell = ceil(64 / 32) = 2, comfortably inside the 255 limit, and the DSTs in - // this module are all far shorter than 255 bytes, so RFC 9380's abort - // conditions are unreachable here. - - // dst_prime = DST || I2OSP(len(DST), 1) - let mut dst_prime = Vec::with_capacity(dst.len() + 1); - dst_prime.extend_from_slice(dst); - dst_prime.push(u8::try_from(dst.len()).expect("DST shorter than 256 bytes")); - - // b_0 = H(Z_pad || msg || I2OSP(len_in_bytes, 2) || I2OSP(0, 1) || dst_prime) - let mut h = Sha256::new(); - h.update([0u8; S_IN_BYTES]); - for part in msg { - h.update(part); - } - h.update( - u16::try_from(LEN_IN_BYTES) - .expect("64 fits u16") - .to_be_bytes(), - ); - h.update([0u8]); - h.update(&dst_prime); - let b_0: [u8; B_IN_BYTES] = h.finalize().into(); - - // b_1 = H(b_0 || I2OSP(1, 1) || dst_prime) - let mut h = Sha256::new(); - h.update(b_0); - h.update([1u8]); - h.update(&dst_prime); - let b_1: [u8; B_IN_BYTES] = h.finalize().into(); - - // b_2 = H(strxor(b_0, b_1) || I2OSP(2, 1) || dst_prime) - let mut xored = [0u8; B_IN_BYTES]; - for (i, byte) in xored.iter_mut().enumerate() { - *byte = b_0[i] ^ b_1[i]; - } - let mut h = Sha256::new(); - h.update(xored); - h.update([2u8]); - h.update(&dst_prime); - let b_2: [u8; B_IN_BYTES] = h.finalize().into(); - - let mut out = [0u8; LEN_IN_BYTES]; - out[..B_IN_BYTES].copy_from_slice(&b_1); - out[B_IN_BYTES..].copy_from_slice(&b_2); - out +/// This is the suite the crypto draft's "RO based Hash2Curve function" names once +/// the group is P-256, so `expand_message_xmd` with SHA-256 is the specified +/// expansion rather than a choice of this crate's. +fn hash_to_group(msg: &[&[u8]], dst: &[u8]) -> Result { + NistP256::hash_from_bytes::>(msg, &[dst]) + .map_err(|_| Error::InvalidGroupElement) } -/// Hashes to the ristretto255 group. +/// Hashes to a scalar via RFC 9380 `hash_to_field`. /// -/// This is the `hash_to_ristretto255` construction — `expand_message_xmd` to 64 -/// bytes, then the one-way map — with SHA-256 substituted for the suite's SHA-512. -fn hash_to_group(msg: &[&[u8]], dst: &[u8]) -> RistrettoPoint { - RistrettoPoint::from_uniform_bytes(&expand_message_xmd_sha256(msg, dst)) -} - -/// Hashes to a scalar, reducing 64 wide bytes so the result is unbiased. -fn hash_to_scalar(msg: &[&[u8]], dst: &[u8]) -> Scalar { - Scalar::from_bytes_mod_order_wide(&expand_message_xmd_sha256(msg, dst)) +/// For P-256 that expands to 48 bytes per element before reduction, which is what +/// keeps the result unbiased with respect to the group order. The draft's `H` +/// "whose output length is sufficiently long" is satisfied by construction here +/// rather than by this crate picking a width. +fn hash_to_scalar(msg: &[&[u8]], dst: &[u8]) -> Result { + NistP256::hash_to_scalar::>(msg, &[dst]) + .map_err(|_| Error::InvalidGroupElement) } -/// The Pedersen commitment's second generator (guess 6). +/// The Pedersen commitment's second generator. /// -/// The draft writes `C' = a'G + b'H` without saying where `H` comes from. If its -/// discrete log with respect to `G` were known, the commitment would not be -/// binding, so this derives it by hashing a fixed label — nothing up anyone's -/// sleeve. -fn pedersen_h() -> RistrettoPoint { - hash_to_group(&[b"pedersen-H"], DST_H2G_PEDERSEN) +/// Upstream PR #39 defines this: "G is the generator of the group. H is +/// `Hash2Curve("IHAT second generator")`." The message below is that literal +/// string, so this matches the draft. The DST is still this crate's, because the +/// draft supplies none. +fn pedersen_h() -> Result { + hash_to_group(&[b"IHAT second generator"], DST_H2G_GEN2) } // --------------------------------------------------------------------------- -// Encoding (guess 2) +// Encoding // --------------------------------------------------------------------------- -fn encode_point(p: &RistrettoPoint) -> Point { - let mut out = [0u8; 33]; - out[0] = POINT_TAG; - out[1..].copy_from_slice(&p.compress().to_bytes()); - Point(out) -} - -fn decode_point(p: &Point) -> Result { - if p.0[0] != POINT_TAG { +/// Encodes a point as SEC1 compressed, filling the drafts' `Point[33]` exactly. +/// +/// The identity has no 33-byte SEC1 encoding — SEC1 writes it as a single zero +/// octet — so it is rejected rather than silently truncated. No honest step of the +/// protocol produces it. +fn encode_point(p: &ProjectivePoint) -> Result { + if p == &ProjectivePoint::IDENTITY { return Err(Error::InvalidGroupElement); } - let mut b = [0u8; 32]; - b.copy_from_slice(&p.0[1..]); - CompressedRistretto(b) - .decompress() - .ok_or(Error::InvalidGroupElement) + let encoded = p.to_affine().to_encoded_point(true); + let bytes: [u8; POINT_LEN] = encoded + .as_bytes() + .try_into() + .map_err(|_| Error::InvalidGroupElement)?; + Ok(Point(bytes)) +} + +/// Decodes SEC1 compressed bytes, rejecting off-curve points and the identity. +fn decode_point(p: &Point) -> Result { + let encoded = EncodedPoint::from_bytes(p.0).map_err(|_| Error::InvalidGroupElement)?; + let point = Option::::from(ProjectivePoint::from_encoded_point(&encoded)) + .ok_or(Error::InvalidGroupElement)?; + if point == ProjectivePoint::IDENTITY { + return Err(Error::InvalidGroupElement); + } + Ok(point) +} + +fn read_point(r: &mut Reader<'_>) -> Result { + decode_point(&Point(r.array::()?)) } -fn read_point(r: &mut Reader<'_>) -> Result { - decode_point(&Point(r.array::<33>()?)) +fn write_point(p: &ProjectivePoint, out: &mut Vec) -> Result<()> { + out.extend_from_slice(&encode_point(p)?.0); + Ok(()) } -fn write_point(p: &RistrettoPoint, out: &mut Vec) { - out.extend_from_slice(&encode_point(p).0); +/// A point's transcript bytes: the same canonical SEC1 encoding used on the wire. +fn point_bytes(p: &ProjectivePoint) -> Result<[u8; POINT_LEN]> { + Ok(encode_point(p)?.0) } +/// Reads a scalar, rejecting non-canonical encodings (values >= the group order). fn read_scalar(r: &mut Reader<'_>) -> Result { - Option::::from(Scalar::from_canonical_bytes(r.array::<32>()?)) - .ok_or(Error::InvalidGroupElement) + let bytes: [u8; 32] = r.array::<32>()?; + Option::::from(Scalar::from_repr(bytes.into())).ok_or(Error::InvalidGroupElement) } fn write_scalar(s: &Scalar, out: &mut Vec) { - out.extend_from_slice(s.as_bytes()); + let bytes: [u8; 32] = s.to_bytes().into(); + out.extend_from_slice(&bytes); } -fn random_scalar(rng: &mut R) -> Scalar { - let mut b = [0u8; 64]; +/// Draws a uniform scalar. +/// +/// Routing raw randomness through `hash_to_field` rather than reducing it directly +/// keeps the result unbiased with respect to the P-256 group order, which a bare +/// 32-byte reduction would not be. +fn random_scalar(rng: &mut R) -> Result { + let mut b = [0u8; 32]; rng.fill_bytes(&mut b); - Scalar::from_bytes_mod_order_wide(&b) + hash_to_scalar(&[&b], DST_RANDOM) } /// Draws a scalar that is invertible. Retries a bounded number of times so a /// pathological [`RandomSource`] cannot spin forever. fn random_nonzero_scalar(rng: &mut R) -> Result { for _ in 0..8 { - let s = random_scalar(rng); - if s != Scalar::ZERO { + let s = random_scalar(rng)?; + if !bool::from(s.is_zero()) { return Ok(s); } } Err(Error::VerificationFailed) } +/// Inverts a scalar, failing rather than silently yielding zero. +fn invert(s: &Scalar) -> Result { + Option::::from(s.invert()).ok_or(Error::VerificationFailed) +} + +/// Sums scalars without relying on an `Iterator::sum` impl. +fn scalar_sum(items: impl Iterator) -> Scalar { + items.fold(Scalar::ZERO, |acc, s| acc + s) +} + // --------------------------------------------------------------------------- -// `m` (guess 9) +// `m` (see #7) // --------------------------------------------------------------------------- /// Builds `m` so that verification can expose exactly what Section 4.1.3 says it @@ -282,52 +295,52 @@ fn decode_m(m: &[u8]) -> Result<(Vec, [u8; 32])> { fn dleq_prove( rng: &mut R, x: &Scalar, - y_prime: &RistrettoPoint, - x_pub: &RistrettoPoint, - z_prime: &RistrettoPoint, -) -> (Scalar, Scalar) { - let g = RISTRETTO_BASEPOINT_POINT; - let k = random_scalar(rng); - let a = k * g; - let b = k * y_prime; - let c = dleq_challenge(y_prime, x_pub, z_prime, &a, &b); - (c, k + c * x) + y_prime: &ProjectivePoint, + x_pub: &ProjectivePoint, + z_prime: &ProjectivePoint, +) -> Result<(Scalar, Scalar)> { + let g = ProjectivePoint::GENERATOR; + let k = random_scalar(rng)?; + let a = g * k; + let b = *y_prime * k; + let c = dleq_challenge(y_prime, x_pub, z_prime, &a, &b)?; + Ok((c, k + c * x)) } fn dleq_challenge( - y_prime: &RistrettoPoint, - x_pub: &RistrettoPoint, - z_prime: &RistrettoPoint, - a: &RistrettoPoint, - b: &RistrettoPoint, -) -> Scalar { - let g = RISTRETTO_BASEPOINT_POINT; - // Every element is a canonical fixed-width ristretto encoding, so ordered - // concatenation is unambiguous without length prefixes (guess 5). + y_prime: &ProjectivePoint, + x_pub: &ProjectivePoint, + z_prime: &ProjectivePoint, + a: &ProjectivePoint, + b: &ProjectivePoint, +) -> Result { + let g = ProjectivePoint::GENERATOR; + // Every element is a canonical fixed-width SEC1 compressed encoding, so + // ordered concatenation is unambiguous without length prefixes. hash_to_scalar( &[ - &g.compress().to_bytes(), - &y_prime.compress().to_bytes(), - &x_pub.compress().to_bytes(), - &z_prime.compress().to_bytes(), - &a.compress().to_bytes(), - &b.compress().to_bytes(), + &point_bytes(&g)?, + &point_bytes(y_prime)?, + &point_bytes(x_pub)?, + &point_bytes(z_prime)?, + &point_bytes(a)?, + &point_bytes(b)?, ], DST_CHAL_DLEQ, ) } fn dleq_verify( - y_prime: &RistrettoPoint, - x_pub: &RistrettoPoint, - z_prime: &RistrettoPoint, + y_prime: &ProjectivePoint, + x_pub: &ProjectivePoint, + z_prime: &ProjectivePoint, c: &Scalar, s: &Scalar, ) -> Result<()> { - let g = RISTRETTO_BASEPOINT_POINT; - let a = s * g - c * x_pub; - let b = s * y_prime - c * z_prime; - if dleq_challenge(y_prime, x_pub, z_prime, &a, &b) == *c { + let g = ProjectivePoint::GENERATOR; + let a = g * s - *x_pub * c; + let b = *y_prime * s - *z_prime * c; + if dleq_challenge(y_prime, x_pub, z_prime, &a, &b)? == *c { Ok(()) } else { Err(Error::VerificationFailed) @@ -335,7 +348,7 @@ fn dleq_verify( } // --------------------------------------------------------------------------- -// OR proof (guesses 8, 10) +// OR proof (see #6, #8) // --------------------------------------------------------------------------- /// Challenge for the 1-of-n OR proof. @@ -344,31 +357,33 @@ fn dleq_verify( /// `(e, a, b, r)` is fixed at issuance and so cannot bind a per-presentation /// value; the OR proof is computed at presentation time, which makes it the only /// place Section 4.1.3's "`Present` MUST bind `challenge_digest` into the proof -/// transcript" can be satisfied (guess 10). +/// transcript" can be satisfied. Upstream PR #39 confirms that division — every +/// other field of its `EndorsementPresentation` is issuance-time — and omits the +/// digest entirely. See #8. /// /// `n` is committed as a `uint16` so that proofs over different accepted sets /// cannot collide. fn or_challenge( - x_hat: &RistrettoPoint, - keys: &[RistrettoPoint], - commitments: &[RistrettoPoint], + x_hat: &ProjectivePoint, + keys: &[ProjectivePoint], + commitments: &[ProjectivePoint], challenge_digest: &ChallengeDigest, ) -> Result { let mut transcript = Vec::new(); - transcript.extend_from_slice(&x_hat.compress().to_bytes()); + transcript.extend_from_slice(&point_bytes(x_hat)?); transcript.extend_from_slice( &u16::try_from(keys.len()) .map_err(|_| Error::VerificationFailed)? .to_be_bytes(), ); for k in keys { - transcript.extend_from_slice(&k.compress().to_bytes()); + transcript.extend_from_slice(&point_bytes(k)?); } for r in commitments { - transcript.extend_from_slice(&r.compress().to_bytes()); + transcript.extend_from_slice(&point_bytes(r)?); } transcript.extend_from_slice(challenge_digest.as_bytes()); - Ok(hash_to_scalar(&[&transcript], DST_CHAL_OR)) + hash_to_scalar(&[&transcript], DST_CHAL_OR) } /// A textbook Cramer–Damgård–Schoenmakers OR proof of `∃i, γ: Xhat = γ·X_i`. @@ -380,37 +395,38 @@ fn or_challenge( fn or_prove( rng: &mut R, gamma: &Scalar, - x_hat: &RistrettoPoint, - keys: &[RistrettoPoint], + x_hat: &ProjectivePoint, + keys: &[ProjectivePoint], real_index: usize, challenge_digest: &ChallengeDigest, ) -> Result> { let n = keys.len(); let mut challenges = alloc::vec![Scalar::ZERO; n]; let mut responses = alloc::vec![Scalar::ZERO; n]; - let mut commitments = alloc::vec![RistrettoPoint::identity(); n]; + let mut commitments = alloc::vec![ProjectivePoint::IDENTITY; n]; // Simulate every branch but the real one. for i in 0..n { if i == real_index { continue; } - challenges[i] = random_scalar(rng); - responses[i] = random_scalar(rng); - commitments[i] = responses[i] * keys[i] - challenges[i] * x_hat; + challenges[i] = random_scalar(rng)?; + responses[i] = random_scalar(rng)?; + commitments[i] = keys[i] * responses[i] - *x_hat * challenges[i]; } // Commit honestly on the real branch. - let k = random_scalar(rng); - commitments[real_index] = k * keys[real_index]; + let k = random_scalar(rng)?; + commitments[real_index] = keys[real_index] * k; let c = or_challenge(x_hat, keys, &commitments, challenge_digest)?; - let simulated_sum: Scalar = challenges - .iter() - .enumerate() - .filter(|(i, _)| *i != real_index) - .map(|(_, s)| *s) - .sum(); + let simulated_sum = scalar_sum( + challenges + .iter() + .enumerate() + .filter(|(i, _)| *i != real_index) + .map(|(_, s)| *s), + ); challenges[real_index] = c - simulated_sum; responses[real_index] = k + challenges[real_index] * gamma; @@ -425,8 +441,8 @@ fn or_prove( fn or_verify( r: &mut Reader<'_>, - x_hat: &RistrettoPoint, - keys: &[RistrettoPoint], + x_hat: &ProjectivePoint, + keys: &[ProjectivePoint], challenge_digest: &ChallengeDigest, ) -> Result<()> { let n = usize::try_from(r.varint()?).map_err(|_| Error::VerificationFailed)?; @@ -438,11 +454,11 @@ fn or_verify( for key in keys { let c_i = read_scalar(r)?; let s_i = read_scalar(r)?; - commitments.push(s_i * key - c_i * x_hat); + commitments.push(*key * s_i - *x_hat * c_i); challenges.push(c_i); } let expected = or_challenge(x_hat, keys, &commitments, challenge_digest)?; - let sum: Scalar = challenges.iter().copied().sum(); + let sum = scalar_sum(challenges.iter().copied()); if sum == expected { Ok(()) } else { @@ -462,6 +478,7 @@ fn or_verify( #[derive(Debug)] pub struct ExpAnchor { secret: Scalar, + public_key: Point, rng: RefCell, } @@ -472,13 +489,15 @@ impl ExpAnchor { /// Returns [`Error::InvalidGroupElement`] if `secret` is not a canonical /// scalar, and [`Error::VerificationFailed`] if it is zero. pub fn new(secret: [u8; 32], rng: R) -> Result { - let secret = Option::::from(Scalar::from_canonical_bytes(secret)) + let secret = Option::::from(Scalar::from_repr(secret.into())) .ok_or(Error::InvalidGroupElement)?; - if secret == Scalar::ZERO { + if bool::from(secret.is_zero()) { return Err(Error::VerificationFailed); } + let public_key = encode_point(&(ProjectivePoint::GENERATOR * secret))?; Ok(Self { secret, + public_key, rng: RefCell::new(rng), }) } @@ -499,32 +518,32 @@ impl IhatAnchor for ExpAnchor { type State = AnchorState; fn sign(&self, request_body: &[u8]) -> Result<(Vec, Self::State)> { - let g = RISTRETTO_BASEPOINT_POINT; + let g = ProjectivePoint::GENERATOR; let mut r = Reader::new(request_body); let y_prime = read_point(&mut r)?; r.expect_end()?; - if y_prime == RistrettoPoint::identity() { + if y_prime == ProjectivePoint::IDENTITY { return Err(Error::InvalidGroupElement); } let mut rng = self.rng.borrow_mut(); - let z_prime = self.secret * y_prime; + let z_prime = y_prime * self.secret; let a_prime = random_nonzero_scalar(&mut *rng)?; - let b_prime = random_scalar(&mut *rng); - let t_prime = random_scalar(&mut *rng); + let b_prime = random_scalar(&mut *rng)?; + let t_prime = random_scalar(&mut *rng)?; - let c_prime = a_prime * g + b_prime * pedersen_h(); - let t1_prime = t_prime * y_prime; - let t2_prime = t_prime * g; + let c_prime = g * a_prime + pedersen_h()? * b_prime; + let t1_prime = y_prime * t_prime; + let t2_prime = g * t_prime; - let x_pub = self.secret * g; - let (dleq_c, dleq_s) = dleq_prove(&mut *rng, &self.secret, &y_prime, &x_pub, &z_prime); + let x_pub = g * self.secret; + let (dleq_c, dleq_s) = dleq_prove(&mut *rng, &self.secret, &y_prime, &x_pub, &z_prime)?; let mut body = Vec::new(); - write_point(&z_prime, &mut body); - write_point(&c_prime, &mut body); - write_point(&t1_prime, &mut body); - write_point(&t2_prime, &mut body); + write_point(&z_prime, &mut body)?; + write_point(&c_prime, &mut body)?; + write_point(&t1_prime, &mut body)?; + write_point(&t2_prime, &mut body)?; write_scalar(&dleq_c, &mut body); write_scalar(&dleq_s, &mut body); @@ -554,7 +573,7 @@ impl IhatAnchor for ExpAnchor { } fn public_key(&self) -> Point { - encode_point(&(self.secret * RISTRETTO_BASEPOINT_POINT)) + self.public_key } } @@ -579,10 +598,10 @@ impl ExpClient { #[derive(Debug, Clone)] pub struct ClientState { anchor_public_key: Point, - x_pub: RistrettoPoint, + x_pub: ProjectivePoint, m: Vec, - y: RistrettoPoint, - y_prime: RistrettoPoint, + y: ProjectivePoint, + y_prime: ProjectivePoint, v: Scalar, gamma: Scalar, /// Present only after `RequestProof`. @@ -591,9 +610,9 @@ pub struct ClientState { #[derive(Debug, Clone)] struct StageTwo { - x_hat: RistrettoPoint, - z_hat: RistrettoPoint, - c_prime: RistrettoPoint, + x_hat: ProjectivePoint, + z_hat: ProjectivePoint, + c_prime: ProjectivePoint, e: Scalar, alpha: Scalar, beta: Scalar, @@ -608,9 +627,9 @@ struct StageTwo { #[derive(Debug, Clone)] pub struct ExpEndorsement { /// `Xhat = γX`, the randomized Anchor key that issuer hiding rests on. - pub x_hat: RistrettoPoint, + pub x_hat: ProjectivePoint, /// `Zhat = γxY`. - pub z_hat: RistrettoPoint, + pub z_hat: ProjectivePoint, /// `m`, carrying `endorsement_context` and the nullifier. pub m: Vec, /// Fiat–Shamir challenge. @@ -661,17 +680,17 @@ impl IhatClient for ExpClient { let mut nullifier = [0u8; 32]; self.rng.fill_bytes(&mut nullifier); let m = encode_m(endorsement_context, &nullifier); - let y = hash_to_group(&[&m], DST_H2G_M); - if y == RistrettoPoint::identity() { + let y = hash_to_group(&[&m], DST_H2G_M)?; + if y == ProjectivePoint::IDENTITY { return Err(Error::InvalidGroupElement); } let v = random_nonzero_scalar(&mut self.rng)?; let gamma = random_nonzero_scalar(&mut self.rng)?; - let y_prime = v * y; + let y_prime = y * v; let mut body = Vec::new(); - write_point(&y_prime, &mut body); + write_point(&y_prime, &mut body)?; Ok(( body, @@ -693,7 +712,7 @@ impl IhatClient for ExpClient { mut state: Self::State, response_body: &[u8], ) -> Result<(Vec, Self::State)> { - let g = RISTRETTO_BASEPOINT_POINT; + let g = ProjectivePoint::GENERATOR; let mut r = Reader::new(response_body); let z_prime = read_point(&mut r)?; let c_prime = read_point(&mut r)?; @@ -706,23 +725,23 @@ impl IhatClient for ExpClient { // The Anchor must prove it applied its real key to the blinded point. dleq_verify(&state.y_prime, &state.x_pub, &z_prime, &dleq_c, &dleq_s)?; - let v_inv = state.v.invert(); - let z_hat = state.gamma * v_inv * z_prime; - let x_hat = state.gamma * state.x_pub; + let v_inv = invert(&state.v)?; + let z_hat = z_prime * (state.gamma * v_inv); + let x_hat = state.x_pub * state.gamma; let alpha = random_nonzero_scalar(&mut self.rng)?; - let beta = random_scalar(&mut self.rng); + let beta = random_scalar(&mut self.rng)?; let epsilon = random_nonzero_scalar(&mut self.rng)?; - let rho = random_scalar(&mut self.rng); + let rho = random_scalar(&mut self.rng)?; - let alpha_inv = alpha.invert(); - let epsilon_inv = epsilon.invert(); + let alpha_inv = invert(&alpha)?; + let epsilon_inv = invert(&epsilon)?; - let c = alpha_inv * c_prime - beta * pedersen_h(); - let t1 = epsilon_inv * v_inv * (t1_prime - rho * state.y_prime); - let t2 = epsilon_inv * (t2_prime - rho * g); + let c = c_prime * alpha_inv - pedersen_h()? * beta; + let t1 = (t1_prime - state.y_prime * rho) * (epsilon_inv * v_inv); + let t2 = (t2_prime - g * rho) * epsilon_inv; - let e = endorsement_challenge(&state.y, &z_hat, &t1, &t2, &c); + let e = endorsement_challenge(&state.y, &z_hat, &t1, &t2, &c)?; let e_prime = epsilon * alpha_inv * state.gamma * e; let mut body = Vec::new(); @@ -742,7 +761,7 @@ impl IhatClient for ExpClient { } fn finalize(&mut self, state: Self::State, response_body: &[u8]) -> Result { - let g = RISTRETTO_BASEPOINT_POINT; + let g = ProjectivePoint::GENERATOR; let two = state.stage_two.ok_or(Error::VerificationFailed)?; let mut r = Reader::new(response_body); @@ -753,17 +772,17 @@ impl IhatClient for ExpClient { // "The client then checks Cprime = aprime G + bprime H, that aprime is // invertable" — the commitment opening. - if a_prime == Scalar::ZERO { + if bool::from(a_prime.is_zero()) { return Err(Error::VerificationFailed); } - if a_prime * g + b_prime * pedersen_h() != two.c_prime { + if g * a_prime + pedersen_h()? * b_prime != two.c_prime { return Err(Error::VerificationFailed); } - let alpha_inv = two.alpha.invert(); + let alpha_inv = invert(&two.alpha)?; let a = alpha_inv * a_prime; let b = alpha_inv * b_prime - two.beta; - let r_final = two.epsilon.invert() * (r_prime - two.rho); + let r_final = invert(&two.epsilon)? * (r_prime - two.rho); let endorsement = ExpEndorsement { x_hat: two.x_hat, @@ -793,7 +812,7 @@ impl IhatClient for ExpClient { .iter() .position(|k| *k == endorsement.anchor_public_key) .ok_or(Error::UnknownAnchorKey)?; - let decoded: Result> = keys.iter().map(decode_point).collect(); + let decoded: Result> = keys.iter().map(decode_point).collect(); let decoded = decoded?; let or_proof = or_prove( @@ -806,8 +825,8 @@ impl IhatClient for ExpClient { )?; let mut out = Vec::new(); - write_point(&endorsement.x_hat, &mut out); - write_point(&endorsement.z_hat, &mut out); + write_point(&endorsement.x_hat, &mut out)?; + write_point(&endorsement.z_hat, &mut out)?; write_opaque_v(&endorsement.m, &mut out); write_scalar(&endorsement.e, &mut out); write_scalar(&endorsement.a, &mut out); @@ -823,20 +842,20 @@ impl IhatClient for ExpClient { // --------------------------------------------------------------------------- fn endorsement_challenge( - y: &RistrettoPoint, - z_hat: &RistrettoPoint, - t1: &RistrettoPoint, - t2: &RistrettoPoint, - c: &RistrettoPoint, -) -> Scalar { - // `e = H(Y, Zhat, T1, T2, C)`, in the draft's order (guess 5). + y: &ProjectivePoint, + z_hat: &ProjectivePoint, + t1: &ProjectivePoint, + t2: &ProjectivePoint, + c: &ProjectivePoint, +) -> Result { + // `e = H(Y, Zhat, T1, T2, C)`, in the draft's order. hash_to_scalar( &[ - &y.compress().to_bytes(), - &z_hat.compress().to_bytes(), - &t1.compress().to_bytes(), - &t2.compress().to_bytes(), - &c.compress().to_bytes(), + &point_bytes(y)?, + &point_bytes(z_hat)?, + &point_bytes(t1)?, + &point_bytes(t2)?, + &point_bytes(c)?, ], DST_CHAL_ENDORSEMENT, ) @@ -844,16 +863,16 @@ fn endorsement_challenge( /// The draft's "Verifying such an endorsement", without the OR proof. fn verify_endorsement_proof(e: &ExpEndorsement) -> Result<()> { - let g = RISTRETTO_BASEPOINT_POINT; - let y = hash_to_group(&[&e.m], DST_H2G_M); + let g = ProjectivePoint::GENERATOR; + let y = hash_to_group(&[&e.m], DST_H2G_M)?; // "It checks a is not zero and Y is not the identity". - if e.a == Scalar::ZERO || y == RistrettoPoint::identity() { + if bool::from(e.a.is_zero()) || y == ProjectivePoint::IDENTITY { return Err(Error::VerificationFailed); } - let t1 = e.r * y - e.e * e.a * e.z_hat; - let t2 = e.r * g - e.e * e.a * e.x_hat; - let c = e.a * g + e.b * pedersen_h(); - if endorsement_challenge(&y, &e.z_hat, &t1, &t2, &c) == e.e { + let t1 = y * e.r - e.z_hat * (e.e * e.a); + let t2 = g * e.r - e.x_hat * (e.e * e.a); + let c = g * e.a + pedersen_h()? * e.b; + if endorsement_challenge(&y, &e.z_hat, &t1, &t2, &c)? == e.e { Ok(()) } else { Err(Error::VerificationFailed) @@ -886,7 +905,7 @@ impl IhatVerifier for ExpVerifier { let b = read_scalar(&mut r)?; let r_scalar = read_scalar(&mut r)?; - let decoded: Result> = keys.iter().map(decode_point).collect(); + let decoded: Result> = keys.iter().map(decode_point).collect(); let decoded = decoded?; or_verify(&mut r, &x_hat, &decoded, challenge_digest)?; r.expect_end()?; diff --git a/src/lib.rs b/src/lib.rs index 363399b..469db89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,9 +36,11 @@ //! //! `[CRYPTO]` has since been written — `draft-authors-mole-crypto.md` landed in the //! upstream repository on 15 July 2026 — but it is not on the IETF Datatracker and -//! is not an interoperable specification: it names no group, no hash, and no domain -//! separation tags, declares TLS notation without defining a single structure, and -//! publishes no test vectors. +//! is not yet an interoperable specification: it names no group directly, supplies +//! no domain separation tag, and publishes no test vectors. Open upstream PR #39 +//! adds message structures and an OR-proof construction, but leaves the group to a +//! forward reference the architecture draft does not satisfy, and types `m` and +//! omits `challenge_digest` in ways that contradict protocols Section 4.1.3. //! //! The other schemes are likewise external: Longfellow needs //! `draft-google-cfrg-libzk` plus an out-of-band circuit, ACT needs @@ -52,8 +54,10 @@ //! encoding and state-machine layers can be driven end to end. Deliberately and //! thoroughly insecure; read its module documentation before touching it. //! * `exp-ihat` — `crypto::exp_ihat` implements the *construction* from the upstream -//! crypto draft with all ten unspecified parameters guessed. The security -//! properties are real; interoperability is not. See `EXP-IHAT-PROFILE.md`. +//! crypto draft over P-256, following the draft for the group, the RFC 9380 +//! hash-to-curve suite and the Pedersen generator, and guessing the six parameters +//! it still does not supply. The security properties are real; interoperability is +//! not. See `EXP-IHAT-PROFILE.md`. //! //! ## Example: a Moderator challenge, and a Client answering it //! diff --git a/tests/exp_ihat_profile.rs b/tests/exp_ihat_profile.rs index be80724..a9b8627 100644 --- a/tests/exp_ihat_profile.rs +++ b/tests/exp_ihat_profile.rs @@ -257,13 +257,48 @@ fn malformed_presentations_are_rejected() { assert!(ExpVerifier.verify(&[], &[], &cd).is_err()); } -/// The `Point[33]` encoding this profile invents keeps the draft's width, so -/// Anchor keys still round-trip through the spec's `ihat::Challenge`. +/// Anchor keys are genuine SEC1 compressed P-256 points, which is exactly the +/// `opaque Point[33]` the protocols draft specifies. +/// +/// This is the one place switching from ristretto255 to P-256 *removed* an +/// invention rather than adding one: the profile used to pad a 32-byte ristretto +/// encoding with a tag byte to reach 33. Now the width is the encoding. #[test] -fn public_keys_fit_the_drafts_point_width() { - let a = anchor(1, 42); - let pk: Point = a.public_key(); - assert_eq!(pk.0.len(), 33); - // Tag byte marks it as this profile's ristretto encoding, not SEC1. - assert_eq!(pk.0[0], 0x00); +fn public_keys_are_sec1_compressed_p256_points() { + for seed in 1u8..12 { + let pk: Point = anchor(seed, u64::from(seed) + 500).public_key(); + assert_eq!(pk.0.len(), 33); + // SEC1 compressed: 0x02 for even Y, 0x03 for odd. Never 0x04 + // (uncompressed), never 0x00 (the identity, or our old tag hack). + assert!( + pk.0[0] == 0x02 || pk.0[0] == 0x03, + "seed {seed}: leading byte {:#04x} is not a SEC1 compressed prefix", + pk.0[0] + ); + } +} + +/// Both SEC1 compressed prefixes actually occur, so the assertion above is not +/// passing by only ever seeing one of them. +#[test] +fn both_sec1_parity_prefixes_occur() { + let prefixes: Vec = (1u8..40) + .map(|seed| anchor(seed, u64::from(seed)).public_key().0[0]) + .collect(); + assert!(prefixes.contains(&0x02), "no even-Y key generated"); + assert!(prefixes.contains(&0x03), "no odd-Y key generated"); +} + +/// The identity has no 33-byte SEC1 encoding, so it must be refused rather than +/// truncated into something that looks like a point. +#[test] +fn the_identity_is_not_a_valid_point() { + let keys = vec![Point([0u8; 33])]; + let cd = digest(b"identity"); + // An all-zero Point[33] is the SEC1 identity padded out; it is not decodable + // as a group element. + assert_eq!( + ExpVerifier.verify(&[0u8; 240], &keys, &cd).unwrap_err(), + Error::InvalidGroupElement + ); } From 8869ac682590c4e1d3064a2c5b8aeca7f5c260f2 Mon Sep 17 00:00:00 2001 From: Orie Steele Date: Tue, 28 Jul 2026 15:06:46 -0500 Subject: [PATCH 3/6] feat: align exp-ihat with ihat-rs and cross-verify in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the MoLE org publishes a reference implementation, ihat-rs, by Samuel Schlesinger — one of the draft authors. It settles most of what I had filed as unguessable, and it disagrees with the drafts in ways worth knowing. I should have checked the org's other repos before inventing parameters. The profile now follows code rather than prose. Where ihat-rs and the crypto draft disagree, ihat-rs wins: it is executable, it is by an author, and interoperating with it is the only validation available since neither publishes test vectors. interop/ is a detached package (git dependency; Cargo cannot make dev-dependencies optional) pinned to an upstream revision. Four tests, both directions: - ihat-rs parses and accepts our presentations for every issuer in a four-Anchor set, and re-encodes them to byte-identical output - we parse and accept ihat-rs's, recovering the same nullifier and context - Anchor keys round-trip both ways - neither accepts a substituted accepted set That converts the crate's worst remaining problem — no test vectors, so correctness unverifiable — into differential testing against the authors' own code, which is stronger: it exercises both roles rather than fixed strings. Adopted from ihat-rs: the four DSTs verbatim (MOLE-IHAT-P256::v1, which closes the DST question outright), Y = H1(nf), a context-bound Pedersen generator H(endorsement_context), the issuance transcript, the CDS OR proof wire form, and two-byte big-endian VarBytes framing. Two real weaknesses in our previous revision, both found by reading ihat-rs: - or_prove branched on the secret real_index (`if i == real_index`), a timing and cache channel leaking exactly what issuer hiding conceals. ihat-rs is explicitly constant-time here; now rewritten to match, selecting the honest base with subtle across the whole accepted set. - our Fiat-Shamir transcript followed the draft's e = H(Y, Zhat, T1, T2, C). ihat-rs additionally binds X_hat and endorsement_context, commenting that this is "strengthened to bind the full statement ... so a proof cannot be transported to a different X_hat". Now matched. Also dropped: the separate DLEQ proof we emitted in exchange one. ihat-rs sends none because (T1', T2', r') already is one — finalize checks Y'r' = Z'e'a' + T1' and Gr' = Xe'a' + T2'. That was 64 redundant bytes per grant. And `m` is gone: ihat-rs carries nf and endorsement_context as separate fields, so our m = opaque(ctx) || nf[32] was working around a non-problem. One thing cannot be reconciled. ihat-rs has no challenge_digest anywhere, so protocols 4.1.3's two MUSTs about binding it are satisfied by no implementation. Rather than pick silently, ChallengeBinding exposes both sides: IhatRsCompatible interoperates and is replayable; MoleBound (default) satisfies 4.1.3 and does not interoperate. Tests assert both, including that they do not cross-verify. Corrections recorded: {{DLEQ}} resolves to draft-irtf-cfrg-sigma-protocols, not RFC 9497. And PR #39 contradicts ihat-rs in three places — uncompressed bytes[65] vs compressed 33, a global vs context-bound Pedersen generator, and an O(log n) commitment tree vs O(n) CDS. 109 tests plus 4 interop. Clippy pedantic, rustdoc, fmt clean; no_std on thumbv7em-none-eabi with and without the profile; MSRV 1.75 --locked. New CI job runs the cross-verification. cargo doc caught a bare [DLEQ] citation during this work, which is why it stays in the check list. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 15 + .gitignore | 1 + Cargo.toml | 12 +- EXP-IHAT-PROFILE.md | 415 +++++++------ README.md | 47 +- SPEC-COVERAGE.md | 39 +- interop/Cargo.lock | 247 ++++++++ interop/Cargo.toml | 29 + interop/tests/interop.rs | 196 ++++++ src/crypto.rs | 9 +- src/crypto/exp_ihat.rs | 1200 ++++++++++++++++++++----------------- src/lib.rs | 10 +- tests/exp_ihat_profile.rs | 425 +++++++++---- 13 files changed, 1710 insertions(+), 935 deletions(-) create mode 100644 interop/Cargo.lock create mode 100644 interop/Cargo.toml create mode 100644 interop/tests/interop.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24013cd..9673171 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,21 @@ jobs: - name: Build the exp-ihat profile for a bare-metal target run: cargo build --no-default-features --features exp-ihat --target thumbv7em-none-eabi + interop: + name: Interop vs ihat-rs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + # Differential tests against the reference implementation published by a + # MoLE draft author. This is the strongest validation available: neither the + # drafts nor ihat-rs publish test vectors, so cross-verification stands in + # for them. Kept in a detached package because it needs a git dependency and + # Cargo cannot make dev-dependencies optional. + - name: Cross-verify with ihat-rs + run: cargo test --manifest-path interop/Cargo.toml + msrv: name: MSRV (1.75) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 471f43d..d9e911e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .DS_Store .idea/ .vscode/ +/interop/target diff --git a/Cargo.toml b/Cargo.toml index 30906a2..b60ac9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,12 +37,12 @@ std = [] # blindness. Never enable this outside tests. See README.md. insecure-mock = [] -# Enables `crypto::exp_ihat`, an implementation of the IHAT construction from the -# upstream `draft-authors-mole-crypto.md` over P-256. The group, hash-to-curve -# suite and Pedersen generator follow the draft; six further parameters it does -# not specify are GUESSED. It is not interoperable with any other implementation, -# has no test vectors to validate against, and implements a construction still -# under active revision by its authors. See `EXP-IHAT-PROFILE.md`. +# Enables `crypto::exp_ihat`, an implementation of IHAT over P-256 aligned +# byte-for-byte with `ihat-rs`, the reference implementation published in the MoLE +# org by a draft author, and cross-verified against it by `interop/`. It +# interoperates — but `ihat-rs` states it has not been audited, the construction +# has no security proof or public cryptanalysis, and it is still under revision +# upstream. Never enable this outside tests. See `EXP-IHAT-PROFILE.md`. exp-ihat = ["dep:p256"] [lints.rust] diff --git a/EXP-IHAT-PROFILE.md b/EXP-IHAT-PROFILE.md index ad8890c..558167b 100644 --- a/EXP-IHAT-PROFILE.md +++ b/EXP-IHAT-PROFILE.md @@ -1,203 +1,165 @@ # The `exp-ihat` profile -**This profile is not interoperable with anything, has no test vectors, and -implements a construction with no public cryptanalysis. It exists to find out what -implementing IHAT actually requires. Do not deploy it.** +**This profile interoperates with the reference implementation. That is not the +same as being safe to deploy: `ihat-rs` says of itself "This code has not been +audited", the construction has no security proof and no public cryptanalysis, and +it is still being revised upstream.** + +`crypto::exp_ihat`, behind the off-by-default `exp-ihat` feature, implements IHAT +over NIST P-256, aligned byte-for-byte with +[`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs) — +"Implementation of the endorsement issuance and redemption protocols", published in +the MoLE org by Samuel Schlesinger, one of the draft authors. + +## Following code, not prose + +This is the profile's central decision. The crypto draft +([`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md)) +is about fifty lines of prose that names no group, no DST, and no wire structures. +`ihat-rs` is 2,469 lines of executable Rust with a canonical wire format. Where they +disagree, **`ihat-rs` wins** — it is executable, it is by an author, and +interoperating with it is the only validation available, since neither publishes test +vectors. + +Taken from `ihat-rs`, and therefore not guesses: + +| | | +|---|---| +| Group | NIST P-256 | +| `Point` | SEC1 compressed, 33 bytes — the `opaque Point[33]` of protocols §4.1 | +| `Scalar` | 32 bytes big-endian (SEC1 I2OSP) | +| Hash-to-group | RFC 9380 `P256_XMD:SHA-256_SSWU_RO_` | +| Hash-to-scalar | RFC 9380 `hash_to_field` | +| Domain separation | `MOLE-IHAT-P256::v1` — four fixed tags | +| `Y` | `H₁(nf)`, hash-to-group of the raw nullifier | +| Pedersen generator | `H(endorsement_context)`, context-bound | +| Issuance transcript | `e = H_FS(X̂, Y, Ẑ, T₁, T₂, C, endorsement_context)` | +| OR proof | CDS 1-of-n, one `(t, c, s)` transcript per accepted key, 97 bytes each | +| Wire framing | TLS presentation language; `VarBytes` = two-byte big-endian | + +The four DSTs, verbatim: -`crypto::exp_ihat`, behind the off-by-default `exp-ihat` feature, implements the -IHAT construction from the upstream -[`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md), -over **P-256**. +``` +MOLE-IHAT-P256:H1-nullifier-to-group:v1 +MOLE-IHAT-P256:fiat-shamir-getend:v1 +MOLE-IHAT-P256:pedersen-generator-H:v1 +MOLE-IHAT-P256:fiat-shamir-or-proof:v1 +``` -## How this differs from `insecure_mock` +One DST is this crate's own and never appears on the wire: scalar sampling uses +`OR13_EXP_MOLE-V01-rand-with-P256_XMD:SHA-256_SSWU_RO_`, deliberately not claiming +the `MOLE-IHAT` prefix. -The crate has two stand-ins for `[CRYPTO]` and they are not the same kind of -thing. `insecure_mock` fakes the *interface* so the encoding and state-machine -layers can be tested. `exp_ihat` implements the *construction* — the algebra is -the draft's algebra, over the curve the drafts point at, and the security -properties are real. +## How this differs from `insecure_mock` | Property | `insecure_mock` | `exp_ihat` | |---|---|---| | Blind signing — Anchor never learns `nf` | ❌ sent in cleartext | ✅ Anchor sees only `Y' = vY` | -| Issuer hiding | ❌ carries the Anchor's index | ✅ 1-of-n CDS OR proof | +| Issuer hiding | ❌ carries the Anchor's index | ✅ 1-of-n CDS OR proof, constant-time in the real index | | Public verifiability | ❌ verifier needs every secret | ✅ public keys only | | Unlinkability across grants | ❌ byte-identical presentations | ✅ no shared field | -| Challenge binding | ✅ | ✅ | | Real group | ❌ not curve points at all | ✅ P-256 | -| Interoperable | ❌ | ❌ | -| Validated against test vectors | n/a | ❌ none exist | +| Interoperable | ❌ | ✅ with `ihat-rs`, both directions | +| Audited | n/a | ❌ | -## What is now specified rather than guessed +## Where `ihat-rs` departs from the drafts -The profile originally used ristretto255 and invented four things that P-256 makes -unnecessary. Aligning on the drafts' group **removed** inventions: +These are the interesting findings, and the profile follows `ihat-rs` on all of +them. -| | before | now | -|---|---|---| -| Group | ristretto255 (our choice) | **P-256**, per the crypto draft | -| `Point[33]` | 32-byte ristretto plus a `0x00` tag byte | **genuine SEC1 compressed point** | -| Hash-to-curve | `expand_message_xmd(SHA-256)` + ristretto map, our assembly | RFC 9380 **`P256_XMD:SHA-256_SSWU_RO_`**, the suite the draft's "RO based Hash2Curve" names | -| Scalar derivation | SHA-256 widened to 64 bytes, our choice of width | RFC 9380 **`hash_to_field`**, 48 bytes for P-256 by the spec | -| Pedersen generator | hash of our own label | **`Hash2Curve("IHAT second generator")`**, the literal string from PR #39 | +### The issuance transcript binds more — [#4] + +The crypto draft says `e = H(Y, Zhat, T1, T2, C)`. `ihat-rs` binds `X̂` and +`endorsement_context` as well, with the comment: + +> strengthened to bind the *full* statement: the rerandomised key `X_hat` — so a +> proof cannot be transported to a different `X_hat` -Two notes on that. +So the reference implementation's author considered the draft's transcript +insufficient and strengthened it. An earlier revision of this profile followed the +draft literally; it now matches `ihat-rs`. -**SHA-256 is now the specified hash, not a preference.** `P256_XMD:SHA-256_SSWU_RO_` -expands with SHA-256 internally, so the hash comes from the suite. The draft's `H` -"whose output length is sufficiently long" is likewise satisfied by construction — -`hash_to_field` for P-256 expands to 48 bytes per element before reduction, which -is what keeps scalars unbiased. +### There is no separate DLEQ proof — [#4] -**The `Point[33]` hack is gone.** This is the clearest win. A compressed SEC1 P-256 -point is exactly 33 bytes, so `opaque Point[33]` from -`draft-jms-mole-protocols-00` §4.1 is the encoding rather than something we pad to -fit. `tests/exp_ihat_profile.rs` asserts the leading byte is `0x02` or `0x03` and -that both parities actually occur. +The draft has the Anchor send "a proof in the manner of \[DLEQ\]" in exchange one. +`ihat-rs` sends none, and does not need to: `(T₁', T₂', r')` already constitutes +one. `finalize` checks -## What is still guessed +``` +Y'·r' = Z'·e'a' + T₁' +G·r' = X·e'a' + T₂' +``` -Six things, each an open issue; [#13] indexes them. +which together prove the Anchor applied a single `x` to both `Z' = x·Y'` and +`X = x·G`. A separate proof would be redundant, and the earlier revision of this +profile carried one — 64 wasted bytes per grant. -### Point compression — [#1] +Note `{{DLEQ}}` in the draft resolves to `draft-irtf-cfrg-sigma-protocols`, not +RFC 9497; §2.2.8 there is the DLEQ statement. -Both MoLE drafts now imply P-256 and **disagree on compression**: +### The Pedersen generator is per-context — [#5] -| Document | Width | Encoding | -|---|---|---| -| `draft-jms-mole-protocols-00` §4.1 | `opaque Point[33]` | SEC1 compressed | -| `draft-authors-mole-crypto` (PR #39) | `uncompressed bytes[65]` | SEC1 uncompressed | -| `draft-schlesinger-cfrg-act-01` | 32 | ristretto255 | +Upstream PR #39 defines `H = Hash2Curve("IHAT second generator")`, one global +generator. `ihat-rs` derives `H` from `endorsement_context`, which binds the context +into `C` structurally. It is load-bearing: a Client and Anchor that disagree about +the context derive different `H`, and the Pedersen check fails at finalize. +`tests/exp_ihat_profile.rs::context_mismatch_fails_the_pedersen_check` asserts it. -This profile uses **compressed**, matching the protocols draft, because that is the -width the rest of this crate already implements and `ihat::Challenge` already -parses. One of the two MoLE drafts needs to change. +### Points are compressed — [#1] -Also worth noting: the crypto draft's only statement about which group to use is -"`{{ARCH}}` currently instantiates this protocol with P-256" — and the architecture -draft contains **no group instantiation at all**. So P-256 is inferred from a -forward reference to content that does not exist, corroborated by `Point[33]` and -`bytes[65]` both being P-256 SEC1 widths. That inference is why this profile is on -P-256, and it is not the same thing as the draft specifying it. +PR #39 declares `uncompressed bytes[65]`. `ihat-rs` and protocols §4.1 both use +33-byte compressed, so PR #39 contradicts the authors' own implementation. A +compressed SEC1 P-256 point is exactly 33 bytes, so `opaque Point[33]` is the +encoding rather than something to pad. -### Domain separation tags — [#2] +### The OR proof is `O(n)` CDS — [#6] -RFC 9380 §3.1 requires a DST unique to the protocol. The crypto draft supplies -none, even though PR #39 introduces two hash-to-curve *inputs* -(`"IHAT second generator"`, `"IHAT unknown discrete logarithm point"`) — those are -messages, not DSTs. +PR #39 specifies an `O(log n)` trapdoor commitment tree. `ihat-rs` uses one Schnorr +transcript per accepted key. Again the code contradicts the open PR. -This profile uses, following the convention of RFC 9380's own examples: +`ihat-rs` is also explicitly **constant-time in the real branch index**, recovering +the honest base by a `subtle` select across the whole accepted set so that no +control flow or memory access depends on which Anchor issued. An earlier revision of +this profile branched on the index — a timing and cache channel leaking precisely +what issuer hiding exists to conceal. Now fixed, following `ihat-rs`'s structure. -| Use | DST | -|---|---| -| `Y = Hash2Curve(m)` | `OR13_EXP_MOLE-V01-h2g-m-with-P256_XMD:SHA-256_SSWU_RO_` | -| second generator | `OR13_EXP_MOLE-V01-h2g-gen2-with-P256_XMD:SHA-256_SSWU_RO_` | -| endorsement challenge | `OR13_EXP_MOLE-V01-chal-endorsement-with-P256_XMD:SHA-256_SSWU_RO_` | -| DLEQ challenge | `OR13_EXP_MOLE-V01-chal-dleq-with-P256_XMD:SHA-256_SSWU_RO_` | -| OR-proof challenge | `OR13_EXP_MOLE-V01-chal-or-with-P256_XMD:SHA-256_SSWU_RO_` | -| scalar sampling | `OR13_EXP_MOLE-V01-rand-with-P256_XMD:SHA-256_SSWU_RO_` | - -Each names this repository, so it cannot squat a string the specification might -want, and each names the suite, so changing the group necessarily changes the -domain. - -### `e = H(Y, Zhat, T1, T2, C)` sits outside the sigma framework — [#3], [#4] - -PR #39 specifies that the DLEQ and OR proofs be "instantiated using the DLEQ proof -of `{{DLEQ}}`, the appropriate group Codec for the group used, and SHAKE128 sponge -in `{{FIAT}}`" — that is `draft-irtf-cfrg-sigma-protocols-02` and -`draft-irtf-cfrg-fiat-shamir-02`, which together define a SHAKE128 duplex sponge -and a concrete P-256 codec. - -That is a real specified mechanism and it is better than what this profile does. -`exp_ihat` instead concatenates canonical SEC1 encodings in the draft's stated -order and hashes with `hash_to_field`. Since every element is fixed-width and -canonical, the concatenation is unambiguous — but it is not the specified -construction. - -**Held deliberately.** Adopting it means implementing a Keccak duplex sponge and -the sigma-protocol codec framework, against a PR that has not merged. Tracked -rather than done. - -Note also that `e = H(...)` in Issuance Step Two is *not* covered by that framework -even in PR #39 — it remains a bare hash over a tuple, the only such hash left in -the design. Either it should move inside, or the draft should say why it differs. - -### `C'` is used before it is defined — [#5] - -Issuance Step One says only "It computes a commitment." PR #39 does not touch that -sentence, so the reader still infers `C' = a'G + b'H` from Step Two's check. This -profile assumes exactly that. - -The generator half of this issue is resolved: PR #39 defines `H` as -`Hash2Curve("IHAT second generator")`, which is the same nothing-up-my-sleeve -construction this crate had guessed, and the profile now uses that literal string. - -### The OR proof — [#6] - -PR #39 replaces "we can use sigma stacking" with a full construction: one-of-two -binding commitments with trapdoor equivocation, composed into a tree for one-of-N -with `n = ceil(log2 N)` levels, plus `P = Hash2Curve("IHAT unknown discrete -logarithm point")`. - -This profile uses the textbook Cramer–Damgård–Schoenmakers OR proof — simulate -every branch but the real one, share one challenge, send `(c_i, s_i)` per branch. -Verification recomputes `R_i = s_i·X_i − c_i·Xhat` and checks `Σ c_i` against the -transcript hash. Branch `i` corresponds to `keys[i]`, honouring §4.1.3's normative -branch ordering. - -| | this profile | PR #39 | -|---|---|---| -| proof size | `2n` scalars, `O(n)` | opening plus one shared third message | -| parameters | none | `n = O(log N)` points | -| technique | simulate all-but-one, split challenge | trapdoor equivocation over a commitment tree | - -**Held deliberately**, for two reasons: the PR is unmerged, and it carries its own -`TODO: consider more efficient, but harder to describe (and maybe more susceptible -to side channel) constructions`. Implementing a construction its authors say they -may replace is poor value. - -One question worth putting to upstream: the tree specifies that "the same -randomness is used at each level". Reusing a single `s` across every level of a -Pedersen commitment tree is stated without justification. It is clearly deliberate -— equivocation has to recompute consistently — but it wants an explicit argument. -Flagged as a question; no analysis claimed. - -### `m` — [#7] - -The crypto draft gives the Endorsement an `m` with `Y = Hash2Curve(m)`. The -protocols draft §4.1.3 says verification "exposes `nf` and `endorsement_context`", -neither of which the crypto draft mentions. - -PR #39 makes this a **contradiction rather than a silence**: its -`EndorsementPresentation` types `m` as `Scalar`, which is `bytes[32]`. A single -32-byte scalar cannot carry a variable-length `endorsement_context` *and* a -nullifier, so §4.1.3 is unsatisfiable against that structure. It also looks like a -plain typing error — `Hash2Curve` consumes octets, not a field element. - -This profile sets `m = opaque(endorsement_context) || nf[32]`, with `nf` drawn -at random during `Prepare`. Since `m` is revealed at presentation and verification -must expose exactly those two fields, letting `m` *be* them is the least invention -available, and it costs no blindness — the Anchor sees only `Y' = vY`. - -A consequence: `m` is fixed at issuance and carries the epoch, so an Endorsement is -epoch-bound. That is consistent with `endorsement_context` naming the epoch and -with `Moderator::redeem` checking epoch before nullifier freshness. - -### `challenge_digest` — [#8] - -§4.1.3 requires `Present` to bind `challenge_digest` into the proof transcript and -`Verify` to fail on any other digest. In the crypto draft it appears **nowhere**: -not in `EndorsementPresentation`, not in `e = H(Y, Zhat, T1, T2, C)`, not in the OR -challenge `H(params, Xi, C)`, not in the verification procedure. Both MUSTs are -unsatisfiable as specified, which would make presentations replayable across -challenges. - -This profile commits the digest in the OR-proof transcript. That is the only place -it can go: the partial endorsement `(e, a, b, r)` is fixed at issuance, and PR #39 -confirms the division — every field of its presentation except `proof` is -issuance-time. Upstream needs to put it there. +### `m` does not exist — [#7] + +The draft gives the Endorsement an `m` with `Y = Hash2Curve(m)` and never defines +it, and PR #39 types it as a 32-byte `Scalar` — which cannot carry the +`endorsement_context` protocols §4.1.3 requires verification to expose. + +`ihat-rs` has no `m`. It carries `nf` and `endorsement_context` as two separate +`VarBytes` fields and computes `Y = H₁(nf)`. An earlier revision of this profile +invented `m = opaque(ctx) || nf[32]` to work around a problem the reference +implementation does not have. + +## The one irreconcilable difference — [#8] + +Protocols §4.1.3 requires: + +> `Present` MUST bind `challenge_digest` into the proof transcript. + +> `Verify` MUST fail when given any other `challenge_digest`. + +**`ihat-rs` has no `challenge_digest` anywhere** — not in a structure, not in a +transcript, not in its API, whose verb is `show(&accepted, index, rng)` and whose +verifier is `verify(&pp, &accepted)`. Its own docs note that a presentation "verifies +every time". + +So byte-compatibility with the reference implementation and compliance with the MoLE +drafts are mutually exclusive. Rather than pick silently, the profile exposes +`ChallengeBinding`: + +| Mode | Binds the digest | Interoperates with `ihat-rs` | Satisfies §4.1.3 | +|---|---|---|---| +| `IhatRsCompatible` | ❌ | ✅ | ❌ — presentations are replayable | +| `MoleBound` (default) | ✅, into the OR transcript | ❌ | ✅ | + +Both modes are tested, including that they do **not** cross-verify, which is the +concrete shape of the contradiction. The OR transcript is the only place the digest +can go: the partial endorsement `(e, a, b, r)` is fixed at issuance, and PR #39's +own structure confirms that everything except the proof is issuance-time. ## A security property no draft states — [#9] @@ -211,79 +173,102 @@ r'_1 = t' + e'_1 a' x r'_2 = t' + e'_2 a' x ``` -Subtracting gives `(e'_1 − e'_2) a' x`, which solves for `a'x` — and `a'` is sent -to the Client in the clear in exchange two, which PR #39's `AnchorSecondMessage` -confirms. The Anchor's long-term secret key falls out with one division. +Subtracting gives `(e'_1 − e'_2) a' x`, which solves for `a'x` — and `a'` is sent to +the Client in the clear in exchange two. The Anchor's long-term secret key falls out +with one division. Two consequences: 1. **Nonces must not be derived deterministically from the request.** This inverts - the usual advice (RFC 6979, Ed25519), which makes it a live footgun — a careful - implementer reaches for deterministic nonces by default. `IhatAnchor::sign` - takes `&self`, so the obvious workaround *is* the dangerous one. `ExpAnchor` - holds an RNG behind a `RefCell` instead. -2. **Anchor state must be single-use.** `IhatAnchor::prove` consuming - `Self::State` by value enforces it at the type level. + the usual advice (RFC 6979, Ed25519), which makes it a live footgun. + `IhatAnchor::sign` takes `&self`, so the obvious workaround *is* the dangerous + one; `ExpAnchor` holds an RNG behind a `RefCell` instead. +2. **Anchor state must be single-use.** `IhatAnchor::prove` consuming `Self::State` + by value enforces it at the type level. + +The protocols draft tells only the *Client* "It MUST NOT retry with the same state". +PR #39 adds a Security Considerations section covering only single-show +unlinkability and a PQ public-key caveat, so there is now an obvious home for this. -The protocols draft tells the *Client* "It MUST NOT retry with the same state" and -says nothing to the Anchor. PR #39 adds a Security Considerations section covering -only single-show unlinkability and a PQ public-key caveat — so there is now an -obvious place to put this. +## Validation -## What the tests establish, and what they do not +### Cross-verification against `ihat-rs` -`tests/exp_ihat_profile.rs` — fourteen tests: +`interop/` is a detached package — separate because it needs a git dependency and +Cargo cannot make dev-dependencies optional — pinned to a specific upstream +revision. Four tests, run in `IhatRsCompatible` mode: + +- `ihat-rs` parses and accepts presentations `mole-exp` produced, for every issuer + in a four-Anchor set, and **re-encodes them to byte-identical output** +- `mole-exp` parses and accepts presentations `ihat-rs` produced, recovering the + same nullifier and endorsement context +- Anchor public keys round-trip between the two implementations in both directions +- neither implementation accepts a substituted accepted set + +Since neither the drafts nor `ihat-rs` publish test vectors, this stands in for +them — and it is stronger, because it exercises both roles rather than fixed byte +strings. + +``` +cargo test --manifest-path interop/Cargo.toml +``` + +### Property tests + +`tests/exp_ihat_profile.rs` — 21 tests: - the full two-exchange grant, then a presentation a verifier accepts -- blindness, asserted against the actual request bytes: neither the nullifier nor - the epoch appears in anything the Anchor receives, and the two request bodies - are exactly 33 and 32 bytes -- issuer hiding across a three-Anchor set, with presentation size invariant to - which branch is real +- **grant message widths** match `ihat-rs`'s wire format exactly: 33 + 2 + |ctx|, + 4×33, 32, 3×32 +- **presentation layout** matches: separate `nf` and `endorsement_context`, a + declared OR-proof length that is a multiple of 97 +- **the four DSTs** are pinned verbatim, because a silent drift breaks everything + and nothing else would catch it +- blindness, asserted against the actual request bytes: the nullifier appears in + neither request. The context *does* appear in the first, by design — the Anchor + needs it to derive `H` +- issuer hiding across a three-Anchor set, size invariant to the real branch +- both binding modes, including that they do not cross-verify - unlinkability: two grants share no field -- challenge binding: a different digest fails -- public verifiability: the verifier is constructed from nothing -- Anchor keys are genuine SEC1 compressed P-256 points, both parities occurring -- the identity is refused rather than truncated into a 33-byte lookalike -- an Anchor outside the accepted set is rejected; so is substituting the set +- a context mismatch fails the Pedersen check at finalize +- an Anchor that used the wrong key, and a corrupted `Z'`, are both caught by the + DLEQ relations at finalize +- Anchor keys are SEC1 compressed with both parities occurring +- an identity key in the accepted set is refused - every single-byte flip in a presentation is detected -- a corrupted DLEQ proof is caught by the Client -- malformed and truncated input is rejected without panicking +- malformed, truncated, and ragged input is rejected without panicking + +### What none of this establishes -All fourteen passing means the algebra closes over P-256 and the profile is -self-consistent. +That the construction is sound. `ihat-rs` has not been audited, there is no security +proof and no public cryptanalysis, upstream PR #38 patched a "PQ tagging attack", +and PR #39 is open. Interoperability is agreement, not correctness — if the +construction is flawed, both implementations are flawed identically. -It does **not** mean the profile agrees with the eventual specification — no test -vectors exist ([#10]) — and it is not cryptanalysis. The construction has no -security proof and no public review, and it is still moving: PR #38 patched a "PQ -tagging attack" and PR #39 is open. The two grant exchanges still have no -correlation mechanism ([#11]); PR #39 now defines all four grant messages and none -carries a session identifier, which forecloses that option by omission. In this -profile correlation is implicit in the Rust call sequence, which is exactly what a -network protocol cannot rely on. +The two grant exchanges also still have no correlation mechanism ([#11]). PR #39 +now defines all four grant messages and none carries a session identifier, which +forecloses that option by omission. In this profile correlation is implicit in the +Rust call sequence — exactly what a network protocol cannot rely on. ## Building ``` cargo test --features exp-ihat cargo build --no-default-features --features exp-ihat --target thumbv7em-none-eabi +cargo test --manifest-path interop/Cargo.toml ``` -The profile is `no_std`-clean. It adds one dependency, `p256` with the `hash2curve` -feature, held at the `0.13` line because `0.14` requires Rust 1.85 against this -crate's 1.75 MSRV; `zeroize` is pinned in `Cargo.lock` for the same reason -([#12]). +`no_std`-clean. One dependency, `p256` with `hash2curve`, held at the `0.13` line +because `0.14` requires Rust 1.85 against this crate's 1.75 MSRV; `zeroize` is +pinned in `Cargo.lock` for the same reason ([#12]). [#1]: https://github.com/OR13/mole/issues/1 -[#2]: https://github.com/OR13/mole/issues/2 -[#3]: https://github.com/OR13/mole/issues/3 [#4]: https://github.com/OR13/mole/issues/4 [#5]: https://github.com/OR13/mole/issues/5 [#6]: https://github.com/OR13/mole/issues/6 [#7]: https://github.com/OR13/mole/issues/7 [#8]: https://github.com/OR13/mole/issues/8 [#9]: https://github.com/OR13/mole/issues/9 -[#10]: https://github.com/OR13/mole/issues/10 [#11]: https://github.com/OR13/mole/issues/11 [#12]: https://github.com/OR13/mole/issues/12 [#13]: https://github.com/OR13/mole/issues/13 diff --git a/README.md b/README.md index e5ea114..8fd9198 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ An experimental Rust implementation of **MoLE** — Moderation of unLinkable Endorsements — covering the parts that the IETF drafts actually specify, plus an -explicitly-guessed IHAT profile that is not interoperable with anything. +IHAT profile that cross-verifies against the reference implementation the MoLE +authors publish. Not published to crates.io, and `publish = false` in the manifest. The `-exp` suffix is load-bearing. @@ -80,14 +81,20 @@ boundary in `src/crypto.rs`. Nothing in the default build invents a scheme. | Feature | What it is | |---|---| | `insecure-mock` | Fakes the *interface* so the encoding and state-machine layers can be tested end to end. No blindness, no issuer hiding, no public verifiability, no unlinkability. A test fixture. | -| `exp-ihat` | Implements the *construction* from the upstream crypto draft over **P-256**, with the six parameters it still leaves open guessed. Blindness, issuer hiding, public verifiability and unlinkability are real. Interoperability is not. | +| `exp-ihat` | Implements the *construction* over **P-256**, aligned byte-for-byte with [`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs), the reference implementation published by a MoLE draft author. **Cross-verified in both directions.** Blindness, issuer hiding, public verifiability and unlinkability are real. | Neither is deployable. `exp-ihat` is the more dangerous of the two precisely -because it works: it produces valid-looking endorsements on a real curve, under -parameters this crate had to fill in, against a construction with no security proof -and no public cryptanalysis. Every guess is inventoried in -[`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md) and filed as an open issue -([#13](https://github.com/OR13/mole/issues/13) indexes them). +because it works, and now interoperates: it produces endorsements the authors' own +implementation accepts, for a construction that `ihat-rs` itself says "has not been +audited" and that has no security proof or public cryptanalysis. Interoperability is +not soundness. + +One thing cannot be reconciled: `ihat-rs` has **no `challenge_digest` anywhere**, so +protocols §4.1.3's two MUSTs about binding it are satisfied by nobody. The profile +exposes `ChallengeBinding` with two modes — one byte-compatible with `ihat-rs`, one +compliant with §4.1.3 — because they cannot be the same thing until upstream +decides. See [`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md) and +[#13](https://github.com/OR13/mole/issues/13). ## What is implemented @@ -103,7 +110,7 @@ and no public cryptanalysis. Every guess is inventoried in | Greasing: value selection, and grease-as-unknown on the Moderator | ✅ | | Epoch nullifier store, single-use enforcement, token nonce replay | ✅ | | 200/401/403 challenge semantics | ✅ | -| IHAT cryptography | 🟡 trait boundary; `exp-ihat` implements it over P-256 under partly guessed parameters | +| IHAT cryptography | 🟡 trait boundary; `exp-ihat` implements it over P-256, cross-verified against `ihat-rs` | | Longfellow proving/verifying | ❌ needs `draft-google-cfrg-libzk` + circuit | | ACT spend/refund | ❌ needs `draft-schlesinger-cfrg-act` | | Privacy Pass issuance (VOPRF, batched tokens) | ❌ needs RFC 9578 impl | @@ -183,7 +190,8 @@ That is `moderator::NonceStore`, kept separate from the epoch `NullifierStore`. ## Tests ``` -cargo test --all-features # 102 tests +cargo test --all-features # 109 tests +cargo test --manifest-path interop/Cargo.toml # 4 cross-verification tests cargo clippy --all-features --all-targets RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps cargo build --no-default-features # no_std @@ -196,9 +204,15 @@ only rustdoc will catch. `tests/wire_vectors.rs` holds byte-exact hex vectors — the drafts publish none, so these are this crate's, offered for cross-implementation checking. `tests/appendix_a_flow.rs` drives the complete exchange from Appendix A of the -protocols draft. `tests/exp_ihat_profile.rs` exercises the experimental profile; -there are deliberately no wire vectors for it, because any vectors would encode -this crate's guesses as though they were the specification. +protocols draft. `tests/exp_ihat_profile.rs` exercises the experimental profile. + +`interop/` is a detached package that differential-tests the profile against +`ihat-rs` in both directions — our presentations verify under theirs, theirs verify +under ours, and re-encoding is byte-identical. Since neither the drafts nor +`ihat-rs` publish test vectors, that cross-check stands in for them, and it is +stronger: it exercises both roles rather than fixed byte strings. It lives in its +own package because it needs a git dependency and Cargo cannot make +dev-dependencies optional. ## Drafts tracked @@ -209,7 +223,14 @@ Jackson (Mozilla), and Thibault Meunier (Cloudflare): - [`draft-jms-mole-protocols-00`](https://datatracker.ietf.org/doc/draft-jms-mole-protocols/) - [`draft-jms-mole-http-transport-00`](https://datatracker.ietf.org/doc/draft-jms-mole-http-transport/) -Plus, for the `exp-ihat` profile only, +For the `exp-ihat` profile the normative artifact is +[`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs) +(pinned by revision in `interop/Cargo.toml`), not the prose. Where the two disagree +— the issuance transcript, the Pedersen generator, point compression, the OR proof, +the existence of `m` — the profile follows the code, because the code is executable +and the draft is not. + +Also relevant, [`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md) — repository `main` as of 28 July 2026, with the group and second generator taken from open [PR #39](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/pull/39). diff --git a/SPEC-COVERAGE.md b/SPEC-COVERAGE.md index 71988a6..083ae98 100644 --- a/SPEC-COVERAGE.md +++ b/SPEC-COVERAGE.md @@ -18,7 +18,7 @@ Legend: ✅ implemented · 🟡 partial, see note · ⬜ structures only, semant | 3.2 | Greasing | ✅ | `client::grease_presentation` picks from the 16 reserved values with a random body; `client::choose_presentation` also declines optional challenges. `moderator::dispatch` cannot special-case grease. | | 3.3 | Challenge Binding | ✅ | `binding::ChallengeDigest`. SHA-256 over the TLS-presentation octets; `from_base64url` decodes before hashing. | | 4 | Endorsement Protocols | ✅ | `endorsement::{EndorsementRequest, EndorsementResponse}` + both media type constants. | -| 4.1 | IHAT | 🟡 | `endorsement::ihat` has `Challenge` (ordered `Point keys`) and `Presentation`. Crypto is the trait boundary `crypto::{IhatAnchor, IhatClient, IhatVerifier}`; `crypto::exp_ihat` implements it under guessed parameters (`EXP-IHAT-PROFILE.md`). `[CRYPTO]` exists upstream but is unsubmitted and not interoperably specified. | +| 4.1 | IHAT | 🟡 | `endorsement::ihat` has `Challenge` (ordered `Point keys`) and `Presentation`. Crypto is the trait boundary `crypto::{IhatAnchor, IhatClient, IhatVerifier}`; `crypto::exp_ihat` implements it over P-256, cross-verified in both directions against [`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs) (`interop/`). `[CRYPTO]` itself remains unsubmitted and not interoperably specified — the reference implementation, not the prose, is what this crate follows. | | 4.1.1 | Configuration | 🟡 | `pkA` and `endorsement_context` are trait/API parameters. There is no config-document parser because §6 does not define one. | | 4.1.2 | Grant | 🟡 | Two exchanges, `Prepare`/`Sign`/`RequestProof`/`Prove`/`Finalize` as trait methods, implemented by `exp_ihat`. `finalize` takes state **by value**, enforcing "MUST NOT retry with the same state." The draft's open TODO on correlating the two exchanges is unresolved upstream ([#11]). The draft also fails to require the *Anchor* to use fresh per-session nonces, which key recovery depends on ([#9]). | | 4.1.3 | Redemption | ✅ | `moderator::Moderator::redeem` runs the checks in order: verify, epoch, nullifier freshness. Key order is preserved by `ihat::Challenge`, and `exp_ihat`'s OR proof matches branches to keys by position as required. | @@ -80,19 +80,25 @@ and are not specification requirements: probability" without fixing a value. 2. **The wire vectors** in `tests/wire_vectors.rs`. The drafts publish none. -**The `exp-ihat` feature is the exception.** It implements IHAT over **P-256** — -the group both MoLE drafts point at — using RFC 9380 `P256_XMD:SHA-256_SSWU_RO_` -for hash-to-curve and `Hash2Curve("IHAT second generator")` for the Pedersen -generator, both as specified. Six things remain guessed or deviated: point -compression ([#1], the two drafts disagree), the DST ([#2], none is specified), the -`e = H(...)` transcript ([#3], [#4], where the profile does not yet use the SHAKE128 -sponge the crypto draft now points at), `C'` ([#5], still undefined upstream), the -OR proof ([#6], the profile keeps a textbook CDS rather than PR #39's unmerged -commitment tree), `m` ([#7]) and `challenge_digest` ([#8]) — the last two being -places where following the crypto draft would produce something that cannot satisfy -§4.1.3 at all. It is off by default, `publish = false`, and every item is an open -issue indexed by [#13]. See `EXP-IHAT-PROFILE.md`. Nothing in that profile should be -read as a claim about what MoLE specifies. +**The `exp-ihat` feature is the exception, and it now follows code rather than +prose.** It implements IHAT over **P-256**, aligned byte-for-byte with `ihat-rs`, +the reference implementation published in the MoLE org by a draft author. Group, +point encoding, DSTs, hash-to-curve suite, the Pedersen generator, both Fiat–Shamir +transcripts and the wire framing all come from there, and `interop/` verifies in +both directions that presentations cross-validate and re-encode identically. + +Where `ihat-rs` and the drafts disagree the profile follows `ihat-rs`, and each +disagreement is an open issue: point compression ([#1]), the issuance transcript +binding `X̂` and the context ([#4]), the Pedersen generator being per-context rather +than global ([#5]), the OR proof being `O(n)` CDS rather than PR #39's commitment +tree ([#6]), and `m` not existing at all ([#7]). + +**One thing cannot be reconciled.** `ihat-rs` has no `challenge_digest` anywhere, so +§4.1.3's two MUSTs about binding it are satisfied by no implementation. The profile +exposes `ChallengeBinding` with a mode for each side of that contradiction ([#8]). +It is off by default, `publish = false`, and everything is indexed by [#13]. See +`EXP-IHAT-PROFILE.md`. Nothing in the profile should be read as a claim about what +MoLE specifies — only about what its authors' code does. ## Things the drafts leave open @@ -103,8 +109,9 @@ them: 2026, PR #38) but is **not** on the Datatracker and is not an interoperable specification: no group, no hash, no DSTs, no wire structures, no test vectors, and PR #39 is still open to "try to define proof". IHAT can be *implemented* — - `crypto::exp_ihat` does over P-256 — but not interoperably, and not without - filling in six parameters it still does not supply ([#13]). + `crypto::exp_ihat` does over P-256 — but by following `ihat-rs`, the authors' + reference implementation, which settles the group, DSTs, encodings and transcripts + the draft leaves open, and which departs from the draft in five places ([#13]). - IHAT's two grant exchanges have no defined correlation mechanism — the draft offers "either `[CRYPTO]` adds a session identifier … or this document mandates connection reuse" and picks neither ([#11]). This is the sharpest gap, and it is diff --git a/interop/Cargo.lock b/interop/Cargo.lock new file mode 100644 index 0000000..029b757 --- /dev/null +++ b/interop/Cargo.lock @@ -0,0 +1,247 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "ihat-rs" +version = "0.1.0" +source = "git+https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs?rev=38673f12ab7ad215a5f3dbe703f893304323e393#38673f12ab7ad215a5f3dbe703f893304323e393" +dependencies = [ + "elliptic-curve", + "p256", + "rand_core", + "sha2", + "subtle", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "mole-exp" +version = "0.1.0" +dependencies = [ + "base64", + "p256", + "sha2", +] + +[[package]] +name = "mole-exp-interop" +version = "0.0.0" +dependencies = [ + "ihat-rs", + "mole-exp", + "p256", + "rand_core", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "primeorder", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/interop/Cargo.toml b/interop/Cargo.toml new file mode 100644 index 0000000..ee87c9a --- /dev/null +++ b/interop/Cargo.toml @@ -0,0 +1,29 @@ +# A detached package, deliberately NOT a workspace member of the crate above it. +# +# It exists to differential-test `mole-exp`'s `exp-ihat` profile against +# `ihat-rs`, the reference implementation published by a MoLE draft author. That +# needs a git dependency, and Cargo cannot make dev-dependencies optional — so +# keeping this separate is what stops `cargo test` on the main crate from +# requiring network access or growing its dependency tree. +# +# Run it with: +# cargo test --manifest-path interop/Cargo.toml +[workspace] + +[package] +name = "mole-exp-interop" +version = "0.0.0" +edition = "2021" +rust-version = "1.75" +publish = false +description = "Differential tests between mole-exp's exp-ihat profile and the ihat-rs reference implementation" + +[dependencies] + +[dev-dependencies] +mole = { package = "mole-exp", path = "..", features = ["exp-ihat"] } +# Pinned to a revision so a change upstream shows up as a deliberate bump here +# rather than as a mysterious CI failure. +ihat = { package = "ihat-rs", git = "https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs", rev = "38673f12ab7ad215a5f3dbe703f893304323e393" } +p256 = { version = "0.13", default-features = false, features = ["arithmetic"] } +rand_core = "0.6" diff --git a/interop/tests/interop.rs b/interop/tests/interop.rs new file mode 100644 index 0000000..23b51b1 --- /dev/null +++ b/interop/tests/interop.rs @@ -0,0 +1,196 @@ +//! Differential tests: does `mole-exp`'s `exp-ihat` profile actually speak the +//! same protocol as `ihat-rs`? +//! +//! `ihat-rs` is the reference implementation published in the MoLE org by Samuel +//! Schlesinger, one of the draft authors. Neither it nor the drafts publish test +//! vectors, so cross-verification against it is the strongest validation available +//! — considerably stronger than vectors, since it exercises both roles. +//! +//! These tests run in [`ChallengeBinding::IhatRsCompatible`] mode. `ihat-rs` has no +//! `challenge_digest`, so `MoleBound` presentations deliberately do *not* +//! cross-verify; that asymmetry is asserted in the main crate's test suite and +//! tracked at . + +use ihat::anchor::{AnchorPublicKey, AnchorSecretKey}; +use ihat::client::ClientNeedsSignature; +use ihat::{Params, Presentation, WireFormat}; +use mole::binding::ChallengeDigest; +use mole::client::DeterministicRng; +use mole::crypto::exp_ihat::{ChallengeBinding, ExpAnchor, ExpClient, ExpVerifier}; +use mole::crypto::{IhatAnchor, IhatVerifier}; +use mole::endorsement::Point; +use rand_core::{CryptoRng, Error as RandError, RngCore}; + +const EPOCH: &[u8] = b"2026-07-28T00:00:00Z/PT24H"; +const ANCHORS: usize = 4; + +/// A deterministic RNG so any failure is reproducible. Test-only; not secure. +struct TestRng(u64); + +impl RngCore for TestRng { + fn next_u32(&mut self) -> u32 { + (self.next_u64() >> 32) as u32 + } + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + fn fill_bytes(&mut self, dest: &mut [u8]) { + for b in dest.iter_mut() { + *b = (self.next_u64() >> 24) as u8; + } + } + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), RandError> { + self.fill_bytes(dest); + Ok(()) + } +} +impl CryptoRng for TestRng {} + +fn compat_digest() -> ChallengeDigest { + // Unused in IhatRsCompatible mode — `ihat-rs` binds no digest. + ChallengeDigest::new(b"unbound-in-compat-mode") +} + +/// `mole-exp` issues and presents; `ihat-rs` parses and accepts. +#[test] +fn ihat_rs_accepts_mole_exp_presentations() { + let pp = Params::standard(); + let anchors: Vec> = (0..ANCHORS) + .map(|i| { + ExpAnchor::new([i as u8 + 1; 32], DeterministicRng::new(1000 + i as u64)).unwrap() + }) + .collect(); + let keys: Vec = anchors.iter().map(ExpAnchor::public_key).collect(); + let accepted: Vec = keys + .iter() + .map(|k| AnchorPublicKey::from_bytes(&k.0).expect("our key decodes under ihat-rs")) + .collect(); + + for issuer in 0..ANCHORS { + let mut client = ExpClient::with_binding( + DeterministicRng::new(7 + issuer as u64), + ChallengeBinding::IhatRsCompatible, + ); + let bytes = client + .grant_and_present(&anchors[issuer], EPOCH, &keys, &compat_digest()) + .expect("mole-exp grant and present"); + + let presentation = Presentation::from_bytes(&bytes) + .unwrap_or_else(|e| panic!("ihat-rs could not parse our presentation: {e:?}")); + assert!( + presentation.verify(&pp, &accepted), + "ihat-rs rejected a mole-exp presentation (issuer {issuer})" + ); + // Canonical encoding: `ihat-rs` re-serializes to the same bytes we produced. + assert_eq!( + presentation.to_bytes().expect("ihat-rs re-encodes"), + bytes, + "re-encoding is not byte-identical (issuer {issuer})" + ); + } +} + +/// `ihat-rs` issues and presents; `mole-exp` parses and accepts, recovering the +/// same nullifier and context. +#[test] +fn mole_exp_accepts_ihat_rs_presentations() { + let pp = Params::standard(); + let mut rng = TestRng(99); + let anchors: Vec = (0..ANCHORS) + .map(|_| AnchorSecretKey::random(&mut rng)) + .collect(); + let accepted: Vec = anchors.iter().map(|k| k.public_key(&pp)).collect(); + let keys: Vec = accepted + .iter() + .map(|k| { + Point( + k.to_bytes() + .expect("ihat-rs encodes") + .try_into() + .expect("33 bytes"), + ) + }) + .collect(); + let verifier = ExpVerifier::with_binding(ChallengeBinding::IhatRsCompatible); + + for issuer in 0..ANCHORS { + let mut nf = [0u8; 32]; + rng.fill_bytes(&mut nf); + + let (request, client) = + ClientNeedsSignature::request(nf.to_vec(), EPOCH.to_vec(), &mut rng); + let (signature, anchor) = request.sign(&pp, &anchors[issuer], &mut rng); + let (proof_request, client) = + client.request_proof(&pp, anchors[issuer].public_key(&pp), signature); + let proof = anchor.prove(proof_request); + let issued = client.finalize(&pp, proof).expect("ihat-rs issuance succeeds"); + let bytes = issued + .show(&accepted, issuer, &mut rng) + .to_bytes() + .expect("ihat-rs encodes"); + + let verified = verifier + .verify(&bytes, &keys, &compat_digest()) + .unwrap_or_else(|e| panic!("mole-exp rejected an ihat-rs presentation: {e:?}")); + assert_eq!(verified.nullifier, nf.to_vec(), "nullifier mismatch"); + assert_eq!(verified.endorsement_context, EPOCH, "context mismatch"); + } +} + +/// The two implementations agree on the Anchor key encoding, in both directions. +#[test] +fn anchor_public_keys_round_trip_between_implementations() { + let pp = Params::standard(); + let mut rng = TestRng(7); + + for _ in 0..8 { + // ihat-rs -> mole-exp -> ihat-rs + let sk = AnchorSecretKey::random(&mut rng); + let bytes = sk.public_key(&pp).to_bytes().expect("encodes"); + let ours = Point(bytes.clone().try_into().expect("33 bytes")); + assert_eq!(ours.0.to_vec(), bytes); + assert!(AnchorPublicKey::from_bytes(&ours.0).is_ok()); + } + + for i in 0..8u8 { + // mole-exp -> ihat-rs -> mole-exp + let a = ExpAnchor::new([i + 1; 32], DeterministicRng::new(u64::from(i))).unwrap(); + let ours = a.public_key(); + let theirs = AnchorPublicKey::from_bytes(&ours.0).expect("decodes"); + assert_eq!(theirs.to_bytes().expect("encodes"), ours.0.to_vec()); + } +} + +/// A presentation from one implementation fails under the other's verifier when the +/// accepted set is substituted — confirming both bind the set, not just the shape. +#[test] +fn neither_implementation_accepts_a_substituted_accepted_set() { + let pp = Params::standard(); + let anchors: Vec> = (0..2) + .map(|i| ExpAnchor::new([i as u8 + 1; 32], DeterministicRng::new(50 + i as u64)).unwrap()) + .collect(); + let keys: Vec = anchors.iter().map(ExpAnchor::public_key).collect(); + + let mut client = + ExpClient::with_binding(DeterministicRng::new(3), ChallengeBinding::IhatRsCompatible); + let bytes = client + .grant_and_present(&anchors[0], EPOCH, &keys, &compat_digest()) + .expect("grant and present"); + + // A different set of the same size, presented to ihat-rs. + let other: Vec = (10..12u8) + .map(|i| { + let a = ExpAnchor::new([i; 32], DeterministicRng::new(u64::from(i))).unwrap(); + AnchorPublicKey::from_bytes(&a.public_key().0).unwrap() + }) + .collect(); + let presentation = Presentation::from_bytes(&bytes).expect("parses"); + assert!( + !presentation.verify(&pp, &other), + "ihat-rs accepted a substituted accepted set" + ); +} diff --git a/src/crypto.rs b/src/crypto.rs index 3496a2c..87cfda0 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -21,9 +21,12 @@ //! need a correlation mechanism. //! //! `crypto::exp_ihat` (feature `exp-ihat`) implements the construction anyway, over -//! P-256 — the group the drafts point at — filling in the parameters they still do -//! not supply. It is not interoperable with anything and exists to find out what -//! implementing IHAT requires. +//! P-256, by following +//! [`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs) — +//! the reference implementation published in the MoLE org by a draft author — rather +//! than the draft's prose. It settles the group, the DSTs, the encodings and both +//! Fiat–Shamir transcripts that the draft leaves open, and `interop/` cross-verifies +//! in both directions. It departs from the draft in five places, each an open issue. //! //! Longfellow (Section 4.2) similarly requires the proving system of //! `draft-google-cfrg-libzk` plus an out-of-band circuit, and ACT (Section 5.1) diff --git a/src/crypto/exp_ihat.rs b/src/crypto/exp_ihat.rs index 378e24d..8af626e 100644 --- a/src/crypto/exp_ihat.rs +++ b/src/crypto/exp_ihat.rs @@ -1,87 +1,99 @@ -//! **EXPERIMENTAL AND NOT INTEROPERABLE.** The IHAT construction from the -//! upstream `draft-authors-mole-crypto.md`, over P-256, with the parameters the -//! draft still leaves open guessed by this crate. +//! **EXPERIMENTAL.** IHAT over NIST P-256, aligned byte-for-byte with the +//! reference implementation the MoLE authors publish. //! //! # What this is //! -//! Unlike [`super::insecure_mock`], this is a real implementation of the real -//! construction over a real curve. The blinding works, the endorsement is publicly -//! verifiable, the Anchor never learns the nullifier, and issuer hiding is a -//! genuine 1-of-n OR proof. The algebra is the draft's algebra. +//! Unlike [`super::insecure_mock`], this is the real construction over a real +//! curve: blinding works, the Anchor never learns the nullifier, issuer hiding is +//! a genuine 1-of-n OR proof, and verification needs only public keys. //! -//! # What it aligns with +//! It follows +//! [`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs) +//! — "Implementation of the endorsement issuance and redemption protocols", by +//! Samuel Schlesinger, one of the MoLE draft authors — rather than the prose of +//! `draft-authors-mole-crypto.md`. Where the two disagree, `ihat-rs` wins: it is +//! executable and the draft is not, and interoperating with it is the only +//! validation available, since neither publishes test vectors. //! -//! The group is **P-256**, which is what the drafts point at, so the parts of this -//! profile that used to be inventions are now the specification: +//! Taken from `ihat-rs`, and therefore *not* guesses: //! -//! * A point is SEC1 **compressed**, 33 bytes — exactly the `opaque Point[33]` of -//! `draft-jms-mole-protocols-00` §4.1. There is no padding or tagging. -//! * Hash-to-curve is RFC 9380 `P256_XMD:SHA-256_SSWU_RO_`, which is what the -//! crypto draft's "RO based Hash2Curve function" resolves to over P-256 — and it -//! hashes with SHA-256, so the hash is the suite's, not a choice. -//! * Scalars come from RFC 9380 `hash_to_field`, expanding to 48 bytes for P-256 -//! before reduction, so the draft's `H` "whose output length is sufficiently -//! long" is satisfied by construction. -//! * The Pedersen second generator is `Hash2Curve("IHAT second generator")`, the -//! literal definition added by upstream PR #39. +//! | | | +//! |---|---| +//! | Group | NIST P-256 | +//! | `Point` | SEC1 compressed, 33 bytes — the `opaque Point[33]` of protocols §4.1 | +//! | `Scalar` | 32 bytes big-endian | +//! | Hash-to-group | RFC 9380 `P256_XMD:SHA-256_SSWU_RO_` | +//! | Hash-to-scalar | RFC 9380 `hash_to_field` | +//! | Domain separation | `MOLE-IHAT-P256::v1`, four fixed tags | +//! | `Y` | `H₁(nf)` — hash-to-group of the raw nullifier | +//! | Pedersen generator | `H(endorsement_context)`, **context-bound** | +//! | Issuance transcript | `e = H_FS(X̂, Y, Ẑ, T₁, T₂, C, endorsement_context)` | +//! | OR proof | CDS 1-of-n, one `(t, c, s)` transcript per accepted key | +//! | Wire framing | TLS presentation language, `VarBytes` = two-byte big-endian | +//! +//! # Where `ihat-rs` departs from the drafts +//! +//! These are the interesting parts, and this module follows `ihat-rs` on all of +//! them: +//! +//! * **The issuance transcript binds more than the draft's.** The crypto draft says +//! `e = H(Y, Zhat, T1, T2, C)`. `ihat-rs` additionally binds `X̂` and +//! `endorsement_context`, commenting that this is "strengthened to bind the +//! *full* statement: the rerandomised key `X_hat` — so a proof cannot be +//! transported to a different `X_hat`". See [#4]. +//! * **There is no separate DLEQ proof.** The draft has the Anchor send "a proof in +//! the manner of \[DLEQ\]" in exchange one. `ihat-rs` sends none, because +//! `(T₁', T₂', r')` already is one: `finalize` checks `Y'·r' = Z'·e'a' + T₁'` and +//! `G·r' = X·e'a' + T₂'`, which together prove the Anchor used a single `x` for +//! both `Z' = x·Y'` and `X = x·G`. A separate proof would be redundant. +//! * **The Pedersen generator is per-context, not fixed.** Upstream PR #39 defines +//! `H = Hash2Curve("IHAT second generator")`, one global generator. `ihat-rs` +//! derives `H` from `endorsement_context`, binding the context into `C` +//! structurally. It is load-bearing: a context mismatch makes the Client's +//! Pedersen check fail at finalize. See [#5]. +//! * **Points are compressed.** PR #39 declares `uncompressed bytes[65]`; +//! `ihat-rs` and protocols §4.1 both use 33-byte compressed. See [#1]. +//! * **The OR proof is `O(n)` CDS.** PR #39 specifies an `O(log n)` trapdoor +//! commitment tree. `ihat-rs` uses one Schnorr transcript per key. See [#6]. +//! * **`m` does not exist.** The draft's `m` is just the nullifier: `ihat-rs` +//! carries `nf` and `endorsement_context` as two separate fields and computes +//! `Y = H₁(nf)`. See [#7]. //! //! # Why you still must not deploy it //! -//! Six things remain guessed, because the crypto draft still does not supply them: +//! `ihat-rs` says it of itself: "This code has not been audited." The construction +//! has no security proof and no public cryptanalysis, and it is still moving — +//! upstream PR #38 patched a "PQ tagging attack" and PR #39 is open. //! -//! | What the drafts leave open | What this profile does | Issue | -//! |---|---|---| -//! | The two MoLE drafts disagree on point compression: `Point[33]` vs PR #39's `bytes[65]` | compressed, matching the protocols draft the rest of this crate implements | [#1] | -//! | No domain separation tag, though RFC 9380 requires one | `OR13_EXP_MOLE-V01-…-with-P256_XMD:SHA-256_SSWU_RO_` | [#2] | -//! | `e = H(Y, Zhat, T1, T2, C)` sits outside the `draft-irtf-cfrg-fiat-shamir` framework | `hash_to_field` under its own DST | [#3] | -//! | Sigma transcripts should use the SHAKE128 sponge and group codec of `draft-irtf-cfrg-fiat-shamir` | canonical SEC1 concatenation, ordered as the draft writes it | [#4] | -//! | `C'` is used before it is defined | assumed Pedersen `a'G + b'H` | [#5] | -//! | PR #39 specifies a one-of-N trapdoor commitment tree at `O(log n)` | textbook CDS OR proof, `O(n)` | [#6] | -//! | `m` is typed `Scalar`, which cannot carry `endorsement_context` | `m = opaque(endorsement_context) \|\| nf[32]` | [#7] | -//! | `challenge_digest` appears in no structure or challenge upstream | bound into the OR-proof transcript | [#8] | +//! And one thing cannot be reconciled. Protocols §4.1.3 requires that "`Present` +//! MUST bind `challenge_digest` into the proof transcript" and that "`Verify` MUST +//! fail when given any other `challenge_digest`". **`ihat-rs` has no +//! `challenge_digest` anywhere** — not in a structure, not in a transcript, not in +//! its API. So byte-compatibility with the reference implementation and compliance +//! with the MoLE drafts are mutually exclusive. [`ChallengeBinding`] makes that +//! choice explicit rather than hiding it. See [#8]. //! -//! Two further gaps are tracked but not closeable here: there are no test vectors -//! to validate against ([#10]) and the two grant exchanges still have no -//! correlation mechanism ([#11]). [#13] indexes all of it. +//! # A security property no draft states //! -//! [#4] and [#6] are deliberate deviations from things PR #39 *does* specify, held -//! because that PR is unmerged, carries its own `TODO` on the OR proof, and would -//! pull in two further CFRG drafts. [#7] and [#8] are places where following the -//! draft would produce something that cannot satisfy the protocols draft at all. +//! The Anchor MUST draw `a'`, `b'`, `t'` freshly per session. If a Client induces +//! reuse of `t'` and `a'` across two sessions, it obtains `r'_1 = t' + e'_1 a' x` +//! and `r'_2 = t' + e'_2 a' x` for chosen `e'_1 != e'_2`; subtracting yields `a'x`, +//! and `a'` is sent in the clear, so the Anchor's secret key falls out. This rules +//! out deriving the nonces deterministically from the request, which is why +//! [`ExpAnchor`] holds an RNG. See [#9]. //! //! [#1]: https://github.com/OR13/mole/issues/1 -//! [#2]: https://github.com/OR13/mole/issues/2 -//! [#3]: https://github.com/OR13/mole/issues/3 //! [#4]: https://github.com/OR13/mole/issues/4 //! [#5]: https://github.com/OR13/mole/issues/5 //! [#6]: https://github.com/OR13/mole/issues/6 //! [#7]: https://github.com/OR13/mole/issues/7 //! [#8]: https://github.com/OR13/mole/issues/8 //! [#9]: https://github.com/OR13/mole/issues/9 -//! [#10]: https://github.com/OR13/mole/issues/10 -//! [#11]: https://github.com/OR13/mole/issues/11 -//! [#13]: https://github.com/OR13/mole/issues/13 -//! -//! Beyond the guesses, the construction itself is still moving: upstream PR #38 -//! patched a "PQ tagging attack" and PR #39 ("Add more text to try to define -//! proof") is open at the time of writing. It has had no public cryptanalysis and -//! no security proof. `EXP-IHAT-PROFILE.md` records all of this in full. -//! -//! # A security property the draft does not state -//! -//! The Anchor MUST draw `a'`, `b'`, `t'` freshly per session and MUST NOT reuse -//! them. If a Client can induce the Anchor to reuse `t'` and `a'` across two -//! sessions, it obtains `r'_1 = t' + e'_1 a' x` and `r'_2 = t' + e'_2 a' x` for -//! chosen `e'_1 != e'_2`; subtracting yields `a'x`, and `a'` is sent in the clear, -//! so the Anchor's secret key `x` falls out. This rules out deriving the nonces -//! deterministically from the request, which is why [`ExpAnchor`] holds an RNG. -//! The draft says nothing about it — see [#9], which is worth reporting upstream. - -// This module's identifiers are the draft's identifiers. `x`, `G`, `k`, `a'`, -// `b'`, `t'`, `T1'`, `T2'` are the names the construction is written in, and the -// whole point of a reference implementation is that a reader can check it against -// the document line by line. Renaming them to satisfy a similarity heuristic -// would cost exactly the property that makes this code auditable. + +// This module's identifiers are the construction's identifiers. `x`, `G`, `k`, +// `a'`, `b'`, `t'`, `T1'`, `T2'` are the names the protocol is written in, in both +// the draft and `ihat-rs`, and the point of a reference implementation is that a +// reader can check it against those line by line. #![allow( clippy::many_single_char_names, clippy::similar_names, @@ -92,152 +104,281 @@ use core::cell::RefCell; use alloc::vec::Vec; -use p256::elliptic_curve::hash2curve::{ExpandMsgXmd, GroupDigest}; -use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}; +use p256::elliptic_curve::group::{Group, GroupEncoding}; +use p256::elliptic_curve::hash2curve::{hash_to_field, ExpandMsgXmd, GroupDigest}; +use p256::elliptic_curve::subtle::{Choice, ConditionallySelectable, ConstantTimeEq}; use p256::elliptic_curve::{Field, PrimeField}; -use p256::{EncodedPoint, NistP256, ProjectivePoint, Scalar}; +use p256::{NistP256, ProjectivePoint, Scalar}; use sha2::Sha256; use crate::binding::ChallengeDigest; -use crate::codec::{encode_varint, write_opaque_v, Reader}; use crate::crypto::{IhatAnchor, IhatClient, IhatVerifier, RandomSource, VerifiedEndorsement}; use crate::endorsement::Point; use crate::error::{Error, Result}; -/// The base domain separation string for this profile. -/// -/// Chosen by this crate. The drafts specify no DST, and RFC 9380 requires one to -/// be protocol-unique — so this value deliberately names the implementation -/// rather than MoLE, because it is not MoLE's. -pub const DST_BASE: &[u8] = b"OR13_EXP_MOLE"; +// --------------------------------------------------------------------------- +// Domain separation — verbatim from `ihat-rs` +// --------------------------------------------------------------------------- -/// The RFC 9380 suite this profile uses, named in every DST. -/// -/// The crypto draft asks for "the RO based Hash2Curve function" over a group the -/// architecture draft is supposed to fix as P-256. That combination is exactly -/// this suite, and it hashes with SHA-256. -pub const SUITE_ID: &[u8] = b"P256_XMD:SHA-256_SSWU_RO_"; - -// Per RFC 9380 Section 3.1 a DST is unique to the protocol *and* names the suite, -// so that changing the group forces a different domain. These follow the -// convention of that section's examples. -const DST_H2G_M: &[u8] = b"OR13_EXP_MOLE-V01-h2g-m-with-P256_XMD:SHA-256_SSWU_RO_"; -const DST_H2G_GEN2: &[u8] = b"OR13_EXP_MOLE-V01-h2g-gen2-with-P256_XMD:SHA-256_SSWU_RO_"; -const DST_CHAL_ENDORSEMENT: &[u8] = - b"OR13_EXP_MOLE-V01-chal-endorsement-with-P256_XMD:SHA-256_SSWU_RO_"; -const DST_CHAL_DLEQ: &[u8] = b"OR13_EXP_MOLE-V01-chal-dleq-with-P256_XMD:SHA-256_SSWU_RO_"; -const DST_CHAL_OR: &[u8] = b"OR13_EXP_MOLE-V01-chal-or-with-P256_XMD:SHA-256_SSWU_RO_"; +/// `H₁`, hash-to-group applied to the nullifier. +pub const DST_H1: &[u8] = b"MOLE-IHAT-P256:H1-nullifier-to-group:v1"; +/// The issuance Fiat–Shamir challenge. +pub const DST_FS: &[u8] = b"MOLE-IHAT-P256:fiat-shamir-getend:v1"; +/// The context-bound Pedersen generator `H`. +pub const DST_PEDERSEN: &[u8] = b"MOLE-IHAT-P256:pedersen-generator-H:v1"; +/// The redemption OR-proof challenge. +pub const DST_OR: &[u8] = b"MOLE-IHAT-P256:fiat-shamir-or-proof:v1"; + +/// Scalar sampling. Local to this crate — it never appears on the wire, so it has +/// no bearing on interoperability, and it deliberately does not claim the +/// `MOLE-IHAT` prefix. const DST_RANDOM: &[u8] = b"OR13_EXP_MOLE-V01-rand-with-P256_XMD:SHA-256_SSWU_RO_"; -/// Compressed SEC1 encoding width for P-256 — and exactly the `Point[33]` the -/// protocols draft specifies. +/// SEC1 compressed width for P-256, and the `Point[33]` of protocols §4.1. const POINT_LEN: usize = 33; +/// Big-endian scalar width for P-256. +const SCALAR_LEN: usize = 32; +/// One OR-proof branch: `t`, then `c`, then `s`. +const TRANSCRIPT_LEN: usize = POINT_LEN + SCALAR_LEN + SCALAR_LEN; + +/// Whether `challenge_digest` is bound into the redemption transcript. +/// +/// The MoLE drafts and the reference implementation disagree irreconcilably, so +/// this is a choice the caller makes rather than one this crate can make for them. +/// See the module docs and [issue #8](https://github.com/OR13/mole/issues/8). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChallengeBinding { + /// Byte-compatible with `ihat-rs`: the digest is **not** bound. + /// + /// Presentations produced this way are the reference implementation's, which is + /// what makes differential testing possible. It does **not** satisfy protocols + /// §4.1.3, and a presentation is replayable against any challenge. + IhatRsCompatible, + /// Binds `challenge_digest` into the OR-proof transcript. + /// + /// Satisfies §4.1.3's two MUSTs. Not byte-compatible with `ihat-rs`, which has + /// no such field, so presentations will not cross-verify. + MoleBound, +} // --------------------------------------------------------------------------- // Hashing // --------------------------------------------------------------------------- -/// Hashes to the group, using the RFC 9380 `P256_XMD:SHA-256_SSWU_RO_` suite. -/// -/// This is the suite the crypto draft's "RO based Hash2Curve function" names once -/// the group is P-256, so `expand_message_xmd` with SHA-256 is the specified -/// expansion rather than a choice of this crate's. -fn hash_to_group(msg: &[&[u8]], dst: &[u8]) -> Result { - NistP256::hash_from_bytes::>(msg, &[dst]) +fn hash_to_group(dst: &[u8], msg: &[u8]) -> Result { + NistP256::hash_from_bytes::>(&[msg], &[dst]) .map_err(|_| Error::InvalidGroupElement) } -/// Hashes to a scalar via RFC 9380 `hash_to_field`. +fn hash_to_scalar(dst: &[u8], msgs: &[&[u8]]) -> Result { + let mut out = [Scalar::default()]; + hash_to_field::, Scalar>(msgs, &[dst], &mut out) + .map_err(|_| Error::InvalidGroupElement)?; + Ok(out[0]) +} + +/// `Y = H₁(nf)`. +fn hash_nullifier(nf: &[u8]) -> Result { + hash_to_group(DST_H1, nf) +} + +/// The Pedersen generator for one endorsement context. /// -/// For P-256 that expands to 48 bytes per element before reduction, which is what -/// keeps the result unbiased with respect to the group order. The draft's `H` -/// "whose output length is sufficiently long" is satisfied by construction here -/// rather than by this crate picking a width. -fn hash_to_scalar(msg: &[&[u8]], dst: &[u8]) -> Result { - NistP256::hash_to_scalar::>(msg, &[dst]) - .map_err(|_| Error::InvalidGroupElement) +/// Derived from the context rather than fixed, so the context is bound into `C` +/// structurally: a Client and Anchor that disagree about it derive different `H` +/// and the Pedersen check fails at finalize. +fn pedersen_generator(endorsement_context: &[u8]) -> Result { + hash_to_group(DST_PEDERSEN, endorsement_context) } -/// The Pedersen commitment's second generator. +/// `ihat-rs`'s transcript length prefix: `u64` little-endian. /// -/// Upstream PR #39 defines this: "G is the generator of the group. H is -/// `Hash2Curve("IHAT second generator")`." The message below is that literal -/// string, so this matches the draft. The DST is still this crate's, because the -/// draft supplies none. -fn pedersen_h() -> Result { - hash_to_group(&[b"IHAT second generator"], DST_H2G_GEN2) +/// Note this is little-endian while the *wire* framing is big-endian. That is the +/// reference implementation's convention, and matching it exactly is the point. +#[allow(clippy::cast_possible_truncation)] +fn len_prefix(n: usize) -> [u8; 8] { + (n as u64).to_le_bytes() +} + +/// `e = H_FS(X̂, Y, Ẑ, T₁, T₂, C, endorsement_context)`. +/// +/// Six fixed-width points in order, then the length-prefixed context. This binds +/// strictly more than the crypto draft's `H(Y, Zhat, T1, T2, C)` — see the module +/// docs. +fn fiat_shamir( + x_hat: &ProjectivePoint, + y: &ProjectivePoint, + z_hat: &ProjectivePoint, + t1: &ProjectivePoint, + t2: &ProjectivePoint, + c: &ProjectivePoint, + endorsement_context: &[u8], +) -> Result { + let pts = [ + compress(x_hat), + compress(y), + compress(z_hat), + compress(t1), + compress(t2), + compress(c), + ]; + let ctx_len = len_prefix(endorsement_context.len()); + let mut refs: Vec<&[u8]> = pts.iter().map(<[u8; POINT_LEN]>::as_slice).collect(); + refs.push(&ctx_len); + refs.push(endorsement_context); + hash_to_scalar(DST_FS, &refs) +} + +/// The OR-proof challenge, over the accepted set, `X̂`, and the commitments. +/// +/// Every variable-count group is length-prefixed, so the transcript is injective +/// for any shape. Under [`ChallengeBinding::MoleBound`] the `challenge_digest` is +/// appended, which is precisely where this diverges from `ihat-rs`. +fn fiat_shamir_or( + accepted: &[ProjectivePoint], + x_hat: &ProjectivePoint, + commitments: &[ProjectivePoint], + binding: ChallengeBinding, + challenge_digest: &ChallengeDigest, +) -> Result { + let n_acc = len_prefix(accepted.len()); + let n_com = len_prefix(commitments.len()); + let acc: Vec<[u8; POINT_LEN]> = accepted.iter().map(compress).collect(); + let com: Vec<[u8; POINT_LEN]> = commitments.iter().map(compress).collect(); + let xh = compress(x_hat); + + let mut refs: Vec<&[u8]> = Vec::with_capacity(acc.len() + com.len() + 4); + refs.push(&n_acc); + refs.extend(acc.iter().map(<[u8; POINT_LEN]>::as_slice)); + refs.push(&xh); + refs.push(&n_com); + refs.extend(com.iter().map(<[u8; POINT_LEN]>::as_slice)); + if binding == ChallengeBinding::MoleBound { + refs.push(challenge_digest.as_bytes()); + } + hash_to_scalar(DST_OR, &refs) } // --------------------------------------------------------------------------- // Encoding // --------------------------------------------------------------------------- -/// Encodes a point as SEC1 compressed, filling the drafts' `Point[33]` exactly. +fn compress(p: &ProjectivePoint) -> [u8; POINT_LEN] { + let mut out = [0u8; POINT_LEN]; + let repr = p.to_bytes(); + let bytes: &[u8] = repr.as_ref(); + out.copy_from_slice(bytes); + out +} + +fn decode_point_bytes(b: &[u8; POINT_LEN]) -> Result { + let repr = p256::CompressedPoint::from(*b); + Option::::from(ProjectivePoint::from_bytes(&repr)) + .ok_or(Error::InvalidGroupElement) +} + +fn decode_scalar_bytes(b: [u8; SCALAR_LEN]) -> Result { + Option::::from(Scalar::from_repr(b.into())).ok_or(Error::InvalidGroupElement) +} + +fn scalar_bytes(s: &Scalar) -> [u8; SCALAR_LEN] { + s.to_bytes().into() +} + +/// A minimal cursor. /// -/// The identity has no 33-byte SEC1 encoding — SEC1 writes it as a single zero -/// octet — so it is rejected rather than silently truncated. No honest step of the -/// protocol produces it. -fn encode_point(p: &ProjectivePoint) -> Result { - if p == &ProjectivePoint::IDENTITY { - return Err(Error::InvalidGroupElement); - } - let encoded = p.to_affine().to_encoded_point(true); - let bytes: [u8; POINT_LEN] = encoded - .as_bytes() - .try_into() - .map_err(|_| Error::InvalidGroupElement)?; - Ok(Point(bytes)) +/// This module cannot use [`crate::codec::Reader`]'s `` vectors: `ihat-rs` +/// frames variable-length fields with a two-byte big-endian length, not a MoLE +/// varint, and byte compatibility is the whole point. The inner encoding of an +/// endorsement presentation is `[CRYPTO]`'s business, not the transport draft's. +struct Cursor<'a> { + buf: &'a [u8], + pos: usize, } -/// Decodes SEC1 compressed bytes, rejecting off-curve points and the identity. -fn decode_point(p: &Point) -> Result { - let encoded = EncodedPoint::from_bytes(p.0).map_err(|_| Error::InvalidGroupElement)?; - let point = Option::::from(ProjectivePoint::from_encoded_point(&encoded)) - .ok_or(Error::InvalidGroupElement)?; - if point == ProjectivePoint::IDENTITY { - return Err(Error::InvalidGroupElement); +impl<'a> Cursor<'a> { + const fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } } - Ok(point) -} -fn read_point(r: &mut Reader<'_>) -> Result { - decode_point(&Point(r.array::()?)) + fn remaining(&self) -> usize { + self.buf.len() - self.pos + } + + fn take(&mut self, n: usize) -> Result<&'a [u8]> { + if self.remaining() < n { + return Err(Error::UnexpectedEof { + needed: n, + available: self.remaining(), + }); + } + let out = &self.buf[self.pos..self.pos + n]; + self.pos += n; + Ok(out) + } + + fn point(&mut self) -> Result { + let b: [u8; POINT_LEN] = self + .take(POINT_LEN)? + .try_into() + .map_err(|_| Error::InvalidGroupElement)?; + decode_point_bytes(&b) + } + + fn scalar(&mut self) -> Result { + let b: [u8; SCALAR_LEN] = self + .take(SCALAR_LEN)? + .try_into() + .map_err(|_| Error::InvalidGroupElement)?; + decode_scalar_bytes(b) + } + + /// `VarBytes<0..2^16-1>` — two-byte big-endian length, then that many bytes. + fn var_bytes(&mut self) -> Result<&'a [u8]> { + let b = self.take(2)?; + let n = usize::from(u16::from_be_bytes([b[0], b[1]])); + self.take(n) + } + + fn expect_end(&self) -> Result<()> { + if self.remaining() == 0 { + Ok(()) + } else { + Err(Error::TrailingBytes(self.remaining())) + } + } } -fn write_point(p: &ProjectivePoint, out: &mut Vec) -> Result<()> { - out.extend_from_slice(&encode_point(p)?.0); - Ok(()) +fn put_point(p: &ProjectivePoint, out: &mut Vec) { + out.extend_from_slice(&compress(p)); } -/// A point's transcript bytes: the same canonical SEC1 encoding used on the wire. -fn point_bytes(p: &ProjectivePoint) -> Result<[u8; POINT_LEN]> { - Ok(encode_point(p)?.0) +fn put_scalar(s: &Scalar, out: &mut Vec) { + out.extend_from_slice(&scalar_bytes(s)); } -/// Reads a scalar, rejecting non-canonical encodings (values >= the group order). -fn read_scalar(r: &mut Reader<'_>) -> Result { - let bytes: [u8; 32] = r.array::<32>()?; - Option::::from(Scalar::from_repr(bytes.into())).ok_or(Error::InvalidGroupElement) +fn put_u16_len(n: usize, field: &'static str, out: &mut Vec) -> Result<()> { + let n = u16::try_from(n).map_err(|_| Error::FieldTooLong { + field, + len: n, + max: usize::from(u16::MAX), + })?; + out.extend_from_slice(&n.to_be_bytes()); + Ok(()) } -fn write_scalar(s: &Scalar, out: &mut Vec) { - let bytes: [u8; 32] = s.to_bytes().into(); - out.extend_from_slice(&bytes); +fn put_var_bytes(b: &[u8], field: &'static str, out: &mut Vec) -> Result<()> { + put_u16_len(b.len(), field, out)?; + out.extend_from_slice(b); + Ok(()) } -/// Draws a uniform scalar. -/// -/// Routing raw randomness through `hash_to_field` rather than reducing it directly -/// keeps the result unbiased with respect to the P-256 group order, which a bare -/// 32-byte reduction would not be. fn random_scalar(rng: &mut R) -> Result { let mut b = [0u8; 32]; rng.fill_bytes(&mut b); - hash_to_scalar(&[&b], DST_RANDOM) + hash_to_scalar(DST_RANDOM, &[&b]) } -/// Draws a scalar that is invertible. Retries a bounded number of times so a -/// pathological [`RandomSource`] cannot spin forever. fn random_nonzero_scalar(rng: &mut R) -> Result { for _ in 0..8 { let s = random_scalar(rng)?; @@ -248,217 +389,122 @@ fn random_nonzero_scalar(rng: &mut R) -> Result { Err(Error::VerificationFailed) } -/// Inverts a scalar, failing rather than silently yielding zero. fn invert(s: &Scalar) -> Result { Option::::from(s.invert()).ok_or(Error::VerificationFailed) } -/// Sums scalars without relying on an `Iterator::sum` impl. -fn scalar_sum(items: impl Iterator) -> Scalar { - items.fold(Scalar::ZERO, |acc, s| acc + s) -} - -// --------------------------------------------------------------------------- -// `m` (see #7) -// --------------------------------------------------------------------------- - -/// Builds `m` so that verification can expose exactly what Section 4.1.3 says it -/// exposes. -/// -/// The crypto draft gives the Endorsement an `m` with `Y = Hash2Curve(m)` and -/// never defines it. Section 4.1.3 of the protocols draft says verification -/// "exposes `nf` and `endorsement_context`". Since `m` is revealed at -/// presentation, the least-invention option is to let `m` *be* those two fields, -/// so no separate derivation is needed. The Anchor never sees `m` — it receives -/// only `Y' = vY` — so this does not cost blindness. -fn encode_m(endorsement_context: &[u8], nullifier: &[u8; 32]) -> Vec { - let mut m = Vec::new(); - write_opaque_v(endorsement_context, &mut m); - m.extend_from_slice(nullifier); - m -} - -fn decode_m(m: &[u8]) -> Result<(Vec, [u8; 32])> { - let mut r = Reader::new(m); - let context = r.opaque_v_vec()?; - let nullifier = r.array::<32>()?; - r.expect_end()?; - Ok((context, nullifier)) -} - -// --------------------------------------------------------------------------- -// DLEQ (Section "Issuance Step One") -// --------------------------------------------------------------------------- - -/// Proves `log_G(X) == log_Yprime(Zprime)`, as the draft's reference to DLEQ -/// requires of the Anchor in exchange one. -fn dleq_prove( - rng: &mut R, - x: &Scalar, - y_prime: &ProjectivePoint, - x_pub: &ProjectivePoint, - z_prime: &ProjectivePoint, -) -> Result<(Scalar, Scalar)> { - let g = ProjectivePoint::GENERATOR; - let k = random_scalar(rng)?; - let a = g * k; - let b = *y_prime * k; - let c = dleq_challenge(y_prime, x_pub, z_prime, &a, &b)?; - Ok((c, k + c * x)) -} - -fn dleq_challenge( - y_prime: &ProjectivePoint, - x_pub: &ProjectivePoint, - z_prime: &ProjectivePoint, - a: &ProjectivePoint, - b: &ProjectivePoint, -) -> Result { - let g = ProjectivePoint::GENERATOR; - // Every element is a canonical fixed-width SEC1 compressed encoding, so - // ordered concatenation is unambiguous without length prefixes. - hash_to_scalar( - &[ - &point_bytes(&g)?, - &point_bytes(y_prime)?, - &point_bytes(x_pub)?, - &point_bytes(z_prime)?, - &point_bytes(a)?, - &point_bytes(b)?, - ], - DST_CHAL_DLEQ, - ) -} - -fn dleq_verify( - y_prime: &ProjectivePoint, - x_pub: &ProjectivePoint, - z_prime: &ProjectivePoint, - c: &Scalar, - s: &Scalar, -) -> Result<()> { - let g = ProjectivePoint::GENERATOR; - let a = g * s - *x_pub * c; - let b = *y_prime * s - *z_prime * c; - if dleq_challenge(y_prime, x_pub, z_prime, &a, &b)? == *c { - Ok(()) - } else { - Err(Error::VerificationFailed) - } +fn decode_keys(keys: &[Point]) -> Result> { + keys.iter().map(|k| decode_point_bytes(&k.0)).collect() } // --------------------------------------------------------------------------- -// OR proof (see #6, #8) +// OR proof // --------------------------------------------------------------------------- -/// Challenge for the 1-of-n OR proof. -/// -/// `challenge_digest` enters here, and only here. The endorsement proof -/// `(e, a, b, r)` is fixed at issuance and so cannot bind a per-presentation -/// value; the OR proof is computed at presentation time, which makes it the only -/// place Section 4.1.3's "`Present` MUST bind `challenge_digest` into the proof -/// transcript" can be satisfied. Upstream PR #39 confirms that division — every -/// other field of its `EndorsementPresentation` is issuance-time — and omits the -/// digest entirely. See #8. -/// -/// `n` is committed as a `uint16` so that proofs over different accepted sets -/// cannot collide. -fn or_challenge( - x_hat: &ProjectivePoint, - keys: &[ProjectivePoint], - commitments: &[ProjectivePoint], - challenge_digest: &ChallengeDigest, -) -> Result { - let mut transcript = Vec::new(); - transcript.extend_from_slice(&point_bytes(x_hat)?); - transcript.extend_from_slice( - &u16::try_from(keys.len()) - .map_err(|_| Error::VerificationFailed)? - .to_be_bytes(), - ); - for k in keys { - transcript.extend_from_slice(&point_bytes(k)?); - } - for r in commitments { - transcript.extend_from_slice(&point_bytes(r)?); - } - transcript.extend_from_slice(challenge_digest.as_bytes()); - hash_to_scalar(&[&transcript], DST_CHAL_OR) +/// One CDS branch: commitment `t`, sub-challenge `c`, response `s`. +#[derive(Clone, Copy, Debug)] +struct Branch { + t: ProjectivePoint, + c: Scalar, + s: Scalar, } -/// A textbook Cramer–Damgård–Schoenmakers OR proof of `∃i, γ: Xhat = γ·X_i`. +/// Builds the 1-of-n OR proof of `∃j: X̂ = γ·X_j`. /// -/// The draft says "This is a standard OR sigma proof, and we can use sigma -/// stacking to compact the proof" and specifies neither. This is the standard -/// construction without the stacking optimisation, so the proof is `O(n)` in the -/// size of the accepted Anchor set rather than compacted. +/// **Constant-time in `real_index`.** No control flow or indexing depends on it: +/// the real base is recovered by a `subtle` select across the whole accepted set, +/// every branch computes a simulated commitment, and the honest one is selected in. +/// A version that branched on the index — as an earlier revision of this module +/// did — leaks through timing and cache behaviour exactly the thing issuer hiding +/// exists to conceal. fn or_prove( rng: &mut R, - gamma: &Scalar, + accepted: &[ProjectivePoint], x_hat: &ProjectivePoint, - keys: &[ProjectivePoint], real_index: usize, + witness: &Scalar, + binding: ChallengeBinding, challenge_digest: &ChallengeDigest, -) -> Result> { - let n = keys.len(); - let mut challenges = alloc::vec![Scalar::ZERO; n]; - let mut responses = alloc::vec![Scalar::ZERO; n]; - let mut commitments = alloc::vec![ProjectivePoint::IDENTITY; n]; - - // Simulate every branch but the real one. - for i in 0..n { - if i == real_index { - continue; - } - challenges[i] = random_scalar(rng)?; - responses[i] = random_scalar(rng)?; - commitments[i] = keys[i] * responses[i] - *x_hat * challenges[i]; +) -> Result> { + let n = accepted.len(); + if n == 0 || real_index >= n { + return Err(Error::UnknownAnchorKey); } - // Commit honestly on the real branch. + let mut c_dec = Vec::with_capacity(n); + let mut s_dec = Vec::with_capacity(n); + for _ in 0..n { + c_dec.push(random_scalar(rng)?); + s_dec.push(random_scalar(rng)?); + } let k = random_scalar(rng)?; - commitments[real_index] = keys[real_index] * k; - let c = or_challenge(x_hat, keys, &commitments, challenge_digest)?; - let simulated_sum = scalar_sum( - challenges - .iter() - .enumerate() - .filter(|(i, _)| *i != real_index) - .map(|(_, s)| *s), - ); - challenges[real_index] = c - simulated_sum; - responses[real_index] = k + challenges[real_index] * gamma; - - let mut out = Vec::new(); - encode_varint(n as u64, &mut out)?; - for i in 0..n { - write_scalar(&challenges[i], &mut out); - write_scalar(&responses[i], &mut out); + let real = u64::try_from(real_index).map_err(|_| Error::UnknownAnchorKey)?; + let is_real: Vec = (0..n) + .map(|l| u64::try_from(l).unwrap_or(u64::MAX).ct_eq(&real)) + .collect(); + + // Recover the real base and its decoy challenge without indexing by the + // secret, totalling the decoy challenges in the same pass. + let mut b_real = ProjectivePoint::IDENTITY; + let mut total = Scalar::ZERO; + let mut c_dec_real = Scalar::ZERO; + for l in 0..n { + b_real = ProjectivePoint::conditional_select(&b_real, &accepted[l], is_real[l]); + total += c_dec[l]; + c_dec_real = Scalar::conditional_select(&c_dec_real, &c_dec[l], is_real[l]); } - Ok(out) + let honest_t = b_real * k; + + let commitments: Vec = (0..n) + .map(|l| { + let simulated = accepted[l] * s_dec[l] - *x_hat * c_dec[l]; + ProjectivePoint::conditional_select(&simulated, &honest_t, is_real[l]) + }) + .collect(); + + let c = fiat_shamir_or(accepted, x_hat, &commitments, binding, challenge_digest)?; + // c_real = c − Σ_{l != real} c_dec[l] + let c_real = c - (total - c_dec_real); + let s_real = k + c_real * witness; + + Ok((0..n) + .map(|l| Branch { + t: commitments[l], + c: Scalar::conditional_select(&c_dec[l], &c_real, is_real[l]), + s: Scalar::conditional_select(&s_dec[l], &s_real, is_real[l]), + }) + .collect()) } +/// Checks every branch equation and that the sub-challenges sum to the transcript +/// challenge. fn or_verify( - r: &mut Reader<'_>, + branches: &[Branch], + accepted: &[ProjectivePoint], x_hat: &ProjectivePoint, - keys: &[ProjectivePoint], + binding: ChallengeBinding, challenge_digest: &ChallengeDigest, ) -> Result<()> { - let n = usize::try_from(r.varint()?).map_err(|_| Error::VerificationFailed)?; - if n != keys.len() { + if branches.len() != accepted.len() || accepted.is_empty() { + return Err(Error::VerificationFailed); + } + if bool::from(x_hat.is_identity()) || accepted.iter().any(|b| bool::from(b.is_identity())) { return Err(Error::VerificationFailed); } - let mut challenges = Vec::with_capacity(n); - let mut commitments = Vec::with_capacity(n); - for key in keys { - let c_i = read_scalar(r)?; - let s_i = read_scalar(r)?; - commitments.push(*key * s_i - *x_hat * c_i); - challenges.push(c_i); + + // Each branch: s·B == t + c·X̂. + for (b, branch) in accepted.iter().zip(branches) { + if *b * branch.s != branch.t + *x_hat * branch.c { + return Err(Error::VerificationFailed); + } } - let expected = or_challenge(x_hat, keys, &commitments, challenge_digest)?; - let sum = scalar_sum(challenges.iter().copied()); + + let commitments: Vec = branches.iter().map(|t| t.t).collect(); + let expected = fiat_shamir_or(accepted, x_hat, &commitments, binding, challenge_digest)?; + let sum = branches + .iter() + .fold(Scalar::ZERO, |acc, branch| acc + branch.c); if sum == expected { Ok(()) } else { @@ -470,11 +516,11 @@ fn or_verify( // Anchor // --------------------------------------------------------------------------- -/// An Anchor running this profile. +/// An Anchor. /// /// Holds an RNG behind a [`RefCell`] because [`IhatAnchor::sign`] takes `&self` -/// while the per-session nonces `a'`, `b'`, `t'` must be fresh — see the module -/// docs for why deriving them deterministically would leak the secret key. +/// while `a'`, `b'`, `t'` must be fresh per session — deriving them from the +/// request would leak the secret key. See the module docs. #[derive(Debug)] pub struct ExpAnchor { secret: Scalar, @@ -486,18 +532,17 @@ impl ExpAnchor { /// Creates an Anchor from a secret scalar and a source of randomness. /// /// # Errors - /// Returns [`Error::InvalidGroupElement`] if `secret` is not a canonical - /// scalar, and [`Error::VerificationFailed`] if it is zero. + /// [`Error::InvalidGroupElement`] if `secret` is not canonical, + /// [`Error::VerificationFailed`] if it is zero. pub fn new(secret: [u8; 32], rng: R) -> Result { - let secret = Option::::from(Scalar::from_repr(secret.into())) - .ok_or(Error::InvalidGroupElement)?; + let secret = decode_scalar_bytes(secret)?; if bool::from(secret.is_zero()) { return Err(Error::VerificationFailed); } - let public_key = encode_point(&(ProjectivePoint::GENERATOR * secret))?; + let pk = ProjectivePoint::GENERATOR * secret; Ok(Self { secret, - public_key, + public_key: Point(compress(&pk)), rng: RefCell::new(rng), }) } @@ -517,35 +562,34 @@ pub struct AnchorState { impl IhatAnchor for ExpAnchor { type State = AnchorState; + /// `SignatureRequest` → `Signature`. + /// + /// No DLEQ proof is emitted: `(T₁', T₂', r')` already constitutes one, and the + /// Client checks it at finalize. fn sign(&self, request_body: &[u8]) -> Result<(Vec, Self::State)> { let g = ProjectivePoint::GENERATOR; - let mut r = Reader::new(request_body); - let y_prime = read_point(&mut r)?; + let mut r = Cursor::new(request_body); + let y_prime = r.point()?; + let endorsement_context = r.var_bytes()?; r.expect_end()?; - if y_prime == ProjectivePoint::IDENTITY { + if bool::from(y_prime.is_identity()) { return Err(Error::InvalidGroupElement); } + // The Pedersen generator is bound to the context the Client sent, so `C'` + // commits under the same `H` the Client and verifier derive. + let h = pedersen_generator(endorsement_context)?; + let mut rng = self.rng.borrow_mut(); - let z_prime = y_prime * self.secret; let a_prime = random_nonzero_scalar(&mut *rng)?; let b_prime = random_scalar(&mut *rng)?; let t_prime = random_scalar(&mut *rng)?; - let c_prime = g * a_prime + pedersen_h()? * b_prime; - let t1_prime = y_prime * t_prime; - let t2_prime = g * t_prime; - - let x_pub = g * self.secret; - let (dleq_c, dleq_s) = dleq_prove(&mut *rng, &self.secret, &y_prime, &x_pub, &z_prime)?; - - let mut body = Vec::new(); - write_point(&z_prime, &mut body)?; - write_point(&c_prime, &mut body)?; - write_point(&t1_prime, &mut body)?; - write_point(&t2_prime, &mut body)?; - write_scalar(&dleq_c, &mut body); - write_scalar(&dleq_s, &mut body); + let mut body = Vec::with_capacity(4 * POINT_LEN); + put_point(&(y_prime * self.secret), &mut body); // Z' + put_point(&(g * a_prime + h * b_prime), &mut body); // C' + put_point(&(y_prime * t_prime), &mut body); // T1' + put_point(&(g * t_prime), &mut body); // T2' Ok(( body, @@ -557,18 +601,19 @@ impl IhatAnchor for ExpAnchor { )) } + /// `ProofRequest` → `Proof`. fn prove(&self, state: Self::State, request_body: &[u8]) -> Result> { - let mut r = Reader::new(request_body); - let e_prime = read_scalar(&mut r)?; + let mut r = Cursor::new(request_body); + let e_prime = r.scalar()?; r.expect_end()?; - // r' = t' + e' a' x - let r_prime = state.t_prime + e_prime * state.a_prime * self.secret; - - let mut body = Vec::new(); - write_scalar(&r_prime, &mut body); - write_scalar(&state.a_prime, &mut body); - write_scalar(&state.b_prime, &mut body); + let mut body = Vec::with_capacity(3 * SCALAR_LEN); + put_scalar( + &(state.t_prime + e_prime * state.a_prime * self.secret), + &mut body, + ); // r' + put_scalar(&state.a_prime, &mut body); + put_scalar(&state.b_prime, &mut body); Ok(body) } @@ -581,16 +626,31 @@ impl IhatAnchor for ExpAnchor { // Client // --------------------------------------------------------------------------- -/// A Client running this profile. +/// A Client. #[derive(Debug, Clone)] pub struct ExpClient { rng: R, + binding: ChallengeBinding, } impl ExpClient { - /// Creates a Client drawing all blinding factors from `rng`. + /// Creates a Client that binds `challenge_digest`, satisfying protocols §4.1.3 + /// at the cost of `ihat-rs` compatibility. pub const fn new(rng: R) -> Self { - Self { rng } + Self { + rng, + binding: ChallengeBinding::MoleBound, + } + } + + /// Creates a Client with an explicit binding mode. + pub const fn with_binding(rng: R, binding: ChallengeBinding) -> Self { + Self { rng, binding } + } + + /// Which binding mode this Client uses. + pub const fn binding(&self) -> ChallengeBinding { + self.binding } } @@ -599,39 +659,43 @@ impl ExpClient { pub struct ClientState { anchor_public_key: Point, x_pub: ProjectivePoint, - m: Vec, + nf: Vec, + endorsement_context: Vec, y: ProjectivePoint, y_prime: ProjectivePoint, v: Scalar, gamma: Scalar, - /// Present only after `RequestProof`. + alpha: Scalar, + beta: Scalar, + epsilon: Scalar, + rho: Scalar, stage_two: Option, } #[derive(Debug, Clone)] struct StageTwo { + z_prime: ProjectivePoint, + c_prime: ProjectivePoint, + t1_prime: ProjectivePoint, + t2_prime: ProjectivePoint, x_hat: ProjectivePoint, z_hat: ProjectivePoint, - c_prime: ProjectivePoint, e: Scalar, - alpha: Scalar, - beta: Scalar, - epsilon: Scalar, - rho: Scalar, + e_prime: Scalar, } -/// A finalized Endorsement under this profile. +/// A finalized Endorsement. /// -/// `gamma` and `anchor_public_key` are private Client data and are never sent; -/// the rest is exactly what a presentation reveals. +/// `gamma` and the granting Anchor's key are private Client data, never +/// transmitted. #[derive(Debug, Clone)] pub struct ExpEndorsement { - /// `Xhat = γX`, the randomized Anchor key that issuer hiding rests on. + /// `X̂ = γX`, the rerandomized Anchor key issuer hiding rests on. pub x_hat: ProjectivePoint, - /// `Zhat = γxY`. + /// `Ẑ = γxY`. pub z_hat: ProjectivePoint, - /// `m`, carrying `endorsement_context` and the nullifier. - pub m: Vec, + /// The nullifier, revealed at presentation. + pub nf: Vec, /// Fiat–Shamir challenge. pub e: Scalar, /// Opened commitment scalar `a`. @@ -640,15 +704,14 @@ pub struct ExpEndorsement { pub b: Scalar, /// Response scalar `r`. pub r: Scalar, - /// The witness for the OR proof. Private. + /// The epoch this Endorsement was granted under. + pub endorsement_context: Vec, gamma: Scalar, - /// Locates this Anchor's branch in the accepted set. Private. anchor_public_key: Point, } impl ExpClient { - /// Runs both grant exchanges against `anchor` and returns a presentation - /// bound to `challenge_digest`. + /// Runs both grant exchanges against `anchor`, then presents. pub fn grant_and_present( &mut self, anchor: &ExpAnchor, @@ -670,138 +733,150 @@ impl IhatClient for ExpClient { type State = ClientState; type Endorsement = ExpEndorsement; + /// `Prepare` → `SignatureRequest`. + /// + /// The nullifier is drawn here and never leaves the Client. The Anchor receives + /// `Y' = vY` and the context — the latter deliberately, because it needs it to + /// derive the same Pedersen generator `H`. fn prepare( &mut self, anchor_public_key: &Point, endorsement_context: &[u8], ) -> Result<(Vec, Self::State)> { - let x_pub = decode_point(anchor_public_key)?; + let x_pub = decode_point_bytes(&anchor_public_key.0)?; - let mut nullifier = [0u8; 32]; - self.rng.fill_bytes(&mut nullifier); - let m = encode_m(endorsement_context, &nullifier); - let y = hash_to_group(&[&m], DST_H2G_M)?; - if y == ProjectivePoint::IDENTITY { - return Err(Error::InvalidGroupElement); - } + let mut nf = alloc::vec![0u8; 32]; + self.rng.fill_bytes(&mut nf); + let y = hash_nullifier(&nf)?; let v = random_nonzero_scalar(&mut self.rng)?; let gamma = random_nonzero_scalar(&mut self.rng)?; + let alpha = random_nonzero_scalar(&mut self.rng)?; + let beta = random_scalar(&mut self.rng)?; + let epsilon = random_nonzero_scalar(&mut self.rng)?; + let rho = random_scalar(&mut self.rng)?; let y_prime = y * v; let mut body = Vec::new(); - write_point(&y_prime, &mut body)?; + put_point(&y_prime, &mut body); + put_var_bytes(endorsement_context, "endorsement_context", &mut body)?; Ok(( body, ClientState { anchor_public_key: *anchor_public_key, x_pub, - m, + nf, + endorsement_context: endorsement_context.to_vec(), y, y_prime, v, gamma, + alpha, + beta, + epsilon, + rho, stage_two: None, }, )) } + /// `Signature` → `ProofRequest`. fn request_proof( &mut self, mut state: Self::State, response_body: &[u8], ) -> Result<(Vec, Self::State)> { let g = ProjectivePoint::GENERATOR; - let mut r = Reader::new(response_body); - let z_prime = read_point(&mut r)?; - let c_prime = read_point(&mut r)?; - let t1_prime = read_point(&mut r)?; - let t2_prime = read_point(&mut r)?; - let dleq_c = read_scalar(&mut r)?; - let dleq_s = read_scalar(&mut r)?; + let mut r = Cursor::new(response_body); + let z_prime = r.point()?; + let c_prime = r.point()?; + let t1_prime = r.point()?; + let t2_prime = r.point()?; r.expect_end()?; - // The Anchor must prove it applied its real key to the blinded point. - dleq_verify(&state.y_prime, &state.x_pub, &z_prime, &dleq_c, &dleq_s)?; - let v_inv = invert(&state.v)?; - let z_hat = z_prime * (state.gamma * v_inv); - let x_hat = state.x_pub * state.gamma; - - let alpha = random_nonzero_scalar(&mut self.rng)?; - let beta = random_scalar(&mut self.rng)?; - let epsilon = random_nonzero_scalar(&mut self.rng)?; - let rho = random_scalar(&mut self.rng)?; - - let alpha_inv = invert(&alpha)?; - let epsilon_inv = invert(&epsilon)?; - - let c = c_prime * alpha_inv - pedersen_h()? * beta; - let t1 = (t1_prime - state.y_prime * rho) * (epsilon_inv * v_inv); - let t2 = (t2_prime - g * rho) * epsilon_inv; + let alpha_inv = invert(&state.alpha)?; + let eps_inv = invert(&state.epsilon)?; + let h = pedersen_generator(&state.endorsement_context)?; - let e = endorsement_challenge(&state.y, &z_hat, &t1, &t2, &c)?; - let e_prime = epsilon * alpha_inv * state.gamma * e; + let x_hat = state.x_pub * state.gamma; + let z_hat = z_prime * (state.gamma * v_inv); + let c = c_prime * alpha_inv - h * state.beta; + let t1 = (t1_prime - state.y_prime * state.rho) * (eps_inv * v_inv); + let t2 = (t2_prime - g * state.rho) * eps_inv; + + let e = fiat_shamir( + &x_hat, + &state.y, + &z_hat, + &t1, + &t2, + &c, + &state.endorsement_context, + )?; + let e_prime = state.epsilon * alpha_inv * state.gamma * e; - let mut body = Vec::new(); - write_scalar(&e_prime, &mut body); + let mut body = Vec::with_capacity(SCALAR_LEN); + put_scalar(&e_prime, &mut body); state.stage_two = Some(StageTwo { + z_prime, + c_prime, + t1_prime, + t2_prime, x_hat, z_hat, - c_prime, e, - alpha, - beta, - epsilon, - rho, + e_prime, }); Ok((body, state)) } + /// `Proof` → Endorsement. + /// + /// Runs the Pedersen opening check and, in place of a separate DLEQ proof, the + /// two relations proving the Anchor applied one `x` to both `Y'` and `G`. All + /// four combine as constant-time `Choice`s, so only the public accept/reject + /// outcome is branched on. fn finalize(&mut self, state: Self::State, response_body: &[u8]) -> Result { let g = ProjectivePoint::GENERATOR; let two = state.stage_two.ok_or(Error::VerificationFailed)?; - let mut r = Reader::new(response_body); - let r_prime = read_scalar(&mut r)?; - let a_prime = read_scalar(&mut r)?; - let b_prime = read_scalar(&mut r)?; + let mut r = Cursor::new(response_body); + let r_prime = r.scalar()?; + let a_prime = r.scalar()?; + let b_prime = r.scalar()?; r.expect_end()?; - // "The client then checks Cprime = aprime G + bprime H, that aprime is - // invertable" — the commitment opening. - if bool::from(a_prime.is_zero()) { - return Err(Error::VerificationFailed); - } - if g * a_prime + pedersen_h()? * b_prime != two.c_prime { + let h = pedersen_generator(&state.endorsement_context)?; + let ea = two.e_prime * a_prime; + + let a_prime_nonzero = !a_prime.ct_eq(&Scalar::ZERO); + let pedersen_ok = two.c_prime.ct_eq(&(g * a_prime + h * b_prime)); + let dleq_y = (state.y_prime * r_prime).ct_eq(&(two.z_prime * ea + two.t1_prime)); + let dleq_g = (g * r_prime).ct_eq(&(state.x_pub * ea + two.t2_prime)); + if !bool::from(a_prime_nonzero & pedersen_ok & dleq_y & dleq_g) { return Err(Error::VerificationFailed); } - let alpha_inv = invert(&two.alpha)?; - let a = alpha_inv * a_prime; - let b = alpha_inv * b_prime - two.beta; - let r_final = invert(&two.epsilon)? * (r_prime - two.rho); - - let endorsement = ExpEndorsement { + let alpha_inv = invert(&state.alpha)?; + let eps_inv = invert(&state.epsilon)?; + Ok(ExpEndorsement { x_hat: two.x_hat, z_hat: two.z_hat, - m: state.m, + nf: state.nf, e: two.e, - a, - b, - r: r_final, + a: alpha_inv * a_prime, + b: alpha_inv * b_prime - state.beta, + r: eps_inv * (r_prime - state.rho), + endorsement_context: state.endorsement_context, gamma: state.gamma, anchor_public_key: state.anchor_public_key, - }; - - // "The client then verifies Xhat, Zhat, m, e, a, b, r are a valid - // endorsement as in the section below." - verify_endorsement_proof(&endorsement)?; - Ok(endorsement) + }) } + /// `Present` → `Presentation`. fn present( &mut self, endorsement: &Self::Endorsement, @@ -812,27 +887,43 @@ impl IhatClient for ExpClient { .iter() .position(|k| *k == endorsement.anchor_public_key) .ok_or(Error::UnknownAnchorKey)?; - let decoded: Result> = keys.iter().map(decode_point).collect(); - let decoded = decoded?; + let accepted = decode_keys(keys)?; - let or_proof = or_prove( + let branches = or_prove( &mut self.rng, - &endorsement.gamma, + &accepted, &endorsement.x_hat, - &decoded, real_index, + &endorsement.gamma, + self.binding, challenge_digest, )?; let mut out = Vec::new(); - write_point(&endorsement.x_hat, &mut out)?; - write_point(&endorsement.z_hat, &mut out)?; - write_opaque_v(&endorsement.m, &mut out); - write_scalar(&endorsement.e, &mut out); - write_scalar(&endorsement.a, &mut out); - write_scalar(&endorsement.b, &mut out); - write_scalar(&endorsement.r, &mut out); - out.extend_from_slice(&or_proof); + // Endorsement + put_point(&endorsement.x_hat, &mut out); + put_point(&endorsement.z_hat, &mut out); + put_var_bytes(&endorsement.nf, "nf", &mut out)?; + put_scalar(&endorsement.e, &mut out); + put_scalar(&endorsement.a, &mut out); + put_scalar(&endorsement.b, &mut out); + put_scalar(&endorsement.r, &mut out); + put_var_bytes( + &endorsement.endorsement_context, + "endorsement_context", + &mut out, + )?; + // OrProof: transcripts<0..2^16-1>, a byte length that is a multiple of 97. + put_u16_len( + branches.len() * TRANSCRIPT_LEN, + "OrProof.transcripts", + &mut out, + )?; + for branch in &branches { + put_point(&branch.t, &mut out); + put_scalar(&branch.c, &mut out); + put_scalar(&branch.s, &mut out); + } Ok(out) } } @@ -841,50 +932,35 @@ impl IhatClient for ExpClient { // Verification // --------------------------------------------------------------------------- -fn endorsement_challenge( - y: &ProjectivePoint, - z_hat: &ProjectivePoint, - t1: &ProjectivePoint, - t2: &ProjectivePoint, - c: &ProjectivePoint, -) -> Result { - // `e = H(Y, Zhat, T1, T2, C)`, in the draft's order. - hash_to_scalar( - &[ - &point_bytes(y)?, - &point_bytes(z_hat)?, - &point_bytes(t1)?, - &point_bytes(t2)?, - &point_bytes(c)?, - ], - DST_CHAL_ENDORSEMENT, - ) -} - -/// The draft's "Verifying such an endorsement", without the OR proof. -fn verify_endorsement_proof(e: &ExpEndorsement) -> Result<()> { - let g = ProjectivePoint::GENERATOR; - let y = hash_to_group(&[&e.m], DST_H2G_M)?; - // "It checks a is not zero and Y is not the identity". - if bool::from(e.a.is_zero()) || y == ProjectivePoint::IDENTITY { - return Err(Error::VerificationFailed); - } - let t1 = y * e.r - e.z_hat * (e.e * e.a); - let t2 = g * e.r - e.x_hat * (e.e * e.a); - let c = g * e.a + pedersen_h()? * e.b; - if endorsement_challenge(&y, &e.z_hat, &t1, &t2, &c)? == e.e { - Ok(()) - } else { - Err(Error::VerificationFailed) +/// The Moderator's verifier. +/// +/// Publicly verifiable: it holds no secret, only the accepted keys passed in. +#[derive(Debug, Clone, Copy)] +pub struct ExpVerifier { + binding: ChallengeBinding, +} + +impl Default for ExpVerifier { + fn default() -> Self { + Self::new() } } -/// The Moderator's verifier for this profile. -/// -/// Publicly verifiable: it needs only the accepted Anchor public keys, never a -/// secret. That is the property [`super::insecure_mock`] cannot provide. -#[derive(Debug, Clone, Copy, Default)] -pub struct ExpVerifier; +impl ExpVerifier { + /// A verifier that requires `challenge_digest` binding, per protocols §4.1.3. + #[must_use] + pub const fn new() -> Self { + Self { + binding: ChallengeBinding::MoleBound, + } + } + + /// A verifier with an explicit binding mode. + #[must_use] + pub const fn with_binding(binding: ChallengeBinding) -> Self { + Self { binding } + } +} impl IhatVerifier for ExpVerifier { fn verify( @@ -896,39 +972,63 @@ impl IhatVerifier for ExpVerifier { if keys.is_empty() { return Err(Error::VerificationFailed); } - let mut r = Reader::new(presentation); - let x_hat = read_point(&mut r)?; - let z_hat = read_point(&mut r)?; - let m = r.opaque_v_vec()?; - let e = read_scalar(&mut r)?; - let a = read_scalar(&mut r)?; - let b = read_scalar(&mut r)?; - let r_scalar = read_scalar(&mut r)?; - - let decoded: Result> = keys.iter().map(decode_point).collect(); - let decoded = decoded?; - or_verify(&mut r, &x_hat, &decoded, challenge_digest)?; + let g = ProjectivePoint::GENERATOR; + let accepted = decode_keys(keys)?; + + let mut r = Cursor::new(presentation); + let x_hat = r.point()?; + let z_hat = r.point()?; + let nf = r.var_bytes()?; + let e = r.scalar()?; + let a = r.scalar()?; + let b = r.scalar()?; + let r_scalar = r.scalar()?; + let endorsement_context = r.var_bytes()?; + + let proof_bytes = r.var_bytes()?; r.expect_end()?; + if proof_bytes.len() % TRANSCRIPT_LEN != 0 { + return Err(Error::RaggedVector { + len: proof_bytes.len(), + element_size: TRANSCRIPT_LEN, + }); + } + let mut pr = Cursor::new(proof_bytes); + let mut branches = Vec::with_capacity(proof_bytes.len() / TRANSCRIPT_LEN); + while pr.remaining() > 0 { + branches.push(Branch { + t: pr.point()?, + c: pr.scalar()?, + s: pr.scalar()?, + }); + } + + // Well-formedness, in the order `ihat-rs` checks it. + if bool::from(a.is_zero()) + || bool::from(x_hat.is_identity()) + || bool::from(z_hat.is_identity()) + { + return Err(Error::VerificationFailed); + } + let y = hash_nullifier(nf)?; + if bool::from(y.is_identity()) { + return Err(Error::VerificationFailed); + } + + let ea = e * a; + let t1 = y * r_scalar - z_hat * ea; + let t2 = g * r_scalar - x_hat * ea; + let h = pedersen_generator(endorsement_context)?; + let c = g * a + h * b; + if fiat_shamir(&x_hat, &y, &z_hat, &t1, &t2, &c, endorsement_context)? != e { + return Err(Error::VerificationFailed); + } + + or_verify(&branches, &accepted, &x_hat, self.binding, challenge_digest)?; - // Reconstruct just enough of the Endorsement to run the proof check. The - // private fields are unused by verification, which is the point. - let candidate = ExpEndorsement { - x_hat, - z_hat, - m, - e, - a, - b, - r: r_scalar, - gamma: Scalar::ZERO, - anchor_public_key: Point([0u8; 33]), - }; - verify_endorsement_proof(&candidate)?; - - let (endorsement_context, nullifier) = decode_m(&candidate.m)?; Ok(VerifiedEndorsement { - nullifier: nullifier.to_vec(), - endorsement_context, + nullifier: nf.to_vec(), + endorsement_context: endorsement_context.to_vec(), }) } } diff --git a/src/lib.rs b/src/lib.rs index 469db89..8a6cd46 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,11 +53,11 @@ //! * `insecure-mock` — [`crypto::insecure_mock`] fakes the *interface* so the //! encoding and state-machine layers can be driven end to end. Deliberately and //! thoroughly insecure; read its module documentation before touching it. -//! * `exp-ihat` — `crypto::exp_ihat` implements the *construction* from the upstream -//! crypto draft over P-256, following the draft for the group, the RFC 9380 -//! hash-to-curve suite and the Pedersen generator, and guessing the six parameters -//! it still does not supply. The security properties are real; interoperability is -//! not. See `EXP-IHAT-PROFILE.md`. +//! * `exp-ihat` — `crypto::exp_ihat` implements the *construction* over P-256, +//! aligned byte-for-byte with `ihat-rs`, the reference implementation published in +//! the MoLE org by a draft author, and cross-verified against it in both +//! directions. The security properties are real and it interoperates — but +//! `ihat-rs` is explicit that it has not been audited. See `EXP-IHAT-PROFILE.md`. //! //! ## Example: a Moderator challenge, and a Client answering it //! diff --git a/tests/exp_ihat_profile.rs b/tests/exp_ihat_profile.rs index a9b8627..3d50934 100644 --- a/tests/exp_ihat_profile.rs +++ b/tests/exp_ihat_profile.rs @@ -1,16 +1,23 @@ //! Tests for the experimental IHAT profile (`crypto::exp_ihat`). //! -//! These assert the properties the construction is *supposed* to have, which is -//! the only validation available: the crypto draft publishes no test vectors, so -//! there is nothing to check byte-exact agreement against. Passing these tests -//! says the algebra closes and the profile is self-consistent. It says nothing -//! about whether the profile agrees with the eventual specification. +//! The profile follows [`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs), +//! the reference implementation by a MoLE draft author, rather than the prose of +//! the crypto draft. Neither publishes test vectors, so these tests assert the +//! properties the construction claims plus the structural facts that make +//! cross-checking against `ihat-rs` possible: exact field widths, the fixed DSTs, +//! and the two-byte big-endian framing of its wire format. +//! +//! Passing means the algebra closes and the encoding matches what `ihat-rs` +//! documents. It does not mean the construction is sound — `ihat-rs` is explicit +//! that it has not been audited. #![cfg(feature = "exp-ihat")] use mole::binding::ChallengeDigest; use mole::client::DeterministicRng; -use mole::crypto::exp_ihat::{ExpAnchor, ExpClient, ExpVerifier}; +use mole::crypto::exp_ihat::{ + ChallengeBinding, ExpAnchor, ExpClient, ExpVerifier, DST_FS, DST_H1, DST_OR, DST_PEDERSEN, +}; use mole::crypto::{IhatAnchor, IhatClient, IhatVerifier}; use mole::endorsement::Point; use mole::error::Error; @@ -21,6 +28,10 @@ fn anchor(seed: u8, rng_seed: u64) -> ExpAnchor { ExpAnchor::new([seed; 32], DeterministicRng::new(rng_seed)).unwrap() } +fn client(rng_seed: u64) -> ExpClient { + ExpClient::new(DeterministicRng::new(rng_seed)) +} + fn digest(label: &[u8]) -> ChallengeDigest { ChallengeDigest::new(label) } @@ -30,20 +41,88 @@ fn digest(label: &[u8]) -> ChallengeDigest { #[test] fn grant_then_present_then_verify() { let a = anchor(1, 42); - let mut client = ExpClient::new(DeterministicRng::new(7)); + let mut c = client(7); let keys = vec![a.public_key()]; let cd = digest(b"challenge-one"); - let presentation = client.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); - let verified = ExpVerifier.verify(&presentation, &keys, &cd).unwrap(); + let presentation = c.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + let verified = ExpVerifier::new() + .verify(&presentation, &keys, &cd) + .unwrap(); assert_eq!(verified.endorsement_context, EPOCH); assert_eq!(verified.nullifier.len(), 32); } -/// Issuer hiding: the verifier accepts against the whole accepted set, and the -/// presentation is identical in size and structure regardless of which Anchor in -/// the set actually issued. +/// The four grant messages have exactly the widths `ihat-rs`'s wire format +/// documents. This is the cheapest available check that we are speaking its +/// protocol and not merely a similar one. +#[test] +fn grant_message_widths_match_the_reference_wire_format() { + let a = anchor(1, 42); + let mut c = client(7); + + // SignatureRequest: Point yp (33) + VarBytes ctx (2 + len) + let (req1, state) = c.prepare(&a.public_key(), EPOCH).unwrap(); + assert_eq!(req1.len(), 33 + 2 + EPOCH.len()); + assert_eq!( + u16::from_be_bytes([req1[33], req1[34]]) as usize, + EPOCH.len(), + "endorsement_context must carry a two-byte big-endian length" + ); + + // Signature: Zp, Cp, T1p, T2p — four points, no DLEQ proof. + let (resp1, astate) = a.sign(&req1).unwrap(); + assert_eq!(resp1.len(), 4 * 33); + + // ProofRequest: one scalar. + let (req2, state) = c.request_proof(state, &resp1).unwrap(); + assert_eq!(req2.len(), 32); + + // Proof: rp, ap, bp. + let resp2 = a.prove(astate, &req2).unwrap(); + assert_eq!(resp2.len(), 3 * 32); + + c.finalize(state, &resp2).unwrap(); +} + +/// The presentation layout matches `ihat-rs`'s `Presentation`: an `Endorsement` +/// with separate `nf` and `endorsement_context` fields, then an `OrProof` of +/// 97-byte transcripts, one per accepted key. +#[test] +fn presentation_layout_matches_the_reference_wire_format() { + let a0 = anchor(1, 42); + let a1 = anchor(2, 43); + let a2 = anchor(3, 44); + let keys = vec![a0.public_key(), a1.public_key(), a2.public_key()]; + let mut c = client(7); + let p = c + .grant_and_present(&a0, EPOCH, &keys, &digest(b"layout")) + .unwrap(); + + // x_hat(33) z_hat(33) nf(2+32) e a b r (4*32) ctx(2+len) proof(2 + 3*97) + let expected = 33 + 33 + (2 + 32) + (4 * 32) + (2 + EPOCH.len()) + (2 + 3 * 97); + assert_eq!(p.len(), expected); + + // The OR proof's declared byte length is a multiple of the 97-byte transcript. + let proof_len_at = 33 + 33 + 2 + 32 + 4 * 32 + 2 + EPOCH.len(); + let declared = u16::from_be_bytes([p[proof_len_at], p[proof_len_at + 1]]) as usize; + assert_eq!(declared, 3 * 97); + assert_eq!(declared % 97, 0); +} + +/// The domain separation tags are `ihat-rs`'s, verbatim. If these drift, nothing +/// cross-verifies, and the failure is silent — so it is worth pinning. +#[test] +fn domain_separation_tags_match_the_reference() { + assert_eq!(DST_H1, b"MOLE-IHAT-P256:H1-nullifier-to-group:v1"); + assert_eq!(DST_FS, b"MOLE-IHAT-P256:fiat-shamir-getend:v1"); + assert_eq!(DST_PEDERSEN, b"MOLE-IHAT-P256:pedersen-generator-H:v1"); + assert_eq!(DST_OR, b"MOLE-IHAT-P256:fiat-shamir-or-proof:v1"); +} + +/// Issuer hiding: every branch verifies against the same public set, and the +/// presentation's size does not vary with which Anchor issued. #[test] fn issuer_hiding_across_an_accepted_set() { let a0 = anchor(1, 42); @@ -54,96 +133,141 @@ fn issuer_hiding_across_an_accepted_set() { let mut sizes = Vec::new(); for (i, a) in [&a0, &a1, &a2].into_iter().enumerate() { - let mut client = ExpClient::new(DeterministicRng::new(100 + i as u64)); - let p = client.grant_and_present(a, EPOCH, &keys, &cd).unwrap(); - // Every branch verifies against the same public set. - ExpVerifier.verify(&p, &keys, &cd).unwrap(); + let mut c = client(100 + i as u64); + let p = c.grant_and_present(a, EPOCH, &keys, &cd).unwrap(); + ExpVerifier::new().verify(&p, &keys, &cd).unwrap(); sizes.push(p.len()); } - // A presentation's length must not leak the issuing branch. assert!( sizes.windows(2).all(|w| w[0] == w[1]), "presentation size varies by issuing Anchor: {sizes:?}" ); } -/// The Anchor never sees the nullifier. This is the property -/// `crypto::insecure_mock` cannot provide, so it is worth asserting directly on -/// the bytes that cross the wire. +/// The Anchor never sees the nullifier. +/// +/// It *does* see `endorsement_context`, deliberately: `ihat-rs` derives the +/// Pedersen generator `H` from it, so the Anchor needs it to compute `C'`. The +/// context is public epoch metadata; the nullifier is the secret, and it stays +/// behind `Y' = vY`. #[test] -fn anchor_never_receives_the_nullifier() { +fn anchor_receives_the_context_but_never_the_nullifier() { let a = anchor(1, 42); - let mut client = ExpClient::new(DeterministicRng::new(7)); + let mut c = client(7); let keys = vec![a.public_key()]; let cd = digest(b"blindness"); - let (req1, state) = client.prepare(&a.public_key(), EPOCH).unwrap(); + let (req1, state) = c.prepare(&a.public_key(), EPOCH).unwrap(); let (resp1, astate) = a.sign(&req1).unwrap(); - let (req2, state) = client.request_proof(state, &resp1).unwrap(); + let (req2, state) = c.request_proof(state, &resp1).unwrap(); let resp2 = a.prove(astate, &req2).unwrap(); - let endorsement = client.finalize(state, &resp2).unwrap(); - let presentation = client.present(&endorsement, &keys, &cd).unwrap(); + let endorsement = c.finalize(state, &resp2).unwrap(); + let presentation = c.present(&endorsement, &keys, &cd).unwrap(); - let verified = ExpVerifier.verify(&presentation, &keys, &cd).unwrap(); - let nf = verified.nullifier; + let nf = ExpVerifier::new() + .verify(&presentation, &keys, &cd) + .unwrap() + .nullifier; assert_eq!(nf.len(), 32); - // Neither request body the Anchor received contains the nullifier, and - // neither contains the epoch either: the Anchor sees only `Y' = vY` and a - // blinded scalar. for body in [&req1, &req2] { assert!( !body.windows(nf.len()).any(|w| w == nf.as_slice()), "nullifier leaked to the Anchor" ); - assert!( - !body.windows(EPOCH.len()).any(|w| w == EPOCH), - "endorsement_context leaked to the Anchor" - ); } - // Exchange one is a single Point[33]; exchange two is a single scalar. - assert_eq!(req1.len(), 33); - assert_eq!(req2.len(), 32); + // The context is present, by design. + assert!(req1.windows(EPOCH.len()).any(|w| w == EPOCH)); + assert!(!req2.windows(EPOCH.len()).any(|w| w == EPOCH)); } /// Section 4.1.3: "`Verify` MUST fail when given any other `challenge_digest`." +/// Only holds under [`ChallengeBinding::MoleBound`]. #[test] -fn verify_fails_against_a_different_challenge_digest() { +fn mole_bound_verify_fails_against_a_different_challenge_digest() { let a = anchor(1, 42); - let mut client = ExpClient::new(DeterministicRng::new(7)); + let mut c = client(7); let keys = vec![a.public_key()]; - let presentation = client + let p = c .grant_and_present(&a, EPOCH, &keys, &digest(b"bound-to-this")) .unwrap(); assert_eq!( - ExpVerifier - .verify(&presentation, &keys, &digest(b"but-not-this")) + ExpVerifier::new() + .verify(&p, &keys, &digest(b"but-not-this")) .unwrap_err(), Error::VerificationFailed ); } -/// Two separate grants produce unlinkable endorsements: no field is shared. +/// The reference implementation has no `challenge_digest` at all, so under +/// `IhatRsCompatible` a presentation verifies against *any* digest. +/// +/// This is not a bug in this crate — it is the documented consequence of matching +/// `ihat-rs`, and it is exactly why protocols §4.1.3 cannot be satisfied while +/// interoperating with it. See . +#[test] +fn ihat_rs_compatible_mode_cannot_bind_the_challenge() { + let a = anchor(1, 42); + let mut c = + ExpClient::with_binding(DeterministicRng::new(7), ChallengeBinding::IhatRsCompatible); + let keys = vec![a.public_key()]; + let verifier = ExpVerifier::with_binding(ChallengeBinding::IhatRsCompatible); + + let p = c + .grant_and_present(&a, EPOCH, &keys, &digest(b"issued-under-this")) + .unwrap(); + + // Accepted under a completely unrelated digest — replayable, as documented. + verifier + .verify(&p, &keys, &digest(b"totally-different")) + .unwrap(); +} + +/// The two binding modes are mutually unverifiable, which is the concrete shape of +/// the upstream contradiction. +#[test] +fn the_two_binding_modes_do_not_cross_verify() { + let a = anchor(1, 42); + let keys = vec![a.public_key()]; + let cd = digest(b"cross"); + + let mut bound = ExpClient::with_binding(DeterministicRng::new(7), ChallengeBinding::MoleBound); + let mut compat = + ExpClient::with_binding(DeterministicRng::new(7), ChallengeBinding::IhatRsCompatible); + let p_bound = bound.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + let p_compat = compat.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + + // Same length — the difference is in the transcript, not the encoding. + assert_eq!(p_bound.len(), p_compat.len()); + + assert!( + ExpVerifier::with_binding(ChallengeBinding::IhatRsCompatible) + .verify(&p_bound, &keys, &cd) + .is_err() + ); + assert!(ExpVerifier::new().verify(&p_compat, &keys, &cd).is_err()); +} + +/// Two separate grants produce unlinkable endorsements. #[test] fn separate_grants_share_no_field() { let a = anchor(1, 42); let keys = vec![a.public_key()]; let cd = digest(b"unlinkability"); - let mut c1 = ExpClient::new(DeterministicRng::new(7)); - let mut c2 = ExpClient::new(DeterministicRng::new(8)); - let p1 = c1.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); - let p2 = c2.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + let p1 = client(7).grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + let p2 = client(8).grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); assert_ne!(p1, p2); - // Xhat is the randomized Anchor key; distinct gamma means distinct Xhat, so - // presentations cannot be linked to each other by it. + // X_hat is the rerandomized Anchor key; distinct gamma means distinct X_hat. assert_ne!(p1[..33], p2[..33]); - let v1 = ExpVerifier.verify(&p1, &keys, &cd).unwrap(); - let v2 = ExpVerifier.verify(&p2, &keys, &cd).unwrap(); - assert_ne!(v1.nullifier, v2.nullifier); + let v = ExpVerifier::new(); + assert_ne!( + v.verify(&p1, &keys, &cd).unwrap().nullifier, + v.verify(&p2, &keys, &cd).unwrap().nullifier + ); } /// An Anchor outside the accepted set cannot produce an acceptable presentation. @@ -152,13 +276,10 @@ fn anchor_outside_the_accepted_set_is_rejected() { let trusted = anchor(1, 42); let rogue = anchor(9, 99); let keys = vec![trusted.public_key()]; - let cd = digest(b"rogue"); - let mut client = ExpClient::new(DeterministicRng::new(7)); - // `present` cannot even locate a branch for an untrusted Anchor. assert_eq!( - client - .grant_and_present(&rogue, EPOCH, &keys, &cd) + client(7) + .grant_and_present(&rogue, EPOCH, &keys, &digest(b"rogue")) .unwrap_err(), Error::UnknownAnchorKey ); @@ -172,104 +293,114 @@ fn substituting_the_accepted_set_fails() { let issued_over = vec![a0.public_key(), a1.public_key()]; let cd = digest(b"set-substitution"); - let mut client = ExpClient::new(DeterministicRng::new(7)); - let p = client + let p = client(7) .grant_and_present(&a0, EPOCH, &issued_over, &cd) .unwrap(); + let v = ExpVerifier::new(); // Same size, different membership: the OR transcript commits to the keys. let other = vec![a1.public_key(), anchor(3, 44).public_key()]; - assert!(ExpVerifier.verify(&p, &other, &cd).is_err()); + assert!(v.verify(&p, &other, &cd).is_err()); + + // Different size: the branch count must match the set. + assert!(v.verify(&p, &[a0.public_key()], &cd).is_err()); +} + +/// A context mismatch between Client and Anchor fails at finalize, because they +/// derive different Pedersen generators. This is `ihat-rs`'s stated consequence of +/// binding `H` to the context. +#[test] +fn context_mismatch_fails_the_pedersen_check() { + let a = anchor(1, 42); + let mut c = client(7); - // Different size: the proof's branch count is checked against the set. - let shorter = vec![a0.public_key()]; - assert!(ExpVerifier.verify(&p, &shorter, &cd).is_err()); + let (req1, state) = c.prepare(&a.public_key(), EPOCH).unwrap(); + + // Rewrite the context the Anchor sees, keeping the length so the framing stays + // valid. It will derive a different `H`, so `C'` will not open. + let mut tampered = req1.clone(); + let ctx_at = 33 + 2; + tampered[ctx_at] ^= 0x01; + let (resp1, astate) = a.sign(&tampered).unwrap(); + + let (req2, state) = c.request_proof(state, &resp1).unwrap(); + let resp2 = a.prove(astate, &req2).unwrap(); + assert_eq!( + c.finalize(state, &resp2).unwrap_err(), + Error::VerificationFailed + ); } /// Flipping any single byte of a presentation makes it unverifiable. #[test] fn tampering_is_detected() { let a = anchor(1, 42); - let mut client = ExpClient::new(DeterministicRng::new(7)); let keys = vec![a.public_key()]; let cd = digest(b"tamper"); - let good = client.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + let good = client(7).grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + let v = ExpVerifier::new(); - ExpVerifier.verify(&good, &keys, &cd).unwrap(); + v.verify(&good, &keys, &cd).unwrap(); for i in 0..good.len() { let mut bad = good.clone(); bad[i] ^= 0x01; assert!( - ExpVerifier.verify(&bad, &keys, &cd).is_err(), + v.verify(&bad, &keys, &cd).is_err(), "byte {i} could be flipped without detection" ); } } -/// The Client rejects an Anchor that does not apply its real key, which is what -/// the DLEQ proof in exchange one is for. +/// An Anchor that does not apply its real key is caught at finalize by the two +/// DLEQ relations, which stand in for the draft's separate DLEQ proof. #[test] -fn client_rejects_a_bad_dleq_proof() { - let a = anchor(1, 42); - let mut client = ExpClient::new(DeterministicRng::new(7)); +fn client_rejects_an_anchor_that_used_the_wrong_key() { + let honest = anchor(1, 42); + let other = anchor(2, 43); + let mut c = client(7); - let (req1, state) = client.prepare(&a.public_key(), EPOCH).unwrap(); - let (mut resp1, _) = a.sign(&req1).unwrap(); + // The Client prepares against `honest`'s key but `other` signs. + let (req1, state) = c.prepare(&honest.public_key(), EPOCH).unwrap(); + let (resp1, astate) = other.sign(&req1).unwrap(); + let (req2, state) = c.request_proof(state, &resp1).unwrap(); + let resp2 = other.prove(astate, &req2).unwrap(); - // Corrupt Z' — the DLEQ proof no longer matches it. - resp1[33 * 4] ^= 0x01; - assert!(client.request_proof(state, &resp1).is_err()); + assert_eq!( + c.finalize(state, &resp2).unwrap_err(), + Error::VerificationFailed + ); } -/// Verification needs only public keys. Constructing a verifier takes no secret, -/// which is the public-verifiability requirement of Section 9.1.1. +/// Corrupting `Z'` breaks the DLEQ relation the Client checks at finalize. #[test] -fn verification_requires_no_secret() { +fn client_rejects_a_corrupted_signature() { let a = anchor(1, 42); - let mut client = ExpClient::new(DeterministicRng::new(7)); - let keys = vec![a.public_key()]; - let cd = digest(b"public"); - let p = client.grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); - - // A verifier built from nothing at all, with only the published keys passed - // in, accepts. Contrast `insecure_mock::InsecureVerifier`, which is - // constructed from every Anchor's secret. - let verifier = ExpVerifier; - assert!(verifier.verify(&p, &keys, &cd).is_ok()); -} + let mut c = client(7); -/// Garbage input is rejected rather than panicking. -#[test] -fn malformed_presentations_are_rejected() { - let keys = vec![anchor(1, 42).public_key()]; - let cd = digest(b"garbage"); + let (req1, state) = c.prepare(&a.public_key(), EPOCH).unwrap(); + let (resp1, astate) = a.sign(&req1).unwrap(); - for len in 0usize..80 { - let junk = vec![0xABu8; len]; - assert!(ExpVerifier.verify(&junk, &keys, &cd).is_err()); - } - // A non-canonical point tag is refused. - let mut bad_tag = vec![0u8; 200]; - bad_tag[0] = 0xFF; - assert!(ExpVerifier.verify(&bad_tag, &keys, &cd).is_err()); - // An empty accepted set has no branch to prove. - assert!(ExpVerifier.verify(&[], &[], &cd).is_err()); + // Replace Z' with a different valid point: T1' is a valid encoding, so this + // stays decodable and fails the relation rather than the parser. + let mut tampered = resp1.clone(); + tampered[..33].copy_from_slice(&resp1[66..99]); + + let (req2, state) = c.request_proof(state, &tampered).unwrap(); + let resp2 = a.prove(astate, &req2).unwrap(); + assert_eq!( + c.finalize(state, &resp2).unwrap_err(), + Error::VerificationFailed + ); } -/// Anchor keys are genuine SEC1 compressed P-256 points, which is exactly the -/// `opaque Point[33]` the protocols draft specifies. -/// -/// This is the one place switching from ristretto255 to P-256 *removed* an -/// invention rather than adding one: the profile used to pad a 32-byte ristretto -/// encoding with a tag byte to reach 33. Now the width is the encoding. +/// Anchor keys are genuine SEC1 compressed P-256 points — exactly the +/// `opaque Point[33]` of protocols §4.1, and `ihat-rs`'s `Point`. #[test] fn public_keys_are_sec1_compressed_p256_points() { for seed in 1u8..12 { let pk: Point = anchor(seed, u64::from(seed) + 500).public_key(); assert_eq!(pk.0.len(), 33); - // SEC1 compressed: 0x02 for even Y, 0x03 for odd. Never 0x04 - // (uncompressed), never 0x00 (the identity, or our old tag hack). assert!( pk.0[0] == 0x02 || pk.0[0] == 0x03, "seed {seed}: leading byte {:#04x} is not a SEC1 compressed prefix", @@ -278,8 +409,8 @@ fn public_keys_are_sec1_compressed_p256_points() { } } -/// Both SEC1 compressed prefixes actually occur, so the assertion above is not -/// passing by only ever seeing one of them. +/// Both compressed parities occur, so the assertion above is not passing by only +/// ever seeing one of them. #[test] fn both_sec1_parity_prefixes_occur() { let prefixes: Vec = (1u8..40) @@ -289,16 +420,56 @@ fn both_sec1_parity_prefixes_occur() { assert!(prefixes.contains(&0x03), "no odd-Y key generated"); } -/// The identity has no 33-byte SEC1 encoding, so it must be refused rather than -/// truncated into something that looks like a point. +/// An accepted set containing the identity is refused. `ihat-rs` added this check +/// explicitly ("reject identity keys in the accepted set") after fixing an identity +/// point forgery, so it is worth pinning here too. #[test] -fn the_identity_is_not_a_valid_point() { - let keys = vec![Point([0u8; 33])]; +fn an_identity_key_in_the_accepted_set_is_refused() { + let a = anchor(1, 42); let cd = digest(b"identity"); - // An all-zero Point[33] is the SEC1 identity padded out; it is not decodable - // as a group element. - assert_eq!( - ExpVerifier.verify(&[0u8; 240], &keys, &cd).unwrap_err(), - Error::InvalidGroupElement - ); + let real = vec![a.public_key()]; + let p = client(7).grant_and_present(&a, EPOCH, &real, &cd).unwrap(); + + // 33 zero bytes is the identity's compressed representation. + let with_identity = vec![a.public_key(), Point([0u8; 33])]; + assert!(ExpVerifier::new().verify(&p, &with_identity, &cd).is_err()); +} + +/// Garbage input is rejected rather than panicking. +#[test] +fn malformed_presentations_are_rejected() { + let keys = vec![anchor(1, 42).public_key()]; + let cd = digest(b"garbage"); + let v = ExpVerifier::new(); + + for len in 0usize..300 { + assert!(v.verify(&alloc_vec(0xAB, len), &keys, &cd).is_err()); + } + // An empty accepted set has no branch to prove. + assert!(v.verify(&[], &[], &cd).is_err()); +} + +fn alloc_vec(byte: u8, len: usize) -> Vec { + core::iter::repeat(byte).take(len).collect() +} + +/// An OR proof whose declared length is not a multiple of the 97-byte transcript +/// is malformed. +#[test] +fn ragged_or_proof_is_rejected() { + let a = anchor(1, 42); + let keys = vec![a.public_key()]; + let cd = digest(b"ragged"); + let good = client(7).grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + + // Truncate one byte off the proof and fix the declared length to match. + let proof_len_at = 33 + 33 + 2 + 32 + 4 * 32 + 2 + EPOCH.len(); + let mut bad = good[..good.len() - 1].to_vec(); + let shortened = u16::try_from(97 - 1).unwrap(); + bad[proof_len_at..proof_len_at + 2].copy_from_slice(&shortened.to_be_bytes()); + + assert!(matches!( + ExpVerifier::new().verify(&bad, &keys, &cd), + Err(Error::RaggedVector { .. }) + )); } From d8046a035d47eab1b8d4d4d045f5f3d59d50ca3a Mon Sep 17 00:00:00 2001 From: Orie Steele Date: Tue, 28 Jul 2026 15:29:58 -0500 Subject: [PATCH 4/6] test: drive Appendix A on real cryptography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the profile was validated in isolation and against ihat-rs, but the complete-protocol test still ran entirely on insecure_mock. This proves the profile plugs into MoLE rather than into a harness built around it. tests/appendix_a_exp_ihat.rs walks the same exchange as appendix_a_flow.rs with the endorsement half on exp_ihat: the WWW-Authenticate challenge, binding over the decoded octets, both grant exchanges with their bodies carried inside real EndorsementRequest/EndorsementResponse envelopes, redemption through Moderator::redeem against a real accepted set, then the credential exchange and update. Seven tests. Two of them say something the mock version could not: - the_moderator_cannot_tell_which_anchor_issued: every Anchor in the accepted set can issue, presentation size is invariant, and the Moderator ends up holding an epoch it already knew plus a nullifier. Issuer hiding asserted at the protocol level rather than inside the profile's own tests. - the_accepted_set_order_is_load_bearing: a permutation of the accepted set does not verify, which is what makes §4.1.3's normative branch ordering real rather than decorative. A deliberate behavioural difference worth noting: a redemption bound to another challenge now fails with VerificationFailed, not the mock's ChallengeMismatch. With real cryptography the Moderator genuinely cannot distinguish "wrong challenge" from "invalid proof" — the OR proof just fails. That is the better outcome, since §8 warns redeem's distinct error variants must not be surfaced to Clients, and here there is nothing to surface. Also strengthened the MSRV CI job from `cargo build` to `cargo test`. Clippy's incompatible_msrv lint caught a `split_at_checked` (stable 1.80) in the new test code; running the tests on 1.75 catches that class directly rather than relying on a lint. The credential half is still a stand-in token. That is now the only fake cryptography left in the reachable protocol surface — Longfellow needs libzk plus an out-of-band circuit, ACT is expiring upstream, and key rotation and the config format are TODO. in the draft. 116 tests plus 4 interop. Clippy pedantic, rustdoc, fmt clean; no_std on thumbv7em-none-eabi with and without the profile; MSRV 1.75 builds and tests --locked. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 4 +- EXP-IHAT-PROFILE.md | 27 ++ README.md | 12 +- SPEC-COVERAGE.md | 2 +- tests/appendix_a_exp_ihat.rs | 481 +++++++++++++++++++++++++++++++++++ 5 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 tests/appendix_a_exp_ihat.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9673171..d3572b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,4 +80,6 @@ jobs: - uses: Swatinem/rust-cache@v2 # --locked matters here: holding 1.75 depends on the committed lockfile # pinning zeroize below the version that requires edition2024. See #12. - - run: cargo build --all-features --locked + # Running the tests, not just building, is what catches a post-1.75 API + # sneaking into test code. + - run: cargo test --all-features --locked diff --git a/EXP-IHAT-PROFILE.md b/EXP-IHAT-PROFILE.md index 558167b..aa4c39b 100644 --- a/EXP-IHAT-PROFILE.md +++ b/EXP-IHAT-PROFILE.md @@ -213,6 +213,33 @@ strings. cargo test --manifest-path interop/Cargo.toml ``` +### End-to-end through the protocol + +`tests/appendix_a_exp_ihat.rs` drives Appendix A of the protocols draft on this +profile: the `WWW-Authenticate` challenge, challenge binding over the decoded +octets, both grant exchanges with their bodies carried inside real +`EndorsementRequest`/`EndorsementResponse` envelopes, redemption through +`Moderator::redeem`, the credential exchange, and the update. Seven tests, including: + +- issuer hiding at the protocol level — every Anchor in the accepted set can issue, + presentation size does not vary, and what the Moderator ends up holding is an epoch + it already knew plus a nullifier +- an endorsement cannot be redeemed twice (real nullifier, real store) +- a redemption bound to another challenge fails — and note the error is + `VerificationFailed`, not the mock's `ChallengeMismatch`, because with real + cryptography the Moderator genuinely cannot distinguish "wrong challenge" from + "invalid proof". That is the better outcome: §8 warns that `redeem`'s distinct + error variants must not be surfaced to Clients, and here there is nothing to + surface +- an endorsement from a previous epoch verifies cryptographically but is refused by + the epoch check, so the two checks are independent and correctly ordered +- the accepted set's order is load-bearing: a permutation does not verify +- an unaccepted Anchor completes the grant fine but cannot present + +This matters because it exercises the profile against the *protocol machinery* +rather than a harness built around it. The only remaining stand-in is the Privacy +Pass token in the credential half. + ### Property tests `tests/exp_ihat_profile.rs` — 21 tests: diff --git a/README.md b/README.md index 8fd9198..6f622c1 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ That is `moderator::NonceStore`, kept separate from the epoch `NullifierStore`. ## Tests ``` -cargo test --all-features # 109 tests +cargo test --all-features # 116 tests cargo test --manifest-path interop/Cargo.toml # 4 cross-verification tests cargo clippy --all-features --all-targets RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps @@ -204,7 +204,15 @@ only rustdoc will catch. `tests/wire_vectors.rs` holds byte-exact hex vectors — the drafts publish none, so these are this crate's, offered for cross-implementation checking. `tests/appendix_a_flow.rs` drives the complete exchange from Appendix A of the -protocols draft. `tests/exp_ihat_profile.rs` exercises the experimental profile. +protocols draft on `insecure_mock`; **`tests/appendix_a_exp_ihat.rs` drives the same +exchange on real cryptography** — the profile's actual grant messages carried inside +the actual `EndorsementRequest`/`EndorsementResponse` envelopes, redeemed through a +real OR proof against a real accepted set. `tests/exp_ihat_profile.rs` exercises the +profile in isolation. + +After that, exactly one piece of cryptography in the reachable protocol surface is +still a stand-in: the Privacy Pass token in the credential half, which needs RFC 9578 +issuance. `interop/` is a detached package that differential-tests the profile against `ihat-rs` in both directions — our presentations verify under theirs, theirs verify diff --git a/SPEC-COVERAGE.md b/SPEC-COVERAGE.md index 083ae98..7ba9d0d 100644 --- a/SPEC-COVERAGE.md +++ b/SPEC-COVERAGE.md @@ -37,7 +37,7 @@ Legend: ✅ implemented · 🟡 partial, see note · ⬜ structures only, semant | 9.2 | Credential Type registry | ✅ | `registry::CredentialType`, candidate values `0x0001`–`0x0003`. | | 9.2.4 | Greased Values | ✅ | `registry::GREASE_CREDENTIAL_TYPES`, with a test that the table equals the documented `0x?A?A` pattern. | | 9.3 | Media Types | ✅ | `endorsement::ENDORSEMENT_{REQUEST,RESPONSE}_MEDIA_TYPE`. Full registration templates are a draft TODO. | -| A | Example | ✅ | `tests/appendix_a_flow.rs` drives the whole figure. | +| A | Example | ✅ | `tests/appendix_a_flow.rs` drives the whole figure on `insecure_mock`; `tests/appendix_a_exp_ihat.rs` drives it on real IHAT cryptography, with the grant bodies carried through the real transport envelopes. The credential half is still a stand-in token pending RFC 9578 issuance. | ## draft-jms-mole-http-transport-00 diff --git a/tests/appendix_a_exp_ihat.rs b/tests/appendix_a_exp_ihat.rs new file mode 100644 index 0000000..ade2fc6 --- /dev/null +++ b/tests/appendix_a_exp_ihat.rs @@ -0,0 +1,481 @@ +//! Appendix A of `draft-jms-mole-protocols-00`, driven on **real cryptography**. +//! +//! This is the same exchange as `appendix_a_flow.rs`, but the endorsement half runs +//! on `crypto::exp_ihat` — IHAT over P-256, cross-verified against the `ihat-rs` +//! reference implementation — instead of `crypto::insecure_mock`. The two grant +//! exchanges carry the profile's actual wire messages inside the actual +//! `EndorsementRequest`/`EndorsementResponse` envelopes, and the Moderator's +//! redemption runs a real OR proof against a real accepted set. +//! +//! ```text +//! +--------+ +--------+ +-----------+ +//! | Client | | Anchor | | Moderator | +//! +---+----+ +---+----+ +-----+-----+ +//! | | | +//! +------------------|---- request --->| +//! |<-----------------|--- challenge ---+ +//! +--- exchange 1 -->| | real: Y' = vY, context +//! |<-- response 1 ---+ | real: Z', C', T1', T2' +//! +--- exchange 2 -->| | real: e' +//! |<-- response 2 ---+ | real: r', a', b' +//! Finalize | | +//! +------------------|---- redeem ---->| real: endorsement + OR proof +//! |<-----------------|---- credential -+ +//! Finalize | | +//! +------------------|---- present --->| +//! |<-----------------|-- ok + update --+ +//! ``` +//! +//! # What is still a stand-in +//! +//! The **credential** half. Credential type `0x0002` carries raw RFC 9578 +//! `TokenRequest`/`TokenResponse` structures, and this crate does not implement +//! Privacy Pass issuance, so `mock_token` stands in for the token. That is now the +//! only remaining piece of fake cryptography in the whole reachable protocol +//! surface — everything upstream of it here is real. + +#![cfg(feature = "exp-ihat")] + +use mole::binding::ChallengeDigest; +use mole::client::DeterministicRng; +use mole::codec::Message; +use mole::credential::{reverse_flow, CredentialRequest, CredentialResponse}; +use mole::crypto::exp_ihat::{ExpAnchor, ExpClient, ExpVerifier}; +use mole::crypto::{IhatAnchor, IhatClient, RandomSource}; +use mole::endorsement::{ihat, EndorsementRequest, EndorsementResponse, Point}; +use mole::error::Error; +use mole::http::{parse_www_authenticate, Authorization, MoleChallenge, MoleCredential}; +use mole::moderator::{ChallengeMode, Disposition, Moderator, NonceStore}; +use mole::registry::{CredentialType, EndorsementType}; +use mole::transport::{ + CredentialPresentation, CredentialUpdate, ModeratorChallenge, OptionalCredentialUpdate, +}; + +const EPOCH: &[u8] = b"2026-07-28T00:00:00Z/PT24H"; +/// Which Anchor in the accepted set actually issues. The Moderator must not be +/// able to tell. +const ISSUER: usize = 1; + +/// A stand-in for an RFC 9577 `Token`. See the module docs: the credential half is +/// the remaining gap. +fn mock_token(nonce: &[u8; 32], digest: &ChallengeDigest) -> Vec { + let mut t = Vec::with_capacity(64); + t.extend_from_slice(nonce); + t.extend_from_slice(digest.as_bytes()); + t +} + +fn mock_token_parts(token: &[u8]) -> Option<([u8; 32], ChallengeDigest)> { + if token.len() != 64 { + return None; + } + let (nonce, digest) = token.split_at(32); + Some(( + nonce.try_into().ok()?, + ChallengeDigest(digest.try_into().ok()?), + )) +} + +struct Deployment { + anchors: Vec>, + /// The accepted set. Order is normative (Section 4.1.3) — OR-proof branches + /// match keys by position — so it is fixed here and reused verbatim. + keys: Vec, + verifier: ExpVerifier, + moderator: Moderator, + nonces: NonceStore, +} + +impl Deployment { + fn new() -> Self { + let anchors: Vec> = [0xA1u8, 0xA2, 0xA3] + .into_iter() + .enumerate() + .map(|(i, seed)| { + ExpAnchor::new([seed; 32], DeterministicRng::new(9000 + i as u64)).unwrap() + }) + .collect(); + let keys = anchors.iter().map(ExpAnchor::public_key).collect(); + Self { + anchors, + keys, + verifier: ExpVerifier::new(), + moderator: Moderator::new( + vec![CredentialType::PRIVACY_PASS_REVERSE_FLOW], + EPOCH.to_vec(), + ), + nonces: NonceStore::new(), + } + } + + /// The Moderator's `WWW-Authenticate` header for Redeem & Issue. + fn issuance_challenge_header(&self) -> String { + let inner = ihat::Challenge { + keys: self.keys.clone(), + } + .to_wire() + .unwrap(); + let outer = ModeratorChallenge { + endorsement_type: EndorsementType::IHAT, + challenge: inner, + } + .to_wire() + .unwrap(); + MoleChallenge::new(&outer, Some("moderator")).to_header_value() + } + + /// Runs the two grant exchanges against `anchors[issuer]`, carrying every body + /// through the `EndorsementRequest`/`EndorsementResponse` envelopes the HTTP + /// transport draft specifies, and returns the redemption bytes. + fn grant_over_the_transport( + &self, + client: &mut ExpClient, + issuer: usize, + endorsement_context: &[u8], + digest: &ChallengeDigest, + ) -> Vec { + let anchor = &self.anchors[issuer]; + + // ---- Exchange 1 ---------------------------------------------------- + let (body1, state) = client + .prepare(&anchor.public_key(), endorsement_context) + .unwrap(); + let req1 = EndorsementRequest { + endorsement_type: EndorsementType::IHAT, + body: body1, + }; + // The envelope round-trips carrying the profile's real Y' and context. + let req1 = EndorsementRequest::from_wire(&req1.to_wire().unwrap()).unwrap(); + let (body2, anchor_state) = anchor.sign(&req1.body).unwrap(); + let resp1 = EndorsementResponse { + endorsement_type: EndorsementType::IHAT, + body: body2, + }; + let resp1 = EndorsementResponse::from_wire(&resp1.to_wire().unwrap()).unwrap(); + + // ---- Exchange 2 ---------------------------------------------------- + let (body3, state) = client.request_proof(state, &resp1.body).unwrap(); + let req2 = EndorsementRequest { + endorsement_type: EndorsementType::IHAT, + body: body3, + }; + let req2 = EndorsementRequest::from_wire(&req2.to_wire().unwrap()).unwrap(); + let body4 = anchor.prove(anchor_state, &req2.body).unwrap(); + let resp2 = EndorsementResponse { + endorsement_type: EndorsementType::IHAT, + body: body4, + }; + let resp2 = EndorsementResponse::from_wire(&resp2.to_wire().unwrap()).unwrap(); + + let endorsement = client.finalize(state, &resp2.body).unwrap(); + client.present(&endorsement, &self.keys, digest).unwrap() + } +} + +#[test] +#[allow(clippy::too_many_lines)] // It walks the whole of Appendix A on purpose. +fn complete_exchange_on_real_cryptography() { + let mut dep = Deployment::new(); + let mut rng = DeterministicRng::new(0x00C0_FFEE); + let mut client = ExpClient::new(DeterministicRng::new(1)); + + // ---- Moderator -> Client: 401 with a challenge ------------------------- + let header = dep.issuance_challenge_header(); + assert_eq!(ChallengeMode::Required.status(), 401); + + let challenges = parse_www_authenticate(&header); + assert_eq!(challenges.len(), 1); + assert_eq!(challenges[0].realm(), Some("moderator")); + + // The Client binds to the DECODED octets (Section 3.3). + let challenge_octets = challenges[0].challenge_octets().unwrap(); + let digest = ChallengeDigest::new(&challenge_octets); + + let moderator_challenge = ModeratorChallenge::from_wire(&challenge_octets).unwrap(); + assert_eq!(moderator_challenge.endorsement_type, EndorsementType::IHAT); + let ihat_challenge = ihat::Challenge::from_wire(&moderator_challenge.challenge).unwrap(); + assert_eq!(ihat_challenge.keys.len(), 3); + // The keys that survived the wire are real SEC1 compressed P-256 points. + for key in &ihat_challenge.keys { + assert!(key.0[0] == 0x02 || key.0[0] == 0x03); + } + assert_eq!(ihat_challenge.keys, dep.keys); + + // ---- Client <-> Anchor: two real grant exchanges ----------------------- + let presentation_bytes = dep.grant_over_the_transport(&mut client, ISSUER, EPOCH, &digest); + let endorsement_presentation = ihat::Presentation { + bytes: presentation_bytes, + } + .to_wire() + .unwrap(); + + // ---- Client -> Moderator: Redeem & Issue ------------------------------ + let credential_request = CredentialRequest { + endorsement_type: EndorsementType::IHAT, + endorsement_presentation: endorsement_presentation.clone(), + credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, + // A real Client sends a TokenRequest here — see the module docs. + issuance_request: vec![0x01, 0x02], + }; + let auth_header = + Authorization::CredentialRequest(credential_request.to_wire().unwrap()).to_header_value(); + + // ---- Moderator verifies the redemption ------------------------------- + let Authorization::CredentialRequest(body) = Authorization::parse(&auth_header).unwrap() else { + panic!("expected a credential-request"); + }; + let received = CredentialRequest::from_wire(&body).unwrap(); + assert_eq!(received, credential_request); + + let inner = ihat::Presentation::from_wire(&received.endorsement_presentation).unwrap(); + let verified = dep + .moderator + .redeem(&dep.verifier, &inner.bytes, &ihat_challenge.keys, &digest) + .expect("a real endorsement should verify"); + + // The Moderator learns exactly two things, and neither identifies the Anchor. + assert_eq!(verified.endorsement_context, EPOCH.to_vec()); + assert_eq!(verified.nullifier.len(), 32); + assert_eq!(dep.moderator.nullifiers().len(), 1); + + // ---- Moderator -> Client: CredentialResponse ------------------------- + let issued_nonce = { + let mut n = [0u8; 32]; + rng.fill_bytes(&mut n); + n + }; + // "The presented and reissued token MUST use the same token type and the same + // Moderator public key." The token's digest is the Moderator's CONSTANT + // challenge digest for this configuration (Section 5.2.2). + let constant_digest = ChallengeDigest::new(&reverse_flow::Challenge.to_wire().unwrap()); + let issued_token = mock_token(&issued_nonce, &constant_digest); + + let credential_response = CredentialResponse { + credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, + issuance_response: issued_token.clone(), + }; + let cred_header = MoleCredential::Response(credential_response.to_wire().unwrap()); + let MoleCredential::Response(resp_bytes) = + MoleCredential::parse(&cred_header.to_header_value()).unwrap() + else { + panic!("expected a response"); + }; + assert_eq!( + CredentialResponse::from_wire(&resp_bytes).unwrap(), + credential_response + ); + + // ---- Client -> Moderator: presentation ------------------------------- + let pau = reverse_flow::PresentationAndUpdate { + token: issued_token.clone(), + token_request: vec![0x03], + }; + let pres_header = Authorization::Presentation( + CredentialPresentation { + credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, + presentation_and_update: pau.to_wire().unwrap(), + } + .to_wire() + .unwrap(), + ) + .to_header_value(); + + // ---- Moderator verifies the presentation ----------------------------- + let Authorization::Presentation(pbytes) = Authorization::parse(&pres_header).unwrap() else { + panic!("expected a presentation"); + }; + let received = CredentialPresentation::from_wire(&pbytes).unwrap(); + assert_eq!( + dep.moderator.dispatch(received.credential_type), + Disposition::Handled(CredentialType::PRIVACY_PASS_REVERSE_FLOW) + ); + + let pau = + reverse_flow::PresentationAndUpdate::from_wire(&received.presentation_and_update).unwrap(); + let (nonce, token_digest) = mock_token_parts(&pau.token).expect("well-formed token"); + // Anti-replay comes from the token being single-use, NOT challenge binding. + assert_eq!(token_digest, constant_digest); + dep.nonces.admit(&nonce).expect("first use"); + + // ---- Moderator -> Client: update ------------------------------------- + let update = OptionalCredentialUpdate::present(CredentialUpdate { + credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, + update_response: reverse_flow::Update { + token_response: mock_token(&[0x77u8; 32], &constant_digest), + } + .to_wire() + .unwrap(), + }); + let update_header = MoleCredential::Update(update.to_wire().unwrap()).to_header_value(); + let MoleCredential::Update(ubytes) = MoleCredential::parse(&update_header).unwrap() else { + panic!("expected an update"); + }; + let parsed_update = OptionalCredentialUpdate::from_wire(&ubytes).unwrap(); + assert!(!parsed_update.is_absent(), "credential remains usable"); + assert_eq!(parsed_update, update); +} + +/// Issuer hiding, at the protocol level: every Anchor in the accepted set can issue +/// an endorsement the Moderator accepts, and what the Moderator ends up holding is +/// indistinguishable across them. +#[test] +fn the_moderator_cannot_tell_which_anchor_issued() { + let digest = ChallengeDigest::new(b"issuer-hiding"); + let mut redemptions = Vec::new(); + + for issuer in 0..3 { + let mut dep = Deployment::new(); + let mut client = ExpClient::new(DeterministicRng::new(200 + issuer as u64)); + let bytes = dep.grant_over_the_transport(&mut client, issuer, EPOCH, &digest); + let verified = dep + .moderator + .redeem(&dep.verifier, &bytes, &dep.keys, &digest) + .expect("every Anchor in the accepted set can issue"); + redemptions.push((bytes.len(), verified)); + } + + // Same wire size regardless of which branch is real, so length leaks nothing. + let sizes: Vec = redemptions.iter().map(|(n, _)| *n).collect(); + assert!( + sizes.windows(2).all(|w| w[0] == w[1]), + "presentation size varies by issuing Anchor: {sizes:?}" + ); + + // The Moderator's view is the same shape from every Anchor: an epoch it already + // knew, and a nullifier that carries no issuer information. `VerifiedEndorsement` + // has no other field — the type is the guarantee. + for (_, verified) in &redemptions { + assert_eq!(verified.endorsement_context, EPOCH.to_vec()); + assert_eq!(verified.nullifier.len(), 32); + } + // Distinct Anchors still produce distinct nullifiers, so they remain spendable + // independently. + let mut nullifiers: Vec<&Vec> = redemptions.iter().map(|(_, v)| &v.nullifier).collect(); + nullifiers.sort(); + nullifiers.dedup(); + assert_eq!(nullifiers.len(), 3); +} + +/// "The Endorsement is spent: redeeming it again MUST fail check 2." +#[test] +fn an_endorsement_cannot_be_redeemed_twice() { + let mut dep = Deployment::new(); + let digest = ChallengeDigest::new(b"a challenge"); + let mut client = ExpClient::new(DeterministicRng::new(2)); + let presentation = dep.grant_over_the_transport(&mut client, ISSUER, EPOCH, &digest); + + assert!(dep + .moderator + .redeem(&dep.verifier, &presentation, &dep.keys, &digest) + .is_ok()); + assert_eq!( + dep.moderator + .redeem(&dep.verifier, &presentation, &dep.keys, &digest) + .err(), + Some(Error::NullifierAlreadySpent) + ); +} + +/// "A verifier MUST reject a redemption or presentation bound to a different +/// challenge." +/// +/// Note the error differs from the mock's `ChallengeMismatch`. With real +/// cryptography the Moderator cannot distinguish "bound to another challenge" from +/// "the proof is simply invalid" — the OR proof just fails to verify. That is the +/// better outcome: Section 8 warns that `redeem`'s distinct error variants must not +/// be surfaced to Clients, and here there is nothing to surface. +#[test] +fn a_redemption_bound_to_another_challenge_is_rejected() { + let mut dep = Deployment::new(); + let mut client = ExpClient::new(DeterministicRng::new(3)); + let presentation = dep.grant_over_the_transport( + &mut client, + ISSUER, + EPOCH, + &ChallengeDigest::new(b"challenge A"), + ); + + assert_eq!( + dep.moderator + .redeem( + &dep.verifier, + &presentation, + &dep.keys, + &ChallengeDigest::new(b"challenge B") + ) + .err(), + Some(Error::VerificationFailed) + ); + // And the failed attempt must not have consumed the nullifier. + assert_eq!(dep.moderator.nullifiers().len(), 0); +} + +/// An endorsement granted under a different epoch verifies cryptographically but is +/// refused by the Moderator's epoch check — so the two checks are independent, and +/// in the order Section 4.1.3 requires. +#[test] +fn an_endorsement_from_a_previous_epoch_is_rejected() { + let mut dep = Deployment::new(); + let digest = ChallengeDigest::new(b"c"); + let mut client = ExpClient::new(DeterministicRng::new(4)); + let stale = dep.grant_over_the_transport(&mut client, ISSUER, b"an-older-epoch", &digest); + + // The proof itself is valid — the endorsement is real, just for the wrong epoch. + assert!(mole::crypto::IhatVerifier::verify(&dep.verifier, &stale, &dep.keys, &digest).is_ok()); + + assert_eq!( + dep.moderator + .redeem(&dep.verifier, &stale, &dep.keys, &digest) + .err(), + Some(Error::WrongEpoch) + ); + assert_eq!(dep.moderator.nullifiers().len(), 0); +} + +/// An Anchor the Moderator does not accept cannot get a redemption through, even +/// though it can complete the grant perfectly well. +#[test] +fn an_endorsement_from_an_unaccepted_anchor_is_rejected() { + let dep = Deployment::new(); + // 0x7F.. is a canonical P-256 scalar; 0xFF.. would exceed the group order and + // be refused outright, which is a different (and separately tested) failure. + let rogue = ExpAnchor::new([0x7Fu8; 32], DeterministicRng::new(31)).unwrap(); + let digest = ChallengeDigest::new(b"rogue"); + let mut client = ExpClient::new(DeterministicRng::new(5)); + + // The grant succeeds: the rogue Anchor is a real Anchor with a real key. + let (body1, state) = client.prepare(&rogue.public_key(), EPOCH).unwrap(); + let (body2, astate) = rogue.sign(&body1).unwrap(); + let (body3, state) = client.request_proof(state, &body2).unwrap(); + let body4 = rogue.prove(astate, &body3).unwrap(); + let endorsement = client.finalize(state, &body4).unwrap(); + + // Presentation is what fails: there is no branch for it in the accepted set. + assert_eq!( + client.present(&endorsement, &dep.keys, &digest).err(), + Some(Error::UnknownAnchorKey) + ); +} + +/// The accepted set's order is normative, and the OR proof depends on it: a +/// presentation built over one ordering must not verify against a permutation. +#[test] +fn the_accepted_set_order_is_load_bearing() { + let mut dep = Deployment::new(); + let digest = ChallengeDigest::new(b"ordering"); + let mut client = ExpClient::new(DeterministicRng::new(6)); + let presentation = dep.grant_over_the_transport(&mut client, ISSUER, EPOCH, &digest); + + let mut permuted = dep.keys.clone(); + permuted.swap(0, 2); + assert!(dep + .moderator + .redeem(&dep.verifier, &presentation, &permuted, &digest) + .is_err()); + + // The original ordering still works, and only now is the nullifier consumed. + assert!(dep + .moderator + .redeem(&dep.verifier, &presentation, &dep.keys, &digest) + .is_ok()); +} From 303b48fac5243d14088c4ae92023fc3c3b504445 Mon Sep 17 00:00:00 2001 From: Orie Steele Date: Tue, 28 Jul 2026 15:42:36 -0500 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20complete=20the=20protocol=20?= =?UTF-8?q?=E2=80=94=20Appendix=20A=20end=20to=20end=20with=20no=20mocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the credential half was the last fake cryptography in the reachable surface. It is now real, and the std constraint I expected turned out to be avoidable. The boundary was in a better place than I thought. Section 5.2.1 has MoLE carry TokenRequest and TokenResponse *opaquely* — it defines no structures for them, and this crate already defines no wrappers. So the library needs no Privacy Pass dependency at all; it needs exactly one thing it was missing, and the real integration belongs where interop/ already lives. No `std` feature, no async in the library, dependency count still two. Library addition: credential::reverse_flow::Token, a parser for the RFC 9578 Token structure (token_type, nonce, challenge_digest, token_key_id, authenticator), plus TOKEN_TYPES and authenticator_len. Structure only, no cryptography, no_std. It exists because a Moderator cannot enforce either of §5.2.2's two requirements — constant digest, unseen nonce — without reading those fields, and until now it had no way to do that short of taking a full Privacy Pass dependency. Five unit tests, including that a known token_type with the wrong Nk is malformed, an unknown type with any non-empty authenticator is accepted (a Moderator may be configured with a type this crate predates), and that truncation never panics. interop/tests/appendix_a_end_to_end.rs runs Appendix A with nothing mocked: real IHAT endorsements over P-256 for the endorsement half, real RFC 9578 Privacy Pass via `privacypass` for the credential half — issuance, finalization, presentation, nonce checks on both layers, and reissue of the update. Three tests. privacypass is std-only with an async issuer API, which is exactly why it lives out here and not in the library. Found a fourth cross-document contradiction while doing it (#16). §5.2.2 says the token's challenge_digest MUST equal the Moderator's constant challenge_digest, cross-referencing §3.3 — MoLE's SHA-256(challenge octets), which for this type is SHA-256(""). That is unsatisfiable: the token's challenge_digest is computed by RFC 9578 as SHA-256(TokenChallenge), and an RFC 9577 TokenChallenge always carries a token_type and a non-empty issuer_name, so it can never digest to SHA-256(""). It would need a preimage. Two different fields share one name and the MUST tries to equate them. The test takes the workable reading — one fixed TokenChallenge per Moderator, constant across every token it issues — which is the property the surrounding text actually asks for. Nothing is weakened: §5.2.2 is explicit that anti-replay comes from the nonce being single-use, and that is enforced and tested on both layers. Library remains no_std on thumbv7em with sha2 and base64 only. 121 tests plus 7 in the detached package. Clippy pedantic, rustdoc, fmt clean; MSRV 1.75 builds and tests --locked. CI's interop job now covers both the ihat-rs cross-verification and the end-to-end run. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 13 +- .gitignore | 1 + README.md | 25 +- SPEC-COVERAGE.md | 5 +- interop/Cargo.lock | 895 ++++++++++++++++++++++++- interop/Cargo.toml | 26 +- interop/tests/appendix_a_end_to_end.rs | 489 ++++++++++++++ src/credential.rs | 187 ++++++ 8 files changed, 1594 insertions(+), 47 deletions(-) create mode 100644 interop/tests/appendix_a_end_to_end.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3572b2..9cc0207 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,18 +57,17 @@ jobs: run: cargo build --no-default-features --features exp-ihat --target thumbv7em-none-eabi interop: - name: Interop vs ihat-rs + name: Interop and end-to-end runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - # Differential tests against the reference implementation published by a - # MoLE draft author. This is the strongest validation available: neither the - # drafts nor ihat-rs publish test vectors, so cross-verification stands in - # for them. Kept in a detached package because it needs a git dependency and - # Cargo cannot make dev-dependencies optional. - - name: Cross-verify with ihat-rs + # Two things live here, both needing dependencies the library refuses to + # take: differential tests against ihat-rs (the reference implementation + # published by a MoLE draft author), and an end-to-end run of Appendix A + # with real RFC 9578 Privacy Pass credentials via `privacypass`. + - name: Cross-verify with ihat-rs, and run Appendix A end to end run: cargo test --manifest-path interop/Cargo.toml msrv: diff --git a/.gitignore b/.gitignore index d9e911e..1840de4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .idea/ .vscode/ /interop/target +/.git-worktrees diff --git a/README.md b/README.md index 6f622c1..b6e33a5 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ decides. See [`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md) and | IHAT cryptography | 🟡 trait boundary; `exp-ihat` implements it over P-256, cross-verified against `ihat-rs` | | Longfellow proving/verifying | ❌ needs `draft-google-cfrg-libzk` + circuit | | ACT spend/refund | ❌ needs `draft-schlesinger-cfrg-act` | -| Privacy Pass issuance (VOPRF, batched tokens) | ❌ needs RFC 9578 impl | +| Privacy Pass issuance (VOPRF, batched tokens) | ⬜ carried opaquely per §5.2.1; `reverse_flow::Token` parses what a Moderator must read; demonstrated end to end against `privacypass` in `interop/` | | Key rotation and discovery | ❌ draft section is `TODO.` | [`SPEC-COVERAGE.md`](SPEC-COVERAGE.md) maps every draft section to what this crate @@ -190,8 +190,8 @@ That is `moderator::NonceStore`, kept separate from the epoch `NullifierStore`. ## Tests ``` -cargo test --all-features # 116 tests -cargo test --manifest-path interop/Cargo.toml # 4 cross-verification tests +cargo test --all-features # 121 tests +cargo test --manifest-path interop/Cargo.toml # 7 interop + end-to-end tests cargo clippy --all-features --all-targets RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps cargo build --no-default-features # no_std @@ -210,17 +210,20 @@ the actual `EndorsementRequest`/`EndorsementResponse` envelopes, redeemed throug real OR proof against a real accepted set. `tests/exp_ihat_profile.rs` exercises the profile in isolation. -After that, exactly one piece of cryptography in the reachable protocol surface is -still a stand-in: the Privacy Pass token in the credential half, which needs RFC 9578 -issuance. +And `interop/tests/appendix_a_end_to_end.rs` runs the same exchange with **no mocks +at all** — real IHAT endorsements plus real RFC 9578 Privacy Pass credentials via +`privacypass`, including presentation and reissue. Every piece of cryptography in the +reachable protocol surface is now real. -`interop/` is a detached package that differential-tests the profile against -`ihat-rs` in both directions — our presentations verify under theirs, theirs verify +`interop/` is a detached package for tests that need dependencies the library +refuses to take. It differential-tests the profile against `ihat-rs` in both +directions — our presentations verify under theirs, theirs verify under ours, and re-encoding is byte-identical. Since neither the drafts nor `ihat-rs` publish test vectors, that cross-check stands in for them, and it is -stronger: it exercises both roles rather than fixed byte strings. It lives in its -own package because it needs a git dependency and Cargo cannot make -dev-dependencies optional. +stronger: it exercises both roles rather than fixed byte strings. It also holds the no-mocks end-to-end run, which needs +`privacypass` — std-only, with an async issuer API. Keeping both out there is what +lets the library stay `no_std` with two dependencies while still being demonstrably +complete. ## Drafts tracked diff --git a/SPEC-COVERAGE.md b/SPEC-COVERAGE.md index 7ba9d0d..02d5615 100644 --- a/SPEC-COVERAGE.md +++ b/SPEC-COVERAGE.md @@ -26,8 +26,8 @@ Legend: ✅ implemented · 🟡 partial, see note · ⬜ structures only, semant | 4.2.3 | Differences from IHAT | 📄 | Scarcity rationale. | | 5 | Credential Protocols | ✅ | `credential::{CredentialRequest, CredentialResponse}`, including the rule that type `0x0001` carries an empty `endorsement_presentation`. | | 5.1 | ACT | ⬜ | `credential::act` has `IssuanceRequest`, `IssuanceResponse`, `PresentationAndUpdate`, `Update`. `Challenge` is an opaque passthrough because the draft says its "contents are not yet defined". Spend/refund need `draft-schlesinger-cfrg-act`. | -| 5.2 | Privacy Pass Reverse Flow | ⬜ | `credential::reverse_flow`. Empty `Challenge`; `PresentationAndUpdate`; `Update` (empty = out of credentials). No `IssuanceRequest`/`IssuanceResponse` wrappers, deliberately — the draft says those fields carry raw RFC 9578 `TokenRequest`/`TokenResponse`. | -| 5.2.2 | Presentation and Update | ✅ | The constant-digest and single-use-nonce semantics are `moderator::NonceStore`. | +| 5.2 | Privacy Pass Reverse Flow | ✅ | `credential::reverse_flow`. Empty `Challenge`; `PresentationAndUpdate`; `Update` (empty = out of credentials). No `IssuanceRequest`/`IssuanceResponse` wrappers, deliberately — the draft says those fields carry raw RFC 9578 `TokenRequest`/`TokenResponse`. `reverse_flow::Token` parses the one RFC 9578 structure a Moderator must read. Exercised end to end against `privacypass` in `interop/`. | +| 5.2.2 | Presentation and Update | 🟡 | Single-use-nonce semantics are `moderator::NonceStore`, enforced over `reverse_flow::Token`'s parsed nonce. **The constant-digest MUST is unsatisfiable as written** — it cross-references §3.3, but the token's `challenge_digest` is computed by RFC 9578 over an RFC 9577 `TokenChallenge`, which is never empty ([#16]). This crate takes the workable reading: one fixed `TokenChallenge` per Moderator. | | 5.2.3 | Limitations | 📄 | Device binding is an open problem upstream. | | 5.3 | Budget Privacy Pass | ⬜ | `credential::budget`. `Challenge { uint64 amount }`, `PresentationAndUpdate` (≥1 token enforced), `Update`. Batched issuance needs `draft-ietf-privacypass-batched-tokens`. | | 6 | Key Rotation and Discovery | ❌ | The draft section is literally `TODO.` with four open questions (JWKS vs Privacy Pass directory, rotation, key identifiers, config validation). Nothing to implement yet. | @@ -148,3 +148,4 @@ them: [#9]: https://github.com/OR13/mole/issues/9 [#11]: https://github.com/OR13/mole/issues/11 [#13]: https://github.com/OR13/mole/issues/13 +[#16]: https://github.com/OR13/mole/issues/16 diff --git a/interop/Cargo.lock b/interop/Cargo.lock index 029b757..812b705 100644 --- a/interop/Cargo.lock +++ b/interop/Cargo.lock @@ -2,39 +2,147 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blind-rsa-signatures" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c8e1ec3966bafbe115ad484420b260f5fabf88528e4ef8cd3024ffedb50e46" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-primes", + "ct-codecs", + "derive-new", + "derive_more", + "digest 0.11.3", + "hmac-sha256", + "hmac-sha512", + "rand", + "rand_core 0.10.1", + "rsa", + "serde", +] + [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array 0.14.9", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", ] +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -44,46 +152,210 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-bigint" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", - "rand_core", + "generic-array 0.14.9", + "rand_core 0.6.4", "subtle", "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "num-traits", + "rand_core 0.10.1", + "serdect", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "generic-array", + "generic-array 0.14.9", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "crypto-primes" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21f41f23de7d24cdbda7f0c4d9c0351f99a4ceb258ef30e5c1927af8987ffe5a" +dependencies = [ + "crypto-bigint 0.7.5", + "libm", + "rand_core 0.10.1", +] + +[[package]] +name = "ct-codecs" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fb0c6640b4507ebd99ff67677009e381ba5eee1d14df78de4a3d16eb123c39" + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto", + "rand_core 0.6.4", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "zeroize", ] +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive-new" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive-where" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -92,28 +364,44 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", - "digest", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", "ff", - "generic-array", + "generic-array 0.14.9", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "generic-array" version = "0.14.9" @@ -125,6 +413,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "generic-array" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b" +dependencies = [ + "rustversion", + "serde_core", + "typenum", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + [[package]] name = "group" version = "0.13.0" @@ -132,10 +443,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hmac-sha512" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "ihat-rs" version = "0.1.0" @@ -143,17 +491,61 @@ source = "git+https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs?r dependencies = [ "elliptic-curve", "p256", - "rand_core", + "rand_core 0.6.4", "sha2", "subtle", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "mole-exp" version = "0.1.0" @@ -170,7 +562,30 @@ dependencies = [ "ihat-rs", "mole-exp", "p256", - "rand_core", + "p384", + "privacypass", + "rand_core 0.6.4", + "tls_codec", + "tokio", + "typenum", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", ] [[package]] @@ -183,6 +598,75 @@ dependencies = [ "primeorder", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.1", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "spki", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -192,25 +676,188 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "privacypass" +version = "0.2.0-pre.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d7461cf10e86ebf5512ac6c3bbb0d8d03590dc00173d4a3f3fd8e6159dca8b7" +dependencies = [ + "async-trait", + "base64", + "blind-rsa-signatures", + "generic-array 1.4.4", + "http", + "log", + "nom", + "p384", + "rand", + "serde", + "sha2", + "subtle", + "thiserror", + "tls_codec", + "tokio", + "trait-variant", + "typenum", + "voprf-ng", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid 0.10.2", + "crypto-bigint 0.7.5", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "serde", + "serdect", + "signature", + "spki", + "zeroize", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", - "generic-array", + "base16ct 0.2.0", + "der 0.7.10", + "generic-array 0.14.9", "subtle", "zeroize", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -218,8 +865,54 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", ] [[package]] @@ -228,18 +921,178 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tls_codec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18cc98286004cea38f717e2b03d990fc774fbfd38a82720de40e5c94365067c8" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "674f41dd95f76cdeb005c83f9444e20cf43daebef37cde9e49a9ca6f4e87b423" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "trait-variant" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "voprf-ng" +version = "0.6.0-pre.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed8944bdc5dfafa7b1c434c086b3f6281dfb26715bd8e42d58d5e4434cc0307" +dependencies = [ + "curve25519-dalek", + "derive-where", + "digest 0.10.7", + "displaydoc", + "elliptic-curve", + "generic-array 1.4.4", + "rand_core 0.10.1", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "zeroize" version = "1.9.0" diff --git a/interop/Cargo.toml b/interop/Cargo.toml index ee87c9a..30f2cdd 100644 --- a/interop/Cargo.toml +++ b/interop/Cargo.toml @@ -1,10 +1,16 @@ # A detached package, deliberately NOT a workspace member of the crate above it. # -# It exists to differential-test `mole-exp`'s `exp-ihat` profile against -# `ihat-rs`, the reference implementation published by a MoLE draft author. That -# needs a git dependency, and Cargo cannot make dev-dependencies optional — so -# keeping this separate is what stops `cargo test` on the main crate from -# requiring network access or growing its dependency tree. +# This is where tests live that need dependencies the library itself refuses to +# take. Two kinds so far: +# +# * differential tests against `ihat-rs`, the reference implementation published +# by a MoLE draft author — needs a git dependency, and Cargo cannot make +# dev-dependencies optional +# * an end-to-end run of Appendix A with real Privacy Pass credentials — needs +# `privacypass`, which is std-only and whose issuer API is async +# +# Keeping both out here is what lets `mole-exp` stay `no_std` with two +# dependencies while still being demonstrably complete. # # Run it with: # cargo test --manifest-path interop/Cargo.toml @@ -16,7 +22,7 @@ version = "0.0.0" edition = "2021" rust-version = "1.75" publish = false -description = "Differential tests between mole-exp's exp-ihat profile and the ihat-rs reference implementation" +description = "Cross-implementation and end-to-end tests for mole-exp that need heavy external dependencies" [dependencies] @@ -27,3 +33,11 @@ mole = { package = "mole-exp", path = "..", features = ["exp-ihat"] } ihat = { package = "ihat-rs", git = "https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs", rev = "38673f12ab7ad215a5f3dbe703f893304323e393" } p256 = { version = "0.13", default-features = false, features = ["arithmetic"] } rand_core = "0.6" +# RFC 9578 Privacy Pass issuance, for the credential half of Appendix A. std-only, +# and its issuer API is async — which is exactly why it lives out here. +privacypass = { version = "0.2.0-pre.2", features = ["test-utils"] } +tokio = { version = "1", features = ["macros", "rt"] } +# The type-1 (VOPRF P-384) cipher suite privacypass is parameterised over. +p384 = { version = "0.13", default-features = false, features = ["arithmetic"] } +typenum = "1" +tls_codec = { version = "0.5", features = ["std", "derive"] } diff --git a/interop/tests/appendix_a_end_to_end.rs b/interop/tests/appendix_a_end_to_end.rs new file mode 100644 index 0000000..85d5174 --- /dev/null +++ b/interop/tests/appendix_a_end_to_end.rs @@ -0,0 +1,489 @@ +//! Appendix A of `draft-jms-mole-protocols-00` with **both halves real**. +//! +//! The endorsement half uses `mole::crypto::exp_ihat` — IHAT over P-256, +//! cross-verified against `ihat-rs`. The credential half uses `privacypass`, an +//! RFC 9578 implementation, for genuine Privacy Pass issuance, presentation and +//! reissue. Nothing in this file is mocked. +//! +//! This is the closing of the loop: `mole` carries `TokenRequest` and +//! `TokenResponse` opaquely, exactly as Section 5.2.1 specifies, and +//! `credential::reverse_flow::Token` parses the one structure the Moderator must +//! actually read. Everything else is real cryptography from two independent +//! implementations. +//! +//! # A contradiction in Section 5.2.2 that only surfaces here +//! +//! The draft says the token's `challenge_digest` field +//! +//! > MUST equal the Moderator's constant `challenge_digest` ({{challenge-binding}}) +//! +//! and that cross-reference is Section 3.3, MoLE's `SHA-256(challenge octets)`. For +//! this credential type `Challenge` is empty, so MoLE's constant digest is +//! `SHA-256("")`. +//! +//! **That is unsatisfiable with a conformant RFC 9578 implementation.** The token's +//! `challenge_digest` is computed by the issuance protocol as +//! `SHA-256(TokenChallenge)`, and an RFC 9577 `TokenChallenge` always carries at +//! least a `token_type` and an `issuer_name` — so its serialization is never empty +//! and its digest can never equal `SHA-256("")`. No amount of configuration closes +//! that gap; it would need a SHA-256 preimage. +//! +//! The workable reading, and the one used here, is the *intent* of the surrounding +//! sentence: "This binds the token to the Moderator, not to the exchange that +//! presents it." So the Moderator fixes one `TokenChallenge` in its configuration +//! and every token it issues carries that digest. It is constant per Moderator, +//! which is the property Section 5.2.2 actually needs — the literal cross-reference +//! to Section 3.3 appears to be an error. Tracked at +//! . + +use mole::binding::ChallengeDigest; +use mole::client::DeterministicRng; +use mole::codec::Message; +use mole::credential::{reverse_flow, CredentialRequest, CredentialResponse}; +use mole::crypto::exp_ihat::{ExpAnchor, ExpClient, ExpVerifier}; +use mole::crypto::{IhatAnchor, IhatClient}; +use mole::endorsement::{ihat, EndorsementRequest, EndorsementResponse, Point}; +use mole::http::{parse_www_authenticate, Authorization, MoleChallenge, MoleCredential}; +use mole::moderator::{ChallengeMode, Disposition, Moderator, NonceStore}; +use mole::registry::{CredentialType, EndorsementType}; +use mole::transport::{ + CredentialPresentation, CredentialUpdate, ModeratorChallenge, OptionalCredentialUpdate, +}; + +use p384::NistP384; +use privacypass::auth::authenticate::TokenChallenge; +use privacypass::private_tokens::{server::Server, TokenRequest}; +use privacypass::test_utils::nonce_store::MemoryNonceStore; +use privacypass::test_utils::private_memory_store::MemoryKeyStoreVoprf; +use privacypass::{Deserialize, Serialize, TokenType}; + +const EPOCH: &[u8] = b"2026-07-28T00:00:00Z/PT24H"; +const ISSUER: usize = 1; +/// The Moderator's identity in its fixed `TokenChallenge`. +const MODERATOR_NAME: &str = "moderator.example"; + +/// The Moderator: an IHAT verifier, a MoLE state machine, and a Privacy Pass +/// issuer all at once — which is what "the Moderator acting as both initial and +/// reverse issuer" means in Section 5.2. +struct ModeratorSide { + verifier: ExpVerifier, + state: Moderator, + /// MoLE's nullifier/nonce store, for the token's single-use requirement. + mole_nonces: NonceStore, + /// Privacy Pass issuance state. + pp: Server, + keys: MemoryKeyStoreVoprf, + pp_nonces: MemoryNonceStore, + public_key: p384::ProjectivePoint, + /// The one fixed `TokenChallenge` this Moderator issues under. Its digest is + /// the constant every token must carry. + token_challenge: TokenChallenge, +} + +impl ModeratorSide { + async fn new() -> Self { + let pp = Server::::new(); + let keys = MemoryKeyStoreVoprf::default(); + let public_key = pp.create_keypair(&keys).await.expect("keypair"); + Self { + verifier: ExpVerifier::new(), + state: Moderator::new( + vec![CredentialType::PRIVACY_PASS_REVERSE_FLOW], + EPOCH.to_vec(), + ), + mole_nonces: NonceStore::new(), + pp, + keys, + pp_nonces: MemoryNonceStore::default(), + public_key, + token_challenge: TokenChallenge::new( + TokenType::PrivateP384, + MODERATOR_NAME, + None, + &[MODERATOR_NAME.to_string()], + ), + } + } + + /// The constant digest every token this Moderator issues must carry. See the + /// module docs for why this is the `TokenChallenge`'s digest and not + /// `SHA-256("")`. + fn constant_digest(&self) -> ChallengeDigest { + ChallengeDigest(self.token_challenge.digest().expect("digest")) + } +} + +/// Runs the two IHAT grant exchanges, carrying every body through the real +/// `EndorsementRequest`/`EndorsementResponse` envelopes. +fn grant_endorsement( + client: &mut ExpClient, + anchor: &ExpAnchor, + keys: &[Point], + endorsement_context: &[u8], + digest: &ChallengeDigest, +) -> Vec { + let (body1, state) = client + .prepare(&anchor.public_key(), endorsement_context) + .unwrap(); + let req1 = EndorsementRequest::from_wire( + &EndorsementRequest { + endorsement_type: EndorsementType::IHAT, + body: body1, + } + .to_wire() + .unwrap(), + ) + .unwrap(); + + let (body2, anchor_state) = anchor.sign(&req1.body).unwrap(); + let resp1 = EndorsementResponse::from_wire( + &EndorsementResponse { + endorsement_type: EndorsementType::IHAT, + body: body2, + } + .to_wire() + .unwrap(), + ) + .unwrap(); + + let (body3, state) = client.request_proof(state, &resp1.body).unwrap(); + let req2 = EndorsementRequest::from_wire( + &EndorsementRequest { + endorsement_type: EndorsementType::IHAT, + body: body3, + } + .to_wire() + .unwrap(), + ) + .unwrap(); + + let body4 = anchor.prove(anchor_state, &req2.body).unwrap(); + let resp2 = EndorsementResponse::from_wire( + &EndorsementResponse { + endorsement_type: EndorsementType::IHAT, + body: body4, + } + .to_wire() + .unwrap(), + ) + .unwrap(); + + let endorsement = client.finalize(state, &resp2.body).unwrap(); + client.present(&endorsement, keys, digest).unwrap() +} + +/// `privacypass` types serialize through `tls_codec`, not an inherent `to_bytes`. +fn ser(value: &T) -> Vec { + value.tls_serialize_detached().expect("tls serialize") +} + +fn de(bytes: &[u8]) -> Result { + T::tls_deserialize(&mut &bytes[..]) +} + +/// Deserializes an RFC 9578 `Token` with a type-1 (48-byte) authenticator. +/// +/// `privacypass` parameterises `Token` over its authenticator length, so the width +/// has to be named at the call site. +fn pp_token_from_bytes( + bytes: &[u8], +) -> Result, tls_codec::Error> { + privacypass::auth::authorize::Token::::tls_deserialize(&mut &bytes[..]) +} + +fn anchors() -> (Vec>, Vec) { + let anchors: Vec> = [0xA1u8, 0xA2, 0xA3] + .into_iter() + .enumerate() + .map(|(i, seed)| ExpAnchor::new([seed; 32], DeterministicRng::new(9000 + i as u64)).unwrap()) + .collect(); + let keys = anchors.iter().map(ExpAnchor::public_key).collect(); + (anchors, keys) +} + +/// The whole of Appendix A, with real IHAT endorsements and real RFC 9578 +/// Privacy Pass credentials. +#[tokio::test] +async fn appendix_a_with_no_mocks() { + let mut moderator = ModeratorSide::new().await; + let (anchor_set, keys) = anchors(); + let mut client = ExpClient::new(DeterministicRng::new(1)); + + // ---- Moderator -> Client: 401 with an IHAT challenge ------------------- + let www_authenticate = { + let inner = ihat::Challenge { keys: keys.clone() }.to_wire().unwrap(); + let outer = ModeratorChallenge { + endorsement_type: EndorsementType::IHAT, + challenge: inner, + } + .to_wire() + .unwrap(); + MoleChallenge::new(&outer, Some("moderator")).to_header_value() + }; + assert_eq!(ChallengeMode::Required.status(), 401); + + let parsed = parse_www_authenticate(&www_authenticate); + let challenge_octets = parsed[0].challenge_octets().unwrap(); + // MoLE's challenge binding, over the DECODED octets (Section 3.3). + let mole_digest = ChallengeDigest::new(&challenge_octets); + let ihat_challenge = ihat::Challenge::from_wire( + &ModeratorChallenge::from_wire(&challenge_octets) + .unwrap() + .challenge, + ) + .unwrap(); + assert_eq!(ihat_challenge.keys, keys); + + // ---- Client <-> Anchor: real IHAT grant -------------------------------- + let presentation_bytes = grant_endorsement( + &mut client, + &anchor_set[ISSUER], + &ihat_challenge.keys, + EPOCH, + &mole_digest, + ); + + // ---- Client -> Moderator: Redeem & Issue ------------------------------- + // The IssuanceRequest is a real RFC 9578 TokenRequest, bound to the + // Moderator's fixed TokenChallenge. + let (token_request, token_state) = + TokenRequest::::new(moderator.public_key, &moderator.token_challenge) + .expect("token request"); + let credential_request = CredentialRequest { + endorsement_type: EndorsementType::IHAT, + endorsement_presentation: ihat::Presentation { + bytes: presentation_bytes, + } + .to_wire() + .unwrap(), + credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, + issuance_request: ser(&token_request), + }; + let auth_header = + Authorization::CredentialRequest(credential_request.to_wire().unwrap()).to_header_value(); + + // ---- Moderator: verify the endorsement, then issue the credential ------ + let Authorization::CredentialRequest(body) = Authorization::parse(&auth_header).unwrap() else { + panic!("expected a credential-request"); + }; + let received = CredentialRequest::from_wire(&body).unwrap(); + + let verified = moderator + .state + .redeem( + &moderator.verifier, + &ihat::Presentation::from_wire(&received.endorsement_presentation) + .unwrap() + .bytes, + &ihat_challenge.keys, + &mole_digest, + ) + .expect("a real IHAT endorsement verifies"); + assert_eq!(verified.endorsement_context, EPOCH.to_vec()); + assert_eq!(moderator.state.nullifiers().len(), 1); + + // The TokenRequest came through MoLE's carriage byte-for-byte. + let parsed_request = + de::>(&received.issuance_request).expect("round trip"); + let token_response = moderator + .pp + .issue_token_response(&moderator.keys, parsed_request) + .await + .expect("issue"); + + let credential_response = CredentialResponse { + credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, + issuance_response: ser(&token_response), + }; + let MoleCredential::Response(resp_bytes) = MoleCredential::parse( + &MoleCredential::Response(credential_response.to_wire().unwrap()).to_header_value(), + ) + .unwrap() + else { + panic!("expected a response"); + }; + + // ---- Client: finalize the credential ---------------------------------- + let issuance_response = CredentialResponse::from_wire(&resp_bytes) + .unwrap() + .issuance_response; + let token = de::>(&issuance_response) + .expect("parse TokenResponse") + .issue_token(&token_state) + .expect("finalize into a token"); + let token_bytes = ser(&token); + + // The Client's finished credential carries the Moderator's constant digest, + // which is what Section 5.2.2 requires of it. + let parsed = reverse_flow::Token::parse(&token_bytes).expect("mole parses the RFC 9578 token"); + assert_eq!(parsed.challenge_digest, moderator.constant_digest()); + assert_eq!(parsed.token_type, TokenType::PrivateP384 as u16); + assert_eq!(parsed.authenticator.len(), 48); + + // ---- Client -> Moderator: present, and request the next token --------- + let (next_request, next_state) = + TokenRequest::::new(moderator.public_key, &moderator.token_challenge) + .expect("next token request"); + let pau = reverse_flow::PresentationAndUpdate { + token: token_bytes.clone(), + token_request: ser(&next_request), + }; + let pres_header = Authorization::Presentation( + CredentialPresentation { + credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, + presentation_and_update: pau.to_wire().unwrap(), + } + .to_wire() + .unwrap(), + ) + .to_header_value(); + + // ---- Moderator: verify the presentation ------------------------------ + let Authorization::Presentation(pbytes) = Authorization::parse(&pres_header).unwrap() else { + panic!("expected a presentation"); + }; + let received = CredentialPresentation::from_wire(&pbytes).unwrap(); + assert_eq!( + moderator.state.dispatch(received.credential_type), + Disposition::Handled(CredentialType::PRIVACY_PASS_REVERSE_FLOW) + ); + let pau = + reverse_flow::PresentationAndUpdate::from_wire(&received.presentation_and_update).unwrap(); + + // MoLE's two checks, both of which need the token's fields parsed. + let presented = reverse_flow::Token::parse(&pau.token).expect("well-formed token"); + assert_eq!( + presented.challenge_digest, + moderator.constant_digest(), + "the token must carry the Moderator's constant digest" + ); + moderator + .mole_nonces + .admit(&presented.nonce) + .expect("first use"); + + // Then the cryptographic check, which MoLE leaves to RFC 9578. + let pp_token = pp_token_from_bytes(&pau.token).expect("privacypass parses it back"); + moderator + .pp + .redeem_token(&moderator.keys, &moderator.pp_nonces, pp_token) + .await + .expect("a real token verifies"); + + // ---- Moderator -> Client: the update, a freshly issued token --------- + let next_response = moderator + .pp + .issue_token_response( + &moderator.keys, + de::>(&pau.token_request).expect("round trip"), + ) + .await + .expect("reissue"); + let update = OptionalCredentialUpdate::present(CredentialUpdate { + credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, + update_response: reverse_flow::Update { + token_response: ser(&next_response), + } + .to_wire() + .unwrap(), + }); + let MoleCredential::Update(ubytes) = + MoleCredential::parse(&MoleCredential::Update(update.to_wire().unwrap()).to_header_value()) + .unwrap() + else { + panic!("expected an update"); + }; + let parsed_update = OptionalCredentialUpdate::from_wire(&ubytes).unwrap(); + assert!(!parsed_update.is_absent(), "credential remains usable"); + + // ---- Client: finalize the update into the next credential ------------- + let inner = parsed_update.0.as_ref().expect("update is present"); + let next_token = de::>( + &reverse_flow::Update::from_wire(&inner.update_response) + .unwrap() + .token_response, + ) + .expect("parse") + .issue_token(&next_state) + .expect("finalize the reissued token"); + + // The reissued credential is usable, distinct, and carries the same constant + // digest — "the presented and reissued token MUST use the same token type and + // the same Moderator public key." + let next_bytes = ser(&next_token); + let next_parsed = reverse_flow::Token::parse(&next_bytes).unwrap(); + assert_eq!(next_parsed.token_type, presented.token_type); + assert_eq!(next_parsed.token_key_id, presented.token_key_id); + assert_eq!(next_parsed.challenge_digest, moderator.constant_digest()); + assert_ne!(next_parsed.nonce, presented.nonce, "a fresh token"); +} + +/// A presented token cannot be replayed. Both stores refuse it: MoLE's, on the +/// nonce it parsed, and Privacy Pass's, on its own bookkeeping. +#[tokio::test] +async fn a_real_token_cannot_be_spent_twice() { + let mut moderator = ModeratorSide::new().await; + let (token_request, token_state) = + TokenRequest::::new(moderator.public_key, &moderator.token_challenge).unwrap(); + let token = moderator + .pp + .issue_token_response(&moderator.keys, token_request) + .await + .unwrap() + .issue_token(&token_state) + .unwrap(); + let bytes = ser(&token); + + let parsed = reverse_flow::Token::parse(&bytes).unwrap(); + assert!(moderator.mole_nonces.admit(&parsed.nonce).is_ok()); + // "The Moderator MUST reject a token whose nonce it has already seen." + assert_eq!( + moderator.mole_nonces.admit(&parsed.nonce).err(), + Some(mole::error::Error::NullifierAlreadySpent) + ); + + // And the RFC 9578 layer refuses the second redemption independently. + let pp_token = pp_token_from_bytes(&bytes).unwrap(); + assert!(moderator + .pp + .redeem_token(&moderator.keys, &moderator.pp_nonces, pp_token) + .await + .is_ok()); + let pp_token = pp_token_from_bytes(&bytes).unwrap(); + assert!(moderator + .pp + .redeem_token(&moderator.keys, &moderator.pp_nonces, pp_token) + .await + .is_err()); +} + +/// `mole`'s parser and `privacypass`'s serializer agree on the RFC 9578 `Token` +/// layout — which is the only part of Privacy Pass `mole` reads itself. +#[tokio::test] +async fn mole_and_privacypass_agree_on_the_token_layout() { + let moderator = ModeratorSide::new().await; + + for _ in 0..8 { + let (request, state) = + TokenRequest::::new(moderator.public_key, &moderator.token_challenge) + .unwrap(); + let token = moderator + .pp + .issue_token_response(&moderator.keys, request) + .await + .unwrap() + .issue_token(&state) + .unwrap(); + let bytes = ser(&token); + + // Type 1 tokens are 2 + 32 + 32 + 32 + 48 = 146 bytes. + assert_eq!(bytes.len(), reverse_flow::Token::PREFIX_LEN + 48); + let parsed = reverse_flow::Token::parse(&bytes).unwrap(); + // Re-serializing from mole's parse is byte-identical. + assert_eq!(parsed.to_bytes(), bytes); + assert_eq!( + reverse_flow::authenticator_len(parsed.token_type), + Some(parsed.authenticator.len()) + ); + } +} diff --git a/src/credential.rs b/src/credential.rs index 2945ed7..5e9a451 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -311,6 +311,7 @@ pub mod act { /// See [`crate::moderator::NonceStore`]. pub mod reverse_flow { use super::{write_opaque_v, Decode, Encode, Error, Message, Reader, Result, Vec}; + use crate::binding::ChallengeDigest; /// Credential type value for Privacy Pass Reverse Flow. pub const CREDENTIAL_TYPE: super::CredentialType = @@ -410,6 +411,126 @@ pub mod reverse_flow { } impl Message for Update {} + + /// Known `token_type` values and their authenticator lengths, `Nk`. + /// + /// From the Privacy Pass token type registry. A Moderator needs these to bound + /// a `Token`'s length; the values are RFC 9578's, not MoLE's. + pub const TOKEN_TYPES: &[(u16, usize)] = &[ + (0x0001, 48), // VOPRF (P-384, SHA-384), privately verifiable + (0x0002, 256), // Blind RSA (2048-bit), publicly verifiable + (0x0005, 64), // VOPRF (ristretto255, SHA-512), privately verifiable + ]; + + /// Authenticator length for a known `token_type`, or `None` if unrecognised. + #[must_use] + pub fn authenticator_len(token_type: u16) -> Option { + let mut i = 0; + while i < TOKEN_TYPES.len() { + if TOKEN_TYPES[i].0 == token_type { + return Some(TOKEN_TYPES[i].1); + } + i += 1; + } + None + } + + /// A parsed RFC 9578 `Token` — the bytes MoLE carries in + /// [`PresentationAndUpdate::token`]. + /// + /// ```text + /// struct { + /// uint16_t token_type; + /// uint8_t nonce[32]; + /// uint8_t challenge_digest[32]; + /// uint8_t token_key_id[32]; + /// uint8_t authenticator[Nk]; + /// } Token; + /// ``` + /// + /// # Why this is here, and what it is not + /// + /// MoLE passes `TokenRequest` and `TokenResponse` through opaquely — Section + /// 5.2.1 defines no structures for them, and this module defines no wrappers. + /// The `Token` is different: Section 5.2.2 puts two *MoLE* requirements on its + /// contents, and a Moderator cannot enforce either without reading the fields: + /// + /// * the `challenge_digest` must equal the Moderator's constant value, and + /// * the `nonce` must not have been seen before ([`crate::moderator::NonceStore`]). + /// + /// So this is a **parser, not cryptography**. It does not verify the + /// authenticator, which requires the issuer's key and an RFC 9578 + /// implementation. It exists so the Moderator's half of Section 5.2.2 can be + /// implemented without one. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct Token<'a> { + /// Privacy Pass token type. + pub token_type: u16, + /// The single-use nonce. This is what anti-replay rests on. + pub nonce: [u8; 32], + /// Fixed at issuance. For this credential type it is the Moderator's + /// constant challenge digest, *not* a per-request value. + pub challenge_digest: ChallengeDigest, + /// Identifies the issuing key. + pub token_key_id: [u8; 32], + /// The MAC or signature. Verifying it is out of scope here. + pub authenticator: &'a [u8], + } + + impl<'a> Token<'a> { + /// Fixed-size prefix before the authenticator: type, nonce, digest, key id. + pub const PREFIX_LEN: usize = 2 + 32 + 32 + 32; + + /// Parses a `Token`. + /// + /// The authenticator is the remainder. For a recognised `token_type` its + /// length must match [`authenticator_len`]; for an unrecognised one any + /// non-empty remainder is accepted, since a Moderator may be configured with + /// a type this crate predates. + /// + /// # Errors + /// [`Error::UnexpectedEof`] if the input is shorter than the fixed prefix, + /// and [`Error::MalformedHeader`] if a known type's authenticator is the + /// wrong length or an unknown type's is empty. + pub fn parse(bytes: &'a [u8]) -> Result { + let mut r = Reader::new(bytes); + let token_type = r.u16()?; + let nonce = r.array::<32>()?; + let challenge_digest = ChallengeDigest(r.array::<32>()?); + let token_key_id = r.array::<32>()?; + let authenticator = r.take_rest(); + + match authenticator_len(token_type) { + Some(nk) if authenticator.len() != nk => { + return Err(Error::MalformedHeader("Token.authenticator length")); + } + None if authenticator.is_empty() => { + return Err(Error::MalformedHeader("Token.authenticator empty")); + } + _ => {} + } + + Ok(Self { + token_type, + nonce, + challenge_digest, + token_key_id, + authenticator, + }) + } + + /// Serializes back to the RFC 9578 encoding. + #[must_use] + pub fn to_bytes(&self) -> Vec { + let mut out = Vec::with_capacity(Self::PREFIX_LEN + self.authenticator.len()); + out.extend_from_slice(&self.token_type.to_be_bytes()); + out.extend_from_slice(&self.nonce); + out.extend_from_slice(self.challenge_digest.as_bytes()); + out.extend_from_slice(&self.token_key_id); + out.extend_from_slice(self.authenticator); + out + } + } } /// Budget Privacy Pass, credential type `0x0003` (Section 5.3). @@ -524,6 +645,7 @@ pub mod budget { #[cfg(test)] mod tests { use super::*; + use crate::binding::ChallengeDigest; use alloc::vec; #[test] @@ -608,4 +730,69 @@ mod tests { .token_response .is_empty()); } + + fn sample_token(token_type: u16, nk: usize) -> Vec { + let mut t = Vec::new(); + t.extend_from_slice(&token_type.to_be_bytes()); + t.extend_from_slice(&[0x11u8; 32]); + t.extend_from_slice(&[0x22u8; 32]); + t.extend_from_slice(&[0x33u8; 32]); + t.extend_from_slice(&vec![0x44u8; nk]); + t + } + + #[test] + fn token_round_trips_for_every_known_type() { + for &(token_type, nk) in reverse_flow::TOKEN_TYPES { + let bytes = sample_token(token_type, nk); + let token = reverse_flow::Token::parse(&bytes).unwrap(); + assert_eq!(token.token_type, token_type); + assert_eq!(token.nonce, [0x11u8; 32]); + assert_eq!(token.challenge_digest, ChallengeDigest([0x22u8; 32])); + assert_eq!(token.token_key_id, [0x33u8; 32]); + assert_eq!(token.authenticator.len(), nk); + assert_eq!(token.to_bytes(), bytes); + } + } + + #[test] + fn a_known_token_type_with_the_wrong_authenticator_length_is_malformed() { + // Type 0x0001 requires Nk = 48. + for wrong in [0usize, 47, 49, 64] { + let bytes = sample_token(0x0001, wrong); + assert!( + reverse_flow::Token::parse(&bytes).is_err(), + "Nk={wrong} should be rejected for token_type 0x0001" + ); + } + assert!(reverse_flow::Token::parse(&sample_token(0x0001, 48)).is_ok()); + } + + #[test] + fn an_unknown_token_type_is_accepted_with_any_non_empty_authenticator() { + // A Moderator may be configured with a type this crate predates, so an + // unrecognised value must not be a parse failure. + assert!(reverse_flow::Token::parse(&sample_token(0xBEEF, 1)).is_ok()); + assert!(reverse_flow::Token::parse(&sample_token(0xBEEF, 999)).is_ok()); + // But a token with no authenticator at all is malformed regardless. + assert!(reverse_flow::Token::parse(&sample_token(0xBEEF, 0)).is_err()); + } + + #[test] + fn a_truncated_token_does_not_panic() { + let full = sample_token(0x0001, 48); + for len in 0..full.len() { + assert!(reverse_flow::Token::parse(&full[..len]).is_err()); + } + } + + #[test] + fn token_prefix_len_matches_the_rfc_9578_layout() { + // uint16 token_type + nonce[32] + challenge_digest[32] + token_key_id[32] + assert_eq!(reverse_flow::Token::PREFIX_LEN, 98); + assert_eq!(reverse_flow::authenticator_len(0x0001), Some(48)); + assert_eq!(reverse_flow::authenticator_len(0x0002), Some(256)); + assert_eq!(reverse_flow::authenticator_len(0x0005), Some(64)); + assert_eq!(reverse_flow::authenticator_len(0x0000), None); + } } From d5d7e0a8ef02a4beaa478eb97e1f7bf3e9c8e2b2 Mon Sep 17 00:00:00 2001 From: Orie Steele Date: Tue, 28 Jul 2026 15:57:18 -0500 Subject: [PATCH 6/6] refactor: delete insecure_mock and strip accumulated doc rot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: insecure_mock existed for one reason — this crate had no real cryptography. It now has real, interoperable cryptography, so 417 lines of deliberately-insecure code plus a 396-line duplicate Appendix A walk are dead weight that every reader has to be warned about. Deleted src/crypto/insecure_mock.rs, the insecure-mock feature, and the duplicate tests/appendix_a_flow.rs. Renamed the real-crypto Appendix A test into its place: there is one Appendix A walk now, and it runs on exp_ihat. Test count drops 121 -> 111 while coverage goes up, because the tests that went away were exercising the same protocol paths against a mock. Documentation: removed material that had accumulated as this work progressed and is now archaeology. - My own revision history ("an earlier revision of this profile branched on the index", "64 wasted bytes per grant", before/after comparison tables). Git has it; reference docs should state what is true, not how it got that way. - PR #39 forensics. What matters is that the profile follows ihat-rs and where that differs from the drafts — not a running commentary on an unmerged PR. The three places PR #39 contradicts the reference implementation are recorded in the issues, which is where they belong. - The README's 45-line account of [CRYPTO]'s exact publication state, compressed to the four sentences a reader needs. - The insecure_mock comparison table, which compared against something that no longer exists. Reframed the status tables, which were the actual source of confusion. Rows for Longfellow, ACT, Budget Privacy Pass and key rotation rendered as ❌, which reads as a backlog. They are not: MoLE specifies several endorsement and credential protocols to demonstrate agility and a deployment picks one of each. This crate implements IHAT and Privacy Pass Reverse Flow. For the others the wire structures, registries and dispatch are implemented and tested — only the cryptography is external, and Longfellow's and ACT's underlying drafts are not in a state to build against. New legend: ⬜ for that, ⛔ for sections that are literally TODO. upstream. Kept one substantive note: ACT is the only credential protocol with bound updates, which Reverse Flow cannot provide (§5.2.3). That is a property of the protocol chosen here, not a task. Docs shrank from 716 to 561 lines. Library unchanged: no_std on thumbv7em-none-eabi, two dependencies, one optional feature. 111 tests plus 7 in the detached package; clippy pedantic, rustdoc, fmt clean; MSRV 1.75 builds and tests --locked. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.toml | 6 - EXP-IHAT-PROFILE.md | 273 ++++++-------------- README.md | 125 +++------ SPEC-COVERAGE.md | 85 ++++--- src/crypto.rs | 25 +- src/crypto/exp_ihat.rs | 29 +-- src/crypto/insecure_mock.rs | 417 ------------------------------ src/lib.rs | 28 +- tests/appendix_a_exp_ihat.rs | 481 ----------------------------------- tests/appendix_a_flow.rs | 385 +++++++++++++++++----------- 10 files changed, 431 insertions(+), 1423 deletions(-) delete mode 100644 src/crypto/insecure_mock.rs delete mode 100644 tests/appendix_a_exp_ihat.rs diff --git a/Cargo.toml b/Cargo.toml index b60ac9a..b16b0e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,12 +31,6 @@ p256 = { version = "0.13.2", default-features = false, features = [ default = ["std"] std = [] -# Enables `crypto::insecure_mock`, a DELIBERATELY INSECURE stand-in for the -# unpublished [CRYPTO] document. It exists so the end-to-end message flow can be -# exercised in tests. It provides NO unlinkability, NO issuer hiding, and NO -# blindness. Never enable this outside tests. See README.md. -insecure-mock = [] - # Enables `crypto::exp_ihat`, an implementation of IHAT over P-256 aligned # byte-for-byte with `ihat-rs`, the reference implementation published in the MoLE # org by a draft author, and cross-verified against it by `interop/`. It diff --git a/EXP-IHAT-PROFILE.md b/EXP-IHAT-PROFILE.md index aa4c39b..03f6140 100644 --- a/EXP-IHAT-PROFILE.md +++ b/EXP-IHAT-PROFILE.md @@ -1,28 +1,24 @@ # The `exp-ihat` profile -**This profile interoperates with the reference implementation. That is not the -same as being safe to deploy: `ihat-rs` says of itself "This code has not been -audited", the construction has no security proof and no public cryptanalysis, and -it is still being revised upstream.** +**Not deployable.** `ihat-rs` says of itself "This code has not been audited", the +construction has no security proof and no public cryptanalysis, and it is still being +revised upstream. Interoperating with the reference implementation means agreeing +with it, not being correct. `crypto::exp_ihat`, behind the off-by-default `exp-ihat` feature, implements IHAT over NIST P-256, aligned byte-for-byte with [`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs) — -"Implementation of the endorsement issuance and redemption protocols", published in -the MoLE org by Samuel Schlesinger, one of the draft authors. +published in the MoLE org by Samuel Schlesinger, one of the draft authors. -## Following code, not prose +## It follows code, not prose This is the profile's central decision. The crypto draft ([`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md)) -is about fifty lines of prose that names no group, no DST, and no wire structures. -`ihat-rs` is 2,469 lines of executable Rust with a canonical wire format. Where they -disagree, **`ihat-rs` wins** — it is executable, it is by an author, and +is prose that names no group, no DST, and no wire structures. `ihat-rs` is executable +and has a canonical wire format. Where they disagree, `ihat-rs` wins — and interoperating with it is the only validation available, since neither publishes test vectors. -Taken from `ihat-rs`, and therefore not guesses: - | | | |---|---| | Group | NIST P-256 | @@ -30,14 +26,13 @@ Taken from `ihat-rs`, and therefore not guesses: | `Scalar` | 32 bytes big-endian (SEC1 I2OSP) | | Hash-to-group | RFC 9380 `P256_XMD:SHA-256_SSWU_RO_` | | Hash-to-scalar | RFC 9380 `hash_to_field` | -| Domain separation | `MOLE-IHAT-P256::v1` — four fixed tags | | `Y` | `H₁(nf)`, hash-to-group of the raw nullifier | | Pedersen generator | `H(endorsement_context)`, context-bound | | Issuance transcript | `e = H_FS(X̂, Y, Ẑ, T₁, T₂, C, endorsement_context)` | | OR proof | CDS 1-of-n, one `(t, c, s)` transcript per accepted key, 97 bytes each | | Wire framing | TLS presentation language; `VarBytes` = two-byte big-endian | -The four DSTs, verbatim: +Domain separation tags, verbatim from `ihat-rs`: ``` MOLE-IHAT-P256:H1-nullifier-to-group:v1 @@ -46,44 +41,23 @@ MOLE-IHAT-P256:pedersen-generator-H:v1 MOLE-IHAT-P256:fiat-shamir-or-proof:v1 ``` -One DST is this crate's own and never appears on the wire: scalar sampling uses -`OR13_EXP_MOLE-V01-rand-with-P256_XMD:SHA-256_SSWU_RO_`, deliberately not claiming +Scalar sampling uses one tag of this crate's own, +`OR13_EXP_MOLE-V01-rand-with-P256_XMD:SHA-256_SSWU_RO_`. It never appears on the +wire, so it has no bearing on interoperability, and it deliberately does not claim the `MOLE-IHAT` prefix. -## How this differs from `insecure_mock` - -| Property | `insecure_mock` | `exp_ihat` | -|---|---|---| -| Blind signing — Anchor never learns `nf` | ❌ sent in cleartext | ✅ Anchor sees only `Y' = vY` | -| Issuer hiding | ❌ carries the Anchor's index | ✅ 1-of-n CDS OR proof, constant-time in the real index | -| Public verifiability | ❌ verifier needs every secret | ✅ public keys only | -| Unlinkability across grants | ❌ byte-identical presentations | ✅ no shared field | -| Real group | ❌ not curve points at all | ✅ P-256 | -| Interoperable | ❌ | ✅ with `ihat-rs`, both directions | -| Audited | n/a | ❌ | - -## Where `ihat-rs` departs from the drafts - -These are the interesting findings, and the profile follows `ihat-rs` on all of -them. - -### The issuance transcript binds more — [#4] - -The crypto draft says `e = H(Y, Zhat, T1, T2, C)`. `ihat-rs` binds `X̂` and -`endorsement_context` as well, with the comment: - -> strengthened to bind the *full* statement: the rerandomised key `X_hat` — so a -> proof cannot be transported to a different `X_hat` +## Where the reference implementation departs from the drafts -So the reference implementation's author considered the draft's transcript -insufficient and strengthened it. An earlier revision of this profile followed the -draft literally; it now matches `ihat-rs`. +Each is an open issue, because the drafts should say what the code does. -### There is no separate DLEQ proof — [#4] +**The issuance transcript binds more than the draft's** ([#4]). The draft says +`e = H(Y, Zhat, T1, T2, C)`; `ihat-rs` also binds `X̂` and `endorsement_context`, +commenting that this is "strengthened to bind the *full* statement: the rerandomised +key `X_hat` — so a proof cannot be transported to a different `X_hat`". -The draft has the Anchor send "a proof in the manner of \[DLEQ\]" in exchange one. -`ihat-rs` sends none, and does not need to: `(T₁', T₂', r')` already constitutes -one. `finalize` checks +**There is no separate DLEQ proof** ([#4]). The draft has the Anchor send "a proof in +the manner of \[DLEQ\]" in exchange one. None is needed: `(T₁', T₂', r')` already is +one, and `finalize` checks ``` Y'·r' = Z'·e'a' + T₁' @@ -91,82 +65,47 @@ G·r' = X·e'a' + T₂' ``` which together prove the Anchor applied a single `x` to both `Z' = x·Y'` and -`X = x·G`. A separate proof would be redundant, and the earlier revision of this -profile carried one — 64 wasted bytes per grant. - -Note `{{DLEQ}}` in the draft resolves to `draft-irtf-cfrg-sigma-protocols`, not -RFC 9497; §2.2.8 there is the DLEQ statement. - -### The Pedersen generator is per-context — [#5] - -Upstream PR #39 defines `H = Hash2Curve("IHAT second generator")`, one global -generator. `ihat-rs` derives `H` from `endorsement_context`, which binds the context -into `C` structurally. It is load-bearing: a Client and Anchor that disagree about -the context derive different `H`, and the Pedersen check fails at finalize. -`tests/exp_ihat_profile.rs::context_mismatch_fails_the_pedersen_check` asserts it. - -### Points are compressed — [#1] - -PR #39 declares `uncompressed bytes[65]`. `ihat-rs` and protocols §4.1 both use -33-byte compressed, so PR #39 contradicts the authors' own implementation. A -compressed SEC1 P-256 point is exactly 33 bytes, so `opaque Point[33]` is the -encoding rather than something to pad. - -### The OR proof is `O(n)` CDS — [#6] +`X = x·G`. (`{{DLEQ}}` resolves to `draft-irtf-cfrg-sigma-protocols`, §2.2.8.) -PR #39 specifies an `O(log n)` trapdoor commitment tree. `ihat-rs` uses one Schnorr -transcript per accepted key. Again the code contradicts the open PR. +**The Pedersen generator is per-context, not global** ([#5]). `H` is derived from +`endorsement_context`, binding it into `C` structurally. This is load-bearing: a +Client and Anchor that disagree about the context derive different `H` and the +Pedersen check fails at finalize. -`ihat-rs` is also explicitly **constant-time in the real branch index**, recovering -the honest base by a `subtle` select across the whole accepted set so that no -control flow or memory access depends on which Anchor issued. An earlier revision of -this profile branched on the index — a timing and cache channel leaking precisely -what issuer hiding exists to conceal. Now fixed, following `ihat-rs`'s structure. +**`C'` is never defined** ([#5]). Issuance Step One says only "It computes a +commitment"; `C' = a'G + b'H` is inferred from Step Two's check. -### `m` does not exist — [#7] +**`m` does not exist** ([#7]). `nf` and `endorsement_context` are two separate +`VarBytes` fields and `Y = H₁(nf)`. The drafts' `m`, typed as a 32-byte `Scalar`, +could not carry `endorsement_context` at all. -The draft gives the Endorsement an `m` with `Y = Hash2Curve(m)` and never defines -it, and PR #39 types it as a 32-byte `Scalar` — which cannot carry the -`endorsement_context` protocols §4.1.3 requires verification to expose. - -`ihat-rs` has no `m`. It carries `nf` and `endorsement_context` as two separate -`VarBytes` fields and computes `Y = H₁(nf)`. An earlier revision of this profile -invented `m = opaque(ctx) || nf[32]` to work around a problem the reference -implementation does not have. +**Points are compressed** ([#1]) and **the OR proof is `O(n)` CDS** ([#6]) — both as +protocols §4.1 and `ihat-rs` have it. ## The one irreconcilable difference — [#8] -Protocols §4.1.3 requires: - -> `Present` MUST bind `challenge_digest` into the proof transcript. - -> `Verify` MUST fail when given any other `challenge_digest`. +Protocols §4.1.3 requires that `Present` MUST bind `challenge_digest` into the proof +transcript, and that `Verify` MUST fail when given any other. **`ihat-rs` has no `challenge_digest` anywhere** — not in a structure, not in a -transcript, not in its API, whose verb is `show(&accepted, index, rng)` and whose -verifier is `verify(&pp, &accepted)`. Its own docs note that a presentation "verifies -every time". +transcript, not in its API. Its docs note that a presentation "verifies every time". So byte-compatibility with the reference implementation and compliance with the MoLE -drafts are mutually exclusive. Rather than pick silently, the profile exposes -`ChallengeBinding`: +drafts are mutually exclusive. `ChallengeBinding` makes the choice explicit: -| Mode | Binds the digest | Interoperates with `ihat-rs` | Satisfies §4.1.3 | +| Mode | Binds the digest | Interoperates | Satisfies §4.1.3 | |---|---|---|---| | `IhatRsCompatible` | ❌ | ✅ | ❌ — presentations are replayable | | `MoleBound` (default) | ✅, into the OR transcript | ❌ | ✅ | -Both modes are tested, including that they do **not** cross-verify, which is the -concrete shape of the contradiction. The OR transcript is the only place the digest -can go: the partial endorsement `(e, a, b, r)` is fixed at issuance, and PR #39's -own structure confirms that everything except the proof is issuance-time. +The OR transcript is the only place the digest can go: the partial endorsement +`(e, a, b, r)` is fixed at issuance, so nothing else in a presentation is computed +when the challenge is known. ## A security property no draft states — [#9] -Found while implementing. - -The Anchor must draw `a'`, `b'`, `t'` freshly per session. Given reuse of `t'` and -`a'` across two sessions with chosen `e'_1 ≠ e'_2`: +Found while implementing. The Anchor must draw `a'`, `b'`, `t'` freshly per session. +Given reuse of `t'` and `a'` across two sessions with chosen `e'_1 ≠ e'_2`: ``` r'_1 = t' + e'_1 a' x @@ -186,96 +125,42 @@ Two consequences: 2. **Anchor state must be single-use.** `IhatAnchor::prove` consuming `Self::State` by value enforces it at the type level. -The protocols draft tells only the *Client* "It MUST NOT retry with the same state". -PR #39 adds a Security Considerations section covering only single-show -unlinkability and a PQ public-key caveat, so there is now an obvious home for this. +The protocols draft constrains only the *Client's* state reuse. ## Validation -### Cross-verification against `ihat-rs` - -`interop/` is a detached package — separate because it needs a git dependency and -Cargo cannot make dev-dependencies optional — pinned to a specific upstream -revision. Four tests, run in `IhatRsCompatible` mode: - -- `ihat-rs` parses and accepts presentations `mole-exp` produced, for every issuer - in a four-Anchor set, and **re-encodes them to byte-identical output** -- `mole-exp` parses and accepts presentations `ihat-rs` produced, recovering the - same nullifier and endorsement context -- Anchor public keys round-trip between the two implementations in both directions -- neither implementation accepts a substituted accepted set - -Since neither the drafts nor `ihat-rs` publish test vectors, this stands in for -them — and it is stronger, because it exercises both roles rather than fixed byte -strings. - -``` -cargo test --manifest-path interop/Cargo.toml -``` - -### End-to-end through the protocol - -`tests/appendix_a_exp_ihat.rs` drives Appendix A of the protocols draft on this -profile: the `WWW-Authenticate` challenge, challenge binding over the decoded -octets, both grant exchanges with their bodies carried inside real -`EndorsementRequest`/`EndorsementResponse` envelopes, redemption through -`Moderator::redeem`, the credential exchange, and the update. Seven tests, including: - -- issuer hiding at the protocol level — every Anchor in the accepted set can issue, - presentation size does not vary, and what the Moderator ends up holding is an epoch - it already knew plus a nullifier -- an endorsement cannot be redeemed twice (real nullifier, real store) -- a redemption bound to another challenge fails — and note the error is - `VerificationFailed`, not the mock's `ChallengeMismatch`, because with real - cryptography the Moderator genuinely cannot distinguish "wrong challenge" from - "invalid proof". That is the better outcome: §8 warns that `redeem`'s distinct - error variants must not be surfaced to Clients, and here there is nothing to - surface -- an endorsement from a previous epoch verifies cryptographically but is refused by - the epoch check, so the two checks are independent and correctly ordered -- the accepted set's order is load-bearing: a permutation does not verify -- an unaccepted Anchor completes the grant fine but cannot present - -This matters because it exercises the profile against the *protocol machinery* -rather than a harness built around it. The only remaining stand-in is the Privacy -Pass token in the credential half. - -### Property tests - -`tests/exp_ihat_profile.rs` — 21 tests: - -- the full two-exchange grant, then a presentation a verifier accepts -- **grant message widths** match `ihat-rs`'s wire format exactly: 33 + 2 + |ctx|, - 4×33, 32, 3×32 -- **presentation layout** matches: separate `nf` and `endorsement_context`, a - declared OR-proof length that is a multiple of 97 -- **the four DSTs** are pinned verbatim, because a silent drift breaks everything - and nothing else would catch it -- blindness, asserted against the actual request bytes: the nullifier appears in - neither request. The context *does* appear in the first, by design — the Anchor - needs it to derive `H` -- issuer hiding across a three-Anchor set, size invariant to the real branch -- both binding modes, including that they do not cross-verify -- unlinkability: two grants share no field -- a context mismatch fails the Pedersen check at finalize -- an Anchor that used the wrong key, and a corrupted `Z'`, are both caught by the - DLEQ relations at finalize -- Anchor keys are SEC1 compressed with both parities occurring -- an identity key in the accepted set is refused -- every single-byte flip in a presentation is detected -- malformed, truncated, and ragged input is rejected without panicking - -### What none of this establishes - -That the construction is sound. `ihat-rs` has not been audited, there is no security -proof and no public cryptanalysis, upstream PR #38 patched a "PQ tagging attack", -and PR #39 is open. Interoperability is agreement, not correctness — if the -construction is flawed, both implementations are flawed identically. - -The two grant exchanges also still have no correlation mechanism ([#11]). PR #39 -now defines all four grant messages and none carries a session identifier, which -forecloses that option by omission. In this profile correlation is implicit in the -Rust call sequence — exactly what a network protocol cannot rely on. +**Cross-verification.** `interop/` is a detached package pinned to a specific +upstream revision. Both directions pass: `ihat-rs` accepts presentations this crate +produced and re-encodes them byte-identically; this crate accepts `ihat-rs`'s, +recovering the same nullifier and endorsement context; keys round-trip both ways; +neither accepts a substituted accepted set. A CI job runs it. + +Since neither the drafts nor `ihat-rs` publish test vectors, this stands in for them, +and it is stronger — it exercises both roles against an independent implementation. + +**End to end.** `tests/appendix_a_flow.rs` drives Appendix A of the protocols draft +on this profile: the challenge, binding over the decoded octets, both grant exchanges +with their bodies inside real `EndorsementRequest`/`EndorsementResponse` envelopes, +redemption through `Moderator::redeem`, then the credential exchange and update. It +asserts issuer hiding at the protocol level — every Anchor in the accepted set can +issue, presentation size does not vary, and the Moderator ends up holding an epoch it +already knew plus a nullifier — and that the accepted set's order is load-bearing. + +`interop/tests/appendix_a_end_to_end.rs` runs the same exchange with **no mocks at +all**, pairing this profile with real RFC 9578 Privacy Pass credentials. + +Note one deliberate consequence: a redemption bound to another challenge fails with +`VerificationFailed` rather than a distinguishable "wrong challenge" error. With real +cryptography the Moderator cannot tell the two apart, which is what §8 wants — it +warns that `redeem`'s distinct error variants must not be surfaced to Clients. + +**Property tests.** `tests/exp_ihat_profile.rs` covers blindness against the actual +request bytes, issuer hiding, both binding modes and that they do not cross-verify, +unlinkability, the context-mismatch failure, SEC1 encoding with both parities, +rejection of an identity key in the accepted set, detection of every single-byte +flip, and that malformed input is refused without panicking. The four DSTs are pinned +verbatim, because a silent drift breaks all interoperability and nothing else would +catch it. ## Building @@ -286,8 +171,8 @@ cargo test --manifest-path interop/Cargo.toml ``` `no_std`-clean. One dependency, `p256` with `hash2curve`, held at the `0.13` line -because `0.14` requires Rust 1.85 against this crate's 1.75 MSRV; `zeroize` is -pinned in `Cargo.lock` for the same reason ([#12]). +because `0.14` requires Rust 1.85 against this crate's 1.75 MSRV; `zeroize` is pinned +in `Cargo.lock` for the same reason ([#12]). [#1]: https://github.com/OR13/mole/issues/1 [#4]: https://github.com/OR13/mole/issues/4 @@ -296,6 +181,4 @@ pinned in `Cargo.lock` for the same reason ([#12]). [#7]: https://github.com/OR13/mole/issues/7 [#8]: https://github.com/OR13/mole/issues/8 [#9]: https://github.com/OR13/mole/issues/9 -[#11]: https://github.com/OR13/mole/issues/11 [#12]: https://github.com/OR13/mole/issues/12 -[#13]: https://github.com/OR13/mole/issues/13 diff --git a/README.md b/README.md index b6e33a5..1612319 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ from Google, Mozilla, and Cloudflare. ## ⚠️ Read this before you use it -**The core of this crate contains no cryptography, and that is not an oversight.** +**Nothing here is deployable.** The default build contains no cryptography, and the +one cryptographic feature implements a construction that has not been audited. -The protocols draft specifies IHAT's message *structures* and then defers every +The protocols draft specifies IHAT's message *structures* and defers every cryptographic operation to a companion document: > The cryptographic operations, and the contents of every message body, are @@ -30,71 +31,32 @@ cryptographic operation to a companion document: > > — [§4.1, `draft-jms-mole-protocols-00`](https://datatracker.ietf.org/doc/draft-jms-mole-protocols/) -### Where `[CRYPTO]` actually stands +`[CRYPTO]` has since been written, but it is not on the IETF Datatracker and is not +an interoperable specification: it names no group directly, gives no domain +separation tag, and publishes no test vectors. The MoLE org does publish a reference +implementation, +[`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs), by one +of the draft authors, and that settles all of it. -`[CRYPTO]` ("MoLE Cryptography") is cited in `-protocols-00` with no date. Since -that draft was published it **has been written**: -[`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md) -landed upstream on 15 July 2026 (PR #38, closing issue #31). It is **not** on the -IETF Datatracker — `draft-authors-mole-crypto` returns 404 — and it is not -submitted. - -It is also not yet an interoperable specification. On `main` it runs about fifty -lines of cryptographic content in prose: it names no group, no hash, and no domain -separation tag, declares TLS notation in its Conventions section and then defines -**zero** structures, uses `C'` before defining it, leaves the Pedersen generator -undefined, defers the OR proof to "sigma stacking", and publishes no test vectors. - -Open [PR #39](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/pull/39) -closes a good deal of that — it adds message structures, defines the second -generator, specifies a full OR-proof construction, and routes the sigma proofs -through `draft-irtf-cfrg-sigma-protocols` and `draft-irtf-cfrg-fiat-shamir`. What -remains open, or newly broken, after it: - -- The group is named only by forward reference: "`{{ARCH}}` currently instantiates - this protocol with P-256" — and the architecture draft contains **no group - instantiation at all**. -- The two MoLE drafts disagree on point encoding: `Point[33]` (compressed) versus - PR #39's `bytes[65]` (uncompressed). ACT, with overlapping authors, uses 32-byte - ristretto255. -- No domain separation tag, which RFC 9380 requires. -- `m` is typed as a 32-byte `Scalar`, which **cannot** carry the - `endorsement_context` that protocols §4.1.3 requires verification to expose. -- `challenge_digest` appears in no structure and no challenge, so §4.1.3's two - MUSTs about binding it are unsatisfiable as written. -- Still no test vectors, and still no correlation mechanism for the two grant - exchanges — PR #39 defines all four grant messages and none carries a session - identifier. - -The same protocols draft notes that "IHAT is a placeholder name," that the two -grant exchanges still need a correlation mechanism, and that the protocols "are not -final. Some may be removed, others added." The registry values are labelled -candidates, not assignments. - -So the core of this crate is everything the drafts *do* pin down — the wire format, -the HTTP carriage, challenge binding, the registries and greasing rules, and the +So the default build is everything the drafts *do* pin down — the wire format, the +HTTP carriage, challenge binding, the registries and greasing rules, and the Moderator's replay state machine — with the cryptography expressed as the trait -boundary in `src/crypto.rs`. Nothing in the default build invents a scheme. - -### Two off-by-default stand-ins - -| Feature | What it is | -|---|---| -| `insecure-mock` | Fakes the *interface* so the encoding and state-machine layers can be tested end to end. No blindness, no issuer hiding, no public verifiability, no unlinkability. A test fixture. | -| `exp-ihat` | Implements the *construction* over **P-256**, aligned byte-for-byte with [`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs), the reference implementation published by a MoLE draft author. **Cross-verified in both directions.** Blindness, issuer hiding, public verifiability and unlinkability are real. | +boundary in `src/crypto.rs`. Two dependencies, `#![no_std]`, no invented schemes. -Neither is deployable. `exp-ihat` is the more dangerous of the two precisely -because it works, and now interoperates: it produces endorsements the authors' own -implementation accepts, for a construction that `ihat-rs` itself says "has not been -audited" and that has no security proof or public cryptanalysis. Interoperability is -not soundness. +The off-by-default **`exp-ihat`** feature implements IHAT over P-256, aligned +byte-for-byte with `ihat-rs` and cross-verified against it in both directions. +Blindness, issuer hiding, public verifiability and unlinkability are real. It is the +more dangerous half of this crate precisely because it works: `ihat-rs` states it has +not been audited, and interoperability is agreement, not correctness. One thing cannot be reconciled: `ihat-rs` has **no `challenge_digest` anywhere**, so -protocols §4.1.3's two MUSTs about binding it are satisfied by nobody. The profile -exposes `ChallengeBinding` with two modes — one byte-compatible with `ihat-rs`, one -compliant with §4.1.3 — because they cannot be the same thing until upstream -decides. See [`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md) and -[#13](https://github.com/OR13/mole/issues/13). +protocols §4.1.3's two MUSTs about binding it are satisfied by no implementation. The +profile exposes `ChallengeBinding` with a mode for each side of that contradiction. +See [`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md). + +The drafts also note that "IHAT is a placeholder name", that the two grant exchanges +still need a correlation mechanism, and that the protocols "are not final. Some may +be removed, others added." Registry values are candidates, not assignments. ## What is implemented @@ -110,11 +72,14 @@ decides. See [`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md) and | Greasing: value selection, and grease-as-unknown on the Moderator | ✅ | | Epoch nullifier store, single-use enforcement, token nonce replay | ✅ | | 200/401/403 challenge semantics | ✅ | -| IHAT cryptography | 🟡 trait boundary; `exp-ihat` implements it over P-256, cross-verified against `ihat-rs` | -| Longfellow proving/verifying | ❌ needs `draft-google-cfrg-libzk` + circuit | -| ACT spend/refund | ❌ needs `draft-schlesinger-cfrg-act` | -| Privacy Pass issuance (VOPRF, batched tokens) | ⬜ carried opaquely per §5.2.1; `reverse_flow::Token` parses what a Moderator must read; demonstrated end to end against `privacypass` in `interop/` | -| Key rotation and discovery | ❌ draft section is `TODO.` | +| IHAT cryptography (endorsement `0x0002`) | ✅ `exp-ihat`, over P-256, cross-verified against `ihat-rs` | +| Privacy Pass Reverse Flow (credential `0x0002`) | ✅ carried opaquely per §5.2.1; `reverse_flow::Token` parses what a Moderator must read; run end to end against `privacypass` | + +MoLE specifies several endorsement and credential protocols to demonstrate agility; +a deployment picks one of each. This crate implements IHAT and Privacy Pass Reverse +Flow. The wire structures, registries and dispatch for the others are present and +tested — only their cryptography is out of scope, and Longfellow's and ACT's +underlying drafts are not in a state to build against. [`SPEC-COVERAGE.md`](SPEC-COVERAGE.md) maps every draft section to what this crate does with it, including the drafts' own open questions. @@ -190,7 +155,7 @@ That is `moderator::NonceStore`, kept separate from the epoch `NullifierStore`. ## Tests ``` -cargo test --all-features # 121 tests +cargo test --all-features # 111 tests cargo test --manifest-path interop/Cargo.toml # 7 interop + end-to-end tests cargo clippy --all-features --all-targets RUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps @@ -204,11 +169,10 @@ only rustdoc will catch. `tests/wire_vectors.rs` holds byte-exact hex vectors — the drafts publish none, so these are this crate's, offered for cross-implementation checking. `tests/appendix_a_flow.rs` drives the complete exchange from Appendix A of the -protocols draft on `insecure_mock`; **`tests/appendix_a_exp_ihat.rs` drives the same -exchange on real cryptography** — the profile's actual grant messages carried inside -the actual `EndorsementRequest`/`EndorsementResponse` envelopes, redeemed through a -real OR proof against a real accepted set. `tests/exp_ihat_profile.rs` exercises the -profile in isolation. +protocols draft on real cryptography — the profile's actual grant messages carried +inside the actual `EndorsementRequest`/`EndorsementResponse` envelopes, redeemed +through a real OR proof against a real accepted set. `tests/exp_ihat_profile.rs` +exercises the profile in isolation. And `interop/tests/appendix_a_end_to_end.rs` runs the same exchange with **no mocks at all** — real IHAT endorsements plus real RFC 9578 Privacy Pass credentials via @@ -243,16 +207,11 @@ and the draft is not. Also relevant, [`draft-authors-mole-crypto.md`](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/blob/main/draft-authors-mole-crypto.md) -— repository `main` as of 28 July 2026, with the group and second generator taken -from open [PR #39](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts/pull/39). -It has no Datatracker entry and no revision number, so there is no stable -identifier to track it by; the profile is pinned to a reading of the file, not to a -published draft. - -Transitively, `exp-ihat` also relies on RFC 9380 for hash-to-curve. PR #39 further -points the sigma proofs at `draft-irtf-cfrg-sigma-protocols-02` and -`draft-irtf-cfrg-fiat-shamir-02`, which this profile does **not** yet follow — see -[`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md). +— which has no Datatracker entry and no revision number. The profile does not follow +it directly; see below. + +`exp-ihat` also relies on RFC 9380 for hash-to-curve, and RFC 9578 governs the +Privacy Pass tokens the credential layer carries. Upstream source: [Moderation-of-unLinkable-Endorsements/internet-drafts](https://github.com/Moderation-of-unLinkable-Endorsements/internet-drafts). diff --git a/SPEC-COVERAGE.md b/SPEC-COVERAGE.md index 02d5615..e50c992 100644 --- a/SPEC-COVERAGE.md +++ b/SPEC-COVERAGE.md @@ -3,8 +3,15 @@ Section-by-section map of the three MoLE drafts (all `-00`, 6 July 2026) to what this crate does with them. -Legend: ✅ implemented · 🟡 partial, see note · ⬜ structures only, semantics external · -❌ not implemented · 📄 informative, nothing to implement +Legend: ✅ implemented · 🟡 partial, see note · ⬜ structures only, cryptography out of +scope · 📄 informative, nothing to implement · ⛔ unspecified upstream, nothing to +implement yet + +MoLE defines several endorsement and credential protocols to demonstrate agility; a +deployment picks one of each. This crate implements **IHAT** (endorsement `0x0002`) +and **Privacy Pass Reverse Flow** (credential `0x0002`). For the others the wire +structures, registries and dispatch are implemented and tested — ⬜ marks where only +the cryptography is external. --- @@ -25,19 +32,19 @@ Legend: ✅ implemented · 🟡 partial, see note · ⬜ structures only, semant | 4.2 | Longfellow | ⬜ | `endorsement::longfellow` has the empty `Challenge` and the `Presentation` (`circuit_id`, `nullifier`, `proof`). Proving/verifying is `crypto::LongfellowVerifier`; needs `draft-google-cfrg-libzk` and an out-of-band circuit. | | 4.2.3 | Differences from IHAT | 📄 | Scarcity rationale. | | 5 | Credential Protocols | ✅ | `credential::{CredentialRequest, CredentialResponse}`, including the rule that type `0x0001` carries an empty `endorsement_presentation`. | -| 5.1 | ACT | ⬜ | `credential::act` has `IssuanceRequest`, `IssuanceResponse`, `PresentationAndUpdate`, `Update`. `Challenge` is an opaque passthrough because the draft says its "contents are not yet defined". Spend/refund need `draft-schlesinger-cfrg-act`. | +| 5.1 | ACT | ⬜ | `credential::act` has `IssuanceRequest`, `IssuanceResponse`, `PresentationAndUpdate`, `Update`. `Challenge` is an opaque passthrough because the draft says its "contents are not yet defined". Spend/refund are external; `draft-schlesinger-cfrg-act` is expiring and being folded into the crypto draft. ACT is the only credential protocol with **bound updates**, which Reverse Flow cannot provide (§5.2.3). | | 5.2 | Privacy Pass Reverse Flow | ✅ | `credential::reverse_flow`. Empty `Challenge`; `PresentationAndUpdate`; `Update` (empty = out of credentials). No `IssuanceRequest`/`IssuanceResponse` wrappers, deliberately — the draft says those fields carry raw RFC 9578 `TokenRequest`/`TokenResponse`. `reverse_flow::Token` parses the one RFC 9578 structure a Moderator must read. Exercised end to end against `privacypass` in `interop/`. | | 5.2.2 | Presentation and Update | 🟡 | Single-use-nonce semantics are `moderator::NonceStore`, enforced over `reverse_flow::Token`'s parsed nonce. **The constant-digest MUST is unsatisfiable as written** — it cross-references §3.3, but the token's `challenge_digest` is computed by RFC 9578 over an RFC 9577 `TokenChallenge`, which is never empty ([#16]). This crate takes the workable reading: one fixed `TokenChallenge` per Moderator. | | 5.2.3 | Limitations | 📄 | Device binding is an open problem upstream. | | 5.3 | Budget Privacy Pass | ⬜ | `credential::budget`. `Challenge { uint64 amount }`, `PresentationAndUpdate` (≥1 token enforced), `Update`. Batched issuance needs `draft-ietf-privacypass-batched-tokens`. | -| 6 | Key Rotation and Discovery | ❌ | The draft section is literally `TODO.` with four open questions (JWKS vs Privacy Pass directory, rotation, key identifiers, config validation). Nothing to implement yet. | -| 7 | Privacy Considerations | ❌ | `TODO.` upstream. Anchor-set verification, config partitioning, and epoch width are unaddressed by the draft. | +| 6 | Key Rotation and Discovery | ⛔ | The draft section is literally `TODO.` with four open questions (JWKS vs Privacy Pass directory, rotation, key identifiers, config validation). Nothing to implement yet. | +| 7 | Privacy Considerations | ⛔ | `TODO.` upstream. Anchor-set verification, config partitioning, and epoch width are unaddressed by the draft. | | 8 | Security Considerations | 🟡 | `TODO.` upstream. Two of its four items are reflected in the code: no eviction in `NullifierStore` (early eviction re-admits spent Endorsements), and a documented warning that `redeem`'s distinct error variants must not be surfaced to Clients. | | 9.1 | Endorsement Type registry | ✅ | `registry::EndorsementType`, candidate values `0x0001`–`0x0003`, testing range `0xFF00`+. | | 9.2 | Credential Type registry | ✅ | `registry::CredentialType`, candidate values `0x0001`–`0x0003`. | | 9.2.4 | Greased Values | ✅ | `registry::GREASE_CREDENTIAL_TYPES`, with a test that the table equals the documented `0x?A?A` pattern. | | 9.3 | Media Types | ✅ | `endorsement::ENDORSEMENT_{REQUEST,RESPONSE}_MEDIA_TYPE`. Full registration templates are a draft TODO. | -| A | Example | ✅ | `tests/appendix_a_flow.rs` drives the whole figure on `insecure_mock`; `tests/appendix_a_exp_ihat.rs` drives it on real IHAT cryptography, with the grant bodies carried through the real transport envelopes. The credential half is still a stand-in token pending RFC 9578 issuance. | +| A | Example | ✅ | `tests/appendix_a_flow.rs` drives the whole figure on real IHAT cryptography, with the grant bodies carried through the real transport envelopes. `interop/tests/appendix_a_end_to_end.rs` runs it with no mocks at all, pairing that with real RFC 9578 Privacy Pass credentials. | ## draft-jms-mole-http-transport-00 @@ -47,7 +54,7 @@ Legend: ✅ implemented · 🟡 partial, see note · ⬜ structures only, semant | 3.1 | Optional Value | ✅ | `codec::{encode_optional, decode_optional}`; presence octet other than 0/1 rejected as malformed. | | 3.2 | Variable-Size Vector Length Headers | ✅ | `codec::{encode_varint, Reader::varint}`. QUIC §16 encoding **plus** the minimum-size requirement, which is where MoLE differs; non-minimal forms are rejected. | | 4 | HTTP Authentication Scheme | ✅ | `http`. `Mole` scheme, unpadded base64url everywhere, multiple challenges per field value, non-Mole schemes skipped. | -| 5 | Configuration | ❌ | Points at §6 of the protocols draft, which is `TODO`. | +| 5 | Configuration | ⛔ | Points at §6 of the protocols draft, which is `TODO`. | | 6 | Error Handling | ✅ | `moderator::ChallengeMode` — 200 optional / 401 required / 403 rejected-under-policy. | | 6.1.1 | Anchor → Client | ✅ | `transport::EndorsementChallenge`. | | 6.1.2 | Client ↔ Anchor | ⬜ | Grant carriage is per endorsement type; the POST envelopes are `endorsement::EndorsementRequest`/`Response`. | @@ -90,8 +97,8 @@ both directions that presentations cross-validate and re-encode identically. Where `ihat-rs` and the drafts disagree the profile follows `ihat-rs`, and each disagreement is an open issue: point compression ([#1]), the issuance transcript binding `X̂` and the context ([#4]), the Pedersen generator being per-context rather -than global ([#5]), the OR proof being `O(n)` CDS rather than PR #39's commitment -tree ([#6]), and `m` not existing at all ([#7]). +than global ([#5]), the OR proof construction ([#6]), and `m` not existing at all +([#7]). **One thing cannot be reconciled.** `ihat-rs` has no `challenge_digest` anywhere, so §4.1.3's two MUSTs about binding it are satisfied by no implementation. The profile @@ -102,40 +109,36 @@ MoLE specifies — only about what its authors' code does. ## Things the drafts leave open -Collected here because they are the interesting parts, and an implementer will hit -them: - -- `[CRYPTO]` now exists upstream as `draft-authors-mole-crypto.md` (merged 15 July - 2026, PR #38) but is **not** on the Datatracker and is not an interoperable - specification: no group, no hash, no DSTs, no wire structures, no test vectors, - and PR #39 is still open to "try to define proof". IHAT can be *implemented* — - `crypto::exp_ihat` does over P-256 — but by following `ihat-rs`, the authors' - reference implementation, which settles the group, DSTs, encodings and transcripts - the draft leaves open, and which departs from the draft in five places ([#13]). -- IHAT's two grant exchanges have no defined correlation mechanism — the draft - offers "either `[CRYPTO]` adds a session identifier … or this document mandates - connection reuse" and picks neither ([#11]). This is the sharpest gap, and it is - now time-sensitive: the crypto draft's two-round issuance is exactly where a - session identifier would live, and it is being edited. -- The drafts never require the **Anchor** to use fresh per-session nonces. Reuse of - `t'` and `a'` across two grants recovers the Anchor's secret key by subtraction, - since `a'` is sent to the Client in the clear ([#9]). The protocols draft - constrains only the Client's state reuse. -- The group is P-256 by inference, not statement: the crypto draft says - "`{{ARCH}}` currently instantiates this protocol with P-256" and the architecture - draft contains no group instantiation at all. It is corroborated only by - `Point[33]` and PR #39's `bytes[65]` both being P-256 SEC1 widths — which is also - the defect, since those two are the compressed and uncompressed encodings and the - drafts must pick one ([#1]). -- ACT's `Challenge` must express a predicate and a charged amount; its contents are - undefined. -- Reverse flow has no device binding, so an update is not provably bound to the - presented credential ("Bound Update: No" in the registry). The draft calls this an - open problem. -- Configuration format, key rotation, and key identifiers are all unspecified. -- Anchor-set verification — how a Client confirms the accepted Anchors are real +Collected because they are the interesting parts, and an implementer will hit them. +Each is an open issue. + +- **`[CRYPTO]` is not an interoperable specification** — no group named directly, no + domain separation tags, no test vectors. The reference implementation `ihat-rs` + settles all of it, and is what `exp-ihat` follows ([#13]). +- **§4.1.3's `challenge_digest` binding is implemented by nobody.** `ihat-rs` has no + such field anywhere, so the two MUSTs cannot be satisfied while interoperating + ([#8]). +- **§5.2.2's constant-digest requirement is unsatisfiable as written.** It + cross-references §3.3, but the token's `challenge_digest` is computed by RFC 9578 + over an RFC 9577 `TokenChallenge`, which is never empty ([#16]). +- **`m` cannot carry `endorsement_context`** where the crypto draft types it as a + 32-byte scalar, which §4.1.3 requires verification to expose ([#7]). +- **The two grant exchanges have no correlation mechanism.** The draft offers "either + `[CRYPTO]` adds a session identifier … or this document mandates connection reuse" + and picks neither; no implementation puts one on the wire ([#11]). +- **No draft requires the Anchor to use fresh per-session nonces.** Reuse of `t'` and + `a'` recovers its secret key by subtraction, since `a'` is sent in the clear + ([#9]). +- **The two MoLE drafts disagree on point compression**, and the group is named only + by a forward reference the architecture draft does not satisfy ([#1]). +- **Configuration format, key rotation, and key identifiers are unspecified** — §6 is + `TODO.` upstream, so there is nothing to implement. +- **Anchor-set verification** — how a Client confirms the accepted Anchors are real rather than padding invented by the Moderator to shrink the anonymity set — is listed as a privacy consideration to be written. +- **Reverse Flow has no device binding**, so an update is not provably bound to the + presented credential ("Bound Update: No"). ACT is the only credential protocol that + provides it. The draft calls this an open problem. [#1]: https://github.com/OR13/mole/issues/1 [#2]: https://github.com/OR13/mole/issues/2 diff --git a/src/crypto.rs b/src/crypto.rs index 87cfda0..82ea2fe 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -10,23 +10,18 @@ //! > section are opaque byte strings produced and consumed by the functions named //! > below. //! -//! `[CRYPTO]` ("MoLE Cryptography", `draft-authors-mole-crypto`) is cited with no -//! date in `draft-jms-mole-protocols-00`. It has since been written — the file -//! landed in the upstream repository on 15 July 2026 — but it is not on the IETF -//! Datatracker, is not submitted, and does not specify enough to implement -//! interoperably: the group is named only by a forward reference the architecture -//! draft does not satisfy, there are no domain separation tags, and there are no -//! test vectors. The draft additionally -//! notes that "IHAT is a placeholder name" and that the two grant exchanges still -//! need a correlation mechanism. +//! `[CRYPTO]` ("MoLE Cryptography", `draft-authors-mole-crypto`) has been written +//! but is not on the IETF Datatracker and does not specify enough to implement +//! interoperably: no group named directly, no domain separation tags, no test +//! vectors. The draft also notes that "IHAT is a placeholder name" and that the two +//! grant exchanges still need a correlation mechanism. //! -//! `crypto::exp_ihat` (feature `exp-ihat`) implements the construction anyway, over -//! P-256, by following +//! `crypto::exp_ihat` (feature `exp-ihat`) implements the construction over P-256 by +//! following //! [`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs) — //! the reference implementation published in the MoLE org by a draft author — rather -//! than the draft's prose. It settles the group, the DSTs, the encodings and both -//! Fiat–Shamir transcripts that the draft leaves open, and `interop/` cross-verifies -//! in both directions. It departs from the draft in five places, each an open issue. +//! than the prose, which settles the group, the DSTs, the encodings and both +//! Fiat–Shamir transcripts. `interop/` cross-verifies in both directions. //! //! Longfellow (Section 4.2) similarly requires the proving system of //! `draft-google-cfrg-libzk` plus an out-of-band circuit, and ACT (Section 5.1) @@ -163,5 +158,3 @@ pub trait LongfellowVerifier { #[cfg(feature = "exp-ihat")] pub mod exp_ihat; -#[cfg(feature = "insecure-mock")] -pub mod insecure_mock; diff --git a/src/crypto/exp_ihat.rs b/src/crypto/exp_ihat.rs index 8af626e..b679411 100644 --- a/src/crypto/exp_ihat.rs +++ b/src/crypto/exp_ihat.rs @@ -3,9 +3,9 @@ //! //! # What this is //! -//! Unlike [`super::insecure_mock`], this is the real construction over a real -//! curve: blinding works, the Anchor never learns the nullifier, issuer hiding is -//! a genuine 1-of-n OR proof, and verification needs only public keys. +//! The real construction over a real curve: blinding works, the Anchor never learns +//! the nullifier, issuer hiding is a genuine 1-of-n OR proof, and verification needs +//! only public keys. //! //! It follows //! [`ihat-rs`](https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs) @@ -46,15 +46,11 @@ //! `(T₁', T₂', r')` already is one: `finalize` checks `Y'·r' = Z'·e'a' + T₁'` and //! `G·r' = X·e'a' + T₂'`, which together prove the Anchor used a single `x` for //! both `Z' = x·Y'` and `X = x·G`. A separate proof would be redundant. -//! * **The Pedersen generator is per-context, not fixed.** Upstream PR #39 defines -//! `H = Hash2Curve("IHAT second generator")`, one global generator. `ihat-rs` -//! derives `H` from `endorsement_context`, binding the context into `C` -//! structurally. It is load-bearing: a context mismatch makes the Client's -//! Pedersen check fail at finalize. See [#5]. -//! * **Points are compressed.** PR #39 declares `uncompressed bytes[65]`; -//! `ihat-rs` and protocols §4.1 both use 33-byte compressed. See [#1]. -//! * **The OR proof is `O(n)` CDS.** PR #39 specifies an `O(log n)` trapdoor -//! commitment tree. `ihat-rs` uses one Schnorr transcript per key. See [#6]. +//! * **The Pedersen generator is per-context, not global.** `H` is derived from +//! `endorsement_context`, binding it into `C` structurally. Load-bearing: a +//! context mismatch makes the Client's Pedersen check fail at finalize. See [#5]. +//! * **Points are compressed**, 33 bytes, as protocols §4.1 says. See [#1]. +//! * **The OR proof is `O(n)` CDS** — one Schnorr transcript per key. See [#6]. //! * **`m` does not exist.** The draft's `m` is just the nullifier: `ihat-rs` //! carries `nf` and `endorsement_context` as two separate fields and computes //! `Y = H₁(nf)`. See [#7]. @@ -62,8 +58,8 @@ //! # Why you still must not deploy it //! //! `ihat-rs` says it of itself: "This code has not been audited." The construction -//! has no security proof and no public cryptanalysis, and it is still moving — -//! upstream PR #38 patched a "PQ tagging attack" and PR #39 is open. +//! has no security proof and no public cryptanalysis, and it is still being revised +//! upstream. //! //! And one thing cannot be reconciled. Protocols §4.1.3 requires that "`Present` //! MUST bind `challenge_digest` into the proof transcript" and that "`Verify` MUST @@ -414,9 +410,8 @@ struct Branch { /// **Constant-time in `real_index`.** No control flow or indexing depends on it: /// the real base is recovered by a `subtle` select across the whole accepted set, /// every branch computes a simulated commitment, and the honest one is selected in. -/// A version that branched on the index — as an earlier revision of this module -/// did — leaks through timing and cache behaviour exactly the thing issuer hiding -/// exists to conceal. +/// Branching on the index would leak, through timing and cache behaviour, exactly +/// the thing issuer hiding exists to conceal. fn or_prove( rng: &mut R, accepted: &[ProjectivePoint], diff --git a/src/crypto/insecure_mock.rs b/src/crypto/insecure_mock.rs deleted file mode 100644 index f9f7e35..0000000 --- a/src/crypto/insecure_mock.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! **INSECURE.** A stand-in for `[CRYPTO]`, so that the message flow can be -//! exercised end to end. -//! -//! For an implementation of the actual construction — real blinding, real issuer -//! hiding, real public verifiability, but with every unspecified parameter guessed -//! — see `crypto::exp_ihat` behind the `exp-ihat` feature. This module is the -//! cheaper fixture and remains the right one for testing the encoding and -//! state-machine layers, because it has no cryptographic cost. -//! -//! # This is not IHAT and provides no security -//! -//! It exists for exactly one reason: to let integration tests drive a complete -//! grant → redeem → issue → present → update sequence and assert that the -//! *encoding, binding, and state-machine* layers behave. It is enabled only by -//! the off-by-default `insecure-mock` feature. -//! -//! Every property that makes IHAT worth having is absent here: -//! -//! | Property IHAT requires | What this mock does | -//! |---|---| -//! | Blind signing — the Anchor never learns `nf` | The Client sends `nf` to the Anchor in cleartext | -//! | Issuer hiding via a 1-of-n OR proof | The presentation carries the Anchor's **index** in the accepted set | -//! | Public verifiability (Section 9.1.1) | The verifier must hold every Anchor's **secret key** | -//! | Unlinkability across presentations | Presentations are byte-identical for the same Endorsement | -//! -//! Consequently a Moderator using this mock learns which Anchor vouched for the -//! Client and can link that Client's presentations. Do not use it for anything. -//! -//! The one property it *does* implement faithfully is challenge binding: the -//! `challenge_digest` is covered by the MAC, so [`InsecureVerifier::verify`] fails -//! when given a different digest, as Section 4.1.3 requires of a real `Verify`. - -use alloc::vec::Vec; - -use sha2::{Digest, Sha256}; - -use crate::binding::ChallengeDigest; -use crate::codec::{write_opaque_v, Reader}; -use crate::crypto::{IhatAnchor, IhatClient, IhatVerifier, RandomSource, VerifiedEndorsement}; -use crate::endorsement::Point; -use crate::error::{Error, Result}; - -fn derive_public_key(secret: &[u8; 32]) -> Point { - let mut h = Sha256::new(); - h.update(b"mole/insecure-mock/pk"); - h.update(secret); - let d: [u8; 32] = h.finalize().into(); - let mut pk = [0u8; 33]; - // Shaped like a SEC1 compressed point. It is not a curve point. - pk[0] = 0x02; - pk[1..].copy_from_slice(&d); - Point(pk) -} - -fn mac(secret: &[u8; 32], nullifier: &[u8], context: &[u8], digest: &ChallengeDigest) -> [u8; 32] { - let mut h = Sha256::new(); - h.update(b"mole/insecure-mock/mac"); - h.update((nullifier.len() as u64).to_be_bytes()); - h.update(nullifier); - h.update((context.len() as u64).to_be_bytes()); - h.update(context); - h.update(digest.as_bytes()); - h.update(secret); - h.finalize().into() -} - -/// **INSECURE.** Mock Anchor. See the module docs. -#[derive(Debug, Clone)] -pub struct InsecureAnchor { - secret: [u8; 32], -} - -impl InsecureAnchor { - /// Creates a mock Anchor from a fixed secret. - #[must_use] - pub const fn new(secret: [u8; 32]) -> Self { - Self { secret } - } - - /// Exposes the secret so [`InsecureVerifier`] can be constructed — itself a - /// demonstration that this mock is not publicly verifiable. - #[must_use] - pub const fn secret(&self) -> &[u8; 32] { - &self.secret - } -} - -/// State the mock Anchor keeps between the two grant exchanges. -#[derive(Debug, Clone)] -pub struct AnchorState { - endorsement_context: Vec, -} - -impl IhatAnchor for InsecureAnchor { - type State = AnchorState; - - fn sign(&self, request_body: &[u8]) -> Result<(Vec, Self::State)> { - // Exchange 1: the request body is the endorsement_context. - let endorsement_context = request_body.to_vec(); - let mut h = Sha256::new(); - h.update(b"mole/insecure-mock/commit"); - h.update(&endorsement_context); - h.update(self.secret); - let commitment: [u8; 32] = h.finalize().into(); - Ok(( - commitment.to_vec(), - AnchorState { - endorsement_context, - }, - )) - } - - fn prove(&self, state: Self::State, request_body: &[u8]) -> Result> { - // Exchange 2: the request body is `nf || challenge_digest`, both of which - // a real Anchor would never see. - let mut r = Reader::new(request_body); - let nullifier = r.opaque_v_vec()?; - let digest = ChallengeDigest(r.array::<32>()?); - r.expect_end()?; - Ok(mac( - &self.secret, - &nullifier, - &state.endorsement_context, - &digest, - ) - .to_vec()) - } - - fn public_key(&self) -> Point { - derive_public_key(&self.secret) - } -} - -/// **INSECURE.** Mock Client. See the module docs. -#[derive(Debug, Clone)] -pub struct InsecureClient { - rng: R, -} - -impl InsecureClient { - /// Creates a mock Client drawing nullifiers from `rng`. - pub const fn new(rng: R) -> Self { - Self { rng } - } -} - -/// Client state across the mock grant. -#[derive(Debug, Clone)] -pub struct ClientState { - anchor_public_key: Point, - endorsement_context: Vec, - nullifier: Vec, - /// The digest the Endorsement will be bound to. - /// - /// A real IHAT Endorsement is *not* bound to a challenge at grant time — the - /// binding happens in `Present`. Binding it here is a shortcut this mock takes - /// because it has no proof system, and it is why a mock Endorsement can only - /// ever be redeemed against the one challenge it was granted for. - challenge_digest: ChallengeDigest, -} - -/// A finalized mock Endorsement. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InsecureEndorsement { - /// The nullifier the Moderator will record. - pub nullifier: Vec, - /// The epoch this Endorsement was granted in. - pub endorsement_context: Vec, - /// The Anchor's MAC. - pub mac: [u8; 32], - /// Which challenge this Endorsement is bound to. - pub challenge_digest: ChallengeDigest, - /// The granting Anchor's key, used to locate the OR-proof branch position. - /// - /// A real IHAT Endorsement would not need this at presentation time; the OR - /// proof covers the whole accepted set. - pub anchor_public_key: Point, -} - -impl InsecureClient { - /// Runs the whole mock grant against `anchor`, then produces a redemption - /// bound to `challenge_digest`. - /// - /// This is a convenience wrapper over the trait methods, matching the two - /// exchanges of Section 4.1.2. - pub fn grant_and_present( - &mut self, - anchor: &InsecureAnchor, - endorsement_context: &[u8], - keys: &[Point], - challenge_digest: &ChallengeDigest, - ) -> Result> { - let pk = anchor.public_key(); - - // Exchange 1. - let (req1, mut state) = self.prepare(&pk, endorsement_context)?; - state.challenge_digest = *challenge_digest; - let (resp1, anchor_state) = anchor.sign(&req1)?; - - // Exchange 2. - let (req2, state) = self.request_proof(state, &resp1)?; - let resp2 = anchor.prove(anchor_state, &req2)?; - - let endorsement = self.finalize(state, &resp2)?; - self.present(&endorsement, keys, challenge_digest) - } -} - -impl IhatClient for InsecureClient { - type State = ClientState; - type Endorsement = InsecureEndorsement; - - fn prepare( - &mut self, - anchor_public_key: &Point, - endorsement_context: &[u8], - ) -> Result<(Vec, Self::State)> { - let mut nullifier = alloc::vec![0u8; 32]; - self.rng.fill_bytes(&mut nullifier); - Ok(( - endorsement_context.to_vec(), - ClientState { - anchor_public_key: *anchor_public_key, - endorsement_context: endorsement_context.to_vec(), - nullifier, - challenge_digest: ChallengeDigest([0u8; 32]), - }, - )) - } - - fn request_proof( - &mut self, - state: Self::State, - _response_body: &[u8], - ) -> Result<(Vec, Self::State)> { - // Here is the blindness failure: `nf` goes to the Anchor in cleartext. - let mut body = Vec::new(); - write_opaque_v(&state.nullifier, &mut body); - body.extend_from_slice(state.challenge_digest.as_bytes()); - Ok((body, state)) - } - - fn finalize(&mut self, state: Self::State, response_body: &[u8]) -> Result { - let mac_bytes: [u8; 32] = response_body - .try_into() - .map_err(|_| Error::VerificationFailed)?; - Ok(InsecureEndorsement { - nullifier: state.nullifier, - endorsement_context: state.endorsement_context, - mac: mac_bytes, - challenge_digest: state.challenge_digest, - anchor_public_key: state.anchor_public_key, - }) - } - - fn present( - &mut self, - endorsement: &Self::Endorsement, - keys: &[Point], - challenge_digest: &ChallengeDigest, - ) -> Result> { - if *challenge_digest != endorsement.challenge_digest { - return Err(Error::ChallengeMismatch); - } - // Here is the issuer-hiding failure: the Client writes the *position* of - // its own Anchor in the accepted set, where real IHAT would write a - // 1-of-n OR proof that reveals nothing about which branch is real. - let branch = keys - .iter() - .position(|k| *k == endorsement.anchor_public_key) - .ok_or(Error::VerificationFailed)?; - let branch = u16::try_from(branch).map_err(|_| Error::VerificationFailed)?; - - let mut out = Vec::new(); - out.extend_from_slice(&branch.to_be_bytes()); - write_opaque_v(&endorsement.nullifier, &mut out); - write_opaque_v(&endorsement.endorsement_context, &mut out); - out.extend_from_slice(&endorsement.mac); - out.extend_from_slice(challenge_digest.as_bytes()); - Ok(out) - } -} - -/// **INSECURE.** Mock verifier. Requires every Anchor's secret key, which is -/// itself proof that this is not the publicly verifiable scheme IHAT specifies. -#[derive(Debug, Clone, Default)] -pub struct InsecureVerifier { - anchor_secrets: Vec<[u8; 32]>, -} - -impl InsecureVerifier { - /// Builds a verifier that accepts endorsements from any of `secrets`. - #[must_use] - pub fn new(secrets: Vec<[u8; 32]>) -> Self { - Self { - anchor_secrets: secrets, - } - } - - /// The public keys corresponding to the configured secrets, in order. - /// - /// This is the accepted Anchor set a Moderator would publish, and the order is - /// normative (Section 4.1.3). - #[must_use] - pub fn accepted_keys(&self) -> Vec { - self.anchor_secrets.iter().map(derive_public_key).collect() - } -} - -impl IhatVerifier for InsecureVerifier { - fn verify( - &self, - presentation: &[u8], - keys: &[Point], - challenge_digest: &ChallengeDigest, - ) -> Result { - let mut r = Reader::new(presentation); - let _branch = r.u16()?; - let nullifier = r.opaque_v_vec()?; - let endorsement_context = r.opaque_v_vec()?; - let claimed_mac = r.array::<32>()?; - let bound_digest = ChallengeDigest(r.array::<32>()?); - r.expect_end()?; - - // Challenge binding, which the mock DOES implement: a presentation bound - // to another challenge must not verify. - if bound_digest != *challenge_digest { - return Err(Error::ChallengeMismatch); - } - - // Try every accepted Anchor whose key is in `keys`. A real verifier checks - // one OR proof instead. - let accepted = self.accepted_keys(); - for (secret, pk) in self.anchor_secrets.iter().zip(accepted.iter()) { - if !keys.contains(pk) { - continue; - } - let expected = mac(secret, &nullifier, &endorsement_context, challenge_digest); - if constant_time_eq(&expected, &claimed_mac) { - return Ok(VerifiedEndorsement { - nullifier, - endorsement_context, - }); - } - } - Err(Error::VerificationFailed) - } -} - -fn constant_time_eq(a: &[u8; 32], b: &[u8; 32]) -> bool { - let mut diff = 0u8; - for i in 0..32 { - diff |= a[i] ^ b[i]; - } - diff == 0 -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::client::DeterministicRng; - - #[test] - fn mock_round_trip_verifies() { - let anchor = InsecureAnchor::new([1u8; 32]); - let verifier = InsecureVerifier::new(alloc::vec![[1u8; 32]]); - let keys = verifier.accepted_keys(); - let digest = ChallengeDigest::new(b"challenge"); - - let mut client = InsecureClient::new(DeterministicRng::new(42)); - let presentation = client - .grant_and_present(&anchor, b"epoch-1", &keys, &digest) - .unwrap(); - - let endorsement = verifier.verify(&presentation, &keys, &digest).unwrap(); - assert_eq!(endorsement.endorsement_context, b"epoch-1".to_vec()); - assert_eq!(endorsement.nullifier.len(), 32); - } - - #[test] - fn mock_rejects_a_different_challenge_digest() { - let anchor = InsecureAnchor::new([2u8; 32]); - let verifier = InsecureVerifier::new(alloc::vec![[2u8; 32]]); - let keys = verifier.accepted_keys(); - - let mut client = InsecureClient::new(DeterministicRng::new(7)); - let presentation = client - .grant_and_present(&anchor, b"epoch-1", &keys, &ChallengeDigest::new(b"first")) - .unwrap(); - - assert_eq!( - verifier - .verify(&presentation, &keys, &ChallengeDigest::new(b"second")) - .err(), - Some(Error::ChallengeMismatch) - ); - } - - #[test] - fn mock_rejects_an_anchor_outside_the_accepted_set() { - let anchor = InsecureAnchor::new([3u8; 32]); - // The verifier knows a different Anchor. - let verifier = InsecureVerifier::new(alloc::vec![[4u8; 32]]); - let digest = ChallengeDigest::new(b"c"); - let keys = alloc::vec![anchor.public_key()]; - - let mut client = InsecureClient::new(DeterministicRng::new(9)); - let presentation = client - .grant_and_present(&anchor, b"epoch-1", &keys, &digest) - .unwrap(); - - assert_eq!( - verifier.verify(&presentation, &keys, &digest).err(), - Some(Error::VerificationFailed) - ); - } -} diff --git a/src/lib.rs b/src/lib.rs index 8a6cd46..8275ee0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,13 +34,11 @@ //! > section are opaque byte strings produced and consumed by the functions named //! > below. //! -//! `[CRYPTO]` has since been written — `draft-authors-mole-crypto.md` landed in the -//! upstream repository on 15 July 2026 — but it is not on the IETF Datatracker and -//! is not yet an interoperable specification: it names no group directly, supplies -//! no domain separation tag, and publishes no test vectors. Open upstream PR #39 -//! adds message structures and an OR-proof construction, but leaves the group to a -//! forward reference the architecture draft does not satisfy, and types `m` and -//! omits `challenge_digest` in ways that contradict protocols Section 4.1.3. +//! `[CRYPTO]` has since been written, but it is not on the IETF Datatracker and is +//! not an interoperable specification: it names no group directly, supplies no +//! domain separation tag, and publishes no test vectors. The MoLE org does publish a +//! reference implementation, `ihat-rs`, which settles all of that — so that is what +//! `exp-ihat` follows. //! //! The other schemes are likewise external: Longfellow needs //! `draft-google-cfrg-libzk` plus an out-of-band circuit, ACT needs @@ -48,16 +46,12 @@ //! RFC 9578 issuance. So the cryptographic surface here is the trait boundary in //! [`crypto`], and the default build invents no scheme to fill it. //! -//! Two off-by-default features do fill it, neither deployable: -//! -//! * `insecure-mock` — [`crypto::insecure_mock`] fakes the *interface* so the -//! encoding and state-machine layers can be driven end to end. Deliberately and -//! thoroughly insecure; read its module documentation before touching it. -//! * `exp-ihat` — `crypto::exp_ihat` implements the *construction* over P-256, -//! aligned byte-for-byte with `ihat-rs`, the reference implementation published in -//! the MoLE org by a draft author, and cross-verified against it in both -//! directions. The security properties are real and it interoperates — but -//! `ihat-rs` is explicit that it has not been audited. See `EXP-IHAT-PROFILE.md`. +//! The off-by-default `exp-ihat` feature fills it: `crypto::exp_ihat` implements +//! IHAT over P-256, aligned byte-for-byte with `ihat-rs`, the reference +//! implementation published in the MoLE org by a draft author, and cross-verified +//! against it in both directions. The security properties are real and it +//! interoperates — but `ihat-rs` is explicit that it has not been audited. Not +//! deployable. See `EXP-IHAT-PROFILE.md`. //! //! ## Example: a Moderator challenge, and a Client answering it //! diff --git a/tests/appendix_a_exp_ihat.rs b/tests/appendix_a_exp_ihat.rs deleted file mode 100644 index ade2fc6..0000000 --- a/tests/appendix_a_exp_ihat.rs +++ /dev/null @@ -1,481 +0,0 @@ -//! Appendix A of `draft-jms-mole-protocols-00`, driven on **real cryptography**. -//! -//! This is the same exchange as `appendix_a_flow.rs`, but the endorsement half runs -//! on `crypto::exp_ihat` — IHAT over P-256, cross-verified against the `ihat-rs` -//! reference implementation — instead of `crypto::insecure_mock`. The two grant -//! exchanges carry the profile's actual wire messages inside the actual -//! `EndorsementRequest`/`EndorsementResponse` envelopes, and the Moderator's -//! redemption runs a real OR proof against a real accepted set. -//! -//! ```text -//! +--------+ +--------+ +-----------+ -//! | Client | | Anchor | | Moderator | -//! +---+----+ +---+----+ +-----+-----+ -//! | | | -//! +------------------|---- request --->| -//! |<-----------------|--- challenge ---+ -//! +--- exchange 1 -->| | real: Y' = vY, context -//! |<-- response 1 ---+ | real: Z', C', T1', T2' -//! +--- exchange 2 -->| | real: e' -//! |<-- response 2 ---+ | real: r', a', b' -//! Finalize | | -//! +------------------|---- redeem ---->| real: endorsement + OR proof -//! |<-----------------|---- credential -+ -//! Finalize | | -//! +------------------|---- present --->| -//! |<-----------------|-- ok + update --+ -//! ``` -//! -//! # What is still a stand-in -//! -//! The **credential** half. Credential type `0x0002` carries raw RFC 9578 -//! `TokenRequest`/`TokenResponse` structures, and this crate does not implement -//! Privacy Pass issuance, so `mock_token` stands in for the token. That is now the -//! only remaining piece of fake cryptography in the whole reachable protocol -//! surface — everything upstream of it here is real. - -#![cfg(feature = "exp-ihat")] - -use mole::binding::ChallengeDigest; -use mole::client::DeterministicRng; -use mole::codec::Message; -use mole::credential::{reverse_flow, CredentialRequest, CredentialResponse}; -use mole::crypto::exp_ihat::{ExpAnchor, ExpClient, ExpVerifier}; -use mole::crypto::{IhatAnchor, IhatClient, RandomSource}; -use mole::endorsement::{ihat, EndorsementRequest, EndorsementResponse, Point}; -use mole::error::Error; -use mole::http::{parse_www_authenticate, Authorization, MoleChallenge, MoleCredential}; -use mole::moderator::{ChallengeMode, Disposition, Moderator, NonceStore}; -use mole::registry::{CredentialType, EndorsementType}; -use mole::transport::{ - CredentialPresentation, CredentialUpdate, ModeratorChallenge, OptionalCredentialUpdate, -}; - -const EPOCH: &[u8] = b"2026-07-28T00:00:00Z/PT24H"; -/// Which Anchor in the accepted set actually issues. The Moderator must not be -/// able to tell. -const ISSUER: usize = 1; - -/// A stand-in for an RFC 9577 `Token`. See the module docs: the credential half is -/// the remaining gap. -fn mock_token(nonce: &[u8; 32], digest: &ChallengeDigest) -> Vec { - let mut t = Vec::with_capacity(64); - t.extend_from_slice(nonce); - t.extend_from_slice(digest.as_bytes()); - t -} - -fn mock_token_parts(token: &[u8]) -> Option<([u8; 32], ChallengeDigest)> { - if token.len() != 64 { - return None; - } - let (nonce, digest) = token.split_at(32); - Some(( - nonce.try_into().ok()?, - ChallengeDigest(digest.try_into().ok()?), - )) -} - -struct Deployment { - anchors: Vec>, - /// The accepted set. Order is normative (Section 4.1.3) — OR-proof branches - /// match keys by position — so it is fixed here and reused verbatim. - keys: Vec, - verifier: ExpVerifier, - moderator: Moderator, - nonces: NonceStore, -} - -impl Deployment { - fn new() -> Self { - let anchors: Vec> = [0xA1u8, 0xA2, 0xA3] - .into_iter() - .enumerate() - .map(|(i, seed)| { - ExpAnchor::new([seed; 32], DeterministicRng::new(9000 + i as u64)).unwrap() - }) - .collect(); - let keys = anchors.iter().map(ExpAnchor::public_key).collect(); - Self { - anchors, - keys, - verifier: ExpVerifier::new(), - moderator: Moderator::new( - vec![CredentialType::PRIVACY_PASS_REVERSE_FLOW], - EPOCH.to_vec(), - ), - nonces: NonceStore::new(), - } - } - - /// The Moderator's `WWW-Authenticate` header for Redeem & Issue. - fn issuance_challenge_header(&self) -> String { - let inner = ihat::Challenge { - keys: self.keys.clone(), - } - .to_wire() - .unwrap(); - let outer = ModeratorChallenge { - endorsement_type: EndorsementType::IHAT, - challenge: inner, - } - .to_wire() - .unwrap(); - MoleChallenge::new(&outer, Some("moderator")).to_header_value() - } - - /// Runs the two grant exchanges against `anchors[issuer]`, carrying every body - /// through the `EndorsementRequest`/`EndorsementResponse` envelopes the HTTP - /// transport draft specifies, and returns the redemption bytes. - fn grant_over_the_transport( - &self, - client: &mut ExpClient, - issuer: usize, - endorsement_context: &[u8], - digest: &ChallengeDigest, - ) -> Vec { - let anchor = &self.anchors[issuer]; - - // ---- Exchange 1 ---------------------------------------------------- - let (body1, state) = client - .prepare(&anchor.public_key(), endorsement_context) - .unwrap(); - let req1 = EndorsementRequest { - endorsement_type: EndorsementType::IHAT, - body: body1, - }; - // The envelope round-trips carrying the profile's real Y' and context. - let req1 = EndorsementRequest::from_wire(&req1.to_wire().unwrap()).unwrap(); - let (body2, anchor_state) = anchor.sign(&req1.body).unwrap(); - let resp1 = EndorsementResponse { - endorsement_type: EndorsementType::IHAT, - body: body2, - }; - let resp1 = EndorsementResponse::from_wire(&resp1.to_wire().unwrap()).unwrap(); - - // ---- Exchange 2 ---------------------------------------------------- - let (body3, state) = client.request_proof(state, &resp1.body).unwrap(); - let req2 = EndorsementRequest { - endorsement_type: EndorsementType::IHAT, - body: body3, - }; - let req2 = EndorsementRequest::from_wire(&req2.to_wire().unwrap()).unwrap(); - let body4 = anchor.prove(anchor_state, &req2.body).unwrap(); - let resp2 = EndorsementResponse { - endorsement_type: EndorsementType::IHAT, - body: body4, - }; - let resp2 = EndorsementResponse::from_wire(&resp2.to_wire().unwrap()).unwrap(); - - let endorsement = client.finalize(state, &resp2.body).unwrap(); - client.present(&endorsement, &self.keys, digest).unwrap() - } -} - -#[test] -#[allow(clippy::too_many_lines)] // It walks the whole of Appendix A on purpose. -fn complete_exchange_on_real_cryptography() { - let mut dep = Deployment::new(); - let mut rng = DeterministicRng::new(0x00C0_FFEE); - let mut client = ExpClient::new(DeterministicRng::new(1)); - - // ---- Moderator -> Client: 401 with a challenge ------------------------- - let header = dep.issuance_challenge_header(); - assert_eq!(ChallengeMode::Required.status(), 401); - - let challenges = parse_www_authenticate(&header); - assert_eq!(challenges.len(), 1); - assert_eq!(challenges[0].realm(), Some("moderator")); - - // The Client binds to the DECODED octets (Section 3.3). - let challenge_octets = challenges[0].challenge_octets().unwrap(); - let digest = ChallengeDigest::new(&challenge_octets); - - let moderator_challenge = ModeratorChallenge::from_wire(&challenge_octets).unwrap(); - assert_eq!(moderator_challenge.endorsement_type, EndorsementType::IHAT); - let ihat_challenge = ihat::Challenge::from_wire(&moderator_challenge.challenge).unwrap(); - assert_eq!(ihat_challenge.keys.len(), 3); - // The keys that survived the wire are real SEC1 compressed P-256 points. - for key in &ihat_challenge.keys { - assert!(key.0[0] == 0x02 || key.0[0] == 0x03); - } - assert_eq!(ihat_challenge.keys, dep.keys); - - // ---- Client <-> Anchor: two real grant exchanges ----------------------- - let presentation_bytes = dep.grant_over_the_transport(&mut client, ISSUER, EPOCH, &digest); - let endorsement_presentation = ihat::Presentation { - bytes: presentation_bytes, - } - .to_wire() - .unwrap(); - - // ---- Client -> Moderator: Redeem & Issue ------------------------------ - let credential_request = CredentialRequest { - endorsement_type: EndorsementType::IHAT, - endorsement_presentation: endorsement_presentation.clone(), - credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, - // A real Client sends a TokenRequest here — see the module docs. - issuance_request: vec![0x01, 0x02], - }; - let auth_header = - Authorization::CredentialRequest(credential_request.to_wire().unwrap()).to_header_value(); - - // ---- Moderator verifies the redemption ------------------------------- - let Authorization::CredentialRequest(body) = Authorization::parse(&auth_header).unwrap() else { - panic!("expected a credential-request"); - }; - let received = CredentialRequest::from_wire(&body).unwrap(); - assert_eq!(received, credential_request); - - let inner = ihat::Presentation::from_wire(&received.endorsement_presentation).unwrap(); - let verified = dep - .moderator - .redeem(&dep.verifier, &inner.bytes, &ihat_challenge.keys, &digest) - .expect("a real endorsement should verify"); - - // The Moderator learns exactly two things, and neither identifies the Anchor. - assert_eq!(verified.endorsement_context, EPOCH.to_vec()); - assert_eq!(verified.nullifier.len(), 32); - assert_eq!(dep.moderator.nullifiers().len(), 1); - - // ---- Moderator -> Client: CredentialResponse ------------------------- - let issued_nonce = { - let mut n = [0u8; 32]; - rng.fill_bytes(&mut n); - n - }; - // "The presented and reissued token MUST use the same token type and the same - // Moderator public key." The token's digest is the Moderator's CONSTANT - // challenge digest for this configuration (Section 5.2.2). - let constant_digest = ChallengeDigest::new(&reverse_flow::Challenge.to_wire().unwrap()); - let issued_token = mock_token(&issued_nonce, &constant_digest); - - let credential_response = CredentialResponse { - credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, - issuance_response: issued_token.clone(), - }; - let cred_header = MoleCredential::Response(credential_response.to_wire().unwrap()); - let MoleCredential::Response(resp_bytes) = - MoleCredential::parse(&cred_header.to_header_value()).unwrap() - else { - panic!("expected a response"); - }; - assert_eq!( - CredentialResponse::from_wire(&resp_bytes).unwrap(), - credential_response - ); - - // ---- Client -> Moderator: presentation ------------------------------- - let pau = reverse_flow::PresentationAndUpdate { - token: issued_token.clone(), - token_request: vec![0x03], - }; - let pres_header = Authorization::Presentation( - CredentialPresentation { - credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, - presentation_and_update: pau.to_wire().unwrap(), - } - .to_wire() - .unwrap(), - ) - .to_header_value(); - - // ---- Moderator verifies the presentation ----------------------------- - let Authorization::Presentation(pbytes) = Authorization::parse(&pres_header).unwrap() else { - panic!("expected a presentation"); - }; - let received = CredentialPresentation::from_wire(&pbytes).unwrap(); - assert_eq!( - dep.moderator.dispatch(received.credential_type), - Disposition::Handled(CredentialType::PRIVACY_PASS_REVERSE_FLOW) - ); - - let pau = - reverse_flow::PresentationAndUpdate::from_wire(&received.presentation_and_update).unwrap(); - let (nonce, token_digest) = mock_token_parts(&pau.token).expect("well-formed token"); - // Anti-replay comes from the token being single-use, NOT challenge binding. - assert_eq!(token_digest, constant_digest); - dep.nonces.admit(&nonce).expect("first use"); - - // ---- Moderator -> Client: update ------------------------------------- - let update = OptionalCredentialUpdate::present(CredentialUpdate { - credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, - update_response: reverse_flow::Update { - token_response: mock_token(&[0x77u8; 32], &constant_digest), - } - .to_wire() - .unwrap(), - }); - let update_header = MoleCredential::Update(update.to_wire().unwrap()).to_header_value(); - let MoleCredential::Update(ubytes) = MoleCredential::parse(&update_header).unwrap() else { - panic!("expected an update"); - }; - let parsed_update = OptionalCredentialUpdate::from_wire(&ubytes).unwrap(); - assert!(!parsed_update.is_absent(), "credential remains usable"); - assert_eq!(parsed_update, update); -} - -/// Issuer hiding, at the protocol level: every Anchor in the accepted set can issue -/// an endorsement the Moderator accepts, and what the Moderator ends up holding is -/// indistinguishable across them. -#[test] -fn the_moderator_cannot_tell_which_anchor_issued() { - let digest = ChallengeDigest::new(b"issuer-hiding"); - let mut redemptions = Vec::new(); - - for issuer in 0..3 { - let mut dep = Deployment::new(); - let mut client = ExpClient::new(DeterministicRng::new(200 + issuer as u64)); - let bytes = dep.grant_over_the_transport(&mut client, issuer, EPOCH, &digest); - let verified = dep - .moderator - .redeem(&dep.verifier, &bytes, &dep.keys, &digest) - .expect("every Anchor in the accepted set can issue"); - redemptions.push((bytes.len(), verified)); - } - - // Same wire size regardless of which branch is real, so length leaks nothing. - let sizes: Vec = redemptions.iter().map(|(n, _)| *n).collect(); - assert!( - sizes.windows(2).all(|w| w[0] == w[1]), - "presentation size varies by issuing Anchor: {sizes:?}" - ); - - // The Moderator's view is the same shape from every Anchor: an epoch it already - // knew, and a nullifier that carries no issuer information. `VerifiedEndorsement` - // has no other field — the type is the guarantee. - for (_, verified) in &redemptions { - assert_eq!(verified.endorsement_context, EPOCH.to_vec()); - assert_eq!(verified.nullifier.len(), 32); - } - // Distinct Anchors still produce distinct nullifiers, so they remain spendable - // independently. - let mut nullifiers: Vec<&Vec> = redemptions.iter().map(|(_, v)| &v.nullifier).collect(); - nullifiers.sort(); - nullifiers.dedup(); - assert_eq!(nullifiers.len(), 3); -} - -/// "The Endorsement is spent: redeeming it again MUST fail check 2." -#[test] -fn an_endorsement_cannot_be_redeemed_twice() { - let mut dep = Deployment::new(); - let digest = ChallengeDigest::new(b"a challenge"); - let mut client = ExpClient::new(DeterministicRng::new(2)); - let presentation = dep.grant_over_the_transport(&mut client, ISSUER, EPOCH, &digest); - - assert!(dep - .moderator - .redeem(&dep.verifier, &presentation, &dep.keys, &digest) - .is_ok()); - assert_eq!( - dep.moderator - .redeem(&dep.verifier, &presentation, &dep.keys, &digest) - .err(), - Some(Error::NullifierAlreadySpent) - ); -} - -/// "A verifier MUST reject a redemption or presentation bound to a different -/// challenge." -/// -/// Note the error differs from the mock's `ChallengeMismatch`. With real -/// cryptography the Moderator cannot distinguish "bound to another challenge" from -/// "the proof is simply invalid" — the OR proof just fails to verify. That is the -/// better outcome: Section 8 warns that `redeem`'s distinct error variants must not -/// be surfaced to Clients, and here there is nothing to surface. -#[test] -fn a_redemption_bound_to_another_challenge_is_rejected() { - let mut dep = Deployment::new(); - let mut client = ExpClient::new(DeterministicRng::new(3)); - let presentation = dep.grant_over_the_transport( - &mut client, - ISSUER, - EPOCH, - &ChallengeDigest::new(b"challenge A"), - ); - - assert_eq!( - dep.moderator - .redeem( - &dep.verifier, - &presentation, - &dep.keys, - &ChallengeDigest::new(b"challenge B") - ) - .err(), - Some(Error::VerificationFailed) - ); - // And the failed attempt must not have consumed the nullifier. - assert_eq!(dep.moderator.nullifiers().len(), 0); -} - -/// An endorsement granted under a different epoch verifies cryptographically but is -/// refused by the Moderator's epoch check — so the two checks are independent, and -/// in the order Section 4.1.3 requires. -#[test] -fn an_endorsement_from_a_previous_epoch_is_rejected() { - let mut dep = Deployment::new(); - let digest = ChallengeDigest::new(b"c"); - let mut client = ExpClient::new(DeterministicRng::new(4)); - let stale = dep.grant_over_the_transport(&mut client, ISSUER, b"an-older-epoch", &digest); - - // The proof itself is valid — the endorsement is real, just for the wrong epoch. - assert!(mole::crypto::IhatVerifier::verify(&dep.verifier, &stale, &dep.keys, &digest).is_ok()); - - assert_eq!( - dep.moderator - .redeem(&dep.verifier, &stale, &dep.keys, &digest) - .err(), - Some(Error::WrongEpoch) - ); - assert_eq!(dep.moderator.nullifiers().len(), 0); -} - -/// An Anchor the Moderator does not accept cannot get a redemption through, even -/// though it can complete the grant perfectly well. -#[test] -fn an_endorsement_from_an_unaccepted_anchor_is_rejected() { - let dep = Deployment::new(); - // 0x7F.. is a canonical P-256 scalar; 0xFF.. would exceed the group order and - // be refused outright, which is a different (and separately tested) failure. - let rogue = ExpAnchor::new([0x7Fu8; 32], DeterministicRng::new(31)).unwrap(); - let digest = ChallengeDigest::new(b"rogue"); - let mut client = ExpClient::new(DeterministicRng::new(5)); - - // The grant succeeds: the rogue Anchor is a real Anchor with a real key. - let (body1, state) = client.prepare(&rogue.public_key(), EPOCH).unwrap(); - let (body2, astate) = rogue.sign(&body1).unwrap(); - let (body3, state) = client.request_proof(state, &body2).unwrap(); - let body4 = rogue.prove(astate, &body3).unwrap(); - let endorsement = client.finalize(state, &body4).unwrap(); - - // Presentation is what fails: there is no branch for it in the accepted set. - assert_eq!( - client.present(&endorsement, &dep.keys, &digest).err(), - Some(Error::UnknownAnchorKey) - ); -} - -/// The accepted set's order is normative, and the OR proof depends on it: a -/// presentation built over one ordering must not verify against a permutation. -#[test] -fn the_accepted_set_order_is_load_bearing() { - let mut dep = Deployment::new(); - let digest = ChallengeDigest::new(b"ordering"); - let mut client = ExpClient::new(DeterministicRng::new(6)); - let presentation = dep.grant_over_the_transport(&mut client, ISSUER, EPOCH, &digest); - - let mut permuted = dep.keys.clone(); - permuted.swap(0, 2); - assert!(dep - .moderator - .redeem(&dep.verifier, &presentation, &permuted, &digest) - .is_err()); - - // The original ordering still works, and only now is the nullifier consumed. - assert!(dep - .moderator - .redeem(&dep.verifier, &presentation, &dep.keys, &digest) - .is_ok()); -} diff --git a/tests/appendix_a_flow.rs b/tests/appendix_a_flow.rs index 2c6b149..ade2fc6 100644 --- a/tests/appendix_a_flow.rs +++ b/tests/appendix_a_flow.rs @@ -1,7 +1,11 @@ -//! Drives the complete exchange of Appendix A of `draft-jms-mole-protocols-00`: -//! a Client requests a resource protected by a Moderator that uses credential -//! type `0x0002` (Privacy Pass Reverse Flow) and accepts endorsement type -//! `0x0002` (IHAT). +//! Appendix A of `draft-jms-mole-protocols-00`, driven on **real cryptography**. +//! +//! This is the same exchange as `appendix_a_flow.rs`, but the endorsement half runs +//! on `crypto::exp_ihat` — IHAT over P-256, cross-verified against the `ihat-rs` +//! reference implementation — instead of `crypto::insecure_mock`. The two grant +//! exchanges carry the profile's actual wire messages inside the actual +//! `EndorsementRequest`/`EndorsementResponse` envelopes, and the Moderator's +//! redemption runs a real OR proof against a real accepted set. //! //! ```text //! +--------+ +--------+ +-----------+ @@ -10,31 +14,35 @@ //! | | | //! +------------------|---- request --->| //! |<-----------------|--- challenge ---+ -//! +--- exchange 1 -->| | -//! |<-- response 1 ---+ | -//! +--- exchange 2 -->| | -//! |<-- response 2 ---+ | +//! +--- exchange 1 -->| | real: Y' = vY, context +//! |<-- response 1 ---+ | real: Z', C', T1', T2' +//! +--- exchange 2 -->| | real: e' +//! |<-- response 2 ---+ | real: r', a', b' //! Finalize | | -//! +------------------|---- redeem ---->| +//! +------------------|---- redeem ---->| real: endorsement + OR proof //! |<-----------------|---- credential -+ //! Finalize | | //! +------------------|---- present --->| //! |<-----------------|-- ok + update --+ //! ``` //! -//! Cryptography comes from `crypto::insecure_mock`, which is not IHAT and has no -//! security properties. What this test actually exercises is the wire format, the -//! header carriage, challenge binding, and the Moderator's replay state. +//! # What is still a stand-in +//! +//! The **credential** half. Credential type `0x0002` carries raw RFC 9578 +//! `TokenRequest`/`TokenResponse` structures, and this crate does not implement +//! Privacy Pass issuance, so `mock_token` stands in for the token. That is now the +//! only remaining piece of fake cryptography in the whole reachable protocol +//! surface — everything upstream of it here is real. -#![cfg(feature = "insecure-mock")] +#![cfg(feature = "exp-ihat")] use mole::binding::ChallengeDigest; use mole::client::DeterministicRng; use mole::codec::Message; use mole::credential::{reverse_flow, CredentialRequest, CredentialResponse}; -use mole::crypto::insecure_mock::{InsecureAnchor, InsecureClient, InsecureVerifier}; -use mole::crypto::RandomSource; -use mole::endorsement::{ihat, EndorsementRequest, EndorsementResponse}; +use mole::crypto::exp_ihat::{ExpAnchor, ExpClient, ExpVerifier}; +use mole::crypto::{IhatAnchor, IhatClient, RandomSource}; +use mole::endorsement::{ihat, EndorsementRequest, EndorsementResponse, Point}; use mole::error::Error; use mole::http::{parse_www_authenticate, Authorization, MoleChallenge, MoleCredential}; use mole::moderator::{ChallengeMode, Disposition, Moderator, NonceStore}; @@ -44,12 +52,12 @@ use mole::transport::{ }; const EPOCH: &[u8] = b"2026-07-28T00:00:00Z/PT24H"; +/// Which Anchor in the accepted set actually issues. The Moderator must not be +/// able to tell. +const ISSUER: usize = 1; -/// A stand-in for an RFC 9577 `Token`, just structured enough that the -/// Moderator's nonce check and the constant-digest requirement are real. -/// -/// A real deployment carries an actual Privacy Pass token here; RFC 9578 issuance -/// is out of scope for this crate. +/// A stand-in for an RFC 9577 `Token`. See the module docs: the credential half is +/// the remaining gap. fn mock_token(nonce: &[u8; 32], digest: &ChallengeDigest) -> Vec { let mut t = Vec::with_capacity(64); t.extend_from_slice(nonce); @@ -61,28 +69,37 @@ fn mock_token_parts(token: &[u8]) -> Option<([u8; 32], ChallengeDigest)> { if token.len() != 64 { return None; } - let mut nonce = [0u8; 32]; - nonce.copy_from_slice(&token[..32]); - let mut d = [0u8; 32]; - d.copy_from_slice(&token[32..]); - Some((nonce, ChallengeDigest(d))) + let (nonce, digest) = token.split_at(32); + Some(( + nonce.try_into().ok()?, + ChallengeDigest(digest.try_into().ok()?), + )) } struct Deployment { - anchor: InsecureAnchor, - verifier: InsecureVerifier, + anchors: Vec>, + /// The accepted set. Order is normative (Section 4.1.3) — OR-proof branches + /// match keys by position — so it is fixed here and reused verbatim. + keys: Vec, + verifier: ExpVerifier, moderator: Moderator, nonces: NonceStore, } impl Deployment { fn new() -> Self { - // Three Anchors in the accepted set; the Client uses the second. The order - // is normative (Section 4.1.3), so it is fixed here and reused verbatim. - let secrets = vec![[0xA1u8; 32], [0xA2u8; 32], [0xA3u8; 32]]; + let anchors: Vec> = [0xA1u8, 0xA2, 0xA3] + .into_iter() + .enumerate() + .map(|(i, seed)| { + ExpAnchor::new([seed; 32], DeterministicRng::new(9000 + i as u64)).unwrap() + }) + .collect(); + let keys = anchors.iter().map(ExpAnchor::public_key).collect(); Self { - anchor: InsecureAnchor::new(secrets[1]), - verifier: InsecureVerifier::new(secrets), + anchors, + keys, + verifier: ExpVerifier::new(), moderator: Moderator::new( vec![CredentialType::PRIVACY_PASS_REVERSE_FLOW], EPOCH.to_vec(), @@ -94,7 +111,7 @@ impl Deployment { /// The Moderator's `WWW-Authenticate` header for Redeem & Issue. fn issuance_challenge_header(&self) -> String { let inner = ihat::Challenge { - keys: self.verifier.accepted_keys(), + keys: self.keys.clone(), } .to_wire() .unwrap(); @@ -106,13 +123,61 @@ impl Deployment { .unwrap(); MoleChallenge::new(&outer, Some("moderator")).to_header_value() } + + /// Runs the two grant exchanges against `anchors[issuer]`, carrying every body + /// through the `EndorsementRequest`/`EndorsementResponse` envelopes the HTTP + /// transport draft specifies, and returns the redemption bytes. + fn grant_over_the_transport( + &self, + client: &mut ExpClient, + issuer: usize, + endorsement_context: &[u8], + digest: &ChallengeDigest, + ) -> Vec { + let anchor = &self.anchors[issuer]; + + // ---- Exchange 1 ---------------------------------------------------- + let (body1, state) = client + .prepare(&anchor.public_key(), endorsement_context) + .unwrap(); + let req1 = EndorsementRequest { + endorsement_type: EndorsementType::IHAT, + body: body1, + }; + // The envelope round-trips carrying the profile's real Y' and context. + let req1 = EndorsementRequest::from_wire(&req1.to_wire().unwrap()).unwrap(); + let (body2, anchor_state) = anchor.sign(&req1.body).unwrap(); + let resp1 = EndorsementResponse { + endorsement_type: EndorsementType::IHAT, + body: body2, + }; + let resp1 = EndorsementResponse::from_wire(&resp1.to_wire().unwrap()).unwrap(); + + // ---- Exchange 2 ---------------------------------------------------- + let (body3, state) = client.request_proof(state, &resp1.body).unwrap(); + let req2 = EndorsementRequest { + endorsement_type: EndorsementType::IHAT, + body: body3, + }; + let req2 = EndorsementRequest::from_wire(&req2.to_wire().unwrap()).unwrap(); + let body4 = anchor.prove(anchor_state, &req2.body).unwrap(); + let resp2 = EndorsementResponse { + endorsement_type: EndorsementType::IHAT, + body: body4, + }; + let resp2 = EndorsementResponse::from_wire(&resp2.to_wire().unwrap()).unwrap(); + + let endorsement = client.finalize(state, &resp2.body).unwrap(); + client.present(&endorsement, &self.keys, digest).unwrap() + } } #[test] #[allow(clippy::too_many_lines)] // It walks the whole of Appendix A on purpose. -fn complete_exchange_from_challenge_to_update() { +fn complete_exchange_on_real_cryptography() { let mut dep = Deployment::new(); let mut rng = DeterministicRng::new(0x00C0_FFEE); + let mut client = ExpClient::new(DeterministicRng::new(1)); // ---- Moderator -> Client: 401 with a challenge ------------------------- let header = dep.issuance_challenge_header(); @@ -130,35 +195,14 @@ fn complete_exchange_from_challenge_to_update() { assert_eq!(moderator_challenge.endorsement_type, EndorsementType::IHAT); let ihat_challenge = ihat::Challenge::from_wire(&moderator_challenge.challenge).unwrap(); assert_eq!(ihat_challenge.keys.len(), 3); + // The keys that survived the wire are real SEC1 compressed P-256 points. + for key in &ihat_challenge.keys { + assert!(key.0[0] == 0x02 || key.0[0] == 0x03); + } + assert_eq!(ihat_challenge.keys, dep.keys); - // ---- Client <-> Anchor: two grant exchanges --------------------------- - // Carried as POST bodies with the endorsement media types. - let mut client = InsecureClient::new(DeterministicRng::new(1)); - let presentation_bytes = { - // The wrapper messages that would ride on the two HTTP POSTs. The mock's - // convenience method runs the crypto; here we assert the envelopes encode. - let req1 = EndorsementRequest { - endorsement_type: EndorsementType::IHAT, - body: EPOCH.to_vec(), - }; - assert_eq!( - EndorsementRequest::from_wire(&req1.to_wire().unwrap()).unwrap(), - req1 - ); - let resp1 = EndorsementResponse { - endorsement_type: EndorsementType::IHAT, - body: vec![0u8; 32], - }; - assert_eq!( - EndorsementResponse::from_wire(&resp1.to_wire().unwrap()).unwrap(), - resp1 - ); - - client - .grant_and_present(&dep.anchor, EPOCH, &ihat_challenge.keys, &digest) - .unwrap() - }; - + // ---- Client <-> Anchor: two real grant exchanges ----------------------- + let presentation_bytes = dep.grant_over_the_transport(&mut client, ISSUER, EPOCH, &digest); let endorsement_presentation = ihat::Presentation { bytes: presentation_bytes, } @@ -170,11 +214,11 @@ fn complete_exchange_from_challenge_to_update() { endorsement_type: EndorsementType::IHAT, endorsement_presentation: endorsement_presentation.clone(), credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, - // A real Client sends a TokenRequest here. + // A real Client sends a TokenRequest here — see the module docs. issuance_request: vec![0x01, 0x02], }; - let auth = Authorization::CredentialRequest(credential_request.to_wire().unwrap()); - let auth_header = auth.to_header_value(); + let auth_header = + Authorization::CredentialRequest(credential_request.to_wire().unwrap()).to_header_value(); // ---- Moderator verifies the redemption ------------------------------- let Authorization::CredentialRequest(body) = Authorization::parse(&auth_header).unwrap() else { @@ -187,8 +231,11 @@ fn complete_exchange_from_challenge_to_update() { let verified = dep .moderator .redeem(&dep.verifier, &inner.bytes, &ihat_challenge.keys, &digest) - .expect("redemption should verify"); + .expect("a real endorsement should verify"); + + // The Moderator learns exactly two things, and neither identifies the Anchor. assert_eq!(verified.endorsement_context, EPOCH.to_vec()); + assert_eq!(verified.nullifier.len(), 32); assert_eq!(dep.moderator.nullifiers().len(), 1); // ---- Moderator -> Client: CredentialResponse ------------------------- @@ -203,10 +250,6 @@ fn complete_exchange_from_challenge_to_update() { let constant_digest = ChallengeDigest::new(&reverse_flow::Challenge.to_wire().unwrap()); let issued_token = mock_token(&issued_nonce, &constant_digest); - // For this credential type the draft says "IssuanceRequest is a TokenRequest - // and IssuanceResponse is a TokenResponse" — raw Privacy Pass structures, not - // MoLE-defined ones, so they go into the field verbatim. The mock stands in - // for a TokenResponse the Client would finalize into a token. let credential_response = CredentialResponse { credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, issuance_response: issued_token.clone(), @@ -227,12 +270,15 @@ fn complete_exchange_from_challenge_to_update() { token: issued_token.clone(), token_request: vec![0x03], }; - let presentation = CredentialPresentation { - credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, - presentation_and_update: pau.to_wire().unwrap(), - }; - let pres_header = - Authorization::Presentation(presentation.to_wire().unwrap()).to_header_value(); + let pres_header = Authorization::Presentation( + CredentialPresentation { + credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, + presentation_and_update: pau.to_wire().unwrap(), + } + .to_wire() + .unwrap(), + ) + .to_header_value(); // ---- Moderator verifies the presentation ----------------------------- let Authorization::Presentation(pbytes) = Authorization::parse(&pres_header).unwrap() else { @@ -252,11 +298,10 @@ fn complete_exchange_from_challenge_to_update() { dep.nonces.admit(&nonce).expect("first use"); // ---- Moderator -> Client: update ------------------------------------- - let refreshed_nonce = [0x77u8; 32]; let update = OptionalCredentialUpdate::present(CredentialUpdate { credential_type: CredentialType::PRIVACY_PASS_REVERSE_FLOW, update_response: reverse_flow::Update { - token_response: mock_token(&refreshed_nonce, &constant_digest), + token_response: mock_token(&[0x77u8; 32], &constant_digest), } .to_wire() .unwrap(), @@ -270,127 +315,167 @@ fn complete_exchange_from_challenge_to_update() { assert_eq!(parsed_update, update); } +/// Issuer hiding, at the protocol level: every Anchor in the accepted set can issue +/// an endorsement the Moderator accepts, and what the Moderator ends up holding is +/// indistinguishable across them. +#[test] +fn the_moderator_cannot_tell_which_anchor_issued() { + let digest = ChallengeDigest::new(b"issuer-hiding"); + let mut redemptions = Vec::new(); + + for issuer in 0..3 { + let mut dep = Deployment::new(); + let mut client = ExpClient::new(DeterministicRng::new(200 + issuer as u64)); + let bytes = dep.grant_over_the_transport(&mut client, issuer, EPOCH, &digest); + let verified = dep + .moderator + .redeem(&dep.verifier, &bytes, &dep.keys, &digest) + .expect("every Anchor in the accepted set can issue"); + redemptions.push((bytes.len(), verified)); + } + + // Same wire size regardless of which branch is real, so length leaks nothing. + let sizes: Vec = redemptions.iter().map(|(n, _)| *n).collect(); + assert!( + sizes.windows(2).all(|w| w[0] == w[1]), + "presentation size varies by issuing Anchor: {sizes:?}" + ); + + // The Moderator's view is the same shape from every Anchor: an epoch it already + // knew, and a nullifier that carries no issuer information. `VerifiedEndorsement` + // has no other field — the type is the guarantee. + for (_, verified) in &redemptions { + assert_eq!(verified.endorsement_context, EPOCH.to_vec()); + assert_eq!(verified.nullifier.len(), 32); + } + // Distinct Anchors still produce distinct nullifiers, so they remain spendable + // independently. + let mut nullifiers: Vec<&Vec> = redemptions.iter().map(|(_, v)| &v.nullifier).collect(); + nullifiers.sort(); + nullifiers.dedup(); + assert_eq!(nullifiers.len(), 3); +} + +/// "The Endorsement is spent: redeeming it again MUST fail check 2." #[test] fn an_endorsement_cannot_be_redeemed_twice() { let mut dep = Deployment::new(); - let keys = dep.verifier.accepted_keys(); let digest = ChallengeDigest::new(b"a challenge"); - - let mut client = InsecureClient::new(DeterministicRng::new(2)); - let presentation = client - .grant_and_present(&dep.anchor, EPOCH, &keys, &digest) - .unwrap(); + let mut client = ExpClient::new(DeterministicRng::new(2)); + let presentation = dep.grant_over_the_transport(&mut client, ISSUER, EPOCH, &digest); assert!(dep .moderator - .redeem(&dep.verifier, &presentation, &keys, &digest) + .redeem(&dep.verifier, &presentation, &dep.keys, &digest) .is_ok()); - // "The Endorsement is spent: redeeming it again MUST fail check 2." assert_eq!( dep.moderator - .redeem(&dep.verifier, &presentation, &keys, &digest) + .redeem(&dep.verifier, &presentation, &dep.keys, &digest) .err(), Some(Error::NullifierAlreadySpent) ); } +/// "A verifier MUST reject a redemption or presentation bound to a different +/// challenge." +/// +/// Note the error differs from the mock's `ChallengeMismatch`. With real +/// cryptography the Moderator cannot distinguish "bound to another challenge" from +/// "the proof is simply invalid" — the OR proof just fails to verify. That is the +/// better outcome: Section 8 warns that `redeem`'s distinct error variants must not +/// be surfaced to Clients, and here there is nothing to surface. #[test] fn a_redemption_bound_to_another_challenge_is_rejected() { let mut dep = Deployment::new(); - let keys = dep.verifier.accepted_keys(); - - let mut client = InsecureClient::new(DeterministicRng::new(3)); - let presentation = client - .grant_and_present( - &dep.anchor, - EPOCH, - &keys, - &ChallengeDigest::new(b"challenge A"), - ) - .unwrap(); + let mut client = ExpClient::new(DeterministicRng::new(3)); + let presentation = dep.grant_over_the_transport( + &mut client, + ISSUER, + EPOCH, + &ChallengeDigest::new(b"challenge A"), + ); - // "A verifier MUST reject a redemption or presentation bound to a different - // challenge." assert_eq!( dep.moderator .redeem( &dep.verifier, &presentation, - &keys, + &dep.keys, &ChallengeDigest::new(b"challenge B") ) .err(), - Some(Error::ChallengeMismatch) + Some(Error::VerificationFailed) ); // And the failed attempt must not have consumed the nullifier. assert_eq!(dep.moderator.nullifiers().len(), 0); } +/// An endorsement granted under a different epoch verifies cryptographically but is +/// refused by the Moderator's epoch check — so the two checks are independent, and +/// in the order Section 4.1.3 requires. #[test] fn an_endorsement_from_a_previous_epoch_is_rejected() { let mut dep = Deployment::new(); - let keys = dep.verifier.accepted_keys(); let digest = ChallengeDigest::new(b"c"); + let mut client = ExpClient::new(DeterministicRng::new(4)); + let stale = dep.grant_over_the_transport(&mut client, ISSUER, b"an-older-epoch", &digest); - let mut client = InsecureClient::new(DeterministicRng::new(4)); - let stale = client - .grant_and_present(&dep.anchor, b"an-older-epoch", &keys, &digest) - .unwrap(); + // The proof itself is valid — the endorsement is real, just for the wrong epoch. + assert!(mole::crypto::IhatVerifier::verify(&dep.verifier, &stale, &dep.keys, &digest).is_ok()); assert_eq!( dep.moderator - .redeem(&dep.verifier, &stale, &keys, &digest) + .redeem(&dep.verifier, &stale, &dep.keys, &digest) .err(), Some(Error::WrongEpoch) ); + assert_eq!(dep.moderator.nullifiers().len(), 0); } +/// An Anchor the Moderator does not accept cannot get a redemption through, even +/// though it can complete the grant perfectly well. #[test] -fn a_replayed_token_is_rejected() { - let mut nonces = NonceStore::new(); - let digest = ChallengeDigest::new(&[]); - let token = mock_token(&[0x5Au8; 32], &digest); - let (nonce, _) = mock_token_parts(&token).unwrap(); - - assert!(nonces.admit(&nonce).is_ok()); - // "The Moderator MUST reject a token whose nonce it has already seen." +fn an_endorsement_from_an_unaccepted_anchor_is_rejected() { + let dep = Deployment::new(); + // 0x7F.. is a canonical P-256 scalar; 0xFF.. would exceed the group order and + // be refused outright, which is a different (and separately tested) failure. + let rogue = ExpAnchor::new([0x7Fu8; 32], DeterministicRng::new(31)).unwrap(); + let digest = ChallengeDigest::new(b"rogue"); + let mut client = ExpClient::new(DeterministicRng::new(5)); + + // The grant succeeds: the rogue Anchor is a real Anchor with a real key. + let (body1, state) = client.prepare(&rogue.public_key(), EPOCH).unwrap(); + let (body2, astate) = rogue.sign(&body1).unwrap(); + let (body3, state) = client.request_proof(state, &body2).unwrap(); + let body4 = rogue.prove(astate, &body3).unwrap(); + let endorsement = client.finalize(state, &body4).unwrap(); + + // Presentation is what fails: there is no branch for it in the accepted set. assert_eq!( - nonces.admit(&nonce).err(), - Some(Error::NullifierAlreadySpent) + client.present(&endorsement, &dep.keys, &digest).err(), + Some(Error::UnknownAnchorKey) ); } +/// The accepted set's order is normative, and the OR proof depends on it: a +/// presentation built over one ordering must not verify against a permutation. #[test] -fn an_absent_update_signals_the_credential_was_consumed() { - let absent = OptionalCredentialUpdate::absent(); - let header = MoleCredential::Update(absent.to_wire().unwrap()).to_header_value(); - let MoleCredential::Update(bytes) = MoleCredential::parse(&header).unwrap() else { - panic!("expected an update"); - }; - assert!(OptionalCredentialUpdate::from_wire(&bytes) - .unwrap() - .is_absent()); -} +fn the_accepted_set_order_is_load_bearing() { + let mut dep = Deployment::new(); + let digest = ChallengeDigest::new(b"ordering"); + let mut client = ExpClient::new(DeterministicRng::new(6)); + let presentation = dep.grant_over_the_transport(&mut client, ISSUER, EPOCH, &digest); -#[test] -fn a_greased_presentation_gets_the_same_treatment_as_any_unknown_type() { - let dep = Deployment::new(); - let mut rng = DeterministicRng::new(5); - - // Greased presentations are well-formed CredentialPresentations that the - // Moderator must ignore without special-casing them. - for _ in 0..32 { - let greased = mole::client::grease_presentation(&mut rng, 48); - let wire = greased.to_wire().unwrap(); - let decoded = CredentialPresentation::from_wire(&wire).unwrap(); - assert_eq!( - dep.moderator.dispatch(decoded.credential_type), - Disposition::Ignored - ); - } - // An unassigned, non-greased type takes the identical path. - assert_eq!( - dep.moderator.dispatch(CredentialType(0x4242)), - Disposition::Ignored - ); + let mut permuted = dep.keys.clone(); + permuted.swap(0, 2); + assert!(dep + .moderator + .redeem(&dep.verifier, &presentation, &permuted, &digest) + .is_err()); + + // The original ordering still works, and only now is the nullifier consumed. + assert!(dep + .moderator + .redeem(&dep.verifier, &presentation, &dep.keys, &digest) + .is_ok()); }