diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a0cfb8..9cc0207 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,26 @@ jobs: - name: Build for a bare-metal target run: cargo build --no-default-features --target thumbv7em-none-eabi + # 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 + + interop: + 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 + # 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: name: MSRV (1.75) runs-on: ubuntu-latest @@ -57,4 +77,8 @@ 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. + # 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/.gitignore b/.gitignore index 471f43d..1840de4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ .DS_Store .idea/ .vscode/ +/interop/target +/.git-worktrees diff --git a/Cargo.lock b/Cargo.lock index 1efc57f..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" @@ -32,6 +44,18 @@ 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.7" @@ -42,6 +66,16 @@ dependencies = [ "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" @@ -52,6 +86,34 @@ dependencies = [ "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.7" @@ -60,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]] @@ -69,13 +143,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "mole" +name = "mole-exp" version = "0.1.0" dependencies = [ "base64", + "p256", "sha2", ] +[[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" @@ -87,6 +200,12 @@ dependencies = [ "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" @@ -98,3 +217,9 @@ 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..b16b0e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,30 +1,43 @@ [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. 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] 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 +# 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] missing_docs = "warn" diff --git a/EXP-IHAT-PROFILE.md b/EXP-IHAT-PROFILE.md new file mode 100644 index 0000000..03f6140 --- /dev/null +++ b/EXP-IHAT-PROFILE.md @@ -0,0 +1,184 @@ +# The `exp-ihat` profile + +**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) — +published in the MoLE org by Samuel Schlesinger, one of the draft authors. + +## 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 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. + +| | | +|---|---| +| 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` | +| `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 | + +Domain separation tags, verbatim from `ihat-rs`: + +``` +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 +``` + +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. + +## Where the reference implementation departs from the drafts + +Each is an open issue, because the drafts should say what the code does. + +**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`". + +**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₁' +G·r' = X·e'a' + T₂' +``` + +which together prove the Anchor applied a single `x` to both `Z' = x·Y'` and +`X = x·G`. (`{{DLEQ}}` resolves to `draft-irtf-cfrg-sigma-protocols`, §2.2.8.) + +**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. + +**`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]). `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. + +**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 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. 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. `ChallengeBinding` makes the choice explicit: + +| Mode | Binds the digest | Interoperates | Satisfies §4.1.3 | +|---|---|---|---| +| `IhatRsCompatible` | ❌ | ✅ | ❌ — presentations are replayable | +| `MoleBound` (default) | ✅, into the OR transcript | ❌ | ✅ | + +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`: + +``` +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. + `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 constrains only the *Client's* state reuse. + +## Validation + +**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 + +``` +cargo test --features exp-ihat +cargo build --no-default-features --features exp-ihat --target thumbv7em-none-eabi +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]). + +[#1]: https://github.com/OR13/mole/issues/1 +[#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 +[#12]: https://github.com/OR13/mole/issues/12 diff --git a/README.md b/README.md index 6f7107a..1612319 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ -# 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 +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. [![CI](https://github.com/OR13/mole/actions/workflows/ci.yml/badge.svg)](https://github.com/OR13/mole/actions/workflows/ci.yml) @@ -14,10 +19,11 @@ from Google, Mozilla, and Cloudflare. ## ⚠️ Read this before you use it -**This crate contains no working 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 -cryptographic operation to a companion document that does not exist yet: +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 > defined in [CRYPTO]. Until that document is complete, bodies in this section are @@ -25,21 +31,32 @@ 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 -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. +`[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. -So what you get here is everything the drafts *do* pin down — the wire format, 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 a future `[CRYPTO]` implementation plugs into. Nothing in this crate -invents a scheme to fill that gap. +boundary in `src/crypto.rs`. Two dependencies, `#![no_std]`, no invented schemes. + +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 no implementation. The +profile exposes `ChallengeBinding` with a mode for each side of that contradiction. +See [`EXP-IHAT-PROFILE.md`](EXP-IHAT-PROFILE.md). -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. +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 @@ -55,23 +72,30 @@ 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 | -| 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 | -| 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. ## 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 `p256`. `#![no_std]` with `alloc` throughout, +including the profile; `forbid(unsafe_code)`. ```rust use mole::binding::ChallengeDigest; @@ -131,15 +155,39 @@ That is `moderator::NonceStore`, kept separate from the epoch `NullifierStore`. ## Tests ``` -cargo test --all-features # 88 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 -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 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 +`privacypass`, including presentation and reissue. Every piece of cryptography in the +reachable protocol surface is now real. + +`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 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 @@ -150,6 +198,21 @@ 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/) +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) +— 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). 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..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. --- @@ -18,26 +25,26 @@ 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 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. `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`. | -| 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.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. | +| 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`. | @@ -72,29 +79,76 @@ 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 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 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 +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 -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. -- 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. -- 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 +[#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 +[#16]: https://github.com/OR13/mole/issues/16 diff --git a/interop/Cargo.lock b/interop/Cargo.lock new file mode 100644 index 0000000..812b705 --- /dev/null +++ b/interop/Cargo.lock @@ -0,0 +1,1100 @@ +# This file is automatically @generated by Cargo. +# 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 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +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 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 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 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 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]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff", + "generic-array 0.14.9", + "group", + "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 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "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" +source = "git+https://github.com/Moderation-of-unLinkable-Endorsements/ihat-rs?rev=38673f12ab7ad215a5f3dbe703f893304323e393#38673f12ab7ad215a5f3dbe703f893304323e393" +dependencies = [ + "elliptic-curve", + "p256", + "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" +dependencies = [ + "base64", + "p256", + "sha2", +] + +[[package]] +name = "mole-exp-interop" +version = "0.0.0" +dependencies = [ + "ihat-rs", + "mole-exp", + "p256", + "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]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "elliptic-curve", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +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 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "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]] +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 = "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" +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..30f2cdd --- /dev/null +++ b/interop/Cargo.toml @@ -0,0 +1,43 @@ +# A detached package, deliberately NOT a workspace member of the crate above it. +# +# 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 +[workspace] + +[package] +name = "mole-exp-interop" +version = "0.0.0" +edition = "2021" +rust-version = "1.75" +publish = false +description = "Cross-implementation and end-to-end tests for mole-exp that need heavy external dependencies" + +[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" +# 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/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/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); + } } diff --git a/src/crypto.rs b/src/crypto.rs index 1ec736f..82ea2fe 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -10,11 +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 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. +//! `[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 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 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) @@ -149,5 +156,5 @@ pub trait LongfellowVerifier { ) -> Result; } -#[cfg(feature = "insecure-mock")] -pub mod insecure_mock; +#[cfg(feature = "exp-ihat")] +pub mod exp_ihat; diff --git a/src/crypto/exp_ihat.rs b/src/crypto/exp_ihat.rs new file mode 100644 index 0000000..b679411 --- /dev/null +++ b/src/crypto/exp_ihat.rs @@ -0,0 +1,1029 @@ +//! **EXPERIMENTAL.** IHAT over NIST P-256, aligned byte-for-byte with the +//! reference implementation the MoLE authors publish. +//! +//! # What 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. +//! +//! 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. +//! +//! 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 | +//! | 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 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]. +//! +//! # 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 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 +//! 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]. +//! +//! # A security property no draft states +//! +//! 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 +//! [#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 + +// 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, + clippy::struct_field_names +)] + +use core::cell::RefCell; + +use alloc::vec::Vec; + +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::{NistP256, ProjectivePoint, Scalar}; +use sha2::Sha256; + +use crate::binding::ChallengeDigest; +use crate::crypto::{IhatAnchor, IhatClient, IhatVerifier, RandomSource, VerifiedEndorsement}; +use crate::endorsement::Point; +use crate::error::{Error, Result}; + +// --------------------------------------------------------------------------- +// Domain separation — verbatim from `ihat-rs` +// --------------------------------------------------------------------------- + +/// `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_"; + +/// 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 +// --------------------------------------------------------------------------- + +fn hash_to_group(dst: &[u8], msg: &[u8]) -> Result { + NistP256::hash_from_bytes::>(&[msg], &[dst]) + .map_err(|_| Error::InvalidGroupElement) +} + +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. +/// +/// 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) +} + +/// `ihat-rs`'s transcript length prefix: `u64` little-endian. +/// +/// 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 +// --------------------------------------------------------------------------- + +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. +/// +/// 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, +} + +impl<'a> Cursor<'a> { + const fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + 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 put_point(p: &ProjectivePoint, out: &mut Vec) { + out.extend_from_slice(&compress(p)); +} + +fn put_scalar(s: &Scalar, out: &mut Vec) { + out.extend_from_slice(&scalar_bytes(s)); +} + +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 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(()) +} + +fn random_scalar(rng: &mut R) -> Result { + let mut b = [0u8; 32]; + rng.fill_bytes(&mut b); + hash_to_scalar(DST_RANDOM, &[&b]) +} + +fn random_nonzero_scalar(rng: &mut R) -> Result { + for _ in 0..8 { + let s = random_scalar(rng)?; + if !bool::from(s.is_zero()) { + return Ok(s); + } + } + Err(Error::VerificationFailed) +} + +fn invert(s: &Scalar) -> Result { + Option::::from(s.invert()).ok_or(Error::VerificationFailed) +} + +fn decode_keys(keys: &[Point]) -> Result> { + keys.iter().map(|k| decode_point_bytes(&k.0)).collect() +} + +// --------------------------------------------------------------------------- +// OR proof +// --------------------------------------------------------------------------- + +/// One CDS branch: commitment `t`, sub-challenge `c`, response `s`. +#[derive(Clone, Copy, Debug)] +struct Branch { + t: ProjectivePoint, + c: Scalar, + s: Scalar, +} + +/// Builds the 1-of-n OR proof of `∃j: X̂ = γ·X_j`. +/// +/// **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. +/// 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], + x_hat: &ProjectivePoint, + real_index: usize, + witness: &Scalar, + binding: ChallengeBinding, + challenge_digest: &ChallengeDigest, +) -> Result> { + let n = accepted.len(); + if n == 0 || real_index >= n { + return Err(Error::UnknownAnchorKey); + } + + 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)?; + + 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]); + } + 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( + branches: &[Branch], + accepted: &[ProjectivePoint], + x_hat: &ProjectivePoint, + binding: ChallengeBinding, + challenge_digest: &ChallengeDigest, +) -> Result<()> { + 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); + } + + // 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 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 { + Err(Error::VerificationFailed) + } +} + +// --------------------------------------------------------------------------- +// Anchor +// --------------------------------------------------------------------------- + +/// An Anchor. +/// +/// Holds an RNG behind a [`RefCell`] because [`IhatAnchor::sign`] takes `&self` +/// 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, + public_key: Point, + rng: RefCell, +} + +impl ExpAnchor { + /// Creates an Anchor from a secret scalar and a source of randomness. + /// + /// # Errors + /// [`Error::InvalidGroupElement`] if `secret` is not canonical, + /// [`Error::VerificationFailed`] if it is zero. + pub fn new(secret: [u8; 32], rng: R) -> Result { + let secret = decode_scalar_bytes(secret)?; + if bool::from(secret.is_zero()) { + return Err(Error::VerificationFailed); + } + let pk = ProjectivePoint::GENERATOR * secret; + Ok(Self { + secret, + public_key: Point(compress(&pk)), + 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; + + /// `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 = Cursor::new(request_body); + let y_prime = r.point()?; + let endorsement_context = r.var_bytes()?; + r.expect_end()?; + 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 a_prime = random_nonzero_scalar(&mut *rng)?; + let b_prime = random_scalar(&mut *rng)?; + let t_prime = random_scalar(&mut *rng)?; + + 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, + AnchorState { + a_prime, + b_prime, + t_prime, + }, + )) + } + + /// `ProofRequest` → `Proof`. + fn prove(&self, state: Self::State, request_body: &[u8]) -> Result> { + let mut r = Cursor::new(request_body); + let e_prime = r.scalar()?; + r.expect_end()?; + + 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) + } + + fn public_key(&self) -> Point { + self.public_key + } +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// A Client. +#[derive(Debug, Clone)] +pub struct ExpClient { + rng: R, + binding: ChallengeBinding, +} + +impl ExpClient { + /// 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, + 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 + } +} + +/// Client state after `Prepare`. +#[derive(Debug, Clone)] +pub struct ClientState { + anchor_public_key: Point, + x_pub: ProjectivePoint, + nf: Vec, + endorsement_context: Vec, + y: ProjectivePoint, + y_prime: ProjectivePoint, + v: Scalar, + gamma: Scalar, + 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, + e: Scalar, + e_prime: Scalar, +} + +/// A finalized Endorsement. +/// +/// `gamma` and the granting Anchor's key are private Client data, never +/// transmitted. +#[derive(Debug, Clone)] +pub struct ExpEndorsement { + /// `X̂ = γX`, the rerandomized Anchor key issuer hiding rests on. + pub x_hat: ProjectivePoint, + /// `Ẑ = γxY`. + pub z_hat: ProjectivePoint, + /// The nullifier, revealed at presentation. + pub nf: 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 epoch this Endorsement was granted under. + pub endorsement_context: Vec, + gamma: Scalar, + anchor_public_key: Point, +} + +impl ExpClient { + /// Runs both grant exchanges against `anchor`, then presents. + 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; + + /// `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_bytes(&anchor_public_key.0)?; + + 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(); + 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, + 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 = 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()?; + + let v_inv = invert(&state.v)?; + let alpha_inv = invert(&state.alpha)?; + let eps_inv = invert(&state.epsilon)?; + let h = pedersen_generator(&state.endorsement_context)?; + + 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::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, + e, + 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 = Cursor::new(response_body); + let r_prime = r.scalar()?; + let a_prime = r.scalar()?; + let b_prime = r.scalar()?; + r.expect_end()?; + + 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(&state.alpha)?; + let eps_inv = invert(&state.epsilon)?; + Ok(ExpEndorsement { + x_hat: two.x_hat, + z_hat: two.z_hat, + nf: state.nf, + e: two.e, + 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, + }) + } + + /// `Present` → `Presentation`. + 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 accepted = decode_keys(keys)?; + + let branches = or_prove( + &mut self.rng, + &accepted, + &endorsement.x_hat, + real_index, + &endorsement.gamma, + self.binding, + challenge_digest, + )?; + + let mut out = Vec::new(); + // 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) + } +} + +// --------------------------------------------------------------------------- +// Verification +// --------------------------------------------------------------------------- + +/// 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() + } +} + +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( + &self, + presentation: &[u8], + keys: &[Point], + challenge_digest: &ChallengeDigest, + ) -> Result { + if keys.is_empty() { + return Err(Error::VerificationFailed); + } + 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)?; + + Ok(VerifiedEndorsement { + nullifier: nf.to_vec(), + endorsement_context: endorsement_context.to_vec(), + }) + } +} diff --git a/src/crypto/insecure_mock.rs b/src/crypto/insecure_mock.rs deleted file mode 100644 index 3401bcc..0000000 --- a/src/crypto/insecure_mock.rs +++ /dev/null @@ -1,411 +0,0 @@ -//! **INSECURE.** A stand-in for the unpublished `[CRYPTO]` document, so that the -//! message flow can be exercised end to end. -//! -//! # 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/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..8275ee0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,24 +25,33 @@ //! //! ## 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, 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 //! `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. -//! -//! 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. +//! [`crypto`], and the default build invents no scheme to fill it. +//! +//! 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_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()); } diff --git a/tests/exp_ihat_profile.rs b/tests/exp_ihat_profile.rs new file mode 100644 index 0000000..3d50934 --- /dev/null +++ b/tests/exp_ihat_profile.rs @@ -0,0 +1,475 @@ +//! Tests for the experimental IHAT profile (`crypto::exp_ihat`). +//! +//! 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::{ + 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; + +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 client(rng_seed: u64) -> ExpClient { + ExpClient::new(DeterministicRng::new(rng_seed)) +} + +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 c = client(7); + let keys = vec![a.public_key()]; + let cd = digest(b"challenge-one"); + + 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); +} + +/// 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); + 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 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()); + } + assert!( + sizes.windows(2).all(|w| w[0] == w[1]), + "presentation size varies by issuing Anchor: {sizes:?}" + ); +} + +/// 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_receives_the_context_but_never_the_nullifier() { + let a = anchor(1, 42); + let mut c = client(7); + let keys = vec![a.public_key()]; + let cd = digest(b"blindness"); + + let (req1, state) = c.prepare(&a.public_key(), EPOCH).unwrap(); + let (resp1, astate) = a.sign(&req1).unwrap(); + let (req2, state) = c.request_proof(state, &resp1).unwrap(); + let resp2 = a.prove(astate, &req2).unwrap(); + let endorsement = c.finalize(state, &resp2).unwrap(); + let presentation = c.present(&endorsement, &keys, &cd).unwrap(); + + let nf = ExpVerifier::new() + .verify(&presentation, &keys, &cd) + .unwrap() + .nullifier; + assert_eq!(nf.len(), 32); + + for body in [&req1, &req2] { + assert!( + !body.windows(nf.len()).any(|w| w == nf.as_slice()), + "nullifier leaked to the Anchor" + ); + } + // 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 mole_bound_verify_fails_against_a_different_challenge_digest() { + let a = anchor(1, 42); + let mut c = client(7); + let keys = vec![a.public_key()]; + + let p = c + .grant_and_present(&a, EPOCH, &keys, &digest(b"bound-to-this")) + .unwrap(); + + assert_eq!( + ExpVerifier::new() + .verify(&p, &keys, &digest(b"but-not-this")) + .unwrap_err(), + Error::VerificationFailed + ); +} + +/// 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 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); + // X_hat is the rerandomized Anchor key; distinct gamma means distinct X_hat. + assert_ne!(p1[..33], p2[..33]); + 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. +#[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()]; + + assert_eq!( + client(7) + .grant_and_present(&rogue, EPOCH, &keys, &digest(b"rogue")) + .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 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!(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); + + 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 keys = vec![a.public_key()]; + let cd = digest(b"tamper"); + let good = client(7).grant_and_present(&a, EPOCH, &keys, &cd).unwrap(); + let v = ExpVerifier::new(); + + v.verify(&good, &keys, &cd).unwrap(); + + for i in 0..good.len() { + let mut bad = good.clone(); + bad[i] ^= 0x01; + assert!( + v.verify(&bad, &keys, &cd).is_err(), + "byte {i} could be flipped without detection" + ); + } +} + +/// 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_an_anchor_that_used_the_wrong_key() { + let honest = anchor(1, 42); + let other = anchor(2, 43); + let mut c = client(7); + + // 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(); + + assert_eq!( + c.finalize(state, &resp2).unwrap_err(), + Error::VerificationFailed + ); +} + +/// Corrupting `Z'` breaks the DLEQ relation the Client checks at finalize. +#[test] +fn client_rejects_a_corrupted_signature() { + let a = anchor(1, 42); + let mut c = client(7); + + let (req1, state) = c.prepare(&a.public_key(), EPOCH).unwrap(); + let (resp1, astate) = a.sign(&req1).unwrap(); + + // 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 — 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); + assert!( + pk.0[0] == 0x02 || pk.0[0] == 0x03, + "seed {seed}: leading byte {:#04x} is not a SEC1 compressed prefix", + pk.0[0] + ); + } +} + +/// 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) + .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"); +} + +/// 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 an_identity_key_in_the_accepted_set_is_refused() { + let a = anchor(1, 42); + let cd = digest(b"identity"); + 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 { .. }) + )); +}