Conversation
Why: to find out what implementing IHAT actually requires, and to correct a claim in this crate's own documentation that had gone stale. [CRYPTO] exists now. draft-authors-mole-crypto.md landed upstream on 2026-07-15 (PR #38, closing issue #31), nine days after the -00 drafts. The README said it "does not exist yet"; that was checkably wrong, and for a crate whose credibility rests on precisely characterising what the drafts pin down, a reviewer finding the document we said was absent is the worst available failure mode. Corrected in README, SPEC-COVERAGE, lib.rs and crypto.rs. It is still not on the Datatracker (404) and still not interoperably specified, so the conclusion is unchanged — only the reason is now accurate. The construction closes. Deriving it: Z' = xY' = xvY, so Zhat = γv⁻¹Z' = γxY and Xhat = γX = γxG. With r_target = ε⁻¹(t'−ρ), the client's T1 = r_target·Y and T2 = r_target·G; then r = ε⁻¹(r'−ρ) = r_target + γxea, so verification recovers rY − ea·Zhat = r_target·Y and rG − ea·Xhat = r_target·G, and aG + bH = α⁻¹C' − βH = C. The algebra is self-consistent and implementable. So crypto::exp_ihat implements it, behind an off-by-default feature. Unlike insecure_mock this is the real construction: blinding works, the Anchor never learns the nullifier, issuer hiding is a genuine 1-of-n CDS OR proof, and verification needs only public keys. Ten parameters had to be guessed, because the draft is ~50 lines of prose that names no group, no hash, no DSTs, declares TLS notation without defining a single structure, and publishes no test vectors. Choices: ristretto255 (the construction stacks three sigma protocols and curve25519's cofactor 8 makes those unsound); DST base OR13_EXP_MOLE, deliberately naming this repo so it cannot squat a string the spec might want; SHA-256 widened to 64 bytes via expand_message_xmd, because bare 32-byte reduction mod ℓ≈2^252 is biased; Point[33] filled as a 0x00 tag plus 32-byte ristretto, keeping the drafts' wire width intact. Each guess is an open issue; #13 indexes them. Two findings worth reporting upstream: - Anchor nonce reuse recovers the secret key (#9). Reuse of t' and a' across two grants gives r'_1 − r'_2 = (e'_1 − e'_2)a'x, and a' is sent to the Client in the clear. This rules out deterministic nonce derivation, which inverts the usual advice and is a live footgun given IhatAnchor::sign takes &self — the obvious workaround is the dangerous one. No draft says this. - The group is ambiguous across drafts (#1): Point[33] implies SEC1 256-bit while ACT uses 32-byte ristretto255, and upstream #37 folds ACT into the crypto draft. Renamed the package to mole-exp with publish = false. The lib is still `mole` to import, so no call sites move. Nothing here is publishable and the -exp suffix should make that hard to forget. Twelve tests in tests/exp_ihat_profile.rs assert blindness against the actual request bytes, issuer hiding with size invariant to the real branch, challenge binding, public verifiability, and detection of every single-byte flip. They establish self-consistency and nothing more: there are no vectors to check against (#10), the construction has no security proof or public cryptanalysis, and upstream PR #39 is still open to define the proof. 100 tests pass; clippy pedantic, rustdoc, fmt clean; no_std verified on thumbv7em-none-eabi with and without the profile; MSRV 1.75 holds --locked with zeroize pinned below the edition2024 bump (#12). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Why: P-256 is the group both MoLE drafts point at, and switching to it
*removes* inventions rather than adding them.
Four things that were this crate's choices are now the specification:
- Point[33] is a genuine SEC1 compressed P-256 point. The 0x00 tag byte that
padded a 32-byte ristretto encoding out to the drafts' width is gone. This
was the only place the profile had to invent an encoding.
- Hash-to-curve is RFC 9380 P256_XMD:SHA-256_SSWU_RO_, which is what the
crypto draft's "RO based Hash2Curve function" resolves to over P-256. The
hand-rolled expand_message_xmd is deleted in favour of p256's hash2curve.
- Scalars come from RFC 9380 hash_to_field, expanding to 48 bytes for P-256
before reduction, so the draft's "output length sufficiently long" is met by
construction rather than by this crate choosing a width.
- The Pedersen second generator is Hash2Curve("IHAT second generator"), the
literal string upstream PR #39 defines.
SHA-256 therefore stops being a preference and becomes the specification:
P256_XMD:SHA-256_SSWU_RO_ expands with SHA-256 internally. The earlier
argument for ristretto255 — curve25519's cofactor 8 against three stacked
sigma protocols — turned out to be moot, because P-256 has cofactor 1.
Also corrects an error in this crate's earlier analysis, recorded on #4: the
crypto draft's {{DLEQ}} reference has always been
I-D.draft-irtf-cfrg-sigma-protocols, not RFC 9497. It never reached an issue
body or the code, but it understated how much specified machinery the draft
already pointed at.
Six items remain open, each an issue, and two of them are now deliberate
deviations from things PR #39 does specify rather than gaps:
- #1 the two MoLE drafts disagree on point compression, Point[33] vs
bytes[65]; and the group is still only named by a forward reference the
architecture draft does not satisfy
- #2 no DST upstream; ours now name the suite per RFC 9380 3.1 convention
- #3/#4 e = H(Y, Zhat, T1, T2, C) is the one hash left outside any framework;
the profile does not yet use the SHAKE128 sponge and P-256 codec of
draft-irtf-cfrg-fiat-shamir
- #5 C' is still used before it is defined
- #6 textbook CDS OR proof rather than PR #39's one-of-N trapdoor commitment
tree, held because that PR is unmerged and carries its own TODO saying the
construction may be replaced
- #7/#8 m typed as Scalar cannot carry endorsement_context, and
challenge_digest appears in no upstream structure or challenge, so following
the crypto draft here would produce something that cannot satisfy protocols
4.1.3 at all
102 tests pass. The profile gains two: Anchor keys are SEC1 compressed with
both parities occurring, and the identity is refused rather than truncated
into a 33-byte lookalike. Clippy pedantic, rustdoc and fmt clean; no_std
verified on thumbv7em-none-eabi with and without the profile; MSRV 1.75 holds
--locked, with zeroize still pinned because p256 reaches it via
elliptic-curve (#12).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update: switched from ristretto255 to P-256Aligned the profile with the group the drafts point at. This removed inventions rather than adding them — four things that were this crate's choices are now the specification:
The clearest win is SHA-256 stopped being a preference and became the specification — Worth recording: the original argument for ristretto255 was curve25519's cofactor 8 against three stacked sigma protocols. P-256 has cofactor 1, so that argument was moot for the drafts' actual curve. CorrectionThe crypto draft's What is still openSix items, and two of them are now deliberate deviations from things upstream PR #39 does specify rather than gaps:
Verification102 tests (profile up from 12 to 14 — new ones assert SEC1 compressed encoding with both parities occurring, and that the identity is refused rather than truncated into a 33-byte lookalike). Clippy pedantic, rustdoc, fmt clean; CI green: run 30393126931. |
Why: the MoLE org publishes a reference implementation, ihat-rs, by Samuel
Schlesinger — one of the draft authors. It settles most of what I had filed as
unguessable, and it disagrees with the drafts in ways worth knowing. I should
have checked the org's other repos before inventing parameters.
The profile now follows code rather than prose. Where ihat-rs and the crypto
draft disagree, ihat-rs wins: it is executable, it is by an author, and
interoperating with it is the only validation available since neither
publishes test vectors.
interop/ is a detached package (git dependency; Cargo cannot make
dev-dependencies optional) pinned to an upstream revision. Four tests, both
directions:
- ihat-rs parses and accepts our presentations for every issuer in a
four-Anchor set, and re-encodes them to byte-identical output
- we parse and accept ihat-rs's, recovering the same nullifier and context
- Anchor keys round-trip both ways
- neither accepts a substituted accepted set
That converts the crate's worst remaining problem — no test vectors, so
correctness unverifiable — into differential testing against the authors' own
code, which is stronger: it exercises both roles rather than fixed strings.
Adopted from ihat-rs: the four DSTs verbatim (MOLE-IHAT-P256:<use>:v1, which
closes the DST question outright), Y = H1(nf), a context-bound Pedersen
generator H(endorsement_context), the issuance transcript, the CDS OR proof
wire form, and two-byte big-endian VarBytes framing.
Two real weaknesses in our previous revision, both found by reading ihat-rs:
- or_prove branched on the secret real_index (`if i == real_index`), a timing
and cache channel leaking exactly what issuer hiding conceals. ihat-rs is
explicitly constant-time here; now rewritten to match, selecting the honest
base with subtle across the whole accepted set.
- our Fiat-Shamir transcript followed the draft's e = H(Y, Zhat, T1, T2, C).
ihat-rs additionally binds X_hat and endorsement_context, commenting that
this is "strengthened to bind the full statement ... so a proof cannot be
transported to a different X_hat". Now matched.
Also dropped: the separate DLEQ proof we emitted in exchange one. ihat-rs
sends none because (T1', T2', r') already is one — finalize checks
Y'r' = Z'e'a' + T1' and Gr' = Xe'a' + T2'. That was 64 redundant bytes per
grant. And `m` is gone: ihat-rs carries nf and endorsement_context as separate
fields, so our m = opaque<V>(ctx) || nf[32] was working around a non-problem.
One thing cannot be reconciled. ihat-rs has no challenge_digest anywhere, so
protocols 4.1.3's two MUSTs about binding it are satisfied by no
implementation. Rather than pick silently, ChallengeBinding exposes both
sides: IhatRsCompatible interoperates and is replayable; MoleBound (default)
satisfies 4.1.3 and does not interoperate. Tests assert both, including that
they do not cross-verify.
Corrections recorded: {{DLEQ}} resolves to draft-irtf-cfrg-sigma-protocols,
not RFC 9497. And PR #39 contradicts ihat-rs in three places — uncompressed
bytes[65] vs compressed 33, a global vs context-bound Pedersen generator, and
an O(log n) commitment tree vs O(n) CDS.
109 tests plus 4 interop. Clippy pedantic, rustdoc, fmt clean; no_std on
thumbv7em-none-eabi with and without the profile; MSRV 1.75 --locked. New CI
job runs the cross-verification. cargo doc caught a bare [DLEQ] citation
during this work, which is why it stays in the check list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update: aligned with
|
| PR #39 | ihat-rs |
|
|---|---|---|
| Point encoding | uncompressed bytes[65] |
compressed 33 (agrees with protocols §4.1) |
| Pedersen generator | global Hash2Curve("IHAT second generator") |
per-context H(endorsement_context) |
| OR proof | O(log n) trapdoor commitment tree |
O(n) CDS |
The one thing that cannot be reconciled
ihat-rs has no challenge_digest anywhere — not in a structure, not in a transcript, not in its API. Its verifier docs say a presentation "verifies every time". So protocols §4.1.3's two MUSTs about binding it are satisfied by no implementation, including the authors' own.
Rather than pick silently, ChallengeBinding exposes both sides:
| Mode | Binds digest | Interoperates | Satisfies §4.1.3 |
|---|---|---|---|
IhatRsCompatible |
❌ | ✅ | ❌ — replayable |
MoleBound (default) |
✅ | ❌ | ✅ |
Both tested, including that they don't cross-verify, and a test that asserts compat mode accepts an unrelated digest — so the deficiency is visible rather than latent.
Issues
Closed #2 (DSTs), #3 (hash), #7 (m doesn't exist). #1, #5, #6 narrowed to upstream contradictions. #8 is now the sharpest finding in the tracker. #10 largely superseded. #13 has the full before/after.
Correction
{{DLEQ}} resolves to draft-irtf-cfrg-sigma-protocols (§2.2.8 is the DLEQ statement), not RFC 9497 as I said earlier. Recorded on #4.
Verification
109 tests + 4 interop. Clippy pedantic, rustdoc, fmt clean; no_std on thumbv7em-none-eabi with and without the profile; MSRV 1.75 --locked. CI green, all four jobs: run 30394911541.
cargo doc caught a bare [DLEQ] citation during this work — the same failure class that reddened CI at the start of the day, which is why it stays in the check list.
Still not deployable
ihat-rs says of itself: "This code has not been audited." Interoperability is agreement, not correctness — if the construction is flawed, both implementations are flawed identically.
Why: the profile was validated in isolation and against ihat-rs, but the complete-protocol test still ran entirely on insecure_mock. This proves the profile plugs into MoLE rather than into a harness built around it. tests/appendix_a_exp_ihat.rs walks the same exchange as appendix_a_flow.rs with the endorsement half on exp_ihat: the WWW-Authenticate challenge, binding over the decoded octets, both grant exchanges with their bodies carried inside real EndorsementRequest/EndorsementResponse envelopes, redemption through Moderator::redeem against a real accepted set, then the credential exchange and update. Seven tests. Two of them say something the mock version could not: - the_moderator_cannot_tell_which_anchor_issued: every Anchor in the accepted set can issue, presentation size is invariant, and the Moderator ends up holding an epoch it already knew plus a nullifier. Issuer hiding asserted at the protocol level rather than inside the profile's own tests. - the_accepted_set_order_is_load_bearing: a permutation of the accepted set does not verify, which is what makes §4.1.3's normative branch ordering real rather than decorative. A deliberate behavioural difference worth noting: a redemption bound to another challenge now fails with VerificationFailed, not the mock's ChallengeMismatch. With real cryptography the Moderator genuinely cannot distinguish "wrong challenge" from "invalid proof" — the OR proof just fails. That is the better outcome, since §8 warns redeem's distinct error variants must not be surfaced to Clients, and here there is nothing to surface. Also strengthened the MSRV CI job from `cargo build` to `cargo test`. Clippy's incompatible_msrv lint caught a `split_at_checked` (stable 1.80) in the new test code; running the tests on 1.75 catches that class directly rather than relying on a lint. The credential half is still a stand-in token. That is now the only fake cryptography left in the reachable protocol surface — Longfellow needs libzk plus an out-of-band circuit, ACT is expiring upstream, and key rotation and the config format are TODO. in the draft. 116 tests plus 4 interop. Clippy pedantic, rustdoc, fmt clean; no_std on thumbv7em-none-eabi with and without the profile; MSRV 1.75 builds and tests --locked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update: Appendix A now runs on real cryptography
Two say something the mock version couldn't:
One deliberate behavioural changeA redemption bound to another challenge now fails with CIStrengthened the MSRV job from 116 tests + 4 interop, all four jobs green: run 30396614290. What's leftThe credential half still uses a stand-in token. That is now the only fake cryptography in the reachable protocol surface — Longfellow needs |
Why: the credential half was the last fake cryptography in the reachable surface. It is now real, and the std constraint I expected turned out to be avoidable. The boundary was in a better place than I thought. Section 5.2.1 has MoLE carry TokenRequest and TokenResponse *opaquely* — it defines no structures for them, and this crate already defines no wrappers. So the library needs no Privacy Pass dependency at all; it needs exactly one thing it was missing, and the real integration belongs where interop/ already lives. No `std` feature, no async in the library, dependency count still two. Library addition: credential::reverse_flow::Token, a parser for the RFC 9578 Token structure (token_type, nonce, challenge_digest, token_key_id, authenticator), plus TOKEN_TYPES and authenticator_len. Structure only, no cryptography, no_std. It exists because a Moderator cannot enforce either of §5.2.2's two requirements — constant digest, unseen nonce — without reading those fields, and until now it had no way to do that short of taking a full Privacy Pass dependency. Five unit tests, including that a known token_type with the wrong Nk is malformed, an unknown type with any non-empty authenticator is accepted (a Moderator may be configured with a type this crate predates), and that truncation never panics. interop/tests/appendix_a_end_to_end.rs runs Appendix A with nothing mocked: real IHAT endorsements over P-256 for the endorsement half, real RFC 9578 Privacy Pass via `privacypass` for the credential half — issuance, finalization, presentation, nonce checks on both layers, and reissue of the update. Three tests. privacypass is std-only with an async issuer API, which is exactly why it lives out here and not in the library. Found a fourth cross-document contradiction while doing it (#16). §5.2.2 says the token's challenge_digest MUST equal the Moderator's constant challenge_digest, cross-referencing §3.3 — MoLE's SHA-256(challenge octets), which for this type is SHA-256(""). That is unsatisfiable: the token's challenge_digest is computed by RFC 9578 as SHA-256(TokenChallenge), and an RFC 9577 TokenChallenge always carries a token_type and a non-empty issuer_name, so it can never digest to SHA-256(""). It would need a preimage. Two different fields share one name and the MUST tries to equate them. The test takes the workable reading — one fixed TokenChallenge per Moderator, constant across every token it issues — which is the property the surrounding text actually asks for. Nothing is weakened: §5.2.2 is explicit that anti-replay comes from the nonce being single-use, and that is enforced and tested on both layers. Library remains no_std on thumbv7em with sha2 and base64 only. 121 tests plus 7 in the detached package. Clippy pedantic, rustdoc, fmt clean; MSRV 1.75 builds and tests --locked. CI's interop job now covers both the ihat-rs cross-verification and the end-to-end run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Appendix A now runs with no mocks at allThe credential half was the last fake cryptography in the reachable surface. It's real now — and the The boundary was in a better place than I thought§5.2.1 has MoLE carry No Library addition
It's there because a Moderator cannot enforce either of §5.2.2's two requirements — constant digest, unseen nonce — without reading those fields, and until now it had no way to do that short of taking a full Privacy Pass dependency. Five unit tests, including that a known End to end, nothing mocked
A fourth cross-document contradiction — #16Found by doing this; it doesn't surface any other way. §5.2.2 says the token's That is unsatisfiable. The token's The test takes the workable reading: one fixed This is the cheapest of the upstream findings to fix — a cross-reference removal and one sentence. Verification121 tests + 7 in the detached package. Clippy pedantic, rustdoc, fmt clean; MSRV 1.75 builds and tests |
Why: insecure_mock existed for one reason — this crate had no real
cryptography. It now has real, interoperable cryptography, so 417 lines of
deliberately-insecure code plus a 396-line duplicate Appendix A walk are dead
weight that every reader has to be warned about.
Deleted src/crypto/insecure_mock.rs, the insecure-mock feature, and the
duplicate tests/appendix_a_flow.rs. Renamed the real-crypto Appendix A test
into its place: there is one Appendix A walk now, and it runs on exp_ihat.
Test count drops 121 -> 111 while coverage goes up, because the tests that went
away were exercising the same protocol paths against a mock.
Documentation: removed material that had accumulated as this work progressed
and is now archaeology.
- My own revision history ("an earlier revision of this profile branched on the
index", "64 wasted bytes per grant", before/after comparison tables). Git has
it; reference docs should state what is true, not how it got that way.
- PR #39 forensics. What matters is that the profile follows ihat-rs and where
that differs from the drafts — not a running commentary on an unmerged PR. The
three places PR #39 contradicts the reference implementation are recorded in
the issues, which is where they belong.
- The README's 45-line account of [CRYPTO]'s exact publication state, compressed
to the four sentences a reader needs.
- The insecure_mock comparison table, which compared against something that no
longer exists.
Reframed the status tables, which were the actual source of confusion. Rows for
Longfellow, ACT, Budget Privacy Pass and key rotation rendered as ❌, which reads
as a backlog. They are not: MoLE specifies several endorsement and credential
protocols to demonstrate agility and a deployment picks one of each. This crate
implements IHAT and Privacy Pass Reverse Flow. For the others the wire
structures, registries and dispatch are implemented and tested — only the
cryptography is external, and Longfellow's and ACT's underlying drafts are not
in a state to build against. New legend: ⬜ for that, ⛔ for sections that are
literally TODO. upstream.
Kept one substantive note: ACT is the only credential protocol with bound
updates, which Reverse Flow cannot provide (§5.2.3). That is a property of the
protocol chosen here, not a task.
Docs shrank from 716 to 561 lines. Library unchanged: no_std on
thumbv7em-none-eabi, two dependencies, one optional feature. 111 tests plus 7 in
the detached package; clippy pedantic, rustdoc, fmt clean; MSRV 1.75 builds and
tests --locked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements IHAT over NIST P-256 in
crypto::exp_ihat, behind an off-by-default feature, aligned byte-for-byte withihat-rs— the reference implementation published in the MoLE org by Samuel Schlesinger, one of the draft authors. Renames the package tomole-expwithpublish = false; the lib is stillmoleto import.It follows code, not prose
The crypto draft is prose that names no group, no DST, and no wire structures.
ihat-rsis executable with a canonical wire format. Where they disagree,ihat-rswins — it is by an author, and interoperating with it is the only validation available since neither publishes test vectors.Taken from it: P-256, SEC1 compressed
Point[33], RFC 9380P256_XMD:SHA-256_SSWU_RO_,hash_to_fieldfor scalars, the fourMOLE-IHAT-P256:<use>:v1DSTs verbatim,Y = H₁(nf), a context-bound Pedersen generator, both Fiat–Shamir transcripts, and CDS 1-of-n for the OR proof.Validation
Cross-verified in both directions.
interop/is a detached package pinned to an upstream revision:ihat-rsaccepts presentations produced here and re-encodes them byte-identically, this crate acceptsihat-rs's, keys round-trip, and neither accepts a substituted accepted set.Appendix A end to end with no mocks.
interop/tests/appendix_a_end_to_end.rspairs the profile with real RFC 9578 Privacy Pass credentials viaprivacypass— issuance, finalization, presentation, nonce checks on both layers, reissue.tests/appendix_a_flow.rsruns the same exchange on the profile alone, asserting issuer hiding at the protocol level and that the accepted set's order is load-bearing.The library did not grow
Still
#![no_std]onthumbv7em-none-eabi, still two dependencies (sha2,base64), stillforbid(unsafe_code).exp-ihatadds one optional dependency,p256. The credential layer needed no Privacy Pass dependency at all — §5.2.1 has MoLE carryTokenRequest/TokenResponseopaquely, so the only addition iscredential::reverse_flow::Token, a parser for the one RFC 9578 structure a Moderator must read to enforce §5.2.2. Structure only, no cryptography.privacypassis std-only with an async issuer API, which is why it lives in the detached package and not here.The one thing that cannot be reconciled
ihat-rshas nochallenge_digestanywhere — not in a structure, not in a transcript, not in its API. So protocols §4.1.3's two MUSTs about binding it are satisfied by no implementation, including the authors' own.ChallengeBindingexposes both sides rather than picking silently:IhatRsCompatibleMoleBound(default)Both are tested, including that they do not cross-verify.
Findings worth reporting upstream
Seven, indexed by #13. The sharpest:
challenge_digestbinding is implemented by no oneTokenChallenge, which is never emptyPlus: upstream PR #39 contradicts
ihat-rson point encoding, the Pedersen generator, and the OR-proof construction.Still not deployable
ihat-rsstates it has not been audited. The construction has no security proof and no public cryptanalysis. Interoperability is agreement, not correctness — if the construction is flawed, both implementations are flawed identically.Verification
111 tests plus 7 in the detached package. Clippy pedantic, rustdoc, fmt clean;
no_stdonthumbv7em-none-eabiwith and without the profile; MSRV 1.75 builds and tests--locked. Four CI jobs, including the cross-verification and end-to-end run.🤖 Generated with Claude Code