# PQ Stealth Addresses Post-quantum stealth addresses as an ERC-5564 scheme extension: key-exchange evidence, reference implementations, and separate spending research. # Post-Quantum Stealth Addresses :::info[Mirrored page] Source: [`docs/overview.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/overview.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: ML-KEM key exchange and sender-computable stealth destinations under existing ERC-5564 announcements (the ERC-6538 registry is out of scope). The proposed ERC focuses on detection privacy and address derivation. Spending belongs to the selected account model. The implemented commitment meta-address is **1,217 bytes**: version, a 32-byte public spending-key value/commitment, and an ML-KEM-768 viewing key. The sender encapsulates, derives a commitment, and computes the destination using an agreed deployment binding. The scanner decapsulates, checks the view tag, and confirms the derived address. D-024 leaves final normative format selection open. ## Review the key-exchange proposal 1. [Technical reference](/spec/technical-spec): exact implemented fields, domains, deployment inputs, and receive/scan behavior. 2. [ERC evidence](/spec/erc-evidence): vectors, relevant proofs, and what remains to justify for the commitment profile. 3. [Security overview](/spec/security-analysis): confidentiality, anonymity, full-announcement composition, and adversary boundaries. 4. [Cost report](/research/cost-report): measured L1/L2 announcement and spend gas, meta-address registration, and client-side scanning, each with its provenance and dated price anchors. 5. [Submission roadmap](/research/erc-submission-gap-analysis): portable checker and asset-package work. [Python](/impl/python) is the byte-level source of truth; [TypeScript](/impl/js-client) independently replays its vectors. Generic KEM proofs provide conditional support. They do not yet supply a complete privacy proof for the concrete commitment/CREATE2 flow. ## Supporting research and tools [Spending research](/research/spending) covers Construction A's blinded ML-DSA, C13, account routes, and ZK demos. The browser preimage demo is implemented, but its current UltraHonk backend is not PQ-sound. [EIP-8288](/research/eip-8288-summary) is a prospective integration assessment. The [Lean overview](/lean) separates KEM foundations from spending proofs and classical comparisons. [Maintainer tooling](/lean/tooling) and the generated theorem browser serve the whole research tree; they are not additional ERC implementation requirements. The [current plan](/overview/plan) and [decision log](/spec/decisions) track scope. The ERC text remains human-written and pre-freeze. This site mirrors repository sources; edit those sources and run `pnpm sync` in `wiki/`. # Using this documentation with AI assistants This site follows the [llms.txt](https://llmstxt.org) convention: everything that renders here is also published as plain markdown, so a language model can read the specification, the decision log and the proof notes without scraping HTML. Point your assistant at the URLs below — nothing else is needed. ## Endpoints | URL | What it is | Use when | | --- | --- | --- | | [`/llms.txt`](https://skanislav.github.io/pq-sap/llms.txt) | Index: one line per page with its title and a short description | The model should decide what to fetch (small, a few KB) | | [`/llms-full.txt`](https://skanislav.github.io/pq-sap/llms-full.txt) | Every page concatenated into one markdown file | You want the whole corpus in context at once (roughly 800 KB, about 200k tokens) | | `/assets/md/.md` | The markdown of a single page | You want one page verbatim, e.g. [`/assets/md/spec/erc-draft.md`](https://skanislav.github.io/pq-sap/assets/md/spec/erc-draft.md) | Every route on this site has a markdown twin: take the page path, prefix it with `/assets/md`, and add `.md`. The landing page is `/assets/md/index.md`. :::tip[Absolute URLs] The site lives under `https://skanislav.github.io/pq-sap/`. Links inside `llms.txt` are written relative to the site root (`/spec/erc-draft`), so prefix them with that base when you follow them, and add `/assets/md` + `.md` to get the markdown rather than the HTML. ::: ## Recipes **Chat assistants (Claude, ChatGPT, Gemini …)** — paste the `llms-full.txt` URL, or the URL of a single page's markdown, into the conversation and ask your question. For a focused task the ERC draft plus the technical spec is usually enough: ```text https://skanislav.github.io/pq-sap/assets/md/spec/erc-draft.md https://skanislav.github.io/pq-sap/assets/md/spec/technical-spec.md ``` **Coding agents (Claude Code, Cursor, Copilot, Codex …)** — give the agent the index and let it fetch what it needs: ```text Read https://skanislav.github.io/pq-sap/llms.txt and then the pages you need to implement scheme 2 (ML-KEM-768 + blinded ML-DSA-65) in . Check your output against the v0 test vectors. ``` Cursor users can register `https://skanislav.github.io/pq-sap/llms-full.txt` as a custom docs source (*Settings → Features → Docs*). **Shell** — fetch the corpus directly: ```bash curl -s https://skanislav.github.io/pq-sap/llms.txt curl -s https://skanislav.github.io/pq-sap/llms-full.txt > pq-stealth-docs.md curl -s https://skanislav.github.io/pq-sap/assets/md/spec/decisions.md ``` ## What to read for which task | Task | Pages | | --- | --- | | Implement the scheme | [ERC draft](/spec/erc-draft), [Technical spec](/spec/technical-spec) | | Understand a design choice | [Decisions & findings](/spec/decisions) | | Verify an implementation | [Python executable spec](/impl/python) and the [v0 test vectors](https://github.com/Skanislav/pq-sap/tree/main/python/vectors/v0) (JSON, with negative cases) | | Reason about security | [Lean 4 / VCVio proofs](/lean) and the per-module [proof browser](/lean/proofs/) | | See what is still open | [ERC submission gap analysis](/research/erc-submission-gap-analysis) | ## Ground truth Most pages here are mirrored from markdown that lives next to the code in the [GitHub repository](https://github.com/Skanislav/pq-sap) (`docs/`, `lean/docs/`, the per-implementation `README.md` files), and each mirrored page names its source in a banner at the top. If an assistant needs the latest state, or the test vectors and code themselves, the repository is the source of truth; the site is rebuilt from `main` on every push. Things the markdown cannot convey: the Lean proofs are only as good as their axioms (listed on the [proofs overview](/lean)), and the specification is a working draft that has not been through ERC review yet. Ask the assistant to say which page a claim comes from so you can check it. # Key exchange and address derivation — technical reference :::info[Mirrored page] Source: [`docs/TECHNICAL_SPEC.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/TECHNICAL_SPEC.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Current implementation reference following D-024/D-025, reviewed 2026-09-14. Python defines byte-level behavior; this document describes it. The human-written [ERC draft](/spec/erc-draft) determines the eventual normative profiles and is not generated from these notes. ## 1. Scope The proposal establishes a shared secret with ML-KEM-768 and uses it to derive a recipient-detectable destination under existing ERC-5564 announcements. Spending is delegated to an account model. The sender can compute the address without knowing the spending secret; the viewing-key holder can scan without that secret. D-024 adopts the commitment format `0x02`. Construction A (`0x01`, blinded ML-DSA) remains another implemented address form. Whether both belong in the final ERC remains undecided. Its algebra, encodings, signatures, and historical account measurements are in [Construction A](/research/construction-a). ## 2. Commitment profile and sizes Source: [Python commit module](https://github.com/Skanislav/pq-sap/blob/main/python/pq_stealth/commit.py), mirrored by [TypeScript commit module](https://github.com/Skanislav/pq-sap/blob/main/js-client/src/commit-scheme.ts). | Field | ML-KEM-768 size | Meaning | | --- | --- | --- | | Version | 1 byte | `0x02`; distinct from the target ERC-5564 scheme ID `2` | | `spend_key` | 32 bytes | Public spending-key value or commitment, interpreted by the account profile | | `kem_ek` | 1,184 bytes | ML-KEM encapsulation key | | Meta-address | 1,217 bytes | `0x02 || spend_key || kem_ek` | | `ephemeralPubKey` | 1,088 bytes | ML-KEM ciphertext | | View tag | 1 byte | `SHA-256(ss)[0:1]` | | Stealth address | 20 bytes | Derived from the selected account deployment binding | The format omits ML-DSA public-key material; it still contains a lattice KEM key. Python also exposes other ML-KEM parameter sets for experiments. The reference profile here is ML-KEM-768; format `0x02` does not itself encode the KEM choice, hash domains, chain, or account deployment configuration. Those inputs must be agreed separately; their distribution/profile selection is an ERC review item, not a solved negotiation mechanism. The ERC-6538 registry is not that mechanism and is out of the proposal's scope: it is governed by ecrecover, so an entry can be overwritten by the classical key it was registered under, and binding a PQ key to it does not change that. The meta-address and the deployment binding travel through naming services or are shared off chain; the announcer (ERC-5564) is the only deployed contract the proposal depends on. Since D-027 the implementation makes that selection explicit: an **account profile** (`python/pq_stealth/profiles.py`, `js-client/src/profiles.ts`) names the KEM, the hash domains, the authorization scheme with its message convention, and the deployment-binding layout, and `select_profile` / `selectProfile` accept only the known profiles and a shape-validated binding for one chain. Three identifiers keep distinct roles: the version byte `0x02` describes the encoding, the ERC-5564 scheme ID describes announcement interpretation, and the profile describes the selected authorization and deployment behaviour. A profile name is trusted configuration, not authentication of the factory or verifier behind it. Details: [ML-DSA-44 committed-key profile](/research/ml-dsa-commit-profile). ## 3. Implemented receive and scan flow ```text Recipient: create ML-KEM viewing keys (ek, dk) publish 0x02 || spend_key || ek Sender: encapsulate ek -> shared secret ss and ciphertext ct opener = SHA-256(open_domain || ss) commitment = keccak256(commit_domain || spend_key || opener) address = account_address(commitment, deployment) view_tag = SHA-256(ss)[0:1] use ct as ephemeralPubKey and view_tag as metadata[0] Scanner: decapsulate ct using dk reject tag mismatch; otherwise re-derive commitment and address return payment only when the address also matches ``` The sender knows `ss` and the opener. Authorization must therefore require an additional recipient-only spending secret. The view-tag optimization runs **after decapsulation** and saves further derivation on mismatches; matching a tag alone is not detection of a payment. The Python scan returns `None` on the handled malformed-ciphertext errors, a wrong tag, or an address mismatch. Decode rejects a wrong version or length. These checks do not authenticate the sender, establish log completeness, or make externally supplied account configuration trustworthy. ## 4. Domains and deployment binding | Profile | `open_domain` | `commit_domain` | | --- | --- | --- | | C13 commitment (`sphincs-c13-commit/v0`) | `pq-stealth/sphincs-c13/open/v0` | `pq-stealth/sphincs-c13/commit/v0` | | Preimage demo (`preimage/v0`) | `pq-stealth/preimage/open/v0` | `pq-stealth/preimage/commit/v0` | | ML-DSA-44 committed key (`ml-dsa-44-commit/v0`) | `pq-stealth/ml-dsa-44/open/v0` | `pq-stealth/ml-dsa-44/commit/v0` | For the preimage demo, `spend_key = keccak256("pq-stealth/preimage/key/v0" || sk)` with a 32-byte secret. The C13 profile uses its 32-byte public key directly. For the ML-DSA-44 profile, `spend_key = keccak256("pq-stealth/ml-dsa-44/key/v0" || pk)` with `pk` the canonical 1,312-byte FIPS 204 ML-DSA-44 public key (hashed once; the outer commitment hashes that value with the opener). The parameter set is part of the profile and of the domain strings; ML-DSA-65 would be a different profile with a different verifier. The existing names are byte-level inputs; changing them changes destinations. Ethereum Keccak-256 is not SHA3-256. **The domain set is a call-site argument, never a default.** The 32-byte `spend_key` is opaque, so `commit.py` / `commit-scheme.ts` take `CommitDomains` explicitly in every derivation (`derive_opener`, `derive_commitment`, `send_commit`/`sendCommit`, `check_commit_announcement`/`checkCommitAnnouncement`, `scan_commit`/`scanCommit`); omitting it is a compile error in TypeScript and a `TypeError` at run time in both languages rather than a silent fallback to the C13 profile. The TS entry points check the argument explicitly, because Node executes these modules with types stripped — without the check a forgotten argument would be reinterpreted as the next parameter and derive a wrong destination. The profile-aware wrappers in `profiles.py` / `profiles.ts` pass `profile.domains` and are the API an application should use. `gen_commit_meta_address` likewise refuses a half-seeded ML-KEM keypair instead of drawing the missing seed silently. **KEM admissibility (D-029, corrected per review F2).** The opener and view tag hash only `ss`; they do not re-hash the ciphertext or the encapsulation key. FIPS 203 ML-KEM does **not** put `H(ct)` into a successful shared secret — encapsulation is `(K, r) ← G(m || H(ek))`, `c ← K-PKE.Encrypt(ek, m, r)` — so this profile's design *omits an external transcript hash*, as X-Wing's combiner deliberately does. That omission is permissible here only under the KEM's own security properties and an explicitly recorded open obligation, not a general principle: * **Confidentiality:** ML-KEM-768 IND-CCA (FIPS 203). Implicit rejection: a length-valid modified ciphertext decapsulates to `J(z || c')` — a pseudorandom secret unpredictable to the sender — not to an explicit error. * **Substitution:** a substituted ciphertext therefore does *not* fail decapsulation; it yields a different, sender-unpredictable secret, so the opener and address derived from it are unrelated to the honest ones. The recipient binding relies on IND-CCA-style pseudorandomness of implicit rejection, not on any "decaps fails first" argument. * **Open obligation:** no reduction currently on file establishes that omitting an external transcript hash preserves recipient anonymity in this construction under active substitution experiments. The omission is a reviewed assumption of the profile (with X-Wing as precedent for the pattern), recorded as open work — not a proved property. * **Binding properties:** profiles must state the binding their KEM actually provides (for X-Wing: MAL-BIND-K-PK / MAL-BIND-K-CT, a construction- specific argument; for ML-KEM: the re-encryption check). Admissibility is by stated properties, not by a syntactic "hashes ct internally" formula. A KEM lacking these properties is not made admissible by re-hashing its outputs; adding external transcript binding is a new profile revision, not an edit of existing ones, since changing a domain moves funds. The one-byte view tag is a post-decapsulation filter, not a soundness surface: a tag match alone proves nothing, and a false positive falls through to the full address comparison. The implemented deployment record supplies a 20-byte factory, account creation code, 20-byte verifier, 20-byte frame-context address, and 32-byte salt (default zero). Constructor arguments are encoded as three 32-byte words: ```text init_code = creation_code || commitment || leftpad32(verifier) || leftpad32(frame_ctx) address = keccak256(0xff || factory || salt || keccak256(init_code))[12:32] ``` This describes the current account adapter, not a universal account interface. Changing the deployment binding may change the address. Vectors use synthetic creation code to test deterministic derivation; they do not establish that an account is deployable or that its authorization policy is secure. ## 5. Conformance and security evidence [Commitment vectors](https://github.com/Skanislav/pq-sap/blob/main/python/vectors/v0/commit_vectors.json) cover the C13 and preimage domain profiles, with positive cases plus the negative set (wrong view tag, truncated ciphertext, bit-flipped ciphertext, tampered destination, wrong recipient). Their generator is [generate\_commit\_vectors.py](https://github.com/Skanislav/pq-sap/blob/main/python/vectors/generate_commit_vectors.py). The ML-DSA-44 committed-key profile (§4, D-027) has its own deterministic set in [vectors/v0/mldsa44\_commit\_vectors.json](https://github.com/Skanislav/pq-sap/blob/main/python/vectors/v0/mldsa44_commit_vectors.json), generated by [generate\_mldsa44\_commit\_vectors.py](https://github.com/Skanislav/pq-sap/blob/main/python/vectors/generate_mldsa44_commit_vectors.py): recipient key material and meta-address, the announcement and derived account, the `pk || opener || sig` authorization payload, and negative cases (wrong opener, signature under another key, commitment formed under the C13 domains). Python tests cover encoding, derivation, scanning, and fixtures; TypeScript replays both sets with `npm run test:commit` and `npm run test:mldsa44`. `npm test` is the separate Construction A vector test. Each generator is re-run in CI and compared byte-for-byte with the committed file. The [ERC evidence map](/spec/erc-evidence) separates conformance, relevant Lean results, unproved composition, and optional spending research. In particular, no end-to-end Lean privacy theorem for this concrete commitment/CREATE2 profile is claimed. Read the [security overview](/spec/security-analysis) before deriving security conclusions from a successful roundtrip. ## 6. Account integrations D-025's browser demo proves knowledge of a spending secret and opener with UltraHonk. Its current proof backend is not PQ-sound. C13, blinded ML-DSA, and other account routes are supporting experiments with distinct assumptions. The direct ML-DSA-44 committed-key route (D-027) has a reference verifier in both languages and a local contract path over the vendored ZKNOX ML-DSA-44 verifier; its on-chain key setup relies on a trusted registrar, nothing is deployed, and a spend reveals the key and links that recipient's spent addresses. Construction A's revealed stealth key exposes the recipient's `rho` (D-027). See the [profile record](/research/ml-dsa-commit-profile). The [spending research guide](/research/spending) records those boundaries. They do not require changing the KEM announcement format, but they have their own deployment and network requirements. # ERC evidence — key exchange first :::info[Mirrored page] Source: [`docs/ERC_EVIDENCE.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/ERC_EVIDENCE.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Use this guide to review the D-024 key-exchange proposal without first reading blinded-signature research or building the entire Lean package. The [technical reference](/spec/technical-spec) describes the current implementation; the [security overview](/spec/security-analysis) states the unresolved obligations. The final normative formats remain for the human-written ERC text. ## The review package | Evidence needed for the stated scope | Start here | What it establishes | | --- | --- | --- | | Exact receive/scan behavior and deployment inputs | [Technical reference](/spec/technical-spec), [Python commit module](https://github.com/Skanislav/pq-sap/blob/main/python/pq_stealth/commit.py) | Executable byte-level definition for the current commitment profile | | Reproducibility and independent implementation | [Commitment vectors](https://github.com/Skanislav/pq-sap/blob/main/python/vectors/v0/commit_vectors.json), [Python tests](https://github.com/Skanislav/pq-sap/blob/main/python/tests/test_commit.py), [TS replay](https://github.com/Skanislav/pq-sap/blob/main/js-client/test/commit-vectors.test.ts) | Agreement on deterministic examples and rejection behavior | | Full-announcement security argument | [Security overview](/spec/security-analysis) | Separates KEM confidentiality, recipient anonymity, and the commitment/address composition still to justify | | Portable reviewer checks | [Submission roadmap](/research/erc-submission-gap-analysis) | Planned standalone checker; not implemented by this documentation refactor | | Encoding and generic game support | Lean declarations below | Precisely scoped abstract results, not a verification of application code | A minimal reviewer path is technical reference → commitment vectors → security overview → submission roadmap. Lean supplies additional evidence for named claims. Completing every spending proof or tooling improvement is not a prerequisite for this key-exchange package. ## Relevant Lean results Source files are in [lean/PqStealth](https://github.com/Skanislav/pq-sap/tree/main/lean/PqStealth/). This table is a semantic reading map, **not** an isolated build target. | Modules | Relevant declarations | Limits | | --- | --- | --- | | `Games`, `KEMAnonymity` | [`StealthScheme`](/lean/proofs/games#stealthscheme), [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull), [`perfectlyComplete_ofKEMFull`](/lean/proofs/kemanonymity#perfectlycomplete_ofkemfull), [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) | Generic scheme and auxiliary-data model; concrete commitment instantiation still needed | | `SharedSecretHiding` | [`sharedSecretHiding_eq_indCpaAdvantage`](/lean/proofs/sharedsecrethiding#sharedsecrethiding_eq_indcpaadvantage), [`unlinkAdvantage_ofKEMFull_le_indCpa`](/lean/proofs/sharedsecrethiding#unlinkadvantage_ofkemfull_le_indcpa) | Identifies the hiding terms, not the whole anonymity argument | | `AnonymityFromSPR`, `MLKEM`, `SPRTwoHop` | `KEM.anonAdvantage_le_sprAdv`, [`mlkem768_sprAdv_le_mlwe`](/lean/proofs/sprtwohop#mlkem768_spradv_le_mlwe) | Ciphertext anonymity decomposition with named assumptions and MLWE terms | | `MultiUnlink`, `MultiRecipient` | [`unlinkAdvantageMulti_le_mul`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_mul), [`unlinkAdvantageN_le_mul`](/lean/proofs/multirecipient#unlinkadvantagen_le_mul) | Generic losses for multiple announcements/recipients; requires an applicable single-challenge bound | | `Soundness` | [`falsePositiveRate_ofKEMFull_le`](/lean/proofs/soundness#falsepositiverate_ofkemfull_le), [`soundWithin_ofKEMFull_oneByteTag`](/lean/proofs/soundness#soundwithin_ofkemfull_onebytetag) | Conditional bounds; does not prove arbitrary SHA-256 tag behavior unconditionally | | `Invariants` | [`meta_address_commit_roundtrips_1217`](/lean/proofs/invariants#meta_address_commit_roundtrips_1217) | Abstract encode/decode roundtrip; does not prove commitment hiding, CREATE2 correctness, or runtime code | The current `check_sizes.py` compares Construction A and classical-hybrid vectors, including the documented 1,217/1,218-byte layout difference. It does **not** check `commit_vectors.json`. Commitment conformance currently comes from Python/TS tests, not that checker. ## Separate research and tooling | Track | Modules or artifacts | Relationship to the ERC | | --- | --- | --- | | Construction A address derivation | `Blinding`, Construction A parts of `Invariants`, `ConstructionA`, `BlindingROM`, `BlindingEntropy`, `ConstructionASecurity` | Relevant if format `0x01` is included; not the privacy proof for format `0x02` | | ML-DSA spending security | `Ownership`, `WidenedSigning`, `SpendSecurity`, spending portions of `ConstructionASecurity` | Account/spending research; witness extraction is not completed signature security | | Classical comparison | `DKSAP`, `DKSAPOracle`, `Demo` | Motivation and control experiments; not needed to implement the commitment format | | Shared proof infrastructure | `Reorder`, `ROMUpToBad`, `Controls`, `Axioms` | Supports and audits the development; not normative protocol behavior | | Tooling and upstream work | Lean build pin, citation/size scripts, generated theorem browser, VCVio notes | Maintainer workflow; not reviewer installation requirements | | Account and ZK experiments | ERC-4337/7913/8141 routes, Noir circuits, UI | Feasibility evidence with independent security and network assumptions | | Explicit account profiles | `profiles.py` / `profiles.ts`, [ML-DSA-44 profile record](/research/ml-dsa-commit-profile), `mldsa44_commit_vectors.json` | Receive/scan conformance per profile; optional spend-profile conformance is separate and, for ML-DSA-44, local-only | The [Lean overview](/lean) maps all modules; the [spending guide](/research/spending) maps the account experiments. ## What has not been separated in code `lean/PqStealth.lean` and `Axioms.lean` still import the entire development. `Games.lean` imports `Blinding.lean`; `Invariants.lean` mixes serialization with ML-DSA algebra; `Soundness.lean` imports the classical model. Therefore building a relevant module can still compile unrelated support code. This documentation split does not delete proofs, weaken the axiom audit, change CI, or claim an ERC-only Lean package exists. Maintainers continue to run the full checks for changes to the proof library. Any later module extraction must preserve the audited statements and be reviewed as its own code change. # Key-exchange security — claims and review boundaries :::info[Mirrored page] Source: [`docs/SECURITY_ANALYSIS.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/SECURITY_ANALYSIS.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Current scope after D-024/D-025. The ERC focus is ML-KEM key exchange and sender-computable address derivation. A complete argument must cover the **whole announcement**, including the tag and destination. KEM security alone is not a proof that a derived account address hides its recipient. This is a review map, not a completed reduction. The detailed [Construction A analysis](/research/construction-a-security) applies to format `0x01`; it is supporting research, not the security theorem for format `0x02`. ## What the proposal needs to justify | Property | Available evidence | Remaining boundary | | --- | --- | --- | | Honest sender/scanner agreement | Python commitment tests and independent TS vector replay; abstract KEM completeness results | Instantiate the concrete commitment/deployment flow; KEM correctness assumptions remain explicit | | Shared-secret confidentiality | `SharedSecretHiding.lean` identifies generic hiding terms with KEM IND-CPA advantages | This does not by itself establish anonymity of the recipient key or a quantum-accessible oracle reduction | | Recipient anonymity of ciphertexts | A *proved reduction modulo named assumptions*: `KEMAnonymity.lean` (the game and [`ofKEM`](/lean/proofs/kemanonymity#stealthschemeofkem)), `AnonymityFromSPR.lean` (`anonymity ≤ SPR(b=0) + SPR(b=1)`), `SPRTwoHop.lean` (each SPR term as two named MLWE hops plus residual terms), `MLKEM.lean` (those capstones instantiated at ML-KEM-768). The bound is checked; what grounds it is not. Their own headers record that `anonymity → MLWE` for ML-KEM is **not proved**, here or upstream | The MLWE/primitive advantages are named hypotheses, not bounds, and the ANO-CCA lift through ML-KEM's implicit-rejection FO transform is separate work (Maram–Xagawa is the reference, not a checked import). No checked theorem in this repo says an ML-KEM ciphertext hides its recipient key | | Privacy of tag plus commitment-derived address | Generic [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) announcement model; executable derivation in both languages; [instantiation note](/research/commitment-announcement-privacy) manually maps the commitment [`auxGen`](/lean/proofs/constructiona#auxgen) to the generic four-term chain, IND-CPA identification, and multi-payment/n-recipient result shapes. Its ROM sketch estimates [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) as `2·qH·2⁻²⁵⁶` = `2⁻¹⁷⁵` at `qH = 2⁸⁰`, with `qH` a joint tag/opener/address-oracle budget — no MLWE hop, no lattice point-mass | The concrete `commitKem` / `commitAuxGen` wrapper and the ROM bound are not Lean theorems; the estimate assumes both recipients share the profile and deployment binding, and a classical-ROM model for the public tag. No reduction of the ML-KEM IND-CPA/anonymity terms to MLWE is claimed for this format | | Reliable detection and rejection | Negative vectors; generic conditional soundness results in `Soundness.lean` | A one-byte tag is a filter after decapsulation, not a payment authenticator or a universal false-positive guarantee | | Byte-level interoperability | Deterministic commitment vectors, encoding tests; abstract 1,217-byte roundtrip in `Invariants.lean` | Serialization facts are not cryptographic security or a proof of the Python/TS implementation | | Explicit profile selection | Typed profiles and fail-closed `select_profile` in Python/TS (D-027) | A profile name and its binding are trusted configuration; nothing authenticates a substituted factory, verifier, or registrar | The exact sources and proof-reading order are in [ERC evidence](/spec/erc-evidence). “Needed” here means evidence needed to support our claims; it does not mean ERC reviewers must install Lean or that a formal proof is a process requirement. ## Adversaries and observations The passive observer sees recipient meta-addresses and public announcements. Review must include repeated announcements, the joint view tag/address calculation, publicly known spending-key commitments, and the account profile selected by each recipient. A distinct output in a vector is a correctness sanity check, not evidence of computational unlinkability. A sender knows the shared secret and opener it generated. A viewing-key holder can recover them. Neither must be sufficient to authorize spending: this is an obligation of the selected account model. Account code, verifier selection, message binding, replay controls, and upgrades need separate review. An active attacker can supply malformed announcements or ciphertexts and may observe scanner behavior. Generic passive security results do not establish an active ANO-CCA claim for final FIPS-203 ML-KEM. Timing, resource exhaustion, retry leakage, and scanner/network metadata are not covered by the current formal result. Quantum random-oracle access is also outside that result. The deployment binding is public and supplied separately from the meta-address. Its distribution must prevent substitution and must identify the intended chain/account profile. The ERC-6538 registry does not provide that (its entries are ecrecover-governed and overwritable by the classical key) and is out of scope; the trust in a naming service or an off-chain channel is the application's, not the scheme's. A profile shared by few recipients may itself narrow an observer's candidate set. This requires explicit treatment even when the commitment hash is modeled ideally. ## Spending is a separate assurance track The key-exchange proposal does not require proving widened ML-DSA signing, related-key signature security, or a Noir circuit to describe its receive and scan behavior. Those obligations still matter for anyone using the corresponding spending route. They are not removed by narrowing the ERC's scope. * Construction A has a related-key **ownership-witness** reduction; its signature EUF-CMA layer remains uncomposed and widened HVZK currently has the trivial bound `1`. Concrete `z`-gate content of that gap (research note [widened-z distribution](/research/widened-z-distribution) §4, corrected per review F4 2026-10-01): the honest widened signer aborts a `z`-gate round with probability `1 − 0.383 ≈ 0.617` at ML-DSA-65 ([`mldsa65_widened_z_accept_prob`](/lean/proofs/widenedsigning#mldsa65_widened_z_accept_prob), `WidenedSigning.lean:590`), while the pinned simulator's ring-uniform `z` **aborts with probability ≈ 1** (its gate-pass probability is ≈ 10⁻¹¹⁵⁶). Comparing the two abort masses, the `z`-gate-only transcript-distance contribution is `|0.617 − 1| ≈ 0.383` — not ≈ 0.617; the earlier text subtracted the simulator's success probability from the honest abort mass. Even the 0.383 figure is a `z`-gate-only floor, not a full-scheme bound: the honest prover's other rejection gates (`r₀`, hint) change the honest abort mass, and no nontrivial full-scheme HVZK distance is claimed until the cube-source simulator and its exact transcript experiment land. * The tightened `z` gate is a potential disclosure channel only in the fixed-shift, cube-uniform `z`-gate model. A widened `z` is always `‖z‖∞ < γ₁ − 2β`; a stock `z` lands entirely below that bound with probability `(1047791/1048183)^1280 ≈ 0.620` at ML-DSA-65 (≈ 0.543 at the deployed level-2 profile). A band coefficient rules out a widened `z`-gate response, but band avoidance is only probabilistic evidence: a stock response has the probability above. In that model the test has per-response advantage ≈ 0.38 (0.46 level-2), reaching 0.978 across eight independent responses (0.992 for the level-2 profile, [widened-z table](/research/widened-z-distribution)). It does not establish a full published-signature classifier or recipient/key linkability result: Fiat–Shamir, `r₀`, and hint conditioning are outside the model. An observer therefore cannot use this calculation alone to label an observed signature as a blinded-key spend or narrow candidates to `0x01` recipients (note §3). * Direct C13 spending exposes the recipient key and can link spends. * Direct ML-DSA-44 committed-key spending (D-027) likewise reveals `pk`, whose hash is the published `spend_key`; spent addresses of one recipient are linkable from the first spend. Its local contract path trusts a key registrar for the expanded matrix; it is not a trustless or deployed route. * Construction A's blinded stealth key carries the recipient's `rho` verbatim (`python/tests/test_construction_a_rho.py`). Receive-time announcements reveal only a hashed address, but any full-key disclosure at spend or key-contract deployment identifies the `0x01` recipient. Blinding alone does not guarantee unlinkability after key disclosure. * Composed spend-time reading: the proven announcement bound (`Adv_unlink_q ≤ q · ε_single`, [`unlinkAdvantageMulti_le_mul`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_mul), `MultiUnlink.lean:241`) covers a recipient who only receives and scans. A direct spend breaks that coverage through the key-disclosure channels — `rho` for any revealed Construction A key and the `spend_key` hash for the commit profile. The widened `z`-band calculation is only a z-gate-model discriminator with substantial stock false positives; it does not flag a blinded route in a published signature. Unlinkable spending requires routes that never reveal the key (ZK/preimage). The channel-by-channel map is note §5. * The D-023/D-025 ZK demos use a non-PQ-sound proof backend. The intended witness privacy and ownership statement do not change that assumption. * Prospective aggregation does not automatically supply zero knowledge, repair an underlying proof system, or prove an account's authorization. See [spending research](/research/spending) for implementation evidence and [EIP-8288](/research/eip-8288-summary) for the prospective integration assessment. ## Review priorities 1. Fix the supported profile and deployment-binding distribution in the human-written ERC text; specify every byte-level input. 2. Map the commitment flow into a full-announcement privacy experiment and justify its auxiliary-data independence under explicit hash assumptions. The instantiation is now drafted — [commitment announcement-privacy note](/research/commitment-announcement-privacy) — and the open residue is a checked concrete `commitKem` / `commitAuxGen` wrapper plus a `CommitmentAnonymity.lean` module discharging the [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) ROM bound (sketch `2·qH·2⁻²⁵⁶` there). The deployed tag's classical-ROM uniformity and the profile/common-binding condition must also be stated as assumptions/properties in the human-written ERC text. 3. State the classical/quantum and passive/active models precisely, including what comes from ML-KEM literature versus a checked theorem here. 4. Publish partial-checker coverage honestly and retain full KEM vector replay. 5. Review each spend integration separately before making end-to-end claims. # ERC submission gap analysis — what ethereum/ERCs requires, what we have, what we will contribute :::info[Mirrored page] Source: [`docs/research/erc-submission-gap-analysis.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/erc-submission-gap-analysis.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: ## Current scope update — 2026-09-14 [D-024/D-025](/spec/decisions) supersede this report's original assumption that Construction A alone defines the submission. The focus is key exchange and address derivation, with spending delegated to the account model. Format `0x02` is implemented; whether it is the only normative format is still for the human-written ERC text to settle. Agents must not generate or restore `docs/erc-draft.md` under D-024. The sections below preserve the **2026-08-07 historical assessment**, including its then-current draft, vector inventory, process observations, and estimates. They are not a current draft audit. Revalidate upstream submission requirements and numbering when preparing the package. ### Updated package work | Deliverable | Current basis | Work remaining | | --- | --- | --- | | Commitment-format vectors | `python/vectors/v0/commit_vectors.json`, Python `commit.py`, TS `commit-scheme.ts` | Select and document the profile coverage for the human-written submission | | Construction A vectors | `python/vectors/v0/vectors.json` | Include if that address form is in scope; retain its distinct security boundaries | | Dependency-free checker | Historical design in §5 | Extend the plan to commitment derivation and deployment binding; state partial coverage explicitly | | Security overview | Construction A analysis plus D-018/D-023/D-025 route limitations | Present a route-by-route claims table; account proofs need their own review | | Prospective spending | EIP-8141 experiments; EIP-8288 assessment | Keep protocol-dependent experiments separate from key-exchange conformance | | ERC text and discussion | Human-written text under D-024 | Resolve normative formats, metadata, discussion link, and asset references with the author | | Upstream packaging and Lean placement | [Composition note](/research/erc-lean-composition): measured eipw/lint gates, ERC-8441 as the model, the three admissible Lean routes | Decide evidence route, CC0 scope, and the Magicians thread with the author | ### Checker acceptance criteria The commitment checker should recompute meta-address encoding and lengths, `SHA-256(ss)` view tags, profile-specific opener and commitment hashes, and CREATE2 addresses from explicit deployment inputs. Pin the domain strings, factory, creation code, constructor arguments, and salt; do not silently rename the C13 domains or substitute the preimage profile. Python is authoritative. Use deterministic fixture intermediates in a separately versioned export if needed. Keep the committed v0 vectors unchanged. Any fixture secrets must be public test values. SHA-256/SHAKE256 are in Python's stdlib; Ethereum Keccak-256 is distinct from `hashlib.sha3_256` and needs a reviewed, permissively licensed implementation bundled with the checker and checked against known answers. Report negative cases by what can actually be checked: malformed encodings, wrong view tags, altered commitments, and changed deployment bindings can be recomputed from supplied inputs. Wrong-recipient and corrupted-ciphertext outcomes require real KEM decapsulation; supplied shared secrets do not verify that relationship. Construction A's lattice algebra likewise remains the job of the reference implementations. The checker must distinguish structural rejection, derivation mismatch, and cases requiring the full implementation. Acceptance: a clean Python environment can check the exported assets; full Python/TS replay agrees; every case documents its coverage; the human-written spec links the chosen asset profile. This update plans that work and does not claim the checker is implemented. See the [EIP-8288 summary](/research/eip-8288-summary) for the prospective spending integration. Its estimated charges are not submission conformance requirements. *** ## Historical assessment — 2026-08-07 *2026-08-07. Model: [ethereum/ERCs PR #1932](https://github.com/ethereum/ERCs/pull/1932) (ERC-8373, "Post-Quantum Anchored Key-Binding"), a current PQ-topic submission with all 9 CI checks green. Sources: PR metadata and file list via `gh`, raw `erc-8373.md` from the PR branch, ethereum/ERCs repo root listing, README, `erc-template.md`, the rendered EIP-5564 page, and `gh pr checks 1932`.* **Settled decisions (this session):** deliverable for now is this report only; the future in-assets checker takes the vectors-with-intermediates + stdlib-Python approach; the scheme ID we declare is **`2`** (monotonic, per ERC-5564's own recommendation). *** ## 1. The model: anatomy of PR #1932 ERC-8373 migrates on-chain/agent identity signatures to PQ via an anchored classical→PQ key binding plus a consumer-enforced cutoff. Its *shape* is what matters to us: **Spec file** — `ERCS/erc-8373.md`, 138 lines. Complete preamble: assigned number, ≤44-char title, one-sentence description, four authors all with GitHub handles, `discussions-to` pointing at a live Ethereum Magicians thread, `status: Draft`, `type: Standards Track`, `category: ERC`, `created` date. Sections in template order: Abstract, Motivation, Specification (RFC 2119/8174 boilerplate, definitions table, normative field tables), Rationale, Backwards Compatibility, Test Cases, Reference Implementation, Security Considerations, Copyright (CC0). Cross-references are relative only (`[ERC-8004](https://eips.ethereum.org/EIPS/eip-8004)`, `[EIP-712](https://eips.ethereum.org/EIPS/eip-712)`, `../LICENSE.md`). External standards (FIPS 204/205, RFC 8785) are cited by name in prose, never linked. **Assets** — `assets/erc-8373/`, 11 files, ~1,300 added lines total: | File | Lines | Role | |---|---|---| | `pq-key-binding-v0.spec.md` | 146 | byte-level companion profile (canonicalization, content-address rules) | | `pq-key-binding-v0.vectors.json` | 106 | binding-statement vectors — two independent implementations, two NIST families (ML-DSA-65, SLH-DSA-SHA2-192s), byte-compatible content-addresses | | `pq-key-binding-v0.cutoff-vectors.json` | 62 | admit / reject / unverifiable cases | | `pq-key-binding-v0.rotation{,-vectors}.json` | 30+48 | rotation chain + vectors | | `pq-key-binding-v0.revocation{,-vectors}.json` | 29+45 | revocation record + vectors | | `pq-key-binding-v0.per-agent-anchor.json` | 38 | anchor examples | | `recovery-classes-vectors.json` | 594 | recovery-class suite | | `cutoff_enforce.py` | 109 | **stdlib-Python executable checker** for the cutoff rule | | `recovery_check.py` | 119 | **stdlib-Python executable checker** for recovery classes | Notable properties we should mirror: * **Vectors are grouped by behavior class**, each stating public inputs and the expected verdict; "conformance is exact reproduction". * **Checkers are dependency-free** (stdlib Python) — reviewers run them with nothing installed. 8373 gets this cheaply because its crypto is just SHA-256 over canonical JSON; ours needs a deliberate design (§5). * **Two independent implementations converging byte-for-byte** is presented as the credibility anchor. We already have exactly this: the TypeScript client reproduces the Python vectors byte-identically. * The PR body itself is short: what it is, the one central design decision, discussion link, implementations, authorship confirmation. ## 2. What the ethereum/ERCs repo requires * **Process order:** ideas MUST be discussed on Ethereum Magicians (or Ethereum Research) *before* the PR; `discussions-to` must point at that thread. EIP-1 governs the format; `erc-template.md` in the repo root is the skeleton. * **Layout:** `ERCS/erc-.md`; supporting material in `assets/erc-/`, referenced from the spec as `../assets/erc-/`. * **Numbering:** no formal registry or reservation step; current practice is that the author picks the next unused number (8373 sits in the live ~83xx range — consistent with EIP-8304 in our WG). Editors may reassign; the ERC number ≠ PR number (8373 came in PR 1932). * **CI gauntlet** (all must pass): **EIP Walidator** (eipw — preamble fields, title/description limits, section order), **HTMLProofer** and **Link Check** (every link must resolve; external links are effectively prohibited — relative links to other EIPs/ERCs and to assets only), **Markdown Linter**, **CodeSpell**, **GitGuardian** (no secrets). Plus label/PR-number bookkeeping actions. * **Template constraints:** title ≤ 44 characters and no standard numbers in it; description one full sentence; authors as `Name (@github-handle)` or `Name ` (at least one handle-bearing author is expected for editor pings); `requires` listing every EIP referenced in the Specification; Copyright = CC0 via `../LICENSE.md`. * **License:** everything (spec + assets) is CC0 — publishing the vectors and checker waives rights. ## 3. Fix list for our current draft (`docs/erc-draft.md`) The draft is structurally sound (correct section order, RFC 2119 boilerplate, relative `./eip-5564.md`-style links, CC0 footer, `requires: 5564, 6538`). What fails the gauntlet today: 1. **Title** — "Post-Quantum Stealth Addresses (ML-KEM scheme for ERC-5564)" is 60 chars **and** contains a standard number; both are walidator violations. Fix: `title: Post-Quantum Stealth Addresses` (31 chars); the ML-KEM/5564 detail already lives in the description. 2. **Author** — `Skas Merkushin ` is legal but handle-less; should become `Skas Merkushin (@)` (handle TBD — user to provide). 3. **`discussions-to: TBD`** — hard blocker; the magicians thread must exist first (user action, §6). 4. **`eip: TBD`** — pick the next unused number at PR time (check the ERCS/ dir and open PRs the same day). 5. **Test Cases section** — currently "published with the reference implementation"; must instead reference `../assets/erc-/` files. External-repo pointers belong in the magicians thread, not the spec. 6. **Reference Implementation section** — same: describe what exists, link only assets. The Python/TS/Lean repo gets named in prose at most, linked from the discussion thread. 7. **Scheme ID** — declare the mapping **`schemeId = 2` → this ERC**. ERC-5564 has no registry mechanism; it requires exactly that "a mapping from the schemeId to its specification MUST be declared in the ERC" and recommends monotonically incrementing IDs (SECP256K1 = 1 is the only assigned ID). The reference implementation and vectors must be regenerated with `2` once adopted (vectors currently bake the placeholder only insofar as docs mention it — the vectors JSON itself carries no scheme ID field; verify at package time). ## 4. Gap table: what we have vs. what the package needs | Package piece (8373 analogue) | Ours today | Status | |---|---|---| | `ERCS/erc-.md` | `docs/erc-draft.md`, 160 lines | ✓ needs §3 fixes | | Magicians thread (`discussions-to`) | none | ✗ **user action** | | Assigned number | `TBD` | ✗ pick at PR time | | Conformance vectors in assets | `python/vectors/v0/vectors.json` — 7 cases (2 positive incl. unlinkability of a second payment, 4 negative: wrong view tag / wrong recipient / truncated ct / bit-flipped ct, 1 possession proof), 61 KB, deterministic, byte-identical regeneration | ✓ copy in; size comparable to 8373's suite | | Independent-implementation convergence | TS client replays vectors byte-for-byte (its own credibility line for the PR body) | ✓ exists | | Stdlib executable checker(s) | none — our pytest suite needs kyber-py/dilithium-py | ✗ **the main build item** (§5) | | Vector intermediates enabling a stdlib checker | absent — cases expose only announcement-level data (no shared secret, no derivation seeds, `stealth_pk` on one case only) | ✗ additive vectors change | | Byte-level companion spec in assets | material exists in private `docs/TECHNICAL_SPEC.md` | optional; publication decision (§6) | | PR body | — | write at PR time; 8373's is the template | ## 5. The planned contribution (settled design, future work) * **`ERCS/erc-.md`** — the draft with §3 fixes applied. * **`assets/erc-/vectors.json`** — the v0 vectors extended **additively** with an `intermediates` object per case: the encapsulated shared secret `ss`, the SHAKE256-derived `(s', e')` seed material, and `stealth_pk` for every positive case. Change lands in `python/vectors/generate_vectors.py`; determinism is preserved (additive fields, same seeds), but the file is no longer byte-identical to the committed v0 — bump the schema tag (`v0` → `v1` or `v0+intermediates`) and keep the TS replay green. * **`assets/erc-/check_vectors.py`** — dependency-free checker (stdlib + a ~60-line vendored pure-Python keccak-256, which is not in `hashlib`): verifies sizes and encodings (meta-address version byte and field lengths, announcement layout), view tags = SHA-256(ss)\[0:1], the SHAKE256 derivation chain from `ss` (`hashlib.shake_256` is stdlib), stealth address = keccak256(stealth\_pk)\[12:32], and the expected verdicts on all negative cases that are checkable without decapsulation. **Explicitly out of scope:** the lattice algebra (ML-KEM decaps, the blinding `t' = A·s' + e' + t`) — stated in the checker's docstring as covered by the two reference implementations. This mirrors 8373's honesty about what recomputation covers. * **Optional companion spec** — a byte-level packing profile (pack23 bit order, blinded-sk persistence format, M′ formatting) extracted from TECHNICAL\_SPEC.md, if cleared for publication. ## 6. Prerequisites that are user actions 1. **Open the Ethereum Magicians thread** (the 8373 pattern: title stating the mechanism, first post ≈ the Abstract + Motivation + link to vectors). Must precede the PR; its URL becomes `discussions-to`. 2. **GitHub handle** for the author line. 3. **Publication clearance:** everything entering `assets/` becomes public CC0 — decide whether the companion spec (TECHNICAL\_SPEC extract) ships in v1 of the PR or later. The vectors and checker themselves contain only test-seed-derived data (seeds are `0xa1a1…`-style constants) and are safe to publish. ## 7. Effort estimation | Item | Estimate | |---|---| | Draft fixes (§3) | ~½ day | | Vectors intermediates + regeneration + TS replay re-verified | ~½ day | | Stdlib checker incl. vendored keccak + negative-case handling | ~1 day | | Companion spec extraction (if cleared) | ~½–1 day | | Magicians thread draft, number pick, fork/branch/PR mechanics, local eipw run | ~½ day | | **Total** | **≈ 3–4 working days**, dominated by the checker | Local pre-flight before the PR: run eipw against the adapted markdown (walidator is the pickiest gate), run `check_vectors.py` clean, and click-check every relative link. # Project plan — key-exchange ERC and supporting research :::info[Mirrored page] Source: [`plan.md`](https://github.com/Skanislav/pq-sap/blob/main/plan.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Current scope follows D-024/D-025. Deliver a reviewable proposal for ML-KEM key exchange and sender-computable stealth destinations using existing ERC-5564 announcements; the ERC-6538 registry is out of scope (D-014 scope update, 2026-09-28). The original cohort plan is preserved as [historical context](/research/original-project-plan). ## Core deliverable 1. Describe the supported meta-address profile, KEM announcement, tag, commitment derivation, and deployment binding in human-written ERC text. Format `0x02` is implemented; whether `0x01` is also normative remains open. 2. Package deterministic vectors and Python/TS reference behavior. Provide a small reviewer checker with explicit coverage limits. 3. Present the full-announcement security argument, separating existing generic KEM results from the commitment/address composition still needing review. 4. Publish reproducible receive/scan and data-size measurements with the exact profile, hardware, and environment — assembled in the [cost report](/research/cost-report), which names each figure's provenance and separates setup, recurring, whole-transaction, and client-side costs. 5. Obtain community review before freezing the text and preparing a submission. ## Current evidence and next work | Area | Current state | Next deliverable | | --- | --- | --- | | Key exchange and address derivation | Python `commit.py`, TS `commit-scheme.ts`, deterministic commitment vectors | Agreed profile/deployment binding in the human-written specification | | Portable conformance | Reference tests and cross-language replay | Standalone checker and self-contained assets | | Security | Generic KEM/announcement Lean results; Construction A analysis | Concrete commitment-profile privacy argument and explicit assumptions | | Deployment feasibility | Account routes, browser preimage demo, explicit account profiles with an ML-DSA-44 committed-key reference and local contract path (D-027) | Separate route review; trustless ML-DSA key setup and any ML-DSA-65 verifier remain open; not a prerequisite to describe KEM conformance | | Costs and benchmarks | [Cost report](/research/cost-report): measured 67,580-gas L1 announcement, L2 blob regime, meta-address registration, per-route spend gas, scan throughput, each with provenance and dated price anchors | Keep the report in step with the frozen profile and the parameter-level decision (#30/#31), which moves the spend rows only | | Documentation | Key-exchange review path, research and tooling separated | Keep public claims aligned with actual evidence | Start with the [technical reference](/spec/technical-spec), [ERC evidence map](/spec/erc-evidence), and [submission roadmap](/research/erc-submission-gap-analysis). ## Supporting tracks Construction A's blinded-key derivation, widened ML-DSA signing, ownership/SIS proofs, Noir circuits, alternative account routes, and EIP-8288 integration are [supporting research](/research/spending). They remain available and audited under the current build policy, but are not the key-exchange ERC's required reading or a promise of completed spending security. [Lean tooling](/lean/tooling), upstream contributions, and theorem-browser work serve maintainers. Completing those backlogs is not the ERC critical path. The commitment flow still needs its own security justification; narrowing scope does not allow skipping that obligation. The ERC draft remains human-written. Local changes are reviewed before any commit, push, or PR, under the repository house rule. # Decisions & Findings (ADR-style log) :::info[Mirrored page] Source: [`docs/DECISIONS.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/DECISIONS.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Reading guide (2026-09-14): D-024/D-025 define the current key-exchange focus. Earlier entries preserve dated research decisions and measurements. The [current technical reference](/spec/technical-spec) and [ERC evidence](/spec/erc-evidence) separate those from spending/tooling work. Detailed former technical sections are retained in [Construction A](/research/construction-a). The cohort's end-of-work revision of the proposal, and the closure of its spend stretch goal, are D-034. Locked decisions and empirical findings for the post-quantum ERC-5564 stealth address project. Each entry is dated and states the rationale so the reasoning survives, not just the conclusion. Newest context wins; superseded entries are marked. Status legend: **LOCKED** (decided) · **FINDING** (measured/verified fact) · **OPEN** (owed to a later phase). *** ## D-001 — Key exchange is post-quantum; spending is configurable — **LOCKED** The KEM (detection/viewing) **must** be post-quantum (ML-KEM); the spend authorization is a configurable security level, not necessarily PQ. **Why.** The two quantum threats are not equally urgent. Harvest-now-decrypt-later (HNDL) attacks *confidentiality*: announcements are public and permanent, so a classical key exchange can be broken retroactively to link every past payment to its recipient. Spend authentication is a *live* threat — forging a signature needs a quantum computer to exist *now*, and only touches funds still parked. Ethereum's own roadmap states this directly: "in blockchains, this risk mainly affects confidentiality, not ownership… recording transactions today does not enable retroactive theft" (pq.ethereum.org, 2026-06-27), and lists "privacy-preserving transaction systems" among the HNDL-sensitive categories — which is exactly where stealth addresses sit. So ML-KEM detection is the urgent, non-negotiable core; spend-side PQ is future-proofing. ## D-002 — Alignment with Ethereum's PQ roadmap — **FINDING** pq.ethereum.org and ethereum.org/roadmap/security/quantum-resistance (April 2026) designate **account abstraction** as the mechanism for the PQ signature transition ("transition to quantum-safe authentication through account abstraction, without a disruptive flag day"), with post-quantum signature schemes expected to become available following the Hegotá hard fork and EIP-8141. Our ERC-4337 stealth-account spend path uses the same mechanism — the project maps onto the official roadmap rather than beside it. The roadmap's execution milestone **J\*** is a **vector-math precompile** that will make on-chain lattice signature verification cheap; the target is core PQ infrastructure by approximately 2029, with full execution-layer migration extending beyond that. Application-layer ZK-proof migration is **not** covered by the roadmap — the ZK direction here (D-008) is genuinely novel territory. **Watch item (2026-08-26):** the ACDE call on 2026-08-27 is expected to decide between EIP-8141 and EIP-8130 for Hegotá account abstraction. Update this entry and `plan.md` once the call minutes land. ## D-003 — Construction A (blinded ML-DSA) is the ownership mechanism — **LOCKED** "Prove ownership from the main account" and "derive a per-address key" are the same thing: the blinded ML-DSA signature is a proof of ownership derived from the master key (producible only from `master_secret + shared_secret`), and blinding keeps the per-address key statistically independent, so it doesn't link. You cannot spend from your public EOA (links every stealth account) and you cannot skip derivation (that *is* the unlinkability). What's optional is whether the derived key is PQ (D-001). Default fresh error term `e'` per stealth key (closes the pq-sap v2 reused-`s2` linkability vector). ## D-004 — Gas sponsoring via paymaster + lazy initCode — **LOCKED (design)** The live demo pre-funded the stealth account with ETH so it could pay its own gas — a UX burden and a linkability leak (the funding tx is visible). Fix: an ERC-4337 **paymaster** pays gas so the stealth account holds zero ETH, and `initCode` lets the account **deploy-and-spend in one sponsored UserOp** submitted by a **bundler**. Then the chain sees only: a bundler submitted a UserOp, a paymaster paid, a valid ML-DSA signature authorized it — no funding tx, no main-EOA footprint. Caveat: the paymaster must be neutral (third-party or indirectly funded), else it re-links. ## D-005 — ZKNOX account is limited to ONE large PQ key (EIP-3860) — **FINDING** The ZKNOX ERC-4337 account bakes both public keys into constructor initcode. Measured: account creationCode 4,705 B + two 22.4 kB ML-DSA keys as args = **49,729 B initcode, over the EIP-3860 cap of 49,152 B by 577 B** → `CREATE2` reverts. The hybrid (ECDSA + one ML-DSA) is 27,361 B and deploys fine. Consequence: a **dual-PQ** ZKNOX account (two ML-DSA keys, no ECDSA) is **undeployable** on any post-Shanghai chain. The second slot must be a small key. Genuine PQ-only via ZKNOX would need a compact second PQ scheme (Falcon-512 pk ≈ 897 B) or an account that sets keys post-deploy. ## D-006 — ZKNOX verifier is hardcoded to level-2 Dilithium2 — **FINDING** `ZKNOX_dilithium.verify` slices signatures at fixed Dilithium2 offsets: `cTilde=32, z=2304, h=84` → 2,420-byte sig, 1,312-byte pk. Our scheme's default **ML-DSA-65** (level 3) is `cTilde=48, z=3200, h=61` → 3,309-byte sig, 1,952-byte pk, which does **not** fit those slices — the deployed verifier would reject it. The live Sepolia spend therefore used a purpose-built **level-2 Dilithium2** blinded key (~128-bit PQ, round-3 non-FIPS). Spending our default ML-DSA-65 key on-chain needs a deployed ML-DSA-65 verifier, which does not exist on Sepolia. This is why the spec stays parameter-agile; the blinding algebra itself is level-agnostic. ## D-007 — On-chain: cheap OR post-quantum, not both (today) — **FINDING** Every cheap EVM verification primitive (BN254 pairing precompiles) is classical. Measured/cited costs: PlonK/Groth16 verify \<300k gas but **not** PQ; STARK (PQ-sound) \~5M gas; direct ML-DSA verify ~8M gas. Groth16 is the far-cheap, far-classical corner — Vitalik's roadmap explicitly lists "application-layer ZK proofs (KZG or groth16)" as quantum-vulnerable. **Wrapping a STARK in a SNARK for cheap on-chain verification destroys its post-quantum soundness** (a16z: the wrap "destroys transparency and post-quantum security"), because the on-chain verifier only checks the classical outer proof. So a PQ-sound *statement* proven with a classical proof system is only classically sound — the proof system, not the statement, sets the security. D-001's "proof system is a detail of implementation" holds for *modularity*, never for *security*. ## D-008 — ZK ownership proof: decode-in-reverse relation, pluggable backend — **LOCKED (direction)** Prove control of a stealth address by proving the MLWE relation "I know short `(s, e)` with `A·s + e = t (mod q)`" bound to the userOpHash — **not** a full ML-DSA signature. This is the spec's own parsing trick (subtract with the secret, the small error falls out) as a ZK statement. It skips SHAKE/rejection/hints and is dramatically cheaper in-circuit. Binding the message makes it a lattice signature-of-knowledge (Dilithium- *like*), so it needs its own security analysis — it does not inherit FIPS-204's. **PoC built** (`noir/`, UltraHonk): proves + verifies end to end; **2,168,651 gates** at full ML-DSA-65 (schoolbook), projecting to **~100–200k gates** with NTT-domain convolution. Confirms the ownership statement is practical. But per D-007 the UltraHonk backend is classical, so this PoC delivers Direction A (gas/privacy), not PQ soundness. **Architecture:** define the relation, treat the proof backend as per-user config, and surface each backend's concrete security/cost level. Genuine PQ soundness = a FRI/STARK or lattice-SNARK backend (Noir's only PQ path today is Noirky2/Plonky2 — a fork, u32-limited, no EVM verifier). The elegant end state is "lattice all the way down": MLWE statement + a lattice ZK proof (LNP/LaBRADOR), PQ-sound and native — blocked only by the absence of an on-chain EVM verifier. ## D-009 — Two scheme variants, stated with threat models — **LOCKED** The spec ships two variants, not one, each honest about what it protects: | Variant | Detection | Spend auth | On-chain cost | Protects | |---|---|---|---|---| | PQ-detect / classical-spend (pq-sap v1) | ML-KEM | secp256k1 EOA | cheap, today | privacy (HNDL) | | Full PQ (construction A) | ML-KEM | blinded ML-DSA + 4337 | ~6M deploy + ~8M verify | privacy + long-term funds | | ~~(Direction A) PQ-detect / ZK-spend~~ — **retired**, see below | ML-KEM | ZK ownership proof | ~300k verify (classical) | privacy; funds until QC | Given D-001, the **PQ-detect core is the urgent, differentiated deliverable**; the spend-side variants are future-proofing that ride the account-abstraction rails the EF is building (D-002). *Status (2026-10-29).* The third row is **retired**, not a live option: after D-024 the address form is the commitment meta-address and the spend proof is whatever the account bound to the commitment accepts, and D-025's browser preimage proof is explicitly not PQ-sound. The C13 commit route (D-018) and the ML-DSA-44 committed-key route (D-027) replaced it; `docs/SPENDING_RESEARCH.md` is the current map. D-024's format `0x02` also supersedes this entry's "two variants" framing on the spend axis. ## D-010 — Three algebra invariants machine-checked (Lean items 1–3) — **FINDING** `lean/PqStealth/Invariants.lean`, sorry-free, `lake build` green (2,717 jobs): * **Widened-bound (item 1):** [`cInfNorm_add_le`](/lean/proofs/invariants#cinfnorm_add_le) — centered infinity norm is sub-additive (via mathlib `natAbs_valMinAbs_add_le`); hence [`blinded_norm_bound`](/lean/proofs/invariants#blinded_norm_bound) (`s₁+s'` is 2η-bounded) and [`beta_blinded_eq_two_beta`](/lean/proofs/invariants#beta_blinded_eq_two_beta) (β' = τ·2η = 2·β against VCVio's `beta`). Machine-checks the D-006-adjacent spec finding. * **Ownership↔signing (item 2):** [`ownership_iff_signing`](/lean/proofs/invariants#ownership_iff_signing) — the MLWE relation the Noir circuit (D-008) proves is definitionally signing-key possession; [`blinded_is_ownership_witness`](/lean/proofs/invariants#blinded_is_ownership_witness) derives the witness from the correctness identity. Formally ties the ZK PoC to the scheme. * **Encoding (item 3):** [`stealth_pk_roundtrips`](/lean/proofs/invariants#stealth_pk_roundtrips) (via VCVio's encoding `Laws`) and [`meta_address_roundtrips`](/lean/proofs/invariants#meta_address_roundtrips) (the wrapper roundtrips whenever inner packing does). ## D-012 — Discovery-only scope with ZK spend decouples the KEM; lattice wins discovery on decaps — **FINDING / stretch** Reframe (2026-07-29): the scheme's core is **discovery/detection**; spending is a **ZK ownership proof** (D-008), not a blinded ML-DSA signature. Two consequences, the second measured in `benchmarks/discovery_kem_bench.py`: * **The KEM is decoupled from spend.** Construction A's key-homomorphism requirement (D-003) is what locked us to lattices; with ZK-spend it's gone, so discovery can use any PQ KEM/NIKE, chosen on scan speed + footprint + assumption. The ownership statement need not be MLWE either — a hash-preimage in a FRI/STARK is PQ-sound from hashes alone (D-007), so the scheme *can* be lattice-free end to end if desired. * **ZK-spend shrinks the meta-address by itself.** It sheds the 4,416-B full-precision ML-DSA `t`; meta-address becomes `version + 32-B commitment + KEM pk`. ML-KEM-768: **5,633 B → 1,217 B (4.6×)**, before touching the KEM. Softens the D-011 ~61× meta-address registration finding substantially. * **Lattice still wins *discovery*, on decaps.** A scanner decaps every announcement, so decaps latency is the scan bottleneck. Measured spread \~1,570×: ML-KEM-512 0.011 ms → Classic-McEliece 17.9 ms; every code decoder (HQC 2.5, BIKE 5.2, McEliece 17.9 ms) is a scanning tax, and even NTRU (sntrup761 0.147 ms) is ~8× slower than Module-LWE. So the lattice choice for discovery holds up on performance, independent of standardization. * **Footprint outliers.** Classic-McEliece inverts it (261 kB pk kills the meta-address, but 96-B ct → cheapest announcement 28k gas). CSIDH (isogeny NIKE, not in liboqs; ~64-B keys, ~80 ms/op, contested params) would give a 97-B meta-address — the one real footprint alternative, at a speed/assumption cost. Practical shortlist: **ML-KEM (default) or CSIDH (footprint play)**. * **Matched-level sweep update (2026-08-01, full design space at L1+L3):** **NTRU is the one genuinely competitive alternative** — the earlier sntrup761-only row (8× slower) understated it. At matched L3, NTRU-HPS-2048-677 scans 80k in 4.2 s (3× ML-KEM, usable) with a *smaller* footprint than ML-KEM (930-B pk, 61,680-gas announcement, ~9% cheaper); NTRU-HRSS-701 is equivalent. Code KEMs are disqualifying at L3 (HQC-3 9.5 min, BIKE-L3 21.5 min, McEliece-460896 58 min per 80k; spread ~3,770×). Shortlist becomes: **ML-KEM (default), NTRU (credible hedge with real numbers), CSIDH (footprint-only, low-volume recipients)**. Frodo at L3 is a 15.7 kB meta-address and 60 s scans — the conservative-assumption tax, quantified. Status (2026-08-26): parked as **future work**. The exact ZK-spend address-binding that lets the meta-address drop the `t` is unproven, so the compact `version ‖ commitment ‖ ek` format is not normative for this ERC. The Lean roundtrip for that format stays in `Invariants.lean` as a documented variant. ## D-013 — Security games machine-checked in VCVio; the reduction skeleton is complete — **FINDING** The security analysis's game layer is now formalized in Lean on VCVio (2026-07-31, five new modules in `lean/PqStealth/`, all sorry-free, full `lake build` green at 2,762 jobs, every theorem depending only on the three standard axioms). This extends the plan's "Lean = algebra only" scope to *game definitions + structural reductions*; the computational assumptions at the bottom remain paper-level, exactly as scoped. **The proved chain.** With the announcement modelled faithfully as `(ciphertext, aux(sharedSecret))` — view tag and stealth address included: ``` unlinkability ≤ ssHidingTrue + (sprTrue + sprFalse) + ssHidingFalse [Lean] ssHiding = KEM IND-CPA (real-or-random) → MLWE [Lean bridge + VCVio] SPR ≤ epsilonPrim + 3 seeded-MLWE advantages + epsilonEnc (SPRTwoHop.lean) spend ≤ relatedSpendAdvantage_le_mul (SpendSecurity.lean) spend forgery ≤ MSIS (honest witness always valid) [Lean] all of it instantiated on VCVio's concrete ML-KEM [Lean] ``` Key findings along the way: * **Unlinkability is KEM *anonymity* (key privacy), not IND-CCA** — the hidden bit picks the recipient, not the message. VCVio (and FIPS 203's own proof chain) stops at IND-CCA; anonymity is absent. That gap is the novel piece. * **VCVio already ships the assumption layer definitions**: `LearningWithErrors` (LWE/MLWE, decision + search), `ShortIntegerSolution` (MSIS + SelfTargetMSIS), and a `K-PKE IND-CPA ≤ MLWE` lemma. The latter is a `sorry` placeholder at the pinned VCVio commit — see `lean/README.md` and `lean/PqStealth/MLKEM.lean`. No new MLWE formalization was needed for our reductions to land on VCVio's definitions. (Their `ind_cca_security` is also a self-flagged placeholder; we do not lean on it.) * **The tag/address caveat is a theorem, not a hope**: the auxiliary data hides the recipient *exactly insofar as* the shared secret is pseudorandom ([`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le), proved via triangle inequality over real-vs-random-key intermediate games). * **The open arrow is structured**: `anonymity ≤ SPR(b=1) + SPR(b=0)` is proved (GMP/Maram–Xagawa route); what remains is `SPR(K-PKE) ≤ 2·MLWE` — key hop (`t = A·s+e` → uniform) then ciphertext hop (`(u,v)` is an MLWE sample over `[A|t]`) — plus the ANO-CCA lift through the implicit-rejection FO transform. Both are documented in `AnonymityFromSPR.lean`. Module map (`lean/PqStealth/`): `Games` (StealthScheme + unlinkability game), `KEMAnonymity` (anonymity game, [`ofKEM`](/lean/proofs/kemanonymity#stealthschemeofkem)/[`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull), the bound), `SharedSecretHiding` (hiding = real-or-random bias, typed IND-CPA reduction adversaries), `MLKEMInstance` (bridge to VCVio's concrete ML-KEM), `AnonymityFromSPR` (SPR games + full decomposition capstone), `Ownership` (spend forgery as VCVio `SIS.Problem`, honest-witness validity). **Update (2026-08-26).** The game layer expanded from the original five modules to 18 content modules plus the root and `Axioms.lean`; `lake build` green and sorry-free. That round added `MultiUnlink`, `MultiRecipient`, `KEMAnonymity`, `ConstructionA`, `BlindingROM`, `ROMUpToBad`, `SharedSecretHiding`, `AnonymityFromSPR`, `MLKEM`, `SPRTwoHop`, `Ownership`, `Soundness`, `DKSAPOracle`, `Controls` and `Demo`, and the renamed theorems. A `sorry` under any headline theorem's dependency cone turns the axiom guard into a compile error. A full module map and the iteration queue live in `docs/lean-iterations.md`. **Update (2026-08-27).** Added `Soundness.UniformCoordinate` with `card_fiber_eval'` and [`probOutput_map_get_uniformSample_vector`](/lean/proofs/soundness#proboutput_map_get_uniformsample_vector): a coordinate projection of a uniform vector is uniform, giving a generic `1/|α|` view-tag fallback when the tag is a direct function of a vector-shaped shared secret. `Axioms.lean` froze **118** `#print axioms` blocks at that point. Full `lake build` green and sorry-free. **Update (2026-09-01).** Closed the four remaining Construction A gaps: widened-signature leakage (`WidenedSigning.lean`, acceptance probability `(1047791/1048576)^1280` for ML-DSA-65); ROM bad-query bound for the address hash (`BlindingROM.lean` + `BlindingEntropy.lean`, [`blindBadProb_le_queryBound`](/lean/proofs/blindingentropy#blindbadprob_le_querybound)); SPR-to-MLWE/ANO-CCA (`SPRTwoHop.lean`, [`sprAdv_le_mlwe`](/lean/proofs/sprtwohop#spradv_le_mlwe) with three named seeded-MLWE advantages plus `epsilonPrim`/`epsilonEnc`, and ANO-CCA kept as a separate literature note); and related-key spend extraction (`SpendSecurity.lean`, `ConstructionASecurity.lean`). Added D-021 selecting Construction A and rejecting Construction B for the ERC-5564 sender-address flow. The headline theorems temporarily carried `sorryAx` for the proof obligations scheduled in `docs/lean-iterations.md`. **Update (2026-09-02).** All of those obligations are discharged; `lean/PqStealth/` is sorry-free again and every new theorem is guarded. Along the way several of the frozen statements turned out to be false or vacuous and were corrected rather than proven: the widened-signing mask was ring-uniform (now a FIPS 204 cube sampler), the widened key relation lacked the public-key identity and the `r₀` gate was too narrow for a `2η`-short `s₂`, the key-restoration game identities were swapped, the related-key spend game was a unit stub with advantage `0` (now a real search-witness game with a proven `q`-fold offset-subtraction reduction), the ROM bad-query bound had no query bound on the adversary, and the ML-DSA point-mass bound was unprovable for an opaque [`Prims`](/lean/proofs/constructiona#prims). See `docs/construction-a-security.md` for the current statements and their hypotheses. **Current guard count (2026-10-29).** `Axioms.lean` freezes **154** `#guard_msgs … in #print axioms …` blocks (counted: 154 lines; the file's 155th `#guard_msgs` is the module-docstring mention). The counts in the dated updates above are the totals at that date; the file is the ground truth and `lake build` is the check that they all still pass. ## D-014 — Spend and registry authentication standardize on ERC-7913 signers — **LOCKED (interim; re-scoped by D-020)** *Scope update (2026-08-31):* with EIP-8141 frame transactions SFI'd for Hegotá, ERC-7913 is the **interim** spend/registry encoding (pre-Hegotá chains and L2s without type `0x06`), not the destination — see D-020. The measurements and the EIP-3860 finding below stand. *Scope update (2026-09-28, user, after the ethresear.ch discussion "PQ anonymity for stealth address protocol"):* the **ERC-6538 registry is out of scope for the proposal.** The registry-authentication ratchet recommended below (an ERC-7913-authorized update path so the old ecrecover key cannot overwrite a PQ meta-address) is withdrawn from the ERC text: binding a PQ key to a registry entry that is still governed by ecrecover does not close the overwrite hole, and fixing the registry is a separate contract with its own migration. Meta-addresses are distributed through naming services (ENS and the `.gwei`/`.wei` names the demo already uses) or shared off chain; a wallet may still read ERC-6538 as one source among others but the proposal makes no claim about it. The ERC-5564 announcer stays in scope. Follow-up in issue #36. ERC-7913 (Signature Verifiers, Final 2025) represents a signer as the byte string `verifier || key` and checks it via `IERC7913SignatureVerifier(verifier).verify(key, bytes32 hash, signature) → 0x024ad318`; an empty key falls back to ERC-1271/ecrecover. Decision (2026-08-10): the spend route and the registry-authentication recommendation in the ERC draft are expressed in this encoding — informative in the draft (`requires` stays `5564, 6538`), demonstrated in `js-client/test/e2e-7913.test.ts`. **Why.** One shared stateless verifier serves every stealth account, so the per-account cost collapses to storing signer bytes; both spend modes fit the same interface (blinded ML-DSA signature with `key` = stealth pk, or the D-012 ZK ownership proof with `key` = the 32 B commitment); and the empty-key fallback makes the hybrid ECDSA-co-signer account (D-002's AA migration rail) a configuration, not a custom contract. It is also the standardized remedy for the ERC-6538 overwrite hole our draft flags: registries accepting ERC-7913 authorization from the registered PQ key turn TOFU-plus-PoP into an enforceable update ratchet. **Upstream finding.** ZKNOX's ETHDILITHIUM at HEAD (`df999ed`) is already ERC-7913-native: `ZKNOX_dilithium`/`ZKNOX_ethdilithium` implement `IERC7913SignatureVerifier`, `setKey` is **removed**, and `key` is a 20-byte pointer to a `PKContract` (SSTORE2, `getPublicKey()`), so a full signer is **40 bytes**. The Sepolia-deployed v0.0.10 (`0x092c…21ef`) predates this and still uses the `setKey` flow. OpenZeppelin mainline 5.5.0 ships the account side (`SignerERC7913`, `MultiSignerERC7913{,Weighted}`, `ERC7913Utils`) plus P256/RSA/WebAuthn verifiers — no PQ verifier among them. **What this dissolves.** D-005's EIP-3860 wall was an artifact of baking keys into initcode (49,729 B > 49,152 B cap). With signer bytes in storage and keys behind pointers, account initcode is small regardless of how many PQ keys the account holds; dual-PQ accounts stop being undeployable. **Measured (e2e-7913, anvil, 2026-08-10).** Account initcode **2,952 B** (cap 49,152 B), account deploy **620,750** gas (vs 6.17 M via the old factory+embedded-key route); PKContract deploy **5,324,168** gas (the 22.4 kB expanded pk, one-time per stealth key, replaces the deployed v0.0.10's 5.2 M `setKey`); ERC-7913 verify **~14.97 M** gas — *higher* than the old ~8.15 M because df999ed stores `t1` plain and recomputes `NTT(t1·2^d)` inside every `verify` (work the old flow did once at key-setup time). ERC-7913 is an interface, not an accelerator, and this revision trades per-verify gas for cheaper storage. **Caveats.** The deployed verifier profile is still level-2 round-3 Dilithium, not ML-DSA-65 (D-006); the pythonref at df999ed hardwires `pk_for_eth` to the keccak-PRNG variant, so the fixture generator inlines the SHAKE/NIST expansion (plain `t1`); and the 3-arg `verify` forces an empty FIPS 204 context (`M' = 0x00 || 0x00 || m`, [`m`](/lean/proofs/demo#m) a bytes32), so PoP-style ctx binding must move into the signed hash or a wrapper verifier. **Open.** No published *stateless raw-key* ML-DSA-65 ERC-7913 verifier exists anywhere (pk in calldata, no PKContract) — an unclaimed deliverable. A ctx-binding PoP wrapper verifier and registry-v2 contract changes are likewise deferred. ## D-015 — Hash policy: standard hashes everywhere, no arithmetization-oriented hash — **LOCKED** Decision (2026-08-15): every hash site in the discovery design uses standard bit-oriented hashes — **SHA-256 normative** for tag chains, announcement accumulators and completeness proofs (EIP-8304-aligned); **SHAKE256 unchanged** for KEM-side KDF/domain separation (FIPS 203/204 stack); BLAKE3/BLAKE2s permitted as prover- or indexer-internal choices. No arithmetization-oriented hash (Poseidon/Poseidon2, Vision Mark-32, Skyscraper, Monolith, Grøstl, …) anywhere, including the circuit-heavy spend-membership path. The dual-root accumulator of the 2026-08-05 discovery memo is dropped. **Why.** Trigger: 2026-08-13, the EF announced it is abandoning Poseidon for L1 ("pivoting to SHA or BLAKE", J. Drake — "the key was not SNARK-friendly hashes, but hash-friendly SNARKs"). Two legs, both load-bearing for us: (1) Flock-class binary-field provers (Flock eprint 2026/1329; Binius64; binary GKR) collapsed the ~100× in-circuit penalty for standard hashes to a single-digit per-core factor (82k BLAKE3 / 42k SHA-256 compressions/s on one M4 Max core; 660k BLAKE3/s on 10 cores), removing Poseidon2's only advantage; (2) the Poseidon Cryptanalysis Initiative's 2026 results (full-round CICO-2 on the KoalaBear instance via Slipway eprint 2026/1579; collision prize paused 2026-08-01) plus resultant/FreeLunch attacks across the wider AO-hash family (Griffin/Anemoi/Arion variants, a Rescue set — eprint 2025/259) make any AO hash a research liability. Poseidon2 was never normative here, so this resolves the "monitored risk" of D-013-era docs at zero code cost and makes the discovery pipeline single-hash-family, PQ-sound from standard hashes alone — the same assumption set lean Ethereum converges on. Full analysis: `docs/research/hash-migration-blake2-binius.md`. ## D-016 — Hash-based commitments cannot replace the discovery KEM; the lattice (or another structured PQ KEM) is load-bearing — **FINDING** Question (2026-08-25): can a SHA-256 / BLAKE3 commitment scheme stand in for ML-KEM so the scheme needs no lattice assumption? Answer: **no, by theorem**, and the argument for a structured assumption in discovery is closed. * **Wrong shape.** A commitment's opening stays with the committer; a discovery step needs the *recipient* to recover a secret from a message the *sender* built from public data alone — a trapdoor, i.e. public-key structure. Any hash-only protocol is a random-oracle key exchange, and those are capped: n honest queries ⇒ O(n²)-query break (Barak–Mahmoody CRYPTO'09, tightening Impagliazzo–Rudich STOC'89); Merkle puzzles already sit at the cap. The cap is about the access model, so SHA-256 vs BLAKE3 is irrelevant to it. * **Quantum makes it worse, not better.** Against a quantum eavesdropper Merkle's scheme with classical parties has **no gap** (Grover); the best known classical-party hash-only family only approaches n^\{3/2} (Brassard–Høyer–Kalach–Kaplan–Laplante–Salvail CRYPTO'11). Hash-only key exchange is the one KEX family that gets *less* secure post-quantum. * **Measured (`benchmarks/hash_kex_bench.py`, 2026-08-25).** Classical-attacker 2^128 needs 2^64 hashes per party and a **738 EB** recipient key; even a 2^80 budget (~17 min of the 2026 Bitcoin network) needs a 44 TB key and 64 GPU-s per payment, with zero quantum security. ML-KEM-768: 1,184-B key, 17.2 µs decaps, 67,700 gas, L3 against both. Scan-side hashing is 0.3–1.7 µs per announcement — the hash choice moves scan time \<5%; the KEM decides it. * **What hashes legitimately do.** (a) *Amortize* the KEM: pre-shared-secret tag chains (our §2.2 design, SHA-256 per D-015) pay the 1,088-B ciphertext once per relationship; the root secret still needs one PQ handshake — or an out-of-band channel, which is the honest "hash-only end to end" opt-out for pairs that have one. (b) Own spend/KDF/tags (D-008/D-012/D-015). A published one-time-address list is hash-only but has no unlinkability — not a stealth address. * **The real comparison is ML-KEM vs structured non-lattice KEMs** (D-012): HQC (NIST backup, 2025-03-11; 2,241/4,433 B at L1, 2.37 ms decaps, 190 s per 80k scan, 201k-gas announcement), NTRU, Frodo, McEliece, CSIDH. ML-KEM wins on decaps and footprint. The assumption-diversity hedge, if wanted, is an **optional hybrid ML-KEM‖HQC parameter set** (additive cost, secure if either holds), not a hash construction. Follow-ups: one Rationale sentence in the ERC citing Barak–Mahmoody so "why not hashes" stays closed; permit out-of-band `cs` for tag chains; decide whether to offer the hybrid set (→ resolved in D-017: yes, and it is X-Wing, not ML-KEM‖HQC). Full analysis: `docs/research/hash-based-key-exchange.md`. ## D-017 — Optional PQ/T hybrid parameter set: X-Wing replaces NTRU/HQC as the named hedge; default stays ML-KEM-768 — **LOCKED** Question (2026-08-27): should the ERC offer a hybrid (PQ/T) discovery-KEM parameter set against a *classical* break of Module-LWE, and which one? Decision (user, 2026-08-27): **yes, as optional** — X-Wing (X25519 + ML-KEM-768, bit-identical to CFRG's MLKEM768-X25519). The default set is unchanged. * **Best-value second assumption, measured** (`benchmarks/xwing_bench.py`, validated against the official draft test vectors byte-identically): hybrid decaps 48 µs (ML-KEM 17.2 + native X25519 30.2 + SHA3-256 0.6) → 3.84 s per 80k scan — a 2.8× tax that is still *faster than every non-MLWE family we measured*, including the previous NTRU hedge (NTRU-HPS-2048-677: 4.19 s). Footprint: pk 1,216 B (+32), ct 1,120 B (+32, announce ≈ +1.8% gas at the EIP-7623 floor), decapsulation key = one 32-byte seed. The classical leg dominates the hybrid's scan cost — X25519 is ~1.8× the ML-KEM decapsulation. * **Honest security scope (the reason the pitch must be worded carefully).** For parallel hybrids, ciphertext ANONYMITY is an AND of the components (both ciphertexts are visible), unlike IND-CCA's OR — stated explicitly in Bao–Pan, "Anonymity of X-Wing and its Variants" (PKC 2026, eprint 2026/396), the first anonymity analysis of X-Wing. The X25519 ciphertext is an ephemeral public key — unconditionally weakly anonymous, an anonymity free-rider — so X-Wing's anonymity *equals ML-KEM's, full stop*. Consequence for us: the hybrid hedges the **shared-secret-hiding terms** of the Lean decomposition (view tag + derived stealth address become "MLWE OR strong-DH", per X-Wing's IND-CCA theorems, eprint 2024/039) but **not the SPR/anonymity term** — announcement↔meta-address unlinkability keeps its single point of failure in ML-KEM ciphertext anonymity. Not "DKSAP-grade privacy if lattices fall." Against the HNDL quantum adversary the hybrid is exactly neutral (Shor kills the X25519 leg; degrades to the default scheme). * **Why X-Wing and not ML-KEM‖HQC** (corrects D-016's closing suggestion): the QSF combiner shape requires the PQ leg to be C2PRI (ciphertext second-preimage resistant), which **HQC fails** (Starfighters, eprint 2025/1397; Krämer et al. 2025/1416) — HQC cannot be the second leg of an X-Wing-style combiner, on top of its 138× scan tax. NTRU keeps only a footprint edge and has no combiner spec, no anonymity analysis, and slower scans. * **Standards/deployment footing**: draft-connolly-cfrg-xwing-kem-10 (not an RFC), but bit-identical MLKEM768-X25519 sits in draft-irtf-cfrg-concrete-hybrid-kems and draft-ietf-hpke-pq (IANA HPKE KEM 0x647a); Apple CryptoKit ships it (iOS/macOS 26); BoringSSL, CIRCL, filippo.io, RustCrypto, libcrux implement it; NIST SP 800-227 (final, 2025-09) blesses the combiner shape. Not in liboqs/OpenSSL — irrelevant to us; both our stacks compose it from parts we already carry. * **Normative consequences imported into the ERC**: hybrid meta-address version `0x02` with the 1,216-B X-Wing encapsulation key *(superseded: D-024 assigned `0x02` to the commitment form, and D-028 makes the version byte name the layout — `0x01` Construction A, `0x02` commitment — with the KEM typed by total length, so X-Wing needs no version byte of its own; the hybrid itself remains unimplemented, issue #37)*; `ephemeralPubKey` = 1,120-B X-Wing ciphertext; the decapsulation key MUST be stored/exchanged only as the 32-byte seed (X-Wing's MAL-BIND-K-\{PK,CT} properties fail for expanded keys — Schmieg, eprint 2024/523); the ML-KEM encapsulation-key check MUST run (X-Wing draft §Encapsulation). Everything downstream of `ss` (blinding derivation, view tag, scanning) is unchanged. * OR-anonymity hybrids exist (nested/obfuscated combiner, Günther et al. CRYPTO 2025, eprint 2025/408: Elligator/Kemeleon encodings) but are research-grade with no spec — documented, not offered. Follow-ups: add the hybrid vectors to the conformance set when the set is implemented; Lean instantiation note — an SPR simulator for the X25519 component must sample a random *curve point*, not uniform bytes (curve membership is distinguishable at ~1/2). Full analysis: `docs/research/xwing-hybrid-kem.md`. ## D-018 — SPHINCS- C13 (hash-based) as an ERC-7913 spend signer: Verity-verified verifier vendored, two signer forms, spend-time linkability stated — **FINDING / option** Question (2026-08-27, user): implement the Verity Labs SPHINCS- verifier (https://veritylabs.dev/research/sphincs-minus-verifier) for spending. Done (branch `worktree-sphincs-minus`): what was vendored, what it costs, and where a hash-based key can honestly sit in this scheme. *(Scope update 2026-08-31, D-020: the ERC-7913 wrapper is the interim carrier; under EIP-8141 the same C13 verifier is called from the account's VERIFY frame code. The commitment construction and the linkability finding are carrier-independent and stand.)* * **What was vendored.** `lfglabs-dev/SPHINCS-` `src/SPHINCs-C13Asm.sol` @ `2a40d0a` — byte-identical, sha256 recorded in `js-client/contracts/src/vendor/sphincs-minus/VENDORED_REV.txt` (the write-up prints the commit hash with a typo in its tail; the repo's HEAD is the object we pin). SPHINCS+ "+C" variant (WOTS+C / FORS+C grinding, ePrint 2025/2203): n=16, h=22, d=2, a=19, k=7, w=8, l=43, target sum 208, keccak256 over the FIPS 205 §11.2.2 uncompressed ADRS, 3,688-B signature, 128-bit up to a 2^22-signatures-per-key cap (upstream's table). Verity Labs' Lean refinement (`c13_refines_spec`: EVM model → `verifyBytes` → `verifyParsed`; `propext`/`Classical.choice`/`Quot.sound` plus the residual assembly bridges in their AXIOMS.md) covers exactly this file. Compiled under upstream's settings (via-IR, 200 runs) through a per-path `compilation_restrictions` profile; nothing else in the tree changes profile. Upstream posture: research prototype, not audited; its own agent-assisted review flags a public-grindable message randomizer [`R`](/lean/proofs/demo#r) (the few-time bound is unproven in-repo). Not FIPS 205. * **Two ERC-7913 signers** (`js-client/contracts/src/SphincsC13Signer7913.sol`), both stateless, 52-byte signer strings (`verifier || key`), never reverting on well-typed input (lengths checked, key words built top-aligned so the verifier's two revert paths are unreachable; every soundness failure is a uniform `0xffffffff`): * **raw key**: `key = pkSeed[0:16] || pkRoot[0:16]`, `sig` = the 3,688-B C13 signature. For keys that are public anyway — the D-014 registry- authentication ratchet, an account co-signer / recovery key, or the key behind a D-012 ZK-bound stealth address. * **commit**: `key = keccak256("pq-stealth/sphincs-c13/commit/v0" || pk || opener)`, `sig = pk || opener || c13sig` (3,752 B), `opener = SHA-256("pq-stealth/sphincs-c13/open/v0" || ss)` (a derivative of the shared secret, never `ss` itself, so opening leaks no view-tag material). This is the form that gives a hash-based key a **sender-derivable** stealth address: the sender knows `pk` (a hash-based-spend meta-address would carry the 32-B key) and `ss`, so it can form the commitment → account initcode → CREATE2 address. * **Measured (anvil, `npm run e2e-7913-sphincs`, 2026-08-27).** Verifier deploy 310,750 gas; raw C13 `verify` ≈ **188,092** tx-level (upstream's Sepolia figure: 188,278 — the vendored bytecode behaves as documented); ERC-7913 raw-key wrapper ≈ 194,768; commit form ≈ 196,973; account deploy 620,894 (initcode ≈ 2,984 B). Against the ML-DSA ERC-7913 route (D-014, ≈ 14.97 M): **≈ 77× cheaper per verify**, and no PKContract (the 5.3 M one-time key deploy disappears — the key is 32 bytes in the signer string). Signature 3,688 B vs 3,309 B (+11 %); signer 52 B vs 40 B. * **The finding that governs placement.** Hash-based keys have no key homomorphism, so the sender cannot derive a per-payment public key (D-016; `hash-based-key-exchange.md` row B). The commit form recovers sender-side address derivation, but a spend *opens* the commitment on chain: `pk` is the recipient's registered key, so from the first spend every **spent** address of that recipient is linkable to the recipient (unspent addresses stay unlinkable — `ss` is 256 bits of KEM output and the commitment hides). Blinded ML-DSA has no such identifying event. *(Correction 2026-09-22, D-027: that last sentence holds only while no blinded stealth public key is revealed. `stealth_pk = pack_pk(rho, t1')` carries the recipient's meta-address `rho` verbatim, so any route that discloses the full key — an on-chain ML-DSA verify, a `PKContract`, a pointer-signature key table — is an identifying event for Construction A too; see `python/tests/test_construction_a_rho.py`.)* So SPHINCS- is the right spend signer for (a) registry authentication, (b) co-signer / recovery, (c) the key *behind* a D-012 ZK ownership proof — where a keccak/hash-preimage STARK is what makes the spend side lattice-free end to end — and (d) a documented **linkable-on-spend option** for users who take ≈ 77× cheaper spends over spend-time unlinkability. It is not a replacement for construction A as the default. The ERC draft's informative spend section now says this. * **Conventions and reproducibility.** Byte conventions live in `js-client/src/sphincs.ts` and `python/scripts/sphincs_c13_7913_demo.py` (fixture generator: a real ML-KEM-768 exchange with fixed seeds, the recipient's C13 seed material = `SHA-256("pq-stealth/sphincs-c13/keygen/v0" || spend_seed)` fed to upstream's Rust `signer-c13 keygen` (0.2 s) and `sign-with`; the R / counter grinds are deterministic in `sk_seed`, verified byte-identical on re-run). The e2e re-derives opener and commitment in TypeScript, asserts equality with the fixture and with the contract's `commitment()`, and runs the negative cases (wrong hash, tampered [`R`](/lean/proofs/demo#r), tampered auth path, short sig, wrong root, wrong key length, wrong opener, cross-form signatures) expecting `0xffffffff`, never a revert. * **Caveats carried.** One C13 key across all of a recipient's stealth addresses draws on one 2^22 budget — a wallet MUST count signatures; the bare ERC-7913 `verify` has no ctx (as in D-014, PoP binding goes in the hash); H\_msg takes the 32-byte hash directly (no envelope); the Verity proof is of the Solidity/Yul model, not of solc's output. * **Pointer-signature form (same day, user: "pack the key as ecrecover").** Because the C13 key is one word, the `(v, r, s)` pointer trick of `docs/pointer-signatures-poc.md` needs no key table: `v = 0x52`, [`r`](/lean/proofs/demo#r) = the key, `s` = signature index → address `keccak256(r)[12:]`; `v = 0x53`, [`r`](/lean/proofs/demo#r) = the commitment above → a stealth address in plain address shape that the sender computes without any account. Measured (`npm run e2e-pointer-sig`): `withdrawWithSig` 0x52 **182,799** / 0x53 183,237 vs classic 92,555 (2.0×) and ML-DSA [`recover`](/lean/proofs/dksap#recover) ≈ 15.19 M; publishing the signature ≈ 0.9 M. **Constraint stated late and now written into the POC doc:** [`recover`](/lean/proofs/dksap#recover) authorizes spends *inside adopting contracts*; `keccak256(r)[12:]` cannot hold ETH or tokens itself, so this is a spend-authorization shim for value already held by pointer-aware contracts, not a stealth-receive mechanism — an ERC-5564 payment to a bare address still needs the CREATE2 account route. The user reads the spend-time linkability as a feature (the spend is an explicit, auditable identification event) rather than a defect; recorded as such. Follow-ups: a meta-address version carrying the 32-B C13 key (hash-based- spend variant); link the Lean side — Verified-zkEVM/VCVio main ships a pure-Lean C13 (`HashSig/SLHDSA/C13`) with a KAT against this very verifier, which would let `lean/` state the spend-side assumption as a hash-only EUF-CMA term; decide whether the linkable-on-spend option is offered in the ERC beyond the informative paragraph. ## D-020 — Spend targets EIP-8141 frame transactions; ERC-7913/ERC-4337 become the interim route — **LOCKED** Decision (2026-08-31, user): stop designing the spend path around ERC-7913 signer contracts and ERC-4337 accounts as the destination. EIP-8141 ("Frame Transactions", tx type `0x06`) was moved from CFI to **SFI for Hegotá** on ACDE #244 (2026-08-27), so protocol-native account abstraction is the scheduled substrate and the spend design is expressed against it. (D-019 is allocated on the tagchain branch — SHAKE256 tag chains.) **What EIP-8141 gives this scheme natively.** A frame transaction splits into VERIFY frames (read-only validation run as the sender) and EXECUTE frames; the account is literally an address with code, and validation is arbitrary EVM code ending in `APPROVE` (opcode `0xaa`). The pieces that map onto our stack: * **Signature carriage.** The `ARBITRARY (0x0)` signature scheme carries the PQ signature in the transaction's `signatures` array (100 gas intrinsic; raw bytes are EVM-introspectable via `SIGDATACOPY` *only* for arbitrary-scheme entries). The account's VERIFY code calls the same vendored verifiers we already measure — `SPHINCs-C13Asm` or ETHDILITHIUM — directly. The ERC-7913 `verifier || key` indirection stops being load-bearing; the verifiers survive as libraries. * **Counterfactual receive.** The mempool-recognized validation prefix `[deploy, self_verify]` deploys the account at `tx.sender` in the same transaction (deterministic factory, e.g. the EIP-7997 predeploy) — the announced stealth address is the counterfactual frame-tx account address. This replaces the CREATE2 + EntryPoint + bundler dance; `ENTRY_POINT (0xaa)` is a protocol constant, not deployed code. * **Native sponsorship.** `[deploy, only_verify, pay]` is a recognized prefix: a paymaster frame pays gas so the stealth account spends only the value it holds. This natively removes both the AA21-prefund problem and the privacy leak of funding gas from a linkable EOA — the whole reason the Pimlico integration exists. **What survives from D-014/D-018.** The EIP-3860 lesson stands (keys never go in initcode; the deploy frame stays small, key or pointer in account state). The D-018 commit-signer construction and its linkable-on-spend trade-off are unchanged — they are about key derivation, not the account standard. ERC-7913/4337 remain the *interim* route: chains before Hegotá, and L2s that do not adopt type `0x06`. D-014 is re-scoped accordingly. **The new hard constraint — validation gas.** Public-mempool admission simulates the validation prefix under `MAX_VERIFY_GAS = 100,000` execution gas (500,000 state gas), with storage reads limited to the sender. Measured against that budget: C13 verify is 188,092 gas tx-level (≈166k net of intrinsic + calldata) — **even the cheapest PQ verifier we have does not fit the default mempool prefix**, and ML-DSA at ~15 M is out by two orders of magnitude. Protocol-validated schemes exist for SECP256K1 (2,800 gas) and P256 (6,700 gas) but no PQ scheme has an identifier. Consequences: (a) near-term PQ frame-tx spends ride builder/private inclusion, not the public mempool; (b) **WG feedback item the user can bring**: a protocol-validated PQ signature id (SLH-DSA/ML-DSA) or a raised/negotiable verify budget is what makes PQ stealth spends first-class citizens of the `0x06` mempool. Also to check against the final spec: whether `EXTCODECOPY` of an immutable key blob at another address (the SSTORE2/PKContract pattern) is admissible in the validation prefix, or whether key material must live in the sender's own code/storage. **Repo impact.** `docs/erc-draft.md` spend sections reframed: frame-tx account model is the target, ERC-7913 demoted to the interim expression (still informative, `requires` unchanged). The 4337 contracts, EntryPoint wiring, bundler/Pimlico paths in `js-client/` and `ui/` are kept as the working interim demo, not extended further. Open: a `Stealth8141Account` sketch (VERIFY code calling C13/ML-DSA + APPROVE) once client devnets expose type `0x06`. ## D-011 — On-chain cost is data, not compute; announce hits the EIP-7623 floor — **FINDING** Cost model in `python/benchmarks/onchain_cost.py`, anchored to the measured 67,580-gas Sepolia-fork announcement and reproducing it to **0.04%** (see the provenance correction below). *(Provenance correction, 2026-10-29.)* The 67,580 measurement stands, at scheme ID `2`. Re-run on today's committed vectors the model gives **67,550** (−0.04%): the residual is the zero/nonzero byte split of the encoded arguments, so the model is anchored to 0.04%, not exact. The same re-run showed `announce_dksap` had been reporting the PQ row's measurement as its own (`measured_gas` 67,580, `error_pct` −59.54) because the field was set unconditionally; DKSAP is **modelled only** and now records `null`. The finding in this entry — the floor binds, PQ is 2.5× DKSAP on L1 — is unaffected. See [stretch-goal report](/research/stretch-goal-report) §6. * **The announce tx pays the EIP-7623 calldata floor.** Its exact `announce(uint256,address,bytes,bytes)` calldata is 1,316 B → 4,658 tokens; floor `21,000 + 10·4,658 = 67,580` beats the standard cost (52,446), so the floor is binding. Post-Pectra, a PQ announcement is priced as *bytes*, not execution. EC-DKSAP (33 B point) models at 27,342 gas in the standard regime → PQ is **2.5× on L1** (the 21k base compresses the raw ~33× data ratio). * **L2 dissolves the footprint tax.** Marginal L1 data cost per PQ announcement (dated 2026-07-28: ETH $3,200, L1 8 gwei, blob 1 gwei): \~$1.35 as calldata vs **~$0.004 as an EIP-4844 blob** (~320× cheaper). These are marginal L1 costs, not per-rollup fees (compression/margin float). * **Meta-address registration** (ERC-6538, one-time): 5,633 B ≈ 3.79M gas naive SSTORE vs ~62k for EC (~61×); SSTORE2/code-blob (~200 gas/B) is the recommended store for a payload this size. * **Blinded signing costs ~6× the rejection rounds** of stock ML-DSA-65 (mean ≈30 vs ≈5, N=200, `op_bench.py`) — the `2η`-widened secrets dominate the `r0` check. Corrects the earlier "a handful of extra rounds" claim; a tighter probabilistic norm bound than worst-case `2η` would recover most. Security-level sweep (`benchmarks/param_sweep.py`, 2026-07-28) adds a non-obvious finding: **the blinding penalty is not monotone in the NIST level.** Because `β' = τ·2η` and `η` is 2/4/2 across ML-DSA-44/65/87, the **default L3 set pays the worst penalty** (blinded ≈24.8 rejection rounds, 4.5× stock) — worse than L5 (15.3, 4.0×) or L1 (14.4, 3.1×). Sizes scale cleanly (meta-addr 3,777 / 5,633 / 7,489 B; sig 2,420 / 3,309 / 4,627 B) and scan cost tracks ciphertext size (27.8 / 43.7 / 66.5 µs/ann). A view-tag sweep (`viewtag_sweep.py`) confirms 1 byte is near-optimal for native clients: at 1/256 the false-positive derivations are negligible in native code (~µs each); longer tags only pay off when derivation is the bottleneck (slow/pure-Python scanners), and cost ~nothing on-chain. Baseline decision: **DKSAP is the only EC baseline; Curvy is dropped.** DKSAP (plain-ECDH secp256k1) is the EC scheme ERC-5564 actually deploys, so it is the honest apples-to-apples bar for detection cost. The paper's 66.8% headline is vs Curvy (pairing-based), which nobody runs — comparing to it flatters the result. We measure against DKSAP everywhere (scanning `scan_bench.py`, on-chain `onchain_cost.py`) and do not carry a Curvy number. ## Open items — **OPEN** * Security analysis of construction A (widened-`z` leakage; unlinkability; the Dilithium-like signature-of-knowledge in D-008). Fellow A's core work. * Spec freeze + ERC draft + declared scheme ID `2`. * Cost report: **L2 pricing + ~5.6 kB meta-address story DONE (D-011)**; DKSAP is the single EC baseline (Curvy dropped, D-011). * NTT-domain + negacyclic + centered-range version of the ownership circuit. * Hash-based spend (D-018): remove spend-time linkability by binding the SPHINCS- key through the D-012 ZK ownership proof instead of an opened commitment; meta-address variant carrying the 32-B C13 key. ## D-021 — Construction A retained for the ERC-5564 sender-address flow; Construction B documented as a separate account-transfer design — **LOCKED (2026-09-01)** Construction A is the only implemented branch in which the **sender** computes the destination address from the recipient's meta-address and the shared secret. It is therefore the normative ownership mechanism for the ERC-5564 scheme ID under this project. Construction B, which would require recipient-assisted key generation, changes who can compute the destination and is documented only as a separate account-transfer protocol; it does not share the Construction A scheme ID or claim. The security argument for Construction A is recorded in `docs/construction-a-security.md` and in `lean/PqStealth/ConstructionASecurity.lean`. It is contingent on the explicit named assumptions listed there (ROM/PRF, seeded-MLWE, SelfTargetMSIS, XOF idealization, widened-transcript distance, CMA-to-NMA, and unbounded-signing bridge), not on any unconditional theorem. D-008's custom ZK ownership authorization remains experimental and is not used as evidence for Construction A's spend theorem. ## D-022 — ML-DSA spend cost: deployless code passing rejected, verify profiled, pre-frame deploy is designator-only, EIP-8164 native keys tracked as the target address form — **FINDING / tracked target (2026-09-02)** Full analysis: `docs/research/prefix-deploy-native-keys.md`. Companion code: `python/pq_stealth/native_key.py`, `js-client/src/native-key.ts`, `python/vectors/v0/native_key.json`; `js-client/contracts/src/ntt/` with `contracts/test/{NttPrecompute,DilithiumProfile}.t.sol`. **Rejected: the deployless `eth_call` trick for verification.** Passing the verifier bytecode along with the call is a simulation feature (`eth_call` creation semantics, state overrides); on chain it can only skip code deposit. EIP-8141 has no CREATE frame and no access list, wipes transient storage between frames, and caps the *sum* of frame execution gas (EIP-7825, 2^24), so neither carrying code nor splitting the verify across frames or transactions changes the per-spend cost. Cross-transaction splitting would persist ≈ 3 KB of intermediates (≈ 2 M state gas) to save less than that in compute. **Measured (corrects D-014 / TECHNICAL\_SPEC §7).** The df999ed ERC-7913 verify on the blinded level-2 fixture is 14,911,249 gas and breaks down as: signature slicing 0.59 M, core1 1.39 M, SampleInBall (SHAKE256) 0.94 M, NTT(c) 0.26 M, key-dependent `NTT(t1·2^d)` 1.41 M, core2 (matrix product, 8 NTTs, hints, w1) 5.69 M, final SHAKE256 4.05 M. The earlier claim that the per-verify NTT alone explains the 1.8× over 8.18 M was an inference and is wrong: precomputing it at key setup (`PKContractNtt` + `ZKNOX_dilithium_ntt`) yields 13,678,091 (−8 %), at 7,310,211 for the one-time key contract instead of 5,145,320. SHAKE256 in the EVM is the largest single cost (≈ 5.0 M, 34 %); it is the whole gap to the keccak-XOF `ZKNOX_ethdilithium` (4.9 M), which is not ML-DSA. The 8.18 M figure came from an earlier upstream revision and does not reproduce at df999ed. **Pre-frame deploy (EIP-8141 validation prefix).** Only a delegation designator fits: `SETDELEGATE` (EIP-7819, Draft) + one `SSTORE` (≈ 30 k exec, ≪ 500 k state), giving an EIP-7819 counterfactual address. Full account code does not (our SPHINCS- in-frame deploy needs ≈ 5.7 M state gas vs `MAX_VERIFY_STATE_GAS = 500 k`), and `self_verify` never fits `MAX_VERIFY_GAS = 100 k`, so the prefix stays `[deploy, sponsor VERIFY]` with the PQ verify post-prefix (D-020 pattern). **Tracked target: EIP-8164 native keys + pointer signatures.** EIP-8164 (Draft, no client code) makes an account's code `0xef0101 ‖ ML-DSA-44 pk`, validated by the client at 50 k intrinsic gas with ECDSA permanently rejected, installable by any party from a `native_key_authorization_list` tuple, and it specifies a crafted rootless authorization. For this scheme that yields a second address form: **stealth address = ecrecover of the crafted tuple over the blinded ML-DSA-44 stealth pk**, no factory or initcode, sender-computable; the sender's payment carries the tuple so funds and PQ key land atomically; the recipient spends with a plain native-key transaction — no sponsor, no `PKContract`, no EVM verifier. The Circle ecrecover-pointer proposal (ethresear.ch 25844) is the complementary contract-side half; it does not move value at a bare address. Implemented now: the address derivation with cross-checked vectors, pinning the encoding choices the draft leaves open (chain\_id as 32-byte big-endian inside `r_seed`, `y_parity = 0`, `s = 1`, `r < n`). **Open (user decisions).** (1) Whether client-validated native schemes pass the "no precompiles" bar. (2) ML-DSA-44 as the native-key parameter set versus the scheme's ML-DSA-65 default. (3) Recording the in-flight CRQC race on the crafted ECDSA key (mitigation: private inclusion or an atomic `EXTCODEHASH` check frame if 8164 folds into 8141) and the "all 8164 accounts" anonymity set in the ERC draft. ## D-023 — SPHINCS- C13 verification proven in-circuit; ZK-authorized, key-hiding spend over one frame transaction on the frames testnet — **FINDING / spike (2026-09-03)** Decision context (user): prove the post-quantum check inside a ZK circuit and spend with the proof; SPHINCS- C13 as the statement; pairing-based backend now with a designed swap to a hash-based one later; measure on the frames testnet. Full write-up: `docs/research/zk-sphincs-frames.md`. Artifacts: `noir/sphincs-c13-verify/`, `js-client/contracts/src/frames/\{Stealth8141ZkAccount, Stealth8141ZkFactory\}.sol`, `frames/zk/SphincsC13HonkVerifier.sol`, `contracts/test/ZkAccount.t.sol`, `ui/scripts/{deploy-frames-zk.mjs,e2e-frames-zk.ts}`. **What was built.** A Noir port of the Verity-refined `SPHINCs-C13Asm.sol` (335 keccak calls; WOTS+C chains flattened to 93 hint-driven steps per layer with the digit-sum and per-chain step-count asserted), public inputs `(message, commitment)` as 128-bit halves, everything else private — so the D-018 commit form's spend-time linkability disappears: no key on chain, ever. An account variant whose `executeFrame` verifies the proof carried as the ARBITRARY signature over TXPARAM(0x08), with the verifier as a rotatable pointer swapped only by the account itself through an authorized spend. A CREATE2 factory keyed by the commitment. **Measured.** keccak ≈ 14.5 k UltraHonk gates per call; circuit 6,382,753 gates (2^23); proving 8.4 GB peak, ~4 min wall on a laptop including the one-time 512 MB CRS fetch; proof 11,456 B (ZK flavour), 4 public inputs; bb Solidity verifier 26,318 B runtime plus a 6,577 B external `ZKTranscriptLib`; `executeFrame` with proof verification **3,526,632 gas** in forge. The **frames testnet accepted the 26.3 kB verifier**, so it does not enforce EIP-170's 24,576 B (mainnet would need EIP-7907). Live: verifier `0xF01ecC1dF1868C3B15f0edC4768812b9c435BBfb`, factory `0x303CB317624c74bB20Acbb9E13c8D745C6379826` (`ui/public/frames-zk-deployment.json`). **Verified live end to end** (`npm run e2e:frames-zk`): receive+announce `0xe1df05ee…b6cdb` (295,489), scan match, C13 sign 3.3 s, prove 50.4 s (10.9 GB), spend `0x0fef8acd…2bc1e` status 1 — one frame transaction carrying the in-frame account deploy (37,001 exec gas) and the proof-verified transfer (4,174,008), 9,249,124 total; the first attempt with a 4 M frame died at 63/64 (out of gas inside the verifier), so live cost exceeds forge's 3.53 M by ~18 %. **Findings.** (1) For SPHINCS- the proof buys *unlinkability*, not cost: ≈ 3.5 M per spend against 188 k for the direct verify; against ML-DSA's 13.7 M it is 4× cheaper but that comparison is between different statements. (2) UltraHonk on BN254/KZG is not quantum-sound: a Honk-authorized account is as exposed at rest as an ECDSA one, and the swap must precede a CRQC — an attacker who can forge proofs can also authorize the swap. (3) Wrapping a hash-based proof in a pairing-based EVM proof inherits the wrapper's soundness; the on-chain verifier is the trust root, so "migrate to any STARK backend" means an EVM STARK verifier (1–5 M+ gas with standard hashes, and it must fit the code-size limit), not a recursion trick. (4) Flock (Succinct; R1CS over GF(2), Ligerito PCS, SHA-256/BLAKE3 batched hashing, Rust API, no keccak encoder, no EVM verifier, research stage) has the right shape for the hash-based target but is not pluggable today. (5) Prover cost is laptop-class (2^23, 8 GB); browser proving needs the no-ZK flavour or recursion. **Status.** Spike; not a route decision. The direct C13 verify (D-018) stays the reference hash-based spend; this becomes the named answer to D-018's linkability item, gated on a hash-based EVM verifier. ## D-024 — The scheme is key exchange: commitment meta-address (format 0x02, 1,217 B) adopted; Construction A becomes one address form; spending is delegated to the account model — **LOCKED (2026-09-04)** Decision (user, 2026-09-04): the ERC's subject is the post-quantum key exchange and address derivation, not signatures. "In the original proposal I didn't want to focus on the signatures much and make the main area to be about key-exchange." The spend-side measurements of D-022/D-023 (ML-DSA 13.7 M, proof-verified SPHINCS- 4.2 M, both needing a sponsor) and the protocol's own direction (EIP-8288 aggregation at 3–30 k per dependency, EIP-8164 native keys) settle that spending is the protocol's problem and will be solved there. The ERC draft (`docs/erc-draft.md`) is emptied and from now on **human-written only**; agents must not generate or restore it. **What is adopted.** The commitment meta-address D-012 sketched, now implemented and vectored (`python/pq_stealth/commit.py`, `js-client/src/commit-scheme.ts`, `python/vectors/v0/commit_vectors.json`, 13 + 6 tests, CI): ``` meta-address = 0x02 || spend_key(32) || ML-KEM-768 ek(1184) = 1,217 B opener = SHA-256("pq-stealth/sphincs-c13/open/v0" || ss) commitment = keccak256("pq-stealth/sphincs-c13/commit/v0" || spend_key || opener) address = CREATE2(factory, 0, initcode(commitment, verifier, frameCtx)) ``` `spend_key` is a 32-byte commitment to the spending public key — the key itself for SPHINCS- C13 (D-018), a hash of it for anything larger. The sender needs only the shared secret and a deployment binding (factory, creation code, constructor args); the scanner needs the viewing key. The derivation is byte-identical to the deployed C13 commit signer and the D-023 ZK circuit (checked against the fixture), so the live `Stealth8141ZkFactory` accepts it unchanged, and the Lean roundtrip [`meta_address_commit_roundtrips_1217`](/lean/proofs/invariants#meta_address_commit_roundtrips_1217) (`Invariants.lean`) already covers the encoding. **What changes.** The meta-address shrinks 5,633 → 1,217 B (4.6×): the full-precision `t` existed only for Construction A's key homomorphism, which a commitment-bound account does not need. D-011's registration finding (3.79 M gas naive, ~61× EC) shrinks with it. Construction A (format 0x01) stays as an address form for accounts that want a per-address standard ML-DSA key, not as the mechanism; its security freeze (D-021, Lean) is unaffected. Nothing about the spending scheme leaks at receive time; what a spend reveals is the account model's choice (revealed C13 key, ZK proof, native key, aggregated dependency). **Not decided here.** The domain strings still carry the C13 name (a rename once a second spend scheme exists); whether format 0x02 becomes the *only* normative meta-address for the scheme ID is for the hand-written draft. ## D-025 — Demo spend proves the spending secret, not a signature; the proof is made in the browser — **LOCKED for the demo (2026-09-04)** Decision (user): "we'd want to prove the secret, not the signature, for that demo" and "we need to generate the proof on the client side for the ZK example." The D-023 C13-in-circuit statement (6.4 M gates, 8–11 GB, ~1 min natively) cannot be proven in a browser: bb.js is wasm32, capped at 4 GB. The key-exchange design of D-024 makes the signature unnecessary anyway: the meta-address carries a 32-byte commitment to the spending key, so spending can prove knowledge of what that key commits to. **Statement** (`noir/preimage-ownership`, 2 keccak calls, **40,626 UltraHonk gates**, 2^16): with `spend_key = keccak256("pq-stealth/preimage/key/v0" ‖ sk)` and `commitment = keccak256("pq-stealth/preimage/commit/v0" ‖ spend_key ‖ opener)`, prove knowledge of `(sk, opener)` for the public `commitment`, with the frame tx `sig_hash` as the other public input (same `[message_hi, message_lo, commitment_hi, commitment_lo]` layout as D-023, so `Stealth8141ZkAccount` is unchanged). Opener/commitment domains `pq-stealth/preimage/{open,commit}/v0`; the demo secret is `SHA-256("pq-stealth/preimage/keygen/v0" ‖ spend_seed)`. Python (`pq_stealth.commit`: `PREIMAGE_DOMAINS`, `spend_key_from_secret`), TS (`commit-scheme.ts`: `PREIMAGE_DOMAINS`, `spendKeyFromSecret`, domains threaded through send/scan) and vectors (recipient `c`) cover it. **Measured.** forge: `executeFrame` with the preimage proof **2,942,272 gas**, proof 8,768 B, verifier 26,317 B (+ 6,577 B `ZKTranscriptLib`). Client-side (`ui/src/lib/preimage-prover.ts` = noir\_js 1.0.0-beta.19 + bb.js 4.2.0, WASM): witness 42–61 ms, **prove 0.65–1.3 s**, bb.js verifies its own proof and its verification key is byte-identical to the bb CLI's, i.e. to the deployed verifier. Live on the frames testnet: `PreimageHonkVerifier` `0xaDe68b4b…6b01`, `Stealth8141ZkFactory` `0x86A0679C…88ff` (`ui/public/frames-zk-deployment.json`, now carrying `creationCode` so the browser computes CREATE2 without an RPC); receive `0xe6e86ece…523ee` (295,489), spend `0xa598ec41…1fe65` status 1, frames \[100, deploy 37,001, \*\*proof verify * transfer 3,544,107\*\*], 8,576,431 total. The demo UI's key-exchange route (`ui/src/components/FramesZkSpend.tsx`) proves in a web worker (`preimage-worker.ts`; COOP/COEP headers in `vite.config.ts` for threads) and needs no signer service. The C13 statement and its signer-service prover stay as the signature variant (`frames-zk-c13-deployment.json`, `ZK_CIRCUIT=c13`). **What this is and is not.** Ownership by hash preimage is post-quantum by assumption (D-007 / D-012's "lattice-free end to end"); the proof system is the non-PQ part, exactly as in D-023, and the rotatable verifier is the swap seam. A revealed secret would be catastrophic, so the secret is only ever a witness; the demo derives it from a seed in the page and never transmits it. This is the demo's spend route, not a normative choice for the ERC — that text is hand-written. ## D-026 — Frame-tx nonce independence: expiring nonces for the spend, keyed nonces only where a nullifier is real — **FINDING / direction (2026-09-03; renumbered from a second D-021 and updated against D-024/D-025 on 2026-09-09)** Two Draft EIPs landed after D-020 and change its inherited replay model — EIP-8141's single linear sender nonce. Full analysis and citations in `docs/research/keyed-nonces-as-nullifiers.md`. * **EIP-8250 (Keyed Nonces, Draft 2026-04-16)** replaces `nonce` with `(nonce_keys, nonce_seq)`; non-zero keys get permanent slots in the `NONCE_MANAGER` predeploy at `keccak(pad32(sender) ‖ bytes32(key))`, and consumption is tied to EIP-8141's payment-approval step. Its own Rationale offers this to "single-use-key applications, such as nullifiers" as an atomic spent-once guarantee. * **EIP-8266 (Expiring Nonces, Draft 2026-05-15)** replaces the nonce with a ≤60 s deadline plus a `2^18`-slot ring buffer: `EXPIRING_NONCE_GAS = 13,000` flat, **zero net state growth**, and nodes MAY admit multiple pending transactions per sender. **Finding: we need freshness, not a nullifier.** A nullifier exists because a note is a commitment nothing on-chain stops you presenting twice. pq-sap has no notes — value is native ETH at an account, and the balance is the double-spend defence. So the mainline construction-A spend should take **8266** (13,000 gas, no permanent state) rather than **8250** (`KEYED_NONCE_FIRST_USE_STATE_GAS = 97,920` state gas and one permanent slot *per payment, forever* — the wrong direction for a project whose headline cost finding is D-011's "cost is data"). 8250 is the fallback if the 60 s deadline is too tight for pre-signed spends, and the right tool only where the authorization is detachable from the balance. **What it fixes: the sponsor.** `Stealth8141Account` is not the tx `sender` — a sponsor EOA is (the demo's in-page throwaway key), so today's spend carries a unique public sender that must be funded from somewhere, and every sponsored spend serializes on one nonce (`ui/src/lib/frames.ts` `pendingNonce`, "rapid back-to-back frame txs collide on `latest`"). EIP-8250's Rationale names this case exactly. Either EIP unblocks a **shared public sponsor**: one funded account carrying every stealth spend, each in its own replay domain, nothing for the recipient to fund. This completes D-020's gas-funding-privacy item — paymaster frames fix *who pays*, nonce independence fixes *whose nonce orders the transaction*. Note what happened: ERC-4337 got this free (the bundler is the on-chain sender), EIP-8141 gave it up with the bundler, and these EIPs give it back. No contract change is needed for binding — EIP-8141's `sig_hash` is keccak over the RLP body and 8250 replaces the `nonce` field *inside* it, so `executeFrame`'s existing `FRAME_CTX.sigHash()` check already authenticates `(sender, nonce_keys_hash, nonce_seq)` as 8250 asks. `frame-tx/serialize.ts` would need the new body layout, but it tracks *deployed* ethrex, so not yet. **What it does for generation.** A ZK spend (D-008/D-012) owes (a) address binding and (b) non-replay. Under construction A (b) was free and invisible — the one-time account's own nonce serialized it. A keyed nonce discharges (b) at the protocol layer for the price of picking a key. This does **not** unpark D-012: (a) is the harder half and is untouched. But the variant should now be costed as "binding + one `nonce_key`", not "binding + a nullifier subsystem", with D-012's prize unchanged (meta-address 5,633 B → 1,217 B; KEM decoupled from spend). *Update 2026-09-09.* D-024 has since adopted that 1,217-byte commitment meta-address (format `0x02`) and D-025 made the ZK-authorized spend the demo mainline, so the variant this paragraph costs is no longer parked: (a) is discharged by the commitment binding (the proof is bound to the frame tx's `sig_hash` and the account to the commitment), and (b) is discharged today by the *sponsor's* legacy nonce, since `Stealth8141ZkAccount` is not the tx sender. The shared-sponsor shape below is therefore what D-025's spend grows into once either EIP ships, with one `nonce_key` per payment replacing the sponsor's serial nonce. **Security rule — nonce keys MUST NOT derive from `ss`.** `nonce_keys` are public payload fields (`TXPARAM 0x0F`/`0x10`) and consumed slots are permanent public state. In a stealth scheme the *sender* computes `ss`, so `k = H(ss)` hands every payer a permanent, publicly checkable "has my payment been spent, and where" oracle. Scoped honestly: in today's construction-A shape this is *cheaper* lookup, not new information — the payer knows the stealth address and watches value leave it, and a shared sponsor mixes the `sender` field without hiding that value edge. It becomes decisive where the nonce key is the **only** public per-payment tag: a pooled variant, or any separate entitlement draw (a gas-bank draw is exactly this shape). D-025's spend does not change this scoping: its payer computes the commitment and the CREATE2 address (D-024), so the address is still a known tag and the value edge is still visible. The rule is free, so adopt it unconditionally rather than when the variant that needs it arrives. Required derivation: ``` k = SHAKE256("PQSAP-nonce-key-v1" ‖ K_null ‖ ct) truncated to 256 bits, k != 0 ``` with `K_null` a **nullifying key** from the recipient's master seed under its own domain separator (sender-uncomputable), and `ct` the announcement ciphertext (public, per-payment, no new bytes). Domain separation and `k != 0` are 8250's own MUSTs; **sender-uncomputable is ours** — 8250 has no party that knows the payment secret and is not the spender, so its Security Considerations do not state it. Spec consequence: a third key beside detection (`dk`) and spend, with viewing-key disclosure semantics — anyone holding `K_null` can read every payment's spent flag retroactively; a watch-only export must omit it. Free side effect for the owner: `slot(sender, k)` answers "which payments are unspent" with a storage read instead of a transfer scan. **Budget impact on the D-020 ask.** Neither EIP touches `MAX_VERIFY_GAS` (100,000 execution), but 8250 spends 97,920 of the prefix's 500,000 **state** gas per fresh key — a fifth, before validation begins, capping the prefix at ≤5 fresh keys. The working-group ask therefore grows a second half: alongside a protocol-validated PQ signature identifier or a raised execution budget, the *state* budget must accommodate a first-use keyed nonce next to a PQ verification. **Not fixed:** verification cost (D-007/D-020), announcement size (D-011), the value graph (a shared sponsor mixes sender and gas-funding edges only — value still leaves a unique stealth address), per-address deployment (D-014/D-020). Both EIPs are Draft and unscheduled; EIP-8141 itself is only SFI for Hegotá. Follow-ups (tracked in issue #15): hand the derivation rule and `K_null` to the author of the hand-written ERC draft (D-024: agents do not write `docs/erc-draft.md`) and fold them into the key hierarchy in `docs/TECHNICAL_SPEC.md` before the ERC freeze; adopt the shared-sponsor shape in `ui/`/`js-client/` when a chain exposes either mode; carry both WG items (sender-uncomputable in 8250's Security Considerations; state gas in the D-020 ask) to the authors. ## D-027 — Explicit account profiles over format 0x02; ML-DSA-44 committed-key profile implemented with a trusted-registrar key setup; Construction A `rho` exposure confirmed — **FINDING / implementation (2026-09-22)** Full record: `docs/ml-dsa-commit-profile.md`. Code: `python/pq_stealth/profiles.py`, `js-client/src/profiles.ts`, `python/vectors/v0/mldsa44_commit_vectors.json`, `js-client/contracts/src/{MlDsa44CommitSigner7913,MlDsa44KeyRegistry}.sol`, `js-client/contracts/src/frames/MlDsa44CommitFrameVerifier.sol`, `contracts/test/MlDsa44CommitAccount.t.sol`, `js-client/test/e2e-mldsa44-commit.test.ts`. **Profiles are explicit.** The `0x02` layout is unchanged (1,217 B). The 32-byte `spend_key` stays opaque — no scheme flag inside it, no repurposed version byte, no new or reused ERC-5564 scheme ID. What interprets it is a typed *account profile* (`profile_id/vN`: KEM, hash domains, authorization scheme and message convention, deployment-binding layout, spend-route status) that the application selects before send/scan from trusted configuration; `select_profile` accepts only `sphincs-c13-commit/v0`, `preimage/v0`, `ml-dsa-44-commit/v0`, validates the binding's shape, and fails closed on chain conflicts. A profile ID is not an authentication of the factory/verifier behind it. The C13 and preimage profiles keep their exact domains and fixtures; `0x01` support is untouched. **ML-DSA-44, named as such.** `spend_key = keccak256("pq-stealth/ml-dsa-44/key/v0" ‖ pk)` for the canonical 1,312-byte FIPS 204 key; opener/commitment under `pq-stealth/ml-dsa-44/{open,commit}/v0`; the same CREATE2 binding as the ZK account. Authorization payload `pk ‖ opener ‖ sig` (3,764 B), pure ML-DSA-44, empty context, over the account's 32-byte digest (frame `sig_hash` / `userOpHash`), never hashed twice. ML-DSA-44 because the only executable on-chain verifier in the tree is ZKNOX `df999ed` (`k = l = 4`, D-006); the profile rejects 65-sized inputs by length and no 65 claim is made. Python (`dilithium-py` 1.4.0) and TypeScript (`@noble/post-quantum` 0.6.1) agree byte for byte on meta-address, key commitment, opener, commitment, tag, address and deterministic signatures; 2 valid and 12 invalid authorization vectors cover wrong digest / commitment / opener / signature / key / lengths / domain profile and sender-derived keys. **Verifier identity (measured, local).** `ZKNOX_dilithium` at `df999ed` (NIST/SHAKE profile) accepts stock FIPS 204 ML-DSA-44 signatures with `tr` and `t1` derived on chain from the key bytes: signer verify 14,966,788 (forge) / 15,038,028 tx-level (anvil); `executeFrame` through the unchanged `Stealth8141ZkAccount` 15,013,931 / 15,291,722; account deploy 704,195; key setup 10.8–11.8 M once per recipient key. forge/anvil 1.4.1, solc 0.8.30, prague. Nothing deployed. **Blocked: trustless key setup.** The verifier reads an expanded key `(aHat, tr, t1)`; binding it to the committed `pk` needs `ExpandA(rho)` on chain, ≈ 40 M gas with the vendored Keccak-f (one SHAKE256 over 1,312 B is 4.4 M). `TrustedMlDsa44KeyRegistry` therefore recomputes `tr` and `t1` on chain but trusts its REGISTRAR for `aHat`. The contract path is a reviewed-registrar local integration behind a clean adapter boundary (`IMlDsa44ExpandedKeys`), not a working trustless spend route. D-014's stateless raw-key verifier remains the unclaimed deliverable. *(Review follow-ups, same day.)* The registry gained a registrar-only `replace(pk, aHat)`: without it a registrar *bug* would strand every account committed to a key (signer, adapter and account addresses all pin the registry, so a fresh registry means fresh addresses). The cost is stated in the contract: registrar trust becomes ongoing rather than one-shot. `MlDsa44CommitSigner7913` now reverts with `VerifierCallFailed` when the ~15 M-gas inner verify does not complete (out of gas, malformed return) instead of answering `0xffffffff`, so a gas-capped ERC-1271 caller sees exhaustion, not "invalid", and gas estimation lands on the real cost; OpenZeppelin's `ERC7913Utils` and `Stealth8141ZkAccount._verify` still swallow reverts into "not authorized" by their own design. The adapter's public-input guards are unit-tested directly. Unrelated but in the same change set: `js-client/scripts/rpc-proxy.mjs` answers a replay miss on `eth_getBalance`/`eth_getTransactionCount`/`eth_getCode` from the recorded `eth_getAccountInfo` entry (and vice versa), because anvil picks between the two fetch shapes non-deterministically and CI replays failed on it. **Privacy.** Direct ML-DSA spends reveal `pk`, whose hash is the published `spend_key`: spent addresses of one recipient are linkable from the first spend (same boundary as D-018's C13 commit form; demonstrated in tests and vectors). Construction A: `stealth_pk` carries the recipient's `rho` verbatim (test `test_construction_a_rho.py`), so full-key disclosure at spend or `PKContract` deployment identifies the recipient of a `0x01` meta-address; D-018's "no such identifying event" is corrected in place. Receive-time hiding, key disclosure, signing security, proof-system soundness and account safety are documented as separate properties. The D-025 preimage demo is untouched and remains the browser route; it is not an ML-DSA demo and its UltraHonk backend is not PQ-sound. Per-address key rotation and a PQ-sound proof backend are separate work. **Not decided here.** Sole-normative `0x02` / retirement of `0x01`; whether a linkable direct-ML-DSA route belongs in the ERC; scheme IDs; whether `0x01` is kept with the `rho` linkability stated or reworked (no algebraic repair was attempted). The ERC text stays human-written. ## D-028 — The on-chain key exchange is a shape-checking announcer with a Lean model; the version byte names the layout and the KEM is typed by length; the viewing-key registry is an experiment outside the proposal — **FINDING (2026-09-07; renumbered from D-021 on 2026-09-09 and from D-027 on 2026-09-29; reconciled with D-024 and D-014's scope update on 2026-09-29)** Question (2026-09-07, user): can the key exchange be separated into the Solidity code, and can Lean be set up to verify that contract? Landed via PR #22's successor (this record's earlier text is in that PR's history). **What "the key exchange on-chain" can be.** The EVM computes in public. An on-chain `ML-KEM.Encaps` would publish the shared secret `ss` in the transaction trace, and with it the view tag and every derivation downstream of `ss` — the announcement would name its recipient to anyone reading the chain. So encapsulation and decapsulation are off-chain by necessity, and the chain's whole share of the handshake is (a) the recipient's encapsulation key and (b) the sender's ciphertext riding as the ERC-5564 `ephemeralPubKey`. `js-client/contracts/src/StealthKeyExchange.sol` carries that share, separated from the spend side (accounts, verifiers, factories): * `announce(stealthAddress, ciphertext, metadata)`: checks the announcement's SHAPE — a ciphertext of a supported set's length (768 / 1,088 / 1,568 B for ML-KEM-512/768/1024, 1,120 B for X-Wing; pairwise distinct, so the ciphertext types itself) and `metadata[0]` present — and forwards it byte for byte under scheme ID `2` to the ERC-5564 singleton. Scanners reading the singleton with `caller == StealthKeyExchange` see only well-formed announcements; the truncated-ciphertext vector is rejected on chain (`UnsupportedCiphertextLength(1087)`) instead of being skipped by every scanner. `announce` takes no recipient-identifying input by construction. A format-`0x02` payment (D-024) produces the same 1,088-byte ML-KEM-768 ciphertext, so the announcement path serves both meta-address layouts. Cost: 2,065 gas over a direct singleton call (`test_announce_overhead_gas`), \~3 % of D-011's 67,580-gas EIP-7623 floor. Wallets may keep calling the singleton directly; nothing about the scheme changes. * the pure parameter table (`ciphertextBytes`, `encapsulationKeyBytes`, `metaAddressBytes(kem, form)`, `kemOfMetaAddress`, `isValidAnnouncement`) wallets can call to pre-check what the contract will accept. * a write-once, index-referenced registry of encapsulation keys (SSTORE2, typed by length). **This is an implementation experiment, not part of the proposal**: meta-address distribution and the ERC-6538 registry are out of the ERC's scope (D-014 scope update, 2026-09-28; issue #36). It stays only as an implementation experiment whose Lean model (`js-client/contracts/lean/`) covers its announce/registry theorems; **that model is not an argument for its inclusion in the proposal** — nothing in the ERC's claim set, vectors, or security analysis depends on it (D-032). **Reconciliation with D-024 (done).** The record's earlier table followed D-017 (`0x01` for Construction A, `0x02` for the X-Wing hybrid) and rejected every D-024 meta-address with `UnsupportedMetaAddress(1217, 0x02)`. The table now follows what D-024 and D-027 already imply: **the version byte names the layout** — `0x01` is Construction A (`version ‖ rho ‖ pack23(t) ‖ ek`), `0x02` the commitment form (`version ‖ spend_key(32) ‖ ek`) — and **within a layout the KEM is typed by total length**. The eight lengths (3,777 / 5,633 / 7,489 / 5,665 and 833 / 1,217 / 1,601 / 1,249) are pairwise distinct (`Kem.metaAddressBytes_injective`), so the length alone identifies both set and layout and the version byte is a consistency check; a commitment-length string under `0x01`, or a Construction A length under `0x02`, is rejected. X-Wing therefore needs no version byte of its own (D-017's `0x02` note is superseded in place); the hybrid remains unimplemented off chain (issue #37). Format `0x02` still does not say which KEM it carries by any field — only by length — which is the point issue #37 has to settle before a second KEM is offered. **Lean verification of the contract.** `js-client/contracts/lean/` is a second, dependency-free Lean package (`StealthKeyExchange`, the `lean/` toolchain pin, no Mathlib, no VCVio; seconds on x86): a functional model of the contract with one line per `if`/`revert`, and theorems over all inputs — `announce_toBool` (succeeds iff `isValidAnnouncement`), `announce_ok` (exactly one log entry appended, scheme ID 2, address / ciphertext / metadata unchanged, registry untouched), `announce_no_registry_read` (the announcement path is the same function for every registry content), `registerViewingKey_ok` / `viewingKeyOf_after_register` (append-only, read-back), `kemOfMetaAddress_ok_iff` (typed exactly by layout byte and length), the table's injectivity, and an axiom audit (`Axioms.lean`, `#guard_msgs` on `#print axioms`). Model and bytecode are tied three ways: `Vectors.lean` (generated from the v0 and commitment vectors) replays every conformance case through the model as build-checked `#guard`s, the Foundry suite replays the same vectors and fuzzes `announce` against `isValidAnnouncement` on the real contract, and `scripts/check_constants.py` fails CI if the parameter table drifts between Solidity, Lean, the TS client and both vector files. **What is and is not claimed.** The proofs are about the model; its faithfulness rests on line-by-line correspondence plus the shared tests — the same footing as `lean/` vs. `python/`. A mechanized link to the compiled bytecode (Nethermind's Clear, Yul → Lean 4, or an EVM semantics such as EvmYul) is the upgrade path; both need Mathlib and belong in the multi-hour `lean.yml` lane, not the seconds-fast `ci.yml` job this package runs in. Nothing here is deployed. ## D-029 — The opener and view tag hash only the shared secret; a KEM is admissible to a profile only if its own derivation binds the encapsulation key and ciphertext — **FINDING / direction (2026-09-29; issue #38)** Question (issue #38): the ethresear.ch hybrid combiner derives its per-payment secret from `SHA3-256(DS ‖ r_ec ‖ r_pq ‖ epk ‖ ct ‖ viewing_pk_ec ‖ ek)`, hashing the ciphertext and both public keys into the transcript. Here the opener is `SHA-256(open_domain ‖ ss)` and the view tag is `SHA-256(ss)[0:1]`; neither includes `ct` or `ek`. Should they? **No — but the reason is weaker than a general transcript-binding formula.** FIPS 203 (Algorithms 17–18) does **not** hash the ciphertext into a successful shared secret: encapsulation derives `(K, r) ← G(m ‖ H(ek))` with a random [`m`](/lean/proofs/demo#m), sets `c ← K-PKE.Encrypt(ek, m, r)` and returns `(K, c)` — the successful `ss` contains `H(ek)` through `G`, but **not** `H(ct)`. What decapsulation *does* guarantee is the other direction: a length-valid but modified `ct′` fails the re-encryption check and yields the implicit-rejection secret `J(z ‖ c′)` (a pseudorandom function of the recipient secret and the received ciphertext), not `ss` and not an explicit error. A substituted ciphertext therefore does **not** "fail before the opener is derived" — it silently derives a different, attacker-unpredictable opener, so the recipient binding rests on the implicit rejection secret being computationally indistinguishable from a real one (IND-CCA of ML-KEM), **not** on `ct` appearing in a transcript hash. Consequences for the opener/tag design: * The opener and view tag hash only `ss`; whether that omits an external transcript binding the way X-Wing's combiner does is a **construction- specific question, not a settled one**. X-Wing (draft-10 §5.3/§6) omits the ML-KEM ciphertext from its combiner *deliberately*, and its security rests on a construction-specific argument (MAL-BIND-K/CT properties of the combined key plus the X25519 leg), explicitly not on a general "hash everything into the KDF" principle. We cite X-Wing as precedent for *omission being permissible*, not as a proof that applies to this profile. * **Open obligation (recorded, not resolved):** no reduction is on file that says omitting `H(ct)` from the opener/tag derivation in *this* construction preserves recipient anonymity under ciphertext-substitution experiments (e.g. an active sender re-encrypting to the same `ek` and presenting a modified `ct`; the recipient's view is then `J(z ‖ c′)`-derived, which the sender cannot compute, so the practical question is whether any distinguishing advantage arises from the substitution itself). Until such an argument exists, omitting external transcript hashing is an explicit, reviewed assumption of the profile — not a derived property. * **Derivation bytes are unchanged by this correction.** The implemented `ss` handling already matches FIPS 203 (the python/TS layers call the library's Encaps/Decaps and hash their output); the error here was in the *documentation's formula*, not in any derived address, vector, or Lean size check. External re-hashing would change all of those, and this decision does not authorize it; see the open obligation above for what a future revision would have to prove. **Admissibility requirement (normative for profiles).** A KEM is admissible to a profile on stated *security properties*, not on a syntactic formula: 1. **Confidentiality:** IND-CCA under the standard experiment for the KEM's parameter set (ML-KEM-768 is FIPS 203's NIST security category 3 parameter set). Implicit rejection must be part of the KEM's specification — a decapsulated-but-invalid ciphertext yields a pseudorandom secret, never an explicit failure observable to the sender. 2. **Recipient binding under substitution:** decapsulation of any ciphertext other than the one the honest encapsulator produced must derive a secret the sender cannot predict. For ML-KEM this holds via implicit rejection (`J(z ‖ c′)`) *for ciphertexts decapsulated under the honest `dk`*; it is a KEM property, and this profile additionally accepts the open obligation above rather than claiming a transcript-hash proof. 3. **Binding properties (MAL-BIND-K-PK / MAL-BIND-K-CT, in the X-Wing sense):** a malicious encapsulator cannot produce `(ek, ct)` pairs that decapsulate to a shared secret of its choosing under two different encapsulation keys, nor make one `ct` decapsulate inconsistently. X-Wing names and relies on these; ML-KEM's re-encryption check provides the ciphertext- binding; both are *construction-specific arguments*, and each profile must cite where its own binding comes from. A KEM lacking these properties is not made admissible by re-hashing its outputs; a profile that wants external transcript binding designs it in from the start — a new profile revision, not an edit of existing ones, since changing a domain moves funds. Stated in `docs/TECHNICAL_SPEC.md` §4 so ERC reviewers' "why isn't the ciphertext in the transcript" has a canonical, honest answer: *because the profile's admissibility argument for its KEM does not need it, and the residual question is recorded as open, not asserted as solved*. **View tag.** The thread's naive-view-tag attack (tag as a prefix of the address scalar) does not apply: the tag and the address inputs are different hashes of `ss`, and a format-`0x02` address has no EC scalar. The one-byte tag is a post-decapsulation filter, not a soundness surface — a tag match alone proves nothing, and a false positive falls through to the full address comparison (`check_commit_announcement` returns `None`). ## D-030 — Registry trust is deployment policy, not ERC scope; direct-signature spend routes are informative, and their linkability is a stated property — **FINDING (2026-09-30; issues #28, #33, #29)** Three questions arrived together and are decided together, because they are one question: how much of the spend side belongs in the ERC, and what the ERC says about the parts that stay out. **Registry trust (#28) is deployment policy.** `MlDsa44KeyRegistry.sol` (the issue title says `TrustedMlDsa44KeyRegistry`; the contract is named `MlDsa44KeyRegistry`) has an immutable-set `REGISTRAR`, recomputes `tr` and `t1` from the key bytes on chain, and trusts the registrar only for `aHat = ExpandA(rho)` — the ~40 M-gas operation that is issue #27's unsolved problem. `register` binds once; registrar-only `replace(pk, aHat)` re-binds (`KeyRegistered`, `KeyReplaced` events). Two failures to avoid, and they pull opposite ways: removing `replace` strands every account already committed to a key (signer, adapter and account addresses all pin the registry instance, so a wrong first `aHat` is permanent for funds already at a counterfactual address), while blessing `replace` unconditionally hides that registrar trust is ongoing rather than one-shot. The decision: **keep `replace`, and take the whole question out of normative scope** — exactly as D-014 took meta-address distribution out. Registry mechanics are profile-deployment policy. What a compliant registry must still do: emit both events so observers can audit re-binds, and the deployment must document who holds the role and how it is retired (multisig or time-lock; retire by transferring the role to an address that never calls it once #27 removes the need). §7 of `docs/ml-dsa-commit-profile.md` already describes the registrar as a live trust assumption with a correction path; this record upgrades that to the ERC-facing answer. No contract change. **Direct-signature spend routes (#33) are informative, not normative.** Every direct ML-DSA spend reveals `pk`, whose `keccak256(KEY ‖ pk)` is the published `spend_key`: all spent addresses of one recipient link to each other and to the meta-address from the first spend (`python/tests/test_profiles.py::test_two_direct_spends_are_linkable`, the vectors' `linkage` block, profile doc §5). Making that route normative would force every conforming wallet to implement a route that breaks the scheme's headline property for anyone who uses it. The repository already separates receive/scan conformance from optional spend-profile conformance (profile doc §6); the ERC mirrors that split: **normative = receive/scan** (announcement handling, view tag, address derivation, profile dispatch); **informative annex = direct-signature spend** (ML-DSA-44 commit signer, C13 commit signer), with the linkability property stated in the annex's first paragraph: revealing the key links all spent addresses of the recipient to the meta-address; unlinkable spending requires routes that never reveal the key (ZK / preimage). The contracts and vectors stay as they are — they are the evidence the annex describes; the UI does not expose the route. No code change. **Construction A's `rho` (#29) is a property, not a defect; `0x01` stays.** `stealth_pk = pack_pk(rho, t1')` carries the recipient's matrix seed verbatim, and `rho` is the first 32 bytes of the `0x01` meta-address (construction-a.md §4.1, pinned by `python/tests/test_construction_a_rho.py`). Announcements expose only `keccak256(stealth_pk)[12:]`, so receive-time privacy is unaffected; the exposure is at disclosure — on-chain ML-DSA verify, a deployed `PKContract`, pointer-signature key tables. Repairs considered and rejected here: per-stealth-key `rho' = H(rho ‖ ss)` breaks the correctness identity unless the recipient's key is re-derived per payment (a different scheme; it also re-opens the D-021 security freeze that `lean/` binds `pack rho` injectivity into), and a common-seed `rho` kills the per-recipient partition at the cost of making `ExpandA` global state and weakening the MLWE instance argument. Decision: keep `0x01` with post-disclosure linkability stated as a property, and note that it composes with #33 — a revealed `0x01` direct spend is linkable twice over (`rho` + spend-key hash), which is exactly what the informative annex says. If a repaired `0x01` is ever wanted, that is a new version byte / new profile with its own analysis, under the same new-domain-strings rule as D-029. **ERC text.** The one shared security-considerations paragraph covering both linkability channels is left for the human-authored `erc-draft.md` (D-024); the wording above is the record's language, not the draft's. Cross-links on issues #28, #33 and #29 are the remaining GitHub action, pending the maintainer's OK. ## D-031 — Gas-exhaustion semantics across the spend wrappers: verifier failures surface, underfunding fails fast at a floor, and only a completed `false` is forgery — **FINDING / implementation (2026-09-30)** Issue #32 (from PR #26 review). `MlDsa44CommitSigner7913.verify` had already stopped collapsing inner failures into "invalid": a gas-capped caller sees `VerifierCallFailed`, so exhaustion and forgery are distinguishable there. The layers above still swallowed every revert: * OpenZeppelin's `ERC7913Utils.isValidSignatureNow` (under `SignerERC7913`, i.e. `Stealth7913Account`, `Stealth7913Account4337`, `Stealth8141Account`) treats any inner revert as "not authorized". * `Stealth8141ZkAccount._verify` treated `ok == false` as `verify == false`. An integrator calling under a gas cap could not distinguish resource exhaustion from forgery, and gas estimation settles on the cheap failure path (a revert reads as "signature invalid" → wallet retries with the same underfunded budget, or silently misreports). **The fix, three layers.** 1. `Pq7913Signer` (new, `js-client/contracts/src/Pq7913Signer.sol`): `SignerERC7913` with the swallow removed. A verifier call that does not complete — out of gas under the caller's cap, or a malformed return — reverts `VerifierCallFailed(bytes returnData)`; a completed call that answers anything but the magic is "invalid" (unchanged). All three ERC-7913 account wrappers now inherit it (`Stealth7913Account`, `Stealth7913Account4337`, `Stealth8141Account` — the frame route now validates through `_validate7913` instead of `SignatureChecker.isValidSignatureNow`). The plain-address branch (`signer.length == 20`, ECDSA/ERC-1271) is delegated unchanged. No floor is checkable here: the verifier is caller-chosen at deployment (`verifier || key` bytes), so the contract cannot know a backend's cost. Consumers that catch reverts still see a failure — now an honest "underfunded", not a forged verdict. 2. `Stealth8141ZkAccount.executeFrame` checks `MIN_PROOF_GAS = 4_000_000` before the verifier call and reverts `GasFloorExceeded(floor, available)` (both values in the payload, so the sponsor sees exactly what to resupply). The shipped backends measure (whole call, forge, solc 0.8.30/prague): preimage-ownership ~3.0 M, UltraHonk C13 ~3.6 M — 4 M leaves headroom for larger proofs of the same statements; a cheaper replacement backend is unaffected (the floor is a caller budget, not a verifier cost). Above the floor, a backend revert surfaces as `ProofCallFailed(bytes returnData)`; a completed call with a malformed return is a malfunction (`ProofCallFailed`), not a bad proof; only a completed `verify == false` is `NotAuthorized`. The tooling surface `isValidProof` keeps the bool contract. 3. Documented minimum gas table (in `Pq7913Signer`): C13 verify ~110 k exec / ~188 k tx-level (D-019's anvil figure), preimage ~3.0 M, Honk C13 \~3.6 M, ML-DSA-44 ~16 M (D-027 route), ML-DSA-65 ~15 M (frames route). **What changed observably.** `Stealth8141ZkAccount` rejection tests (`ZkAccount.t.sol`, `PreimageZkAccount.t.sol`) previously expected `NotAuthorized` for wrong digest / tampered proof / other commitment: the Honk backends revert on those (they never answer `false`), and the old `_verify` collapsed the revert. Those tests now expect `ProofCallFailed` (partial selector match — the error carries the backend's own revert data). The account's own surface is unchanged for the accept path and the completed-`false` path; only the failure taxonomy changed. ERC-4337: a reverted `validateUserOp` is still a time-0 validation failure to the EntryPoint; the difference is decodable, not behavioral, at that layer. **What is and is not claimed.** The window fixed is the practical one: the account has gas for its own logic, the verifier does not. A frame so gas-starved that even the account's revert cannot be paid for still reverts generically — no contract can make an exhausted-gas revert self-describing. The floors and the no-swallow rule are about the integrator's diagnosis, not about new authorization power: nothing here changes what a valid proof or signature authorizes. Gas figures are measured on the shipped forge/solc stack and will drift with the toolchain; the assert bounds in `GasExhaustion.t.sol` are deliberately loose (C13 \< 2 M, i.e. 10× the measured figure) so the tests document rather than brittle-pin. Nothing is deployed. ## D-032 — ERC-8441 stays outside this repository; the repo is the EPF cohort artifact and decision log for our own scheme — **LOCKED (2026-10-28)** External work converged on the same design space: a stealth-address proposal for post-quantum recipients with an ML-KEM-768 (and hybrid) discovery layer. Its first public text was the author's draft repository, opened **2026-08-27**; its PQ-anonymity design was posted on ethresear.ch on **2026-09-28** (topic 26094 by `namnc` — this repository replied the same day), and its ERC text was opened as `ethereum/ERCs#2059` on 2026-10-09 and merged as **ERC-8441** on 2026-10-29 — concurrent with this work, not earlier than it (see "Not prior art" below). It is similar to, and in places ahead of, what this project specifies. It is **not** merged into, cited as normative by, or vendored into this repository, and it is not a dependency of any implementation here. **Decision.** This repository stays the record of *our* EPF cohort work: the scheme we specify, the vectors it generates, the proofs we checked, the decisions we made and the reasons for them. ERC-8441 is at most material for a human comparison — never a second scheme compiled, mirrored, or re-exported from here. **Not prior art (user, 2026-10-29; sources verified 2026-10-29).** The claim holds, but three public artifacts precede the post that a reader is most likely to cite, and the earlier "around 2026-10-01" figure matched none of them: 1. **Public draft repo — 2026-08-27.** `namnc/pq-stealth-scheme3-public` was created `2026-08-27T12:33:38Z`; its first commit (`e6dcba0`, `spec/ERC-VVVV-schemeid3.md`) is `2026-08-30T03:22:05Z`. This is the earliest *public* text of the proposal. 2. **Design disclosure — 2026-09-28.** "PQ anonymity for Stealth Address Protocol", ethresear.ch topic [26094](https://ethresear.ch/t/pq-anonymity-for-stealth-address-protocol/26094), created `2026-09-28T01:05:52Z` by `namnc`; the sibling spending post (topic 26095\) is `2026-09-28T01:09:06Z`. This repository replied in that thread the same day (`2026-09-28T08:54:43Z`), and D-029 (2026-09-29) argues against the thread's combiner — the engagement is on our own record, not reconstructed. 3. **Normative text — 2026-10-09 → 2026-10-29.** Opened as `ethereum/ERCs#2059` on 2026-10-09, Magicians thread ([29923](https://ethereum-magicians.org/t/29923)) the same day, merged as ERC-8441 on 2026-10-29 (commit `4878022`). The frontmatter's `created: 2026-08-28` is the authors' self-reported drafting start (consistent with artifact 1), **not** a disclosure date; quote artifact 1 instead. Every artifact postdates our implementation, vectors, contracts and proofs: first commits 2026-08-06/07, JS client 2026-08-14, Lean core 2026-08-17, and the target scheme ID `2` chosen 2026-08-26 06:18Z (`c9fc5b9`) — i.e. **~30 h before the author's public repo existed**. D-013's game layer is 2026-07-31 and D-024's format-`0x02` commitment 2026-09-04. It is therefore a **concurrent** design that converged on the same space, not an earlier disclosure we built on or failed to precede. Nothing in this repo is derived from it, and no claim of priority either way should be read into this record. **Scope of the claim.** "Not prior art" is about *this repository's* derivation history, not a legal priority position. The honest statement is narrow and checkable: our scheme-ID-`2` selection (`c9fc5b9`, 2026-08-26 06:18Z) predates the author's public repo by ~30 h; the format-`0x02` commitment (D-024) is 2026-09-04; and the `ml-dsa-44-commit/v0` profile (`f8b0f77`, 2026-09-27 23:28Z) predates the 09-28 ethresear.ch post (01:05Z) by 1 h 37 min — with the post's own first reply from this repository at 08:54Z the same morning. Date a specific artifact rather than the project. Quote the dates above; never "around October 1". Concretely: 1. No ERC-8441 modules, pages, or decision records land on `main`. The `feat/erc-8441-scheme3-lean` branch carries that work (hybrid-KEM constructor, scheme-3 wire model, ANO-CCA capstones) and stays out; its `docs/DECISIONS.md` numbers D-032–D-034 are therefore **not** part of this log, and a future merge would have to renumber them against this entry. 2. Review scratch — notes, PR comment drafts, anonymity supplements under `docs-review-ws/` — is untracked and now gitignored. It is working material for reviewing somebody else's proposal, not an artifact of ours. 3. The generated proof browser must never publish a module the repository does not contain. `lean/scripts/gen_browser.py` previously only ever added pages, so pages generated on a branch survived its removal and `wiki/scripts/sync.mjs` (which mirrors `lean/docs-proofs/*.md` wholesale) would publish them. It now prunes pages with no matching `lean/PqStealth/.lean`, which is what removed the leftover scheme-3/ANO-CCA pages from a local tree. **Why record it at all.** Because "we did not do this" is itself a decision with a reason, and this repository is the place the reasoning survives alongside the work that was done. Anyone reading the log should not have to reconstruct from an absent branch whether the omission was an oversight or a choice. **Enforcement (2026-10-29).** The rule is now mechanical rather than a review habit: `scripts/check_scope.py` scans every file that would land on `main` (tracked, plus untracked and not gitignored) for identifiers that only exist in that work tree — `Scheme3Wire`, `ANOAssumption`, `ClosedUnlinkability`, `HybridKEM`, `fo-rom`, `scheme3-…`/`scheme-3-…` — and the `scope` job in `.github/workflows/ci.yml` runs it. The bare string `8441` is deliberately not matched: prose that records the separation must stay legal, and four hex digits occur inside vector constants by chance. A planted `Scheme3Wire` was verified to fail the check. **What it does not change.** Scope under D-024/D-025 is unaffected: the key-exchange ERC remains the deliverable, spending stays supporting research. No vector, contract, proof, or measurement in this repo depends on ERC-8441. ## D-033 — The in-repo cohort proposal is the canonical text; the upstream cohort copy is refreshed from it — **LOCKED (2026-10-29)** Question (audit B01-F1, 2026-10-29): two copies of the cohort proposal exist — this repository's `projects/pq-sap.md` and `eth-protocol-fellows/cohort-seven/projects/pq-sap.md` — and they have diverged, so every "the proposal says" statement in outside communication is ambiguous. **Measured drift (2026-10-29).** 86 diff lines, 47 changed lines, all in the repository's favour. The repo copy carries: the Hegotá/EIP-8141 roadmap reference; "we dropped the Curvy run on purpose" with the measured 67,580-gas announcement (anchored at D-011 and `python/benchmarks/onchain_results.json`); construction-A pseudocode with the ML-DSA spending keypair rather than a second ML-KEM keypair; and the cost-report status. The cohort copy still says "The Curvy run and the L2 pricing are still to do" and keeps the `prooving` / `existiing` typos. **Decision.** `projects/pq-sap.md` is canonical. The cohort PR is refreshed *from* it; the cohort copy is never edited in place, because the next refresh would overwrite those edits. The tracked copy now carries that instruction in a header comment so the split cannot silently re-form. **What this does not settle.** Pushing the refresh needs credentials this session did not have (`git push --dry-run` to `Skanislav/pq-sap` failed authentication); until the upstream PR is updated, the safe wording in outside communication is "our repository's proposal text", with the commit hash, not "the cohort proposal". *(Superseded, 2026-10-30 — see D-035.)* The refresh never happened: the cohort PR **was** merged on 2026-10-30 as `c392779`, carrying the pre-retrospective text, so the divergence this entry describes is now permanent upstream and cannot be resolved by refreshing it. The in-repo copy has moved out of `projects/` and the two-copy arrangement is retired. The measurement above stands. ## D-034 — The cohort proposal is revised into a retrospective; the spend stretch goal is closed and the successor-work rule is recorded — **LOCKED (2026-10-29)** End of cohort. Two editorial decisions and one factual one. **The tracked proposal becomes a retrospective.** [`PROJECT_RETROSPECTIVE.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/PROJECT_RETROSPECTIVE.md) keeps the proposal's structure (motivation, research question, deliverables, roadmap, challenges, success criteria, resources) but each section now separates **"As proposed"** from **"Outcome"**. This is deliberate: the value of a cohort artifact at the end is the delta between what was planned and what exists, and rewriting the text into a pure post-hoc description would delete the evidence that the plan was ever different. The success criteria are scored rather than restated — four of five met, the first (a frozen ERC draft with normative text) explicitly partial, with the reason named. (Path updated 2026-10-30; the file left `projects/` under D-035.) **The spend stretch goal closes as delivered, not as required.** The [stretch-goal report](/research/stretch-goal-report) assembles the requested measurements — speed, gas, end-to-end implementation, ZK ownership proof — and keeps them outside the ERC's conformance surface. The verdict it records is that post-quantum spending is possible today at 0.19 M (direct C13) to 3.54 M (ZK proof) gas per spend versus 13.68 M for an on-chain ML-DSA verify, but that the ZK route is a *privacy* purchase rather than a cost saving, and the genuinely cheap routes (EIP-8164 native keys at 50 k, EIP-8288 aggregation at a proposed 3 k/30 k per dependency) are protocol changes a scheme ID cannot deliver. No claim in the ERC package depends on any of it. **If a similar proposal arises, contribute to the existing effort.** Recorded because it is a judgement about where effort is worth spending, not a description of this repository: the remaining risk in PQ stealth addresses is the concrete commitment/address privacy composition, the normative text and its review, and protocol-native spending — not the absence of an implementation. New work should adopt this repository's vectors, deployment binding and per-route gas table and target the open security composition, rather than produce a seventh independent design. **Two provenance defects fixed while assembling the report.** Both are in living documents; the dated entries keep their text and gain a correction note (D-011). * `onchain_cost.py` reported `measured_gas` on *every* row it modelled, so `announce_dksap` carried the PQ row's 67,580 with a `-59.54%` "error". DKSAP was never measured on chain. `measured_gas`/`error_pct` are now `None` unless a measurement is passed in explicitly. * The model reproduces the measurement to 0.04%, not to the gas: 67,550 on the committed vectors against 67,580 measured, the residual being the zero/nonzero-byte split of the encoded arguments. Every "0.00% error" claim is now ≤0.04%. * The native scan slope in circulation had two values; 22.6 µs (1,809 ms / 80,000) is the one that reproduces from the committed row, and the cost report and benchmarks README now quote it and say so. ## D-035 — The cohort proposal leaves `projects/`; the retrospective is the single copy, filed with the docs — **LOCKED (2026-10-30)** Question. The cohort PR merged on 2026-10-30 as `c392779` carrying the pre-retrospective text, so D-033's "refresh the upstream copy from ours" is dead: the public cohort artifact states the 5,633-byte Construction A meta-address as the default and lists ERC-6538 in scope, which no longer matches what this repository implements. A file that (a) restates a design decision the project overturned, (b) duplicates content already in `docs/`, and (c) exists only to be copied upstream is a liability in the artifact trail. **Decision.** `projects/pq-sap.md` → [`docs/PROJECT_RETROSPECTIVE.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/PROJECT_RETROSPECTIVE.md). `projects/` is emptied and removed. The document is kept — it is the only record of the planned-versus-delivered delta (D-034) — but it is filed with the decision log and the specs, where a reader looking for "why does this repository look like this" is already standing, rather than under a directory whose entire purpose was to hold a copy of someone else's artifact. Its 18 former `../docs/…` links became same-directory links, and the header no longer claims canonical-source status it cannot enforce. **Not done, deliberately.** No history rewrite: `git log --diff-filter=D -- projects/pq-sap.md` still recovers the old path, and the merged cohort commit is permanent. Deleting the text from this repository would also delete the only copy of the retrospective, since the cohort repo has the plan and not the work. **What this does not change.** No vector, contract, proof, measurement, or claim in the ERC package referenced `projects/pq-sap.md`; `README.md` is the only inbound link and it is updated in the same change. # pq-stealth — executable spec :::info[Mirrored page] Source: [`python/README.md`](https://github.com/Skanislav/pq-sap/blob/main/python/README.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Python is the byte-level source of truth for the [key-exchange technical reference](/spec/technical-spec). Start with `pq_stealth/commit.py`: ML-KEM viewing keys, commitment-format meta-addresses, sender derivation, and viewing-key scanning (D-024/D-025). `pq_stealth/__init__.py` exposes the separate Construction A API; it is not the commitment-format entry point. Construction A's ML-DSA blinding/signing modules, classical hybrid, and spend helpers are supporting implementations. See [ERC evidence](/spec/erc-evidence) and [spending research](/research/spending) for their boundaries. The Python library is an executable reference, not a hardened production signer. ## Setup ```sh python -m venv venv && . venv/bin/activate pip install -e ".[dev]" # add .[audit] for the liboqs cross-check ``` ## Run ```sh pytest # full reference suite pytest tests/test_commit.py # commitment format / scan behavior pytest tests/test_profiles.py # explicit profiles, ML-DSA-44 key commitment, authorization python vectors/generate_mldsa44_commit_vectors.py --outdir /tmp/pq-mldsa44 cmp vectors/v0/mldsa44_commit_vectors.json /tmp/pq-mldsa44/mldsa44_commit_vectors.json python vectors/generate_commit_vectors.py --outdir /tmp/pq-commit-vectors cmp vectors/v0/commit_vectors.json /tmp/pq-commit-vectors/commit_vectors.json python vectors/generate_vectors.py # regenerate vectors/v0/vectors.json # (deterministic: byte-identical output) ``` ## Docker Self-contained image with the liboqs cross-check backend built in (pinned to match `liboqs-python`): ```sh docker build -t pq-stealth-py . docker run --rm pq-stealth-py # full reference test suite # conformance vectors: regenerate and confirm byte-identical docker run --rm pq-stealth-py sh -c \ "cp vectors/v0/vectors.json /tmp/ref.json \ && python vectors/generate_vectors.py \ && cmp vectors/v0/vectors.json /tmp/ref.json && echo vectors OK" ``` ## Layout | Module | Contents | |---|---| | `pq_stealth/commit.py` | commitment-format key exchange and address derivation (start here) | | `pq_stealth/profiles.py` | explicit account profiles over `0x02`; ML-DSA-44 key commitment and `pk ‖ opener ‖ sig` authorization (D-027) | | `pq_stealth/params.py` | parameter sets (default ML-KEM-768 + ML-DSA-65) | | `pq_stealth/blinding.py` | the algebraic core: `t' = A·s' + e' + t` | | `pq_stealth/encoding.py` | meta-address / full-`t` / blinded-sk packing, keccak addresses | | `pq_stealth/meta.py` | recipient keygen, meta-address assembly | | `pq_stealth/sender.py` | encaps → stealth address → announcement | | `pq_stealth/recipient.py` | scanning with view-tag fast path | | `pq_stealth/signing.py` | blinded FIPS 204 signing, proof of possession | | `pq_stealth/classical/` | classical-spend hybrid (secp256k1 + ML-KEM); see below | Account spendability depends on the selected deployment and verifier. The reference functions derive announcements/addresses; transaction execution is handled by the separate account integrations. A raw Construction A ML-DSA public-key hash is not an ECDSA-controlled EOA. ## Classical-spend hybrid (secp256k1) An alternative construction that keeps the ML-KEM key exchange but blinds a **secp256k1** spending key, so the stealth output is a normal EOA, **spendable today** with a plain ECDSA transaction. It reuses this package's `Announcement`, view-tag, and ERC-6538 registry rail; only the blinding backend and the EOA address rule differ. Full write-up: [`docs/classical-spend-hybrid.md`](/spec/classical-spend-hybrid). It is opt-in: the only dependency beyond the core is `coincurve` (the `bench` extra). ```sh pip install -e ".[bench]" # adds coincurve (libsecp256k1) pytest tests/test_classical_*.py # roundtrip, negative, vectors (18 tests) python vectors/generate_classical_vectors.py # regenerate vectors/classical/v0 # (deterministic: byte-identical output) ``` End-to-end in a few lines (send, detect, derive the EOA key, prove control): ```python from pq_stealth.classical import ( gen_meta_address, send, check_announcement, derive_stealth_privkey, eth_address, ) meta_pub, meta_priv = gen_meta_address() # recipient: secp256k1 + ML-KEM keys ann = send(meta_pub) # sender: ML-KEM encaps -> stealth EOA pay = check_announcement(meta_pub, meta_priv.kem_dk, ann) # recipient: scan + detect priv = derive_stealth_privkey(meta_priv.spend_priv, pay.shared_secret) assert eth_address(priv.public_key) == ann.stealth_address # a plain ECDSA key controls it ``` Unlike the ML-DSA scheme above, value sent here is spendable immediately: `priv` is an ordinary secp256k1 key. | Module | Contents | |---|---| | `pq_stealth/classical/params.py` | parameter sets (default secp256k1 + ML-KEM-768) | | `pq_stealth/classical/blinding.py` | the algebraic core: `t = KDF(ss) mod n`, `P = K + t·G` | | `pq_stealth/classical/encoding.py` | meta-address packing, EOA keccak addresses | | `pq_stealth/classical/meta.py` | recipient keygen, meta-address assembly | | `pq_stealth/classical/sender.py` | encaps → stealth EOA → announcement | | `pq_stealth/classical/recipient.py` | scanning with view-tag fast path | # pq-stealth-client — TypeScript scanning client :::info[Mirrored page] Source: [`js-client/README.md`](https://github.com/Skanislav/pq-sap/blob/main/js-client/README.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Client-side (wallet/frontend) scanning for the post-quantum ERC-5564 stealth address scheme specified in [`docs/TECHNICAL_SPEC.md`](/spec/technical-spec). The commitment route starts at `src/commit-scheme.ts`: decapsulate with the viewing key, compare the tag, derive the commitment and account address using the selected domains/deployment, then compare the announcement. It mirrors Python `pq_stealth/commit.py`; scanning requires no spending secret. `src/scheme.ts` and `src/mldsa65.ts` implement the separate Construction A route, including a hand-port of the polynomial layer checked against Python vectors. Spending helpers and account E2E tests are supporting research, not requirements for commitment-format scanning. See [ERC evidence](/spec/erc-evidence) and [spending research](/research/spending). ## Toolchain Node **>= 26** (`.nvmrc` provided) — tests run the `.ts` sources directly via native type stripping, no build step. TypeScript **7** is used for typechecking only (`strict` + `erasableSyntaxOnly` + `verbatimModuleSyntax`, relative imports carry explicit `.ts` extensions). ## Run ```sh nvm use npm install npm run typecheck # tsc 7, noEmit npm run test:commit # commitment-format Python vector replay npm run test:mldsa44 # ml-dsa-44-commit/v0 profile vector replay; every authorization # payload re-verified with @noble/post-quantum ml_dsa44 npm test # separate Construction A vector replay npm run build-contracts # forge build (requires foundry) npm run e2e # spawns anvil, deploys the ERC-5564 announcer, # announces the vectors on-chain, scans the logs npm run e2e-7913 # ERC-7913 spend route (D-014): blinded sig verifies # through the vendored ZKNOX verifier + an # OpenZeppelin SignerERC7913 account npm run e2e-7913-sphincs # hash-based spend (D-018): SPHINCS- C13 signature # verifies through the vendored Verity-verified verifier, # raw-key and committed ERC-7913 signers, same account npm run e2e-mldsa44 # ml-dsa-44-commit/v0 (D-027): trusted-registrar key setup, # committed-key ERC-7913 verify through the vendored ZKNOX # ML-DSA-44 verifier, executeFrame through the unchanged # Stealth8141ZkAccount; local only, nothing deployed npm run test-contracts # forge test: the key-exchange contract against the # v0 vectors (needs foundry) npm run e2e-key-exchange # register an ML-KEM ek, announce the vectors through # StealthKeyExchange.sol (truncated ct rejected # on-chain), scan the singleton's log npm run e2e-pointer-sig # (v, r, s) pointer signatures with r = the C13 key # (0x52) or its commitment (0x53); ML-DSA 0x50/0x51 # at recover level. Value lives in the vault — see # docs/pointer-signatures-poc.md "Where the value lives" npm run e2e:fork # Sepolia-fork rehearsals (announce/verify + spend); # replays test/state/*.rpc.json offline if present, # otherwise records it (needs a Sepolia RPC) npm run e2e:fork:record # force a fresh recording ``` The Construction A conformance test asserts that the JS-derived stealth public key is byte-identical to the Python reference, and verifies the vectors' possession proof with noble's stock ML-DSA-65 verifier. ## The key-exchange contract (D-028) `contracts/src/StealthKeyExchange.sol` is the on-chain half of the ML-KEM handshake, separated from the spend-side contracts: an `announce` that checks the announcement's shape (ciphertext of a supported length, view tag present) and forwards it byte for byte to the ERC-5564 singleton under scheme ID 2, a parameter table in which the meta-address version byte names the layout (`0x01` Construction A, `0x02` the D-024 commitment form) and the KEM is typed by total length, and a write-once registry of encapsulation keys (SSTORE2, typed by length) that is an implementation experiment outside the proposal (the ERC-6538 registry and meta-address distribution are out of scope, D-014 scope update). Encapsulation itself never runs on-chain — the EVM computes in public and would publish the shared secret. `src/key-exchange.ts` is the client (ABI, the parameter table, `kemOfMetaAddressLength`, `announceViaKeyExchange`, `registerViewingKey`); `contracts/test/StealthKeyExchange.t.sol` replays the v0 and commitment vectors on the bytecode; `contracts/lean/` is a dependency-free Lean 4 model of the contract with proofs (see its README). ## Reproducible fork state The Sepolia-fork e2e tests (`test/e2e-sepolia-fork*.test.ts`, `test/e2e-fork-pq-only.test.ts`) run anvil behind `scripts/rpc-proxy.mjs`, which supports **record/replay** at the RPC boundary (`test/util/anvil.ts` wires it up): * **record** (first run, or `FORK_RECORD=1`): anvil forks the upstream pinned at a block (`--fork-block-number`, latest−5 at record time; `SEPOLIA_FORK_BLOCK` overrides) and every upstream response is saved to `test/state/.rpc.json`, keyed by `(method, params)`. Upstream defaults to `ethereum-sepolia-rpc.publicnode.com` (`SEPOLIA_RPC_URL` overrides). * **replay** (cache file exists): the proxy serves *only* from the cache — no network, no RPC quota, deterministic to the gas unit, ~17× faster. A cache miss (test changed, anvil version changed the fetch pattern) fails loudly with the missing key; re-record then. The cache files are a few hundred KB of public Sepolia contract state and are meant to be committed: anyone can rerun the fork rehearsals offline. CI (`.github/workflows/ci.yml`) does exactly that — replay mode with `SEPOLIA_RPC_URL` pointed at an unreachable address, so no run can silently fall back to a live RPC. Why record at the RPC boundary instead of anvil's native `anvil_dumpState`/`--load-state`? Measured on anvil 1.4.1: a fork's dump contains only accounts touched by local *transactions* — state reached only via `eth_call`/`readContract` (e.g. the factory `getAddress` and verifier `verify` steps here) is silently absent after `--load-state`, and the fork's chain id is not preserved either. The RPC cache is complete by construction. `--dump-state`/`--load-state`/`--state` remain the right tool for *non-fork* chains, where the dump is total. ## Vendored verifier (not committed) `npm run build-contracts` and the 7913 e2e expect the ZKNOX ETHDILITHIUM sources (incl. their `lib/` dependencies: openzeppelin-contracts 5.5.0, solady, forge-std, account-abstraction) at `contracts/lib/ETHDILITHIUM`, pinned to rev `df999ed`. The tree is ~42 MB and is gitignored; restore it with: ```sh git clone --recurse-submodules https://github.com/ZKNoxHQ/ETHDILITHIUM \ contracts/lib/ETHDILITHIUM git -C contracts/lib/ETHDILITHIUM checkout --recurse-submodules df999ed ``` `contracts/lib/ETHDILITHIUM/VENDORED_REV.txt` records the pinned rev of a restored tree. ## ML-DSA-44 committed key (D-027) `src/profiles.ts` mirrors `python/pq_stealth/profiles.py`: explicit account profiles over the 0x02 format, `spendKeyFromMlDsaPk`, `buildAuthorization` (`pk || opener || sig`, pure ML-DSA-44, empty context, over the account's 32-byte digest) and the reference `verifyAuthorization`. On chain, `contracts/src/MlDsa44CommitSigner7913.sol` is the ERC-7913 verifier (`key` = commitment), `contracts/src/MlDsa44KeyRegistry.sol` the one-time key setup (recomputes `tr` and `t1` from the key bytes; **trusts its registrar for the expanded matrix** because on-chain `ExpandA` costs ≈ 40 M gas with the vendored SHAKE; the registrar can `replace` a wrong binding, so that trust is ongoing), and `contracts/src/frames/MlDsa44CommitFrameVerifier.sol` the adapter that lets the existing `Stealth8141ZkFactory` bind it. A spend reveals the key and links that recipient's spent addresses. Record, costs, and open items: `docs/ml-dsa-commit-profile.md`. ## Vendored verifier (committed): SPHINCS- C13 `contracts/src/vendor/sphincs-minus/SPHINCs-C13Asm.sol` is the Verity Labs machine-checked hash-based verifier from `lfglabs-dev/SPHINCS-` @ `2a40d0a`, copied byte for byte (270 lines, MIT; sha256 and re-verification command in `VENDORED_REV.txt` next to it). It is compiled with upstream's settings (via-IR, 200 runs) through a per-path `compilation_restrictions` entry in `contracts/foundry.toml`; everything else keeps the default profile. `contracts/src/SphincsC13Signer7913.sol` wraps it as two ERC-7913 signers (raw 32-byte key, and a commitment opened in the signature that makes the stealth address sender-derivable at the cost of spend-time linkability — see `docs/DECISIONS.md` D-018). Byte conventions: `src/sphincs.ts`. The fixture `python/scripts/sphincs_c13_7913_demo.json` was produced by `python/scripts/sphincs_c13_7913_demo.py` with upstream's Rust signer (`cd signer-wasm && cargo build --release --bin signer-c13`; keygen 0.2 s, one signature a few seconds); the script is deterministic, so a regenerated file must be byte-identical. The e2e test announces the two genuine payments plus the tampered variants (wrong view tag, truncated ciphertext, bit-flipped ciphertext) through a local ERC-5564 announcer on anvil, then scans the `Announcement` logs with viem: recipient A finds exactly the two genuine payments, recipient B finds nothing. # Lean evidence and supporting research :::info[Mirrored page] Source: [`lean/README.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/README.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: [![Lean](https://github.com/Skanislav/pq-sap/actions/workflows/lean.yml/badge.svg)](https://github.com/Skanislav/pq-sap/actions/workflows/lean.yml) The Lean development contains generic KEM/announcement results, Construction A address and spending research, classical controls, and proof infrastructure. The ERC now focuses on key exchange (D-024). Start with the [ERC evidence map](/spec/erc-evidence); reviewing the ERC does not require reading the entire development or building Lean. ## Reading paths * **Key exchange:** [security overview](/spec/security-analysis) → `Games` and `KEMAnonymity` → `SharedSecretHiding` → `AnonymityFromSPR` → `MLKEM` and `SPRTwoHop`. Add `MultiUnlink`, `MultiRecipient`, and the KEM portion of `Soundness` for the corresponding claim. The commitment-format roundtrip is in `Invariants`. * **Construction A address form:** [reference](/research/construction-a) → `Blinding`, `Invariants`, `ConstructionA`, `BlindingROM`, `BlindingEntropy`. * **Spending:** [Construction A security](/research/construction-a-security) → `Ownership`, `WidenedSigning`, `SpendSecurity`, `ConstructionASecurity`. * **Classical comparison:** `Demo`, `DKSAP`, `DKSAPOracle`. * **Maintaining proofs:** the build section below, [pin policy](/lean/vcvio-pin), [upstream notes](/lean/vcvio-upstream), and [tooling guide](/lean/tooling). ## What is established, and what is not The generic full-announcement model decomposes unlinkability into shared-secret hiding, auxiliary-data independence, and KEM anonymity. `SharedSecretHiding` identifies its hiding terms with KEM IND-CPA advantages. `AnonymityFromSPR` and `SPRTwoHop` structure the anonymity reduction with seeded-MLWE terms and named primitive/encoding assumptions. These are conditional statements. Construction A instantiates the auxiliary data with a blinded ML-DSA address. Its ROM query bound is proved in `BlindingROM`/`BlindingEntropy`. That proof is about Construction A, not the D-024 commitment/CREATE2 derivation. The latter has an abstract encoding roundtrip and executable vectors, but no dedicated end-to-end privacy capstone in this tree. The related-key spend reduction proves a bound for **ownership witnesses**. It is not signature EUF-CMA security. Signature-layer losses remain uncomposed; the widened HVZK bound is the trivial `1`. Active ANO-CCA mapping to FIPS-203 ML-KEM, quantum random-oracle access, and runtime side channels are not supplied by these results. See the separate security documents above. ## Complete module map All modules below live in [PqStealth/](https://github.com/Skanislav/pq-sap/tree/main/lean/PqStealth/). Categories describe the mathematics, not isolated build targets. | Track | Modules | Role | | --- | --- | --- | | Announcement model | `Games`, `KEMAnonymity` | Scheme experiments, KEM anonymity, full auxiliary-data model and completeness | | KEM reductions | `SharedSecretHiding`, `AnonymityFromSPR`, `MLKEM`, `SPRTwoHop` | Hiding/IND-CPA bridge, SPR route, concrete ML-KEM types, conditional seeded-MLWE decomposition | | Multiple challenges | `MultiUnlink`, `MultiRecipient` | Generic hybrid bounds | | Detection | `Soundness` | Conditional KEM false-positive bounds and classical comparison | | Mixed encoding/algebra | `Invariants` | Both meta-address roundtrips plus ML-DSA norm/signing-key results | | Construction A derivation | `Blinding`, `ConstructionA`, `BlindingROM`, `BlindingEntropy` | Blinded-key identity, announcement instantiation, address-hash query and entropy bounds | | Construction A spending | `Ownership`, `WidenedSigning`, `SpendSecurity` | Ownership/SIS relation, widened signing facts, related-key witness reduction | | Construction A assembly | `ConstructionASecurity` | Re-exports/combines address and spending statements; not a commitment-profile capstone | | Classical research | `DKSAP`, `DKSAPOracle`, `Demo` | Classical scheme, attack/reduction models, runnable toy example | | Proof support | `Reorder`, `ROMUpToBad`, `Controls` | Sampling identities, random-oracle machinery, positive/negative controls | | Audit | `Axioms` | Frozen axiom lists for selected headline declarations | `Games` still imports `Blinding`; `Invariants` mixes encodings and ML-DSA; `Soundness` imports `DKSAP`. The root and audit import all tracks. This refactor separates documentation, not the compilation graph. No spending proofs or audit guards have been removed. ## Build and verification for maintainers From `lean/`, using the pinned Lean 4.32.0 / VCVio revision in [lakefile.toml](https://github.com/Skanislav/pq-sap/blob/main/lean/lakefile.toml) and [lean-toolchain](https://github.com/Skanislav/pq-sap/blob/main/lean/lean-toolchain): ```sh lake exe cache get lake build python3 scripts/check_citations.py python3 scripts/check_sizes.py ``` VCVio compiles from source; a cold build can take hours. Do not update the pin as part of routine documentation work. The [pin guide](/lean/vcvio-pin) documents the reviewed upgrade procedure. `Axioms.lean` uses `#guard_msgs` around `#print axioms` to freeze selected headline dependency cones; an unexpected `sorryAx` or other axiom changes the output and fails the guard. CI additionally rejects package warnings/sorries. This checks proof dependencies, not whether hypotheses are true, non-vacuous, or applicable to application code. `Demo.lean` guards toy evaluation output. The citation script checks supported source-range citations; the size script checks selected Construction A/classical vector sizes. It does not currently read commitment vectors. Python/TS commitment replay is separate evidence. The [Lean CI workflow](https://github.com/Skanislav/pq-sap/blob/main/.github/workflows/lean.yml) runs the build, warning check, and both scripts. Build status must be read from an actual run; these instructions are not a fresh full-build claim. # The announcement model :::info[Mirrored page] Source: [`lean/docs/announcement-model.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/announcement-model.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Scope: generic announcement model with a Construction A instantiation. This essay does not instantiate the D-024 commitment/CREATE2 profile; that instantiation is written up in [docs/research/commitment-announcement-privacy.md](/research/commitment-announcement-privacy) (research note — the [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) argument there is a sketch, not a theorem). Current claim boundaries and reading order: [ERC evidence](/spec/erc-evidence). Background for `PqStealth/Games.lean`, `PqStealth/KEMAnonymity.lean`, `PqStealth/ConstructionA.lean` and the game-layer controls. Everything here is design rationale: what the Lean model commits to, why, and what it deliberately cannot say. The theorems themselves live in the modules. ## The scheme as an abstract randomized algorithm [`StealthScheme`](/lean/proofs/games#stealthscheme) mirrors VCVio's `AsymmEncAlg`: the algorithmic data lives in `ProbComp`, and the security experiments are built on top. `announce` is the sender's operation (produce an announcement addressed to a meta-address); `scan` is the recipient's detection test; [`CorrectExp`](/lean/proofs/games#correctexp) / [`PerfectlyComplete`](/lean/proofs/games#perfectlycomplete) are the correctness side, matching VCVio's [`CorrectExp`](/lean/proofs/games#correctexp) / `PerfectlyCorrect`. [`MetaPub`](/lean/proofs/constructiona#metapub), [`MetaPriv`](/lean/proofs/constructiona#metapriv) and `Announcement` are opaque type parameters, so the definitions commit to no parameter set. The concrete instantiations (ML-KEM, ML-KEM-768, construction A, DKSAP) come later and reuse the same games. The headline property is UNLINKABILITY (recipient anonymity): given an announcement you cannot tell which recipient it is for. That is the key-privacy sibling of IND-CPA — the hidden bit selects the RECIPIENT, not the message. The one structural fact that holds before any assumption enters is [`unlinkAdvantage_eq_branchDistAdvantage`](/lean/proofs/games#unlinkadvantage_eq_branchdistadvantage): the unlinkability advantage equals the adversary's advantage in distinguishing an announcement to recipient 1 from one to recipient 0. That is the first game hop, and the definitional heart of anonymity. ## Why the adversary is a function, not a two-phase structure The unlinkability adversary is ```lean abbrev StealthScheme.UnlinkAdv (MetaPub Announcement : Type) := MetaPub → MetaPub → Announcement → ProbComp Bool ``` — it sees both public meta-addresses and the challenge announcement, and guesses. It depends only on the public types, not on the scheme, which is what lets the same type serve as a KEM key-privacy adversary in `KEMAnonymity`. Earlier the same object was a two-phase structure with a `State : Type` field: `setup : MetaPub → MetaPub → ProbComp State` followed by `distinguish : State → Announcement → ProbComp Bool`. The two are equivalent for these games, and the function form is strictly better here: * **The games are non-adaptive.** Nothing happens between the two phases that the adversary could react to — the challenger draws two keypairs, then produces one announcement. A two-phase adversary can therefore be replaced by the single function `fun pk0 pk1 c => adv.setup pk0 pk1 >>= fun st => adv.distinguish st c`, and conversely a function is the two-phase adversary with `State := MetaPub × MetaPub`. Nothing in the development ever used `adv.setup` on its own. * **It removes a universe bump.** `UnlinkAdv : Type` instead of `Type 1`, since there is no bundled `State : Type` field. * **It makes the shared game prefix key-only.** [`unlinkSetup`](/lean/proofs/games#unlinksetup) and `KEM.anonSetup` become `ProbComp (MetaPub × MetaPub)` and `ProbComp (PK × PK)`; they no longer take the adversary as an argument. That is what lets `KEM.anonSetup (adv.cipherOf auxGen)` and `(StealthScheme.ofKEMFull kem auxGen).unlinkSetup` be recognized as the same computation, which is the crux of [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le). With a bundled `State` the two prefixes ran different `setup`s and could not be identified. ## Two models of the announcement, and why the coarse one is not enough [`StealthScheme.ofKEM`](/lean/proofs/kemanonymity#stealthschemeofkem) treats the announcement as just the KEM ciphertext, and [`unlinkAdvantage_ofKEM_eq_anonAdvantage`](/lean/proofs/kemanonymity#unlinkadvantage_ofkem_eq_anonadvantage) shows that unlinkability of that scheme EQUALS the KEM's anonymity advantage — the two experiments are the same computation. That is a teaching model for the reduction, not the scheme. With nothing but a ciphertext in the announcement there is nothing for the recipient to check against, so detection is only as strong as the KEM's rejection behaviour, and on a KEM with implicit rejection (ML-KEM) that is no strength at all: `decaps` never returns `none`, so `scan` is the constant `true`. [`StealthScheme.ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) is the faithful version. The announcement is `(ciphertext, auxGen sharedSecret pk)`, with `auxGen : K → PK → Aux` modelling the view tag together with the stealth address. ### Why [`auxGen`](/lean/proofs/constructiona#auxgen) takes the public key The second argument is load-bearing rather than cosmetic. The view tag is a function of the shared secret alone, but the stealth address is not: it is ``` keccak(pack_pk(rho, Power2Round(A ·ᵥ s' + e' + t))) ``` in which `rho` and `t` are the RECIPIENT's own meta-address material and only `(s', e')` come from the shared secret. An announcement therefore carries recipient-dependent data even once the shared secret is idealized. A model with `auxGen : K → Aux` understates what is published — and, worse, it makes the [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) term below identically zero, so the blinding question becomes invisible rather than merely small. ### Why `scan` recomputes, and why the private state is `SK × PK` `ofKEMFull.scan` decapsulates and then RECOMPUTES the auxiliary data from the recovered shared secret and the recipient's own public key, accepting only when it matches the announced one. Testing merely that decapsulation returned `some` would be vacuous on any KEM with implicit rejection — ML-KEM's `decaps` is `fun dk c => return some …` and never rejects, so that test is the constant `true` and every recipient "detects" every announcement. That is why the recipient's private state is `SK × PK`: a scanning wallet holds its own meta-address next to the decapsulation key, and needs both. The decapsulation key alone does not determine the tag. Detection completeness ([`perfectlyComplete_ofKEMFull`](/lean/proofs/kemanonymity#perfectlycomplete_ofkemfull)) is then exactly the statement that the two sides compute the same auxiliary data: the sender builds it from the encapsulated shared secret and the recipient's public key, the recipient rebuilds it from the decapsulated secret and its own public key, and KEM correctness identifies the two secrets. Correctness is VCVio's `KEMScheme.PerfectlyCorrect ProbCompRuntime.probComp`, i.e. `Pr[= true | kem.CorrectExp] = 1` for the experiment "generate a keypair, encapsulate, decapsulate, compare". The hypothesis is doing real work: [`deadKEM_ofKEMFull_not_perfectlyComplete`](/lean/proofs/controls#deadkem_ofkemfull_not_perfectlycomplete) drops it and proves the conclusion false. ## The decomposition, term by term Unlinkability of [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) rests on three separate things, and [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) names each rather than merging them: ``` unlinkAdvantage ≤ sharedSecretHiding … true + auxKeyIndependence + anonAdvantage (adv.cipherOf auxGen) + sharedSecretHiding … false ``` * **`sharedSecretHiding kem auxGen adv b`** — the auxiliary data hides the recipient insofar as the shared secret is pseudorandom. This is a KEM IND-CPA (real-or-random) question, proved equal to VCVio's `KEMScheme.IND_CPA_Advantage` in `SharedSecretHiding`; its reduction to MLWE is the paper-level FO step (see `spr-two-hop.md`). One term per branch. On each branch both sides build the auxiliary data from the SAME public key, which is what makes it a clean real-or-random KEM question and nothing else. * **[`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence)** — once the secret is idealized, the auxiliary data must still not betray WHICH public key produced it. For this scheme that is the blinding argument (`A ·ᵥ s' + e'` masking `t`), and it is again MLWE, not a KEM property. This is the term a shared-secret-only model cannot state at all. * **`KEM.anonAdvantage`** — the ciphertext itself must hide the recipient. KEM anonymity (ANO-CCA), which neither VCVio nor FIPS 203 supplies; see `spr-two-hop.md`. The proof is the triangle inequality over the intermediate games. The intermediate game on branch `b` is `randAuxBranch kem auxGen adv b`: the challenge ciphertext still goes to the selected recipient, and the auxiliary data is still built from that recipient's own public key, but from a fresh random shared secret rather than the real one. That game is exactly what separates the two distinct assumptions a shared-secret-only model silently merged into one term. ## [`cipherOf`](/lean/proofs/kemanonymity#stealthschemeunlinkadvcipherof): the induced ciphertext-anonymity adversary `StealthScheme.UnlinkAdv.cipherOf adv auxGen` turns a full-announcement adversary into a ciphertext-only one: ```lean fun pk0 pk1 c => do let k' ← ($ᵗ K) adv pk0 pk1 (c, auxGen k' pk0) ``` It synthesizes the auxiliary data from a FRESH random shared secret and a FIXED public key — recipient 0, which the anonymity game hands it. Both choices carry weight: * the random secret removes the dependence on the real encapsulation, which is what makes the hop to [`randAuxBranch`](/lean/proofs/kemanonymity#randauxbranch) a pure shared-secret-hiding question; * the fixed key is what makes the reduction constructible at all. A derived adversary cannot consult "whichever key the challenger used" without already knowing the hidden bit. ## Construction A: what is modelled and what is abstracted `ConstructionA.lean` supplies the [`auxGen`](/lean/proofs/constructiona#auxgen) the real scheme uses (`docs/TECHNICAL_SPEC.md` §1, §4), so that the announcement in the model is the announcement in the spec. With ``` (s', e') = expandBlind ss stealthKey = A ·ᵥ s' + e' + t -- A = expandA rho stealthAddress = hashAddr (pack rho (power2Round stealthKey)) viewTag = viewTag ss ``` matching the spec's `keccak256(pack_pk(rho, Power2Round(A·s' + e' + t)))[12:32]` and `SHA-256(ss)[0:1]`. Every *symmetric* primitive is a parameter, not a definition: `expandA` (SHAKE128 matrix expansion), `expandBlind` (`ExpandS ∘ SHAKE256`), `power2Round`, `pack` (`pack_pk`), `hashAddr` (`keccak256 … [12:32]`), `viewTag` (`SHA-256`). "Faithful" here means the *algebraic* shape is the spec's — which key material enters which slot, and in particular that `rho` and `t` are the RECIPIENT's while only `(s', e')` come from the shared secret. It does NOT mean the hash functions are modelled; they are uninterpreted functions, so any statement proved here holds for every instantiation of them, and any statement that NEEDS them to behave randomly cannot be proved here at all. The module's contents, in order: 1. [`auxGen`](/lean/proofs/constructiona#auxgen) and [`stealthAddr_eq_blinded_pk`](/lean/proofs/constructiona#stealthaddr_eq_blinded_pk): the announced stealth key is `power2Round` of the honest ML-DSA public key of the widened secret `(s₁ + s', s₂ + e')` — [`stealth_pk_eq_blinded_keypair`](/lean/proofs/blinding#stealth_pk_eq_blinded_keypair) used in the game model — and that widened secret is an ownership witness for it ([`announced_key_isOwnershipWitness`](/lean/proofs/constructiona#announced_key_isownershipwitness)), so the ZK spend statement of `Invariants.lean` is satisfiable for every announcement construction A produces. 2. [`metaKem`](/lean/proofs/constructiona#metakem) / [`scheme`](/lean/proofs/demo#scheme): [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) needs a [`KEM`](/lean/proofs/kemanonymity#kem) whose public key is exactly the argument [`auxGen`](/lean/proofs/constructiona#auxgen) reads. The ML-KEM keypair alone is not that, so key generation additionally draws the ML-DSA seed `rho` and secret `(s₁, s₂)` and publishes `t = A ·ᵥ s₁ + s₂`; encapsulation and decapsulation ignore the extra components. The spending keypair is drawn from three separate samplers rather than one opaque joint one, because the MLWE reduction has to resample `(s₁, s₂)` for a `rho` handed to it by the challenger, which a joint sampler cannot support. [`unlinkAdvantage_metaKem_le`](/lean/proofs/constructiona#unlinkadvantage_metakem_le) is [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) at [`metaKem`](/lean/proofs/constructiona#metakem) and [`auxGen`](/lean/proofs/constructiona#auxgen); the content is that those two are the spec's objects. 3. [`auxKeyIndependence_eq_zero_of_pk_independent`](/lean/proofs/constructiona#auxkeyindependence_eq_zero_of_pk_independent) (and its corollary [`auxKeyIndependence_tagOnly_eq_zero`](/lean/proofs/constructiona#auxkeyindependence_tagonly_eq_zero)): the positive control pinning the meaning of [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) — it is `0` for any [`auxGen`](/lean/proofs/constructiona#auxgen) that ignores the public key, in particular for a view-tag-only announcement. A model in which the term failed to vanish there would be measuring the wrong thing; and it is exactly the coarser model that [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) was introduced to improve on. 4. The blinding hop towards decision-MLWE: the seeded-MLWE problem [`blindingProblem`](/lean/proofs/constructiona#blindingproblem), the reduction adversary [`mlweAdvOfUnlinkAdv`](/lean/proofs/constructiona#mlweadvofunlinkadv), and the middle-game lemma [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t). ### The blinding hop The hop replaces the real mask `A ·ᵥ s' + e'` inside the announced stealth key by a uniform vector. That is a decision-MLWE step, stated in the SEEDED form the scheme actually uses: the challenge is the seed `rho`, not the matrix, because the reduction must be able to compute `pack rho …` and cannot invert `expandA`. It coincides with `LearningWithErrors.moduleMatrixProblem` (up to the transpose, since `matrixProblem` uses `vecMul`) whenever `expandA <$> sampleRho` is uniform on matrices — the standard "expansion is a random oracle" reading, which is not proved. [`mlweAdvOfUnlinkAdv`](/lean/proofs/constructiona#mlweadvofunlinkadv) receives `(rho, y)`, installs `rho` as recipient 1's matrix seed, draws recipient 1's spending secret itself, simulates recipient 0 honestly, and splices `y` in as the mask: the announced address is `hashAddr (pack rho (power2Round (y + t₁)))`. With `y` real this is the construction-A announcement to recipient 1 (under [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal)); with `y` uniform it is the ideal-blinding announcement of [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t). [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t) is the lattice analogue of [`dksapIdeal_announce_indep`](/lean/proofs/dksap#dksapideal_announce_indep), with the shift `u ↦ u + (t − t')` in place of the group translation: with the mask drawn uniformly, the announcement to a recipient with spending key `t` and one to a recipient with spending key `t'` are the same distribution. Note what it does NOT quantify over: `rho` is shared between the two sides. It has to be — `rho` enters through `pack`, outside the masked argument, so the two sides with different seeds are genuinely different distributions for a general `hashAddr`. ### Documented gaps (statements this model cannot support — not `sorry`s) * **[`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) is not `0` for construction A, and no MLWE bound closes it.** A uniform mask erases `t` (proved: [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t)) but `rho` sits OUTSIDE the masked term, in `pack rho …`. The adversary is handed both meta-addresses, hence both `rho`s; with `hashAddr` an arbitrary function (take it to be the identity, and `pack rho _ = rho`) the address reveals which `rho` produced it. The scheme is fine — `keccak` is not the identity — but the missing ingredient is `hashAddr ∘ pack` as a random oracle, not more proof effort. That extension is `BlindingROM.lean`, below; [`unlinkAdvantage_metaKem_le`](/lean/proofs/constructiona#unlinkadvantage_metakem_le) should be read with it in mind. (The non-vanishing is an argument, NOT machine-checked; a counterexample scheme exhibiting it belongs with the negative controls.) * **The reduction's game 0 is faithful only under [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal).** In the real scheme the view tag and the blinding pair come from the same shared secret; the reduction must take the mask from the MLWE challenger and therefore samples the tag separately. [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal) names precisely that assumption — `(viewTag ss, expandBlind ss)` for uniform `ss` is distributed as three independent samples — which is the SHAKE/SHA-as-random-oracle step. It is false for the concrete primitives and true in the random-oracle model. * **Both games of the reduction still need a bind commutation.** `LearningWithErrors.distr` / `uniformDistr` fix the seed and the mask BEFORE the adversary runs, whereas `randAuxBranch … true` draws the shared secret after the announcement prefix. So identifying `LearningWithErrors.game0` with the real game (on top of [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal)) and `game1` with the ideal-blinding game both require commuting independent samples past one another (`probOutput_bind_eq_tsum` + `tsum_comm`). [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t) is proved in the form the second identification consumes; the plumbing is not done, so **no advantage inequality is claimed**. ### The random-oracle model for the address hash `BlindingROM.lean` is the ROM extension the gap above asks for. The oracle is `hashAddr ∘ pack` as ONE function `Bytes → Addr` — the composite the argument needs to be random, and the composite the spec instantiates with `keccak256` — so the model is `OracleComp (unifSpec + (Bytes →ₒ Addr))` with VCVio's lazily sampled `OracleSpec.randomOracle` (`uniformSampleImpl.withCaching`) as the handler and uniform sampling passed through untouched ([`romImpl`](/lean/proofs/blindingrom#romimpl), mirroring VCVio's own `prfIdealQueryImpl`). The adversary lives in that monad too: it may query the address oracle itself, which is the whole point. The game ([`blindTraceRO`](/lean/proofs/blindingrom#blindtracero) / [`blindGameRO`](/lean/proofs/blindingrom#blindgamero)) is a STANDALONE abstraction of the branch comparison with the mask already idealized — that hop is the MLWE step of `ConstructionA`. It is deliberately not [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) restated: the KEM, the ciphertext and the tag derivation are dropped and `tg`, `rho`, `t` are free parameters. It asks the same question — with a uniform mask, does the address still say whose `rho` and whose `t` built it? — but nothing here identifies the two, and that identification is the first of the three open items below. **The load-bearing new fact.** ```lean theorem run_hashAddrRO_empty (x : Bytes) (f : Addr → ROMComp Bytes Addr α) : (simulateQ romImpl (hashAddrRO x >>= f)).run ∅ = (do let a ← ($ᵗ Addr) (simulateQ romImpl (f a)).run ((∅ : QueryCache _).cacheQuery x a)) ``` Run from the empty cache the announced address is a UNIFORM `Addr` *whatever byte string was hashed*. The challenger's query is the first query of the game — key generation and encapsulation make none — so the cache genuinely is `∅`. This is the step the mask cannot supply: [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t) has to share `rho` between the two sides precisely because `rho` enters through `pack`, outside the masked argument, and an arbitrary `hashAddr` may pass it straight through. Under the oracle it cannot: the output is uniform regardless of the input. **What follows immediately.** [`blindGameRO_eq`](/lean/proofs/blindingrom#blindgamero_eq) says the two branches are the SAME computation, run from caches that differ at one point — `∅` extended at recipient `b`'s address point by the same uniform value. That is the identical-until-bad setup written out. Its degenerate case is proved: [`blindingAdvantageRO_eq_zero_of_no_query`](/lean/proofs/blindingrom#blindingadvantagero_eq_zero_of_no_query) — an adversary that never consults the address oracle has advantage exactly `0`, with BOTH `rho` and `t` gone, which is strictly more than [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t) gives. **Identical until bad, closed.** [`blindingAdvantageRO_le_blindBadProb`](/lean/proofs/blindingrom#blindingadvantagero_le_blindbadprob): the blinding term is at most `blindBadProb true + blindBadProb false`, where `blindBadProb b` is the probability that the adversary, run against the oracle programmed at recipient `b`'s address point, queries that point (VCVio's `withProgramming` flag; `ROMUpToBad.badQueryGame`). The proof goes through the oracle-free middle game [`blindGameROFree`](/lean/proofs/blindingrom#blindgamerofree) and VCVio's programming-oracle engine instantiated for uniform forwarding in `PqStealth/ROMUpToBad.lean`. **Where this stops, precisely.** Two things are open. The first is not even stated in Lean: * **The identification.** [`blindingAdvantageRO`](/lean/proofs/blindingrom#blindingadvantagero) is not connected to `auxKeyIndependence (metaKem P Smp kem) (auxGen P) adv`. Doing so means carrying the KEM, the ciphertext and the `viewTag`/`expandBlind` derivation through the ROM monad and commuting the mask draw into place — the same bind commutation `ConstructionA`'s gap list already names, plus [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal). Until then the ROM results are about the shape of the blinding term, not about the term itself. * `blindBadProb ≤ mlwe + q_H · β`: the mask is uniform only after the MLWE hop, and each of the adversary's `q_H` queries then guesses a point of min-entropy `log₂(1/β)`. Discharging it needs a query bound on the adversary (`IsTotalQueryBound`) plus the counting lemma `probEvent_cache_has_value_le` of VCVio's `Unpredictability.lean`, and the min-entropy of `pack rho (power2Round (u + t))` in `u`, which is a property of `pack`/`power2Round` this model does not have (both are uninterpreted). [`mlweAdvOfBlindAdvRO`](/lean/proofs/blindingrom#mlweadvofblindadvro) type-checks the reduction that the MLWE hop of that bound consumes: simulating the address oracle inside turns a ROM blinding adversary into an ordinary seeded-[`blindingProblem`](/lean/proofs/constructiona#blindingproblem) distinguisher. ## Multi-challenge unlinkability [`UnlinkExp`](/lean/proofs/games#unlinkexp) gives the adversary one challenge announcement. plan.md's deliverable is *unlinkability across payments*: the observer sees the whole chain, so it sees many announcements at once. `MultiUnlink.lean` closes the gap between the two by the standard hybrid argument. The `q`-challenge game keeps the two recipients and the single hidden bit, and hands the adversary all `q` announcements, all of them addressed to recipient `b`: ```lean abbrev StealthScheme.UnlinkAdvMulti (MetaPub Announcement : Type) := MetaPub → MetaPub → List Announcement → ProbComp Bool def unlinkBranchMulti (q : ℕ) (b : Bool) (a : MetaPub × MetaPub) : ProbComp Bool := do let cs ← S.announceList (List.replicate q (if b then a.2 else a.1)) advM a.1 a.2 cs ``` [`announceList`](/lean/proofs/multiunlink#announcelist) samples one independent announcement per public meta-address in a *list of recipients* — not per index of a count — because that is exactly the degree of freedom the hybrids need. [`length_of_mem_support_announceList`](/lean/proofs/multiunlink#length_of_mem_support_announcelist) records that the adversary really does receive `q` announcements; the `List` encoding does not silently drop the count. **The bound.** With `hybridAdv i j` the single-challenge adversary that simulates `i` announcements to recipient 1 and `j` to recipient 0 itself and splices the challenge between them, ```lean theorem unlinkAdvantageMulti_le_sum (q : ℕ) : S.unlinkAdvantageMulti advM q ≤ ∑ k ∈ Finset.range q, S.unlinkAdvantage (S.hybridAdv advM k (q - k - 1)) theorem unlinkAdvantageMulti_le_mul (q : ℕ) {ε : ℝ} (h : ∀ i j, S.unlinkAdvantage (S.hybridAdv advM i j) ≤ ε) : S.unlinkAdvantageMulti advM q ≤ q * ε ``` The sum is the honest statement; the `q · ε` form is the corollary one quotes. **The loss factor is `q`, the number of announcements the observer correlates**, and it is linear, not quadratic: the two recipients are fixed across the whole game, so only the announcements are hybridised. For the parameter write-up this means the single-challenge target has to be `ε ≤ 2⁻ᵏ / q` for a `k`-bit unlinkability claim over `q` observed payments — that inequality is algebra from the theorem; the observation window `q` is a parameter of the threat model, and the write-up should state the value it picks (taking the whole chain as the window costs `log₂ q` bits off the single-payment advantage). The composition with [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) is **in Lean**, as [`unlinkAdvantageMulti_ofKEMFull_le`](/lean/proofs/multiunlink#unlinkadvantagemulti_ofkemfull_le) (`MultiUnlink.lean:281`), which instantiates it at each `hybridAdv k (q − k − 1)` — so every term on the right (two shared-secret-hiding terms, [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence), anonymity) is multiplied by the same `q`. `MultiUnlink.lean` imports `KEMAnonymity`; the derived adversary of each hybrid is legitimate for the same reason as before. **Why the derived adversary is legitimate.** It has to produce the other `q − 1` announcements itself. That is free: adversaries are `ProbComp` computations and the scheme `S` is public, so `S.announce` is available to it. It needs no secret and makes no oracle query the game does not already allow. **The one place probability enters.** Hybrid `k` and hybrid `k+1` differ in the recipient of announcement number `k`. The derived adversary receives *its* challenge first and then simulates the rest, whereas the hybrid samples the announcements in list order; the two computations are therefore not equal in the free monad, only equal in distribution. [`evalDist_announceList_append_cons`](/lean/proofs/multiunlink#evaldist_announcelist_append_cons) pulls the marked announcement to the front by one application of VCVio's `evalDist_bind_bind_swap` per element of the prefix, and [`boolDistAdvantage_congr`](/lean/proofs/multiunlink#probcompbooldistadvantage_congr) transports the advantage along that `evalDist` equality. The definitions are shaped so that this is the *only* reordering in the argument: hybrid `k` IS `unlinkSetup >>= unlinkBranch (hybridAdv …) b` up to that single swap, with `k` and `k+1` the `b = false` and `b = true` cases of one lemma. Indexing the hybrid by the two segment lengths `(i, j)` rather than by `(q, k)` keeps natural subtraction out of the core proof; it appears only in the final telescope, where `k < q` comes from `Finset.mem_range`. [`boolDistAdvantage_le_sum_hybrids`](/lean/proofs/multiunlink#probcompbooldistadvantage_le_sum_hybrids) is the general telescoping step — `dist (H n) (H 0) ≤ ∑ k ∈ range n, dist (H (k+1)) (H k)` for any chain `H : ℕ → ProbComp Bool`, by induction on `n` over the triangle inequality. It is stated separately because it has nothing to do with stealth addresses. **`n` recipients** is the orthogonal generalisation and lives in `MultiRecipient.lean`; see the next section. It composes with [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) the same way `MultiUnlink` does — in Lean, as [`unlinkAdvantageN_ofKEMFull_le`](/lean/proofs/multirecipient#unlinkadvantagen_ofkemfull_le) (`MultiRecipient.lean:358`), giving `Adv_n ≤ n·(n−1)·ε` for a single announcement. Composing that with the `q`-challenge hybrid to get the joint `n·(n−1)·q` loss is still on paper (see "What is NOT proved" below). ## `n`-recipient unlinkability `MultiRecipient.lean` publishes `n` independently generated meta-addresses and lets the adversary NAME the challenge pair after seeing all of them. The adversary is two functions rather than one, because naming the pair and guessing the bit happen at different points of the game, and the first hands a state to the second (as VCVio's two-phase `IND_CPA_Adversary` does; a stateless adversary takes `St := Unit`): ```lean abbrev UnlinkChooseN (MetaPub : Type) (n : ℕ) (St : Type) := (Fin n → MetaPub) → ProbComp ((Fin n × Fin n) × St) abbrev UnlinkGuessN (MetaPub Announcement : Type) (n : ℕ) (St : Type) := St → (Fin n → MetaPub) → Fin n × Fin n → Announcement → ProbComp Bool def unlinkBranchN (b : Bool) (pks : Fin n → MetaPub) : ProbComp Bool := do let i ← pick pks -- ((i₁, i₂), state) let c ← S.announce (pks (if b then i.1.2 else i.1.1)) guess i.2 pks i.1 c ``` Letting the game sample the pair instead would be a strictly weaker notion with no loss factor at all (the reduction would simply sample the same pair), which is why the adversary chooses. **The bound.** `pairGuessAdv j` is the two-recipient adversary that guesses the pair `j`: it splices the two challenge meta-addresses into slots `j`, generates the other `n − 2` itself, runs `pick`, and forwards `guess`'s verdict only when `pick` named exactly `j`. ```lean theorem unlinkAdvantageN_le_sum (hpick) : S.unlinkAdvantageN pick guess ≤ ∑ j ∈ Finset.univ.offDiag, S.unlinkAdvantage (S.pairGuessAdv pick guess j) theorem unlinkAdvantageN_le_mul (hpick) (h : ∀ j, … ≤ ε) : S.unlinkAdvantageN pick guess ≤ (n * (n - 1) : ℕ) * ε ``` **The sum, not a conditioning argument.** Summing over ordered pairs rather than guessing one uniformly is what makes this provable with the machinery already in the tree. Gating the verdict on `pick = j` makes the gated games *disjoint*, so their `Pr[= true]` values add up to the ungated game's ([`sum_probOutput_unlinkBranchNAt`](/lean/proofs/multirecipient#sum_proboutput_unlinkbranchnat)), and `|∑ xⱼ| ≤ ∑ |xⱼ|` finishes it. There is no conditional probability anywhere, and no uniform draw over pairs. The loss factor `n·(n−1)` — the number of ordered pairs of distinct indices, `Finset.univ.offDiag`, whose cardinality Mathlib gives as `n·n − n` — is an artefact of the guessing reduction, not of the scheme. **The one genuinely new fact: exchangeability.** The reduction has to hand the adversary a vector of `n` meta-addresses two of whose slots are the challenger's keys. [`evalDist_pubKeysN_embedPair`](/lean/proofs/multirecipient#evaldist_pubkeysn_embedpair) says that is legitimate: ```lean 𝒟[do let a ← S.unlinkSetup let f ← S.pubKeysN n k (embedPair j f a)] = 𝒟[S.pubKeysN n >>= k] ``` It is proved from the one-slot version [`evalDist_pubKeysN_update`](/lean/proofs/multirecipient#evaldist_pubkeysn_update) by applying it twice, and that one is an induction on `n` over `Fin.cons`: at slot `0` the displaced draw is discarded and the fresh one takes its place (VCVio's `evalDist_bind_const_neverFails` and `evalDist_bind_bind_swap`), at slot `i+1` the induction hypothesis applies under the leading draw. Nothing about `Fin n` permutations is needed — `Function.update` plus `Fin.cons_update` is enough. **The hypothesis, and why it is explicit.** (An earlier version also carried `hkg : Pr[⊥ | S.keygen] = 0` and `hann : ∀ pk, Pr[⊥ | S.announce pk] = 0`, on the grounds that a discarded draw must carry no failure mass. For `ProbComp = OracleComp unifSpec` that is a theorem — VCVio's `probFailure_of_liftM_PMF` — so the two were vacuous and were dropped; the exchangeability and wrong-guess steps discharge it inline.) * `hpick : ∀ pks, ∀ i ∈ support (pick pks), i.1 ≠ i.2` — the adversary must name two DISTINCT recipients. Without it the sum would have to run over the diagonal too, where the reduction cannot embed both challenge keys (the second overwrites the first), and the gated games would no longer add up. Naming the same recipient twice is a degenerate challenge in any case: both branches of the game are then the same computation. **What is NOT proved.** The composition of the `n`-recipient pair-guessing bound with `MultiUnlink`'s `q`-challenge hybrid (the joint `n`-recipient, `q`-announcement game, loss `n·(n−1)·q`). The two single-direction compositions with [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) are in Lean — [`unlinkAdvantageMulti_ofKEMFull_le`](/lean/proofs/multiunlink#unlinkadvantagemulti_ofkemfull_le) (`MultiUnlink.lean:281`) and [`unlinkAdvantageN_ofKEMFull_le`](/lean/proofs/multirecipient#unlinkadvantagen_ofkemfull_le) (`MultiRecipient.lean:358`) — both legitimate instantiations of theorems that hold for every adversary. ## Controls: why the definitions have teeth `Controls.lean` pins one completeness claim (DKSAP) from below by proving a deliberately broken variant broken. The game layer needs the same treatment on both sides, because a definition can be wrong in two opposite ways: an advantage that no scheme can make large measures nothing, and a detection test that no scheme can fail asserts nothing. ### Positive controls: the `1 − keyCollisionProb` ceiling [`leakyScheme`](/lean/proofs/controls#leakyscheme) publishes the recipient's meta-address outright, and [`unlinkAdvantage`](/lean/proofs/games#unlinkadvantage) is proved maximal on it — where maximal means `1 − keyCollisionProb`, the probability that the two recipients drew the same public key. That ceiling is the exact obstruction to an advantage of `1`, and it is not a proof artifact. The unlinkability game draws its two recipients independently from the same `keygen`, so whenever they collide its two branches are literally the same computation and no adversary whatsoever can distinguish them. Any unlinkability or anonymity advantage against a scheme with that `keygen` is therefore capped at `1 − keyCollisionProb`, and the one-comparison adversary [`leakyAdv`](/lean/proofs/controls#leakyadv) (`fun _ pk1 c => pure (decide (c = pk1))`) attains the cap. So [`unlinkAdvantage`](/lean/proofs/games#unlinkadvantage) does detect a recipient leak — it is not a quantity that happens to be small for structural reasons. The statement is proved for an arbitrary scheme with `announce pk = pure pk` rather than for [`leakyScheme`](/lean/proofs/controls#leakyscheme) alone, so the KEM control reuses it: [`leakyKEM`](/lean/proofs/controls#leakykem) encapsulates by echoing the public key, and [`leakyKEM_anonAdvantage_eq`](/lean/proofs/controls#leakykem_anonadvantage_eq) goes through [`unlinkAdvantage_ofKEM_eq_anonAdvantage`](/lean/proofs/kemanonymity#unlinkadvantage_ofkem_eq_anonadvantage). The two controls are literally the same fact seen through the reduction. This is also why [`ofKEM`](/lean/proofs/kemanonymity#stealthschemeofkem) is kept rather than folded into [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) with `Aux := Unit`: the `Unit` version is only isomorphic to it, not the same term. Detection in [`leakyScheme`](/lean/proofs/controls#leakyscheme) is unconditional, which is deliberate — the control is about unlinkability, and a scheme can be perfectly complete and still worthless. ### Negative controls: what the tag comparison buys The negative controls are what make the tag comparison in `ofKEMFull.scan` load-bearing, and they fail in opposite directions: * [`deadKEM`](/lean/proofs/controls#deadkem) always rejects, so [`deadKEM_ofKEMFull_not_perfectlyComplete`](/lean/proofs/controls#deadkem_ofkemfull_not_perfectlycomplete): the scheme is not complete. Stated as an ordinary theorem rather than a tactic script that fails, so the build keeps it honest. * [`ofKEMFullNoTag`](/lean/proofs/controls#stealthschemeofkemfullnotag) drops the comparison — detection is "decapsulation returned `some`". Announcement and private state are unchanged, so the pair [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) / [`ofKEMFullNoTag`](/lean/proofs/controls#stealthschemeofkemfullnotag) isolates exactly the comparison. It IS complete under the same hypothesis ([`perfectlyComplete_ofKEMFullNoTag`](/lean/proofs/controls#perfectlycomplete_ofkemfullnotag)), so completeness alone cannot be the property that justifies the comparison; and on a KEM with implicit rejection it flags an announcement addressed to a different recipient with probability `1` ([`probOutput_falsePositiveExp_ofKEMFullNoTag_eq_one`](/lean/proofs/controls#proboutput_falsepositiveexp_ofkemfullnotag_eq_one)). Detection is then not detection: every scanner "receives" every payment. [`FalsePositiveExp`](/lean/proofs/games#falsepositiveexp) is detection soundness's counterpart to [`CorrectExp`](/lean/proofs/games#correctexp) — two independent recipients, an announcement addressed to the second, scanned with the first one's private state; `true` is a false positive. What the control uses is only that a tag-ignoring scan makes it `1` ([`falsePositiveRate_ofKEMFullNoTag_eq_one`](/lean/proofs/controls#falsepositiverate_ofkemfullnotag_eq_one) restates that in the vocabulary of the next section); the quantitative bounds for the real scans are below. ## Detection soundness `Games` defines two words on top of [`FalsePositiveExp`](/lean/proofs/games#falsepositiveexp): * `falsePositiveRate S := (Pr[= true | S.FalsePositiveExp]).toReal` — `.toReal` because every other number in this development is an `ℝ` advantage, and the bounds below add the rate to distinguishing advantages; * `SoundWithin S ε := S.falsePositiveRate ≤ ε`. `Soundness` proves the two instances. They are not the same kind of statement, and the difference is the point. ### DKSAP: exactly `1 / |F|`, and the reason is not the hash [`dksap_falsePositiveRate_eq`](/lean/proofs/soundness#dksap_falsepositiverate_eq): for **every** hash `h`, and given only that `x ↦ x • g` is injective, ``` falsePositiveRate (dksap g h) = 1 / |F| ``` An equality, not a bound. Unfolding, the stranger's scan fires exactly when ``` m1 + h (r • V1) = m0 + h (v0 • R) ``` where `(m0, v0)` is the scanner's key pair and `(m1, v1)` recipient 1's. Injectivity of `x ↦ x • g` turns the group equation into this scalar one, and recipient 1's spending scalar `m1` is uniform and independent of the other four draws — so for each value of `(m0, v0, v1, r)` exactly one `m1` out of `|F|` triggers, whatever `h` computes. The formal proof is the same sentence: move the `m1` draw innermost with `probOutput_bind_bind_swap`, read it off with `probOutput_uniformSample`, and collapse the never-failing prefix with `probOutput_bind_of_const`. This corrects the issue as it was originally filed (`improvements.md` #5 asked for "exact `0` false positives modulo hash collisions of `h`"). There is no hash-collision term and the rate is not `0`: the leak is the uniform spending scalar, and a collision of `h` is neither necessary nor sufficient for a false positive. At a 254-bit scalar field `1 / |F|` is of course negligible, which is why nobody noticed the mechanism was misdescribed. ### The KEM scheme: a tag term plus a real-or-random term For [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) the scan fires when ``` auxGen (sharedSecret) pk1 = auxGen (recipient 0's decapsulation of c) pk0 ``` Two things have to be true for that to be rare. First, the tag must be hard to hit: `AuxCollisionFree auxGen ε` says that over a **uniform** secret `k`, `auxGen k pk` lands on any prescribed value with probability at most `ε`. Second, recipient 0's decapsulated key must actually look uniform to the announcement — and that is a KEM question, not a tag question. It is the same shape as [`sharedSecretHiding`](/lean/proofs/kemanonymity#sharedsecrethiding) in the unlinkability chain, so it gets the same treatment: a named real-or-random term rather than an assumption swept into the statement. [`falsePositiveIdeal`](/lean/proofs/soundness#falsepositiveideal) is [`FalsePositiveExp`](/lean/proofs/games#falsepositiveexp) with recipient 0's decapsulated key replaced by a fresh `$ᵗ K` (the announcement is untouched — only the scanner's secret is idealized), and ``` decapsRoR kem auxGen := boolDistAdvantage FalsePositiveExp falsePositiveIdeal ``` Then [`falsePositiveRate_ofKEMFull_le`](/lean/proofs/soundness#falsepositiverate_ofkemfull_le): ``` SoundWithin (ofKEMFull kem auxGen) (ε + decapsRoR kem auxGen) ``` by the triangle inequality through [`falsePositiveIdeal`](/lean/proofs/soundness#falsepositiveideal), whose own probability is bounded by `ε` because at that point the verdict IS a tag collision against a uniform secret. [`decapsRoR`](/lean/proofs/soundness#decapsror) is again a KEM IND-CPA question: a distinguisher that told the real decapsulated key from a fresh uniform one inside this experiment is a real-or-random distinguisher for the shared secret of a ciphertext the distinguisher did not create. The `0 ≤ ε` hypothesis is not decoration — with `PK` empty the collision hypothesis is vacuous and a negative `ε` would make the statement false. [`decapsRoR_eq_zero_of_decaps_uniform`](/lean/proofs/soundness#decapsror_eq_zero_of_decaps_uniform) closes the loop from the ideal side: if `kem.decaps` returns a uniform key, the real and idealized experiments are the same computation and `decapsRoR = 0`, hence `SoundWithin ε` outright. That hypothesis is deliberately global and no perfectly correct KEM with `|K| ≥ 2` satisfies it — the lemma is a sanity check pinning [`decapsRoR`](/lean/proofs/soundness#decapsror) as the *only* gap, not a security claim. ### Where `1/256` comes from `taggedAux viewTag rest k pk = (viewTag k, rest k pk)` is the deployed shape: the announcement leads with a short tag derived from the shared secret alone, followed by whatever else the auxiliary data carries (the stealth address). [`auxCollisionFree_taggedAux`](/lean/proofs/soundness#auxcollisionfree_taggedaux) says that if `viewTag` maps a uniform secret to a uniform tag, then ``` AuxCollisionFree (taggedAux viewTag rest) (1 / |T|) ``` The remainder can only help, so the tag alphabet is the whole bound — which is exactly the ERC's tag-length rationale. Chaining with the previous bound gives [`soundWithin_ofKEMFull_taggedAux`](/lean/proofs/soundness#soundwithin_ofkemfull_taggedaux), and at concrete alphabets [`soundWithin_ofKEMFull_byteTag`](/lean/proofs/soundness#soundwithin_ofkemfull_bytetag) (`T = Fin (2 ^ (8n))`, rate `2 ^ (-8n)` plus [`decapsRoR`](/lean/proofs/soundness#decapsror)) and [`soundWithin_ofKEMFull_oneByteTag`](/lean/proofs/soundness#soundwithin_ofkemfull_onebytetag): ``` SoundWithin (ofKEMFull kem (taggedAux viewTag rest)) (1 / 256 + decapsRoR kem (taggedAux viewTag rest)) ``` The uniformity of `viewTag` on a uniform key is a hypothesis. For the concrete ML-KEM shared-secret type it would be "the first byte of a uniform 32-byte string is a uniform byte", which VCVio's `Bytes` API does not currently expose as a projection lemma; stating it as a hypothesis keeps the theorem honest and costs the caller one `simp`. ### What is NOT proved Nothing bounds [`decapsRoR`](/lean/proofs/soundness#decapsror) for ML-KEM-768 — like [`sharedSecretHiding`](/lean/proofs/kemanonymity#sharedsecrethiding), it is left as a named IND-CPA term and inherits the same missing upstream lemma (`docs/spr-two-hop.md`). The soundness statements are also single-announcement: a scanner sweeping `n` announcements false-positives at most `n` times the rate by a union bound, which is not formalized here. # KEM anonymity from SPR: the two-hop route, and what is missing upstream :::info[Mirrored page] Source: [`lean/docs/spr-two-hop.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/spr-two-hop.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Scope: KEM anonymity foundations, relevant to key-exchange review. Dated gap descriptions below are research history; the current declaration map and limits are in [ERC evidence](/spec/erc-evidence) and the [Lean overview](/lean). Background for `PqStealth/KEMAnonymity.lean`, `PqStealth/AnonymityFromSPR.lean`, `PqStealth/SharedSecretHiding.lean` and the ML-KEM modules. It records the lattice route the analysis takes, the caveats that route carries, and the exact shape of the upstream lemma that is not available. ## The teaching point A stealth scheme's UNLINKABILITY reduces to the KEM's ANONYMITY — does a ciphertext hide which public key it was encapsulated to — NOT to IND-CCA, which hides the message. VCVio ships ML-KEM IND-CCA statements (its MLWE reductions are `sorry` at the pinned commit) but NOT anonymity, so the anonymity assumption is stated in `KEMAnonymity.lean` in the same shape VCVio uses for its own games, and the reduction is proved there. The anonymity game is the hidden-bit game with the bit selecting which of two public keys the challenge ciphertext is encapsulated to; the adversary sees both public keys and the ciphertext. The distinguisher is exactly `StealthScheme.UnlinkAdv PK C` — a key-privacy adversary and an unlinkability adversary have the same shape. Still open beyond that file: anonymity → MLWE for ML-KEM, the deeper piece VCVio does not cover (Grubbs–Maram–Paterson), which is where the novel work sits. ## The SPR route (Grubbs–Maram–Paterson EC'22; Maram–Xagawa PKC'23) Neither VCVio nor FIPS 203 supplies `anonymity → MLWE` for ML-KEM. The route the literature takes goes through SPR — *strong pseudorandomness* of ciphertexts: a challenge ciphertext is indistinguishable from one produced by a key-independent simulator (for ML-KEM: uniform bytes). If ciphertexts are indistinguishable from key-independent, they cannot reveal which key they were made for. `AnonymityFromSPR.lean` machine-checks that implication and leaves the lattice content as two named per-branch SPR quantities: ``` anonymity ≤ SPR(branch true) + SPR(branch false) -- proved, sorry-free SPR(ML-KEM) ≤ 2 · MLWE -- the remaining lattice step ``` `KEM.simBranch sim adv` is the anonymity experiment's branch with the challenge ciphertext replaced by a simulated one — computable without either public key, so it carries no information about the hidden bit. `KEM.sprAdv kem sim adv b` is the advantage of distinguishing the real branch `b` from that simulated branch, in the full anonymity context. `KEM.anonAdvantage_le_sprAdv` is then one triangle inequality through the simulated game. ### The two hops The remaining step is the standard two-hop argument, recorded here for the paper analysis: 1. Replace the challenge public key `t = A·s + e` by uniform (Decision-MLWE, secret `s`). 2. With `t` uniform, the ciphertext `(u, v) = (Aᵀr + e₁, tᵀr + e₂ + m)` is itself an MLWE sample with secret [`r`](/lean/proofs/demo#r) over the extended matrix `[A | t]`, hence pseudorandom (Decision-MLWE again). Both hops land on VCVio's `LearningWithErrors.advantage`. ### Caveats for the analysis * This is the **CPA-level** statement. Lifting it to ANO-CCA must track ML-KEM's implicit-rejection Fujisaki–Okamoto transform (Maram–Xagawa). * FIPS 203's final KDF hashes only the message-derived secret and does **not** hash the ciphertext. That is what makes the modular route applicable; a ciphertext-hashing KDF would need a different argument. ## The capstone [`unlinkAdvantage_ofKEMFull_le_full_decomposition`](/lean/proofs/anonymityfromspr#unlinkadvantage_ofkemfull_le_full_decomposition) chains the SPR bound into [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le), so stealth unlinkability with the complete announcement decomposes into five named advantages: * two shared-secret-hiding terms — each a KEM IND-CPA advantage (`SharedSecretHiding`; → MLWE is paper-level, VCVio's own K-PKE lemma being a `sorry` placeholder); * the auxiliary-data key-independence term — the blinding argument (`ConstructionA`; needs a random-oracle model of the address hash, see `announcement-model.md`); * two SPR terms — each → 2·MLWE by the two-hop argument above. No unnamed slack: this is the complete reduction skeleton of the scheme's privacy. ## Shared-secret hiding IS VCVio's KEM IND-CPA advantage [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) bounds unlinkability by the anonymity advantage plus two shared-secret-hiding terms. Those terms are exactly the KEM's IND-CPA (real-or-random shared secret) advantage: the auxiliary announcement data hides the recipient because the shared secret is pseudorandom. `SharedSecretHiding.lean` proves that identity, sorry-free, in two steps. 1. Each hiding term is the bias of a real-or-random guessing game — [`sharedSecretHiding_eq_rorBias`](/lean/proofs/sharedsecrethiding#sharedsecrethiding_eq_rorbias), where `rorGame kem auxGen adv b` is the KEM IND-CPA experiment restricted to the announcement adversary. 2. That bias equals VCVio's own `KEMScheme.IND_CPA_Advantage` of a concrete reduction adversary — [`sharedSecretHiding_eq_indCpaAdvantage`](/lean/proofs/sharedsecrethiding#sharedsecrethiding_eq_indcpaadvantage). ### Why step 2 needs distribution-level reasoning The two experiments draw the same independent samples (two keypairs, the hidden bit, the encapsulation, an idealized shared secret) in different orders, and one branch leaves one draw unused. `OracleComp`'s `bind` is a syntactic free-monad constructor, so neither reordering nor dropping holds on the nose; both hold after `evalDist`, which is all an advantage sees. The bridges are * [`evalDist_uniformSample_bind_const`](/lean/proofs/reorder#evaldist_uniformsample_bind_const) — an unused uniform draw does not change the output distribution; * [`evalDist_bind_pull_front`](/lean/proofs/sharedsecrethiding#evaldist_bind_pull_front) — an independent draw may be pulled to the front of the prefix; * [`boolBiasAdvantage_congr`](/lean/proofs/sharedsecrethiding#boolbiasadvantage_congr) — equal output distributions, equal bias. Both sides are put in a common normal form (`rorNormalForm kem auxGen adv b`): hidden bit first, then the two keypairs in the order recipient 0, recipient 1, then the challenge encapsulation, then the idealized shared secret. VCVio's game draws the challenge keypair first and the hidden bit after the adversary's pre-challenge phase; the real-or-random game draws the bit first and never draws the idealized secret on the real branch. `indCpaAdv kem auxGen adv b` is the reduction adversary: a VCVio `KEMScheme.IND_CPA_Adversary` with `State := PK × PK`. The IND-CPA challenge key is embedded as recipient `b`; the reduction generates the other recipient itself, then feeds `auxGen (challenge key)` to the unlink distinguisher. The state carries both public keys because the auxiliary data must be rebuilt from the same key the real announcement used. The orientation is uniform in `b`: the real shared secret sits on hidden bit `true` on both branches, matching VCVio's convention. Net effect: the shared-secret-hiding half of the unlinkability bound IS a KEM IND-CPA advantage in VCVio's own formulation, so any bound proved for `KEMScheme.IND_CPA_Advantage` transfers to it verbatim. ## What is NOT proved, because VCVio does not supply it A bound on `KEMScheme.IND_CPA_Advantage` for ML-KEM in terms of MLWE. `LatticeCrypto/MLKEM/Security.lean` offers `kpke_ind_cpa_security`, which is about `AsymmEncAlg.IND_CPA_advantage` of **K-PKE** — the underlying public-key encryption scheme — rather than `KEMScheme.IND_CPA_Advantage` of the KEM, and which is itself `sorry` upstream, as are `kpke_delta_correct` and `ind_cca_security` at the pinned commit. The step between the two is the T-transform half of Fujisaki–Okamoto: the KEM's shared secret is derived from a uniformly random message via a hash modelled as a random oracle, so KEM IND-CPA follows from K-PKE IND-CPA plus message entropy. Neither that reduction nor `kpke_ind_cpa_security` itself is in scope here, so the ML-KEM modules state equalities and restated bounds, never an MLWE bound; importing one would import `sorryAx`. ### The exact missing lemma The statement below elaborates as written against the pinned VCVio, under `import LatticeCrypto.HardnessAssumptions.LearningWithErrors` and `open MLKEM`: ```lean theorem MLKEM.kem_ind_cpa_security {params : Params} (ring : NTTRingOps) (encoding : Encoding params) (prims : Primitives params encoding) [DecidableEq encoding.EncodedTHat] [DecidableEq encoding.EncodedU] [DecidableEq encoding.EncodedV] [SampleableType SharedSecret] : ∃ mlwe : LearningWithErrors.Problem (TqMatrix params.k params.k) (TqVec params.k) (TqVec params.k), ∀ cpaAdv : (MLKEM.asKEMScheme ring encoding prims).IND_CPA_Adversary, ∃ mlweAdv : LearningWithErrors.Adversary mlwe, KEMScheme.IND_CPA_Advantage ProbCompRuntime.probComp cpaAdv ≤ |LearningWithErrors.advantage mlwe mlweAdv| ``` The day it lands, composing it with [`sharedSecretHiding_eq_indCpaAdvantage`](/lean/proofs/sharedsecrethiding#sharedsecrethiding_eq_indcpaadvantage) is a one-line `calc`. ## The ML-KEM instantiation VCVio ships a concrete ML-KEM whose checked interface is packaged as `MLKEM.asKEMScheme : KEMScheme ProbComp …`, over the same `ProbComp` monad the game layer uses — so it *is* a [`KEM`](/lean/proofs/kemanonymity#kem) in this development's sense, with no bridge. [`mlkem768StealthScheme`](/lean/proofs/mlkem#mlkem768stealthscheme) is [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) at it: discovery via real ML-KEM, announcement = ciphertext plus auxiliary data derived from the shared secret and the recipient's encapsulation key. What this buys: the terms of [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le), specialized, are now about VCVio's ML-KEM. The shared-secret-hiding terms are exactly its KEM IND-CPA advantage; the anonymity term is the one link neither VCVio nor FIPS 203 supplies. ### Two facts about the concrete encoding * **`DecidableEq` is not synthesizable through the encoding.** `MLKEM.Concrete.concreteEncoding` sets all three encoded types (`EncodedTHat`, `EncodedU`, `EncodedV`) to `ByteArray`, but it is a plain `def`, so instance search cannot see through it. Three `inferInstanceAs (DecidableEq ByteArray)` transports close that; they are definitionally `ByteArray.instDecidableEq`. They are what lets `MLKEM.asKEMScheme` and everything built on it elaborate at `mlkem768Encoding`. * **The concrete ciphertext type admits NO uniform distribution.** `SampleableType β` bundles a `ProbComp β` whose support is all of `β`, and supports of `ProbComp`s are finite, so a `SampleableType` instance forces `β` finite. `ByteArray` is unbounded, hence infinite ([`infinite_byteArray`](/lean/proofs/mlkem#infinite_bytearray): the all-zero arrays have pairwise distinct lengths), hence so is the ciphertext type ([`infinite_mlkem768Ciphertext`](/lean/proofs/mlkem#infinite_mlkem768ciphertext)). Therefore [`isEmpty_sampleableType_mlkem768Ciphertext`](/lean/proofs/mlkem#isempty_sampleabletype_mlkem768ciphertext): `$ᵗ (Ciphertext params encoding)` is unavailable at this parameter set. That is why the generic ML-KEM capstone takes its simulator as an explicit argument rather than as a uniform sample over the ciphertext type. ### The simulator: uniform FIPS 203 ciphertext bytes [`mlkem768UniformCiphertext`](/lean/proofs/mlkem#mlkem768uniformciphertext) is uniform over the fixed-length byte strings of the FIPS 203 ciphertext layout: `32 · du · k = 960` bytes of `u` followed by `32 · dv = 128` bytes of [`v`](/lean/proofs/demo#v), 1088 bytes in total. That is the distribution the SPR literature means by "uniform ciphertext bytes", and it is a *sharper* simulator than uniform-over-the-encoded-type would be, since the latter would range over byte arrays of every length — its support is a strict subset of the encoded type, which is all of `ByteArray × ByteArray`. [`size_uEncoded_encrypt_mlkem768`](/lean/proofs/mlkem#size_uencoded_encrypt_mlkem768) checks the `u` half of that layout against what honest K-PKE encryption actually emits. **Documented gap:** the matching [`v`](/lean/proofs/demo#v)-half check is not machine-checked. VCVio proves it (`byteEncode_size`, `.lake/packages/VCVio/LatticeCrypto/MLKEM/Concrete/Encoding.lean:242`) but that theorem and the `bitsToBytes` definition it rests on are `private`, so the fact is unavailable outside that module and the goal cannot even be stated in terms one can unfold. The `32 · dv` figure is therefore taken from FIPS 203 Algorithm 5 on the strength of the specification alone; [`uEncodedBytes_add_vEncodedBytes_eq_ciphertextBytes`](/lean/proofs/mlkem#uencodedbytes_add_vencodedbytes_eq_ciphertextbytes) confirms only that the two figures chosen sum to VCVio's `Params.ciphertextBytes`, which is arithmetic and corroborates neither summand. ### Upstream asks Three things would shorten this file if VCVio provided them: `DecidableEq` instances (or a structure-eta-friendly encoding) for the concrete encoded types, a public `byteEncode_size`, and [`MLKEM.kem_ind_cpa_security`](/lean/proofs/mlkem#mlkemkem_ind_cpa_security) in the shape given above. ## What is now in Lean `PqStealth/SPRTwoHop.lean` machine-checks the *shape* of the two-hop argument and the two lattice hops themselves. What it does **not** do is prove `SPR ≤ 2·MLWE` outright: the honest headline is a **four-term** bound, of which two terms are decision-MLWE advantages and two are named, unbounded, non-lattice terms. ``` KEM.sprAdv (asKEMScheme ring encoding prims) sim adv b ≤ primitiveIdealization … adv b -- ROM/PRF step + LearningWithErrors.advantage (keyHopProblem …) (keyHopAdv … adv b) -- MLWE + LearningWithErrors.advantage (ctHopProblem …) (ctHopAdv … adv b) -- MLWE + simulatorGap … adv sim b -- encoding + keys ``` [`sprAdv_le_two_hop_decomposition`](/lean/proofs/sprtwohop#spradv_le_two_hop_decomposition) is the generic statement; [`mlkem768_sprAdv_le_two_hop_decomposition`](/lean/proofs/sprtwohop#mlkem768_spradv_le_two_hop_decomposition) is it at [`mlkem768KEM`](/lean/proofs/mlkem#mlkem768kem) and [`mlkem768UniformCiphertext`](/lean/proofs/mlkem#mlkem768uniformciphertext), with no instance hypotheses left open — so it plugs straight into the SPR terms of [`mlkem768_unlinkAdvantage_le_full_decomposition`](/lean/proofs/mlkem#mlkem768_unlinkadvantage_le_full_decomposition). Neither name claims `SPR ≤ 2·MLWE`: the bound has four terms, two of which are not lattice terms. The `## The capstone` section above, and `docs/DECISIONS.md` D-013, still read as if the SPR terms were already `2·MLWE`; with this file landed they are `2·MLWE + 2 named non-lattice terms` per branch, and both places should be corrected. ### The two problems Both are VCVio `LearningWithErrors.Problem`s and both are **seeded**: the challenge sample is `rho`, not the matrix, because the reduction must hand the distinguisher a genuine encapsulation key and cannot invert `SampleNTT`. This is the same choice `ConstructionA.blindingProblem` makes, and it differs from the shape `kpke_ind_cpa_security` announces upstream (`TqMatrix params.k params.k` as the sample), which no reduction can actually use for a key hop. * `keyHopProblem : Problem Seed32 (TqVec k) (TqVec k)` — `noiseless ŝ rho = Â(rho) · ŝ`, secret and error the `PRF_η₁` distributions, uniform side `$ᵗ (TqVec k)`. * `ctHopProblem : Problem (Seed32 × TqVec k) (TqVec k) (RqVec k × Rq)` — sample `(rho, t̂)` with `t̂` already uniform, secret `ŷ`, error `(e₁, e₂)` from `PRF_η₂`, `noiseless ŷ (rho, t̂) = (Âᵀŷ, t̂ᵀŷ)`. That is the MLWE instance over the module-dimension-`k+1` matrix `[Âᵀ | t̂ᵀ]`; VCVio's `Problem` shape carries it as a product-typed output rather than a `k+1` vector, which is the same thing with the last coordinate named. Uniform sampling on `R_q` and `T_q` had to be supplied: VCVio's `MLDSA` files take `SampleableType (RqVec …)` as a hypothesis and never construct it. [`instSampleableTypeRq`](/lean/proofs/sprtwohop#instsampleabletyperq) goes through `Vector Coeff 256` (`LatticeCrypto.Poly` is a plain `def`, invisible to instance search) and [`instSampleableTypeTq`](/lean/proofs/sprtwohop#instsampleabletypetq) transports it along the `TransformPoly` newtype; both need [`instNeZeroMlkemModulus`](/lean/proofs/sprtwohop#instnezeromlkemmodulus), since `MLKEM.modulus` is a `def` and hides `NeZero` from `FinEnum (ZMod n)`. **Fourth upstream ask:** these three instances beside `MLKEM.Arithmetic`. ### The two reduction adversaries `keyHopAdv adv b` plants the challenge `(rho, t̂)` as recipient `b`'s encapsulation key, encrypts honestly to it, then generates the other recipient itself. `ctHopAdv adv b` plants `(rho, t̂)` as recipient `b`'s key and the noisy part as the challenge ciphertext, adding the message contribution `μ` it samples itself — legitimate, since `μ` is independent of everything the challenger draws. ### Why the hop identities are equalities, not distribution arguments Every game in the chain draws the **challenge key first, then the ciphertext, then the other recipient's key**, and [`idealEncrypt`](/lean/proofs/sprtwohop#idealencrypt) draws `(ŷ, e₁, e₂, m)` in that order. With that layout each reduction's `game0` / `game1` is the corresponding game *on the nose*: [`game0_keyHopProblem`](/lean/proofs/sprtwohop#game0_keyhopproblem), [`game1_keyHopProblem`](/lean/proofs/sprtwohop#game1_keyhopproblem), [`game0_ctHopProblem`](/lean/proofs/sprtwohop#game0_cthopproblem), [`game1_ctHopProblem`](/lean/proofs/sprtwohop#game1_cthopproblem) are all `simp only [bind_assoc, pure_bind, …]`, with no `evalDist` reordering anywhere. The only non-syntactic step is [`idealEncrypt_pkOf`](/lean/proofs/sprtwohop#idealencrypt_pkof), the `byteDecode12Vec ∘ byteEncode12Vec` roundtrip — and that is an `Encoding` *field*, not a `Laws` hypothesis, so it costs nothing. The bridge from VCVio's hidden-bit `LearningWithErrors.advantage` to a game distance is [`LearningWithErrors.advantage_eq_boolDistAdvantage`](/lean/proofs/sprtwohop#learningwitherrorsadvantage_eq_booldistadvantage) (`advantage problem adv = (game0 …).boolDistAdvantage (game1 …)`), proved from `boolBiasAdvantage_bind_uniformBool_eq_boolDistAdvantage` with the empty prefix. Without it the MLWE terms could not enter a triangle-inequality chain at all. ### Exactly where this stops **[`primitiveIdealization`](/lean/proofs/sprtwohop#primitiveidealization)** — the distance between the real ML-KEM anonymity branch and its idealized form. Real ML-KEM is *deterministic given the seeds*: `keygenFromSeed` derives `(rho, sigma)` from one `d` and then `s`, `e` from that one `sigma`; `encapsInternal` derives the encryption coins [`r`](/lean/proofs/demo#r) from `G(m ‖ H(ek))`, so the ciphertext randomness is a function of the message **and of the challenge key**. The idealized games draw `rho`, `ŝ`, `ê`, `ŷ`, `e₁`, `e₂`, [`m`](/lean/proofs/demo#m) independently. Closing this gap is the ROM/PRF step (`G` a random oracle, `PRF_η` a PRF; `SampleNTT` stays concrete, since both MLWE problems are seeded on `rho`) — the same modelling `ConstructionA.ExpandIsIdeal` names for the blinding argument, and not an MLWE statement. It is deliberately **not** a `PRFScheme.prfAdvantage`: VCVio's PRF game is a keyed-oracle distinguishing game, whereas this gap bundles `G` as a random oracle with the CBD pushforward of `PRF_η`, which no single PRF query game expresses. Naming it as a game distance keeps it honest. It is measured from the *reordered* real branch [`KEM.anonBranchReordered`](/lean/proofs/sprtwohop#kemanonbranchreordered) (challenge key, its encapsulation, then the other key): the reorder itself is free — [`KEM.evalDist_anonSetup_bind_anonBranch`](/lean/proofs/sprtwohop#kemevaldist_anonsetup_bind_anonbranch), proved with VCVio's relational tactics (`by_equiv`, `rvcstep swap left`, `rvcgen`) — so the term contains nothing but the primitive idealization. **[`simulatorGap`](/lean/proofs/sprtwohop#simulatorgap)** — the distance from the last idealized game to the SPR simulated branch. [`simulatorGap_le`](/lean/proofs/sprtwohop#simulatorgap_le) splits it, by one triangle inequality, into * [`encodingRegularity`](/lean/proofs/sprtwohop#encodingregularity) — `compressDU`/`compressDV` followed by `byteEncodeDUVec` / `byteEncodeDV` applied to *uniform ring elements*, against `sim` (for ML-KEM-768, uniform 1088 FIPS 203 bytes). This is a regularity property of the concrete encoding, it is **not** in `Encoding.Laws`, and stating it needs the `private` `byteEncode_size` discussed above. Compression is lossy, so this is a genuine statistical claim, not a bijection argument. * [`keyRestoration`](/lean/proofs/sprtwohop#keyrestoration) — putting the honest key distribution back once the challenge ciphertext is key-independent. Bounded by the same primitive-idealization and key-hop content applied to a ciphertext-blind distinguisher; naming it separately keeps the encoding claim clean. **Implicit rejection is irrelevant here.** SPR is a CPA-level notion: the distinguisher gets two public keys and one ciphertext and no decapsulation oracle, so `decapsInternal`, `jReject` and the `c = c'` re-encryption check never appear in any game of the chain. The implicit-rejection caveat belongs to the ANO-CCA lift (Maram–Xagawa), not to this file. **Not attempted:** chaining [`mlkem768_sprAdv_le_two_hop_decomposition`](/lean/proofs/sprtwohop#mlkem768_spradv_le_two_hop_decomposition) into [`mlkem768_unlinkAdvantage_le_full_decomposition`](/lean/proofs/mlkem#mlkem768_unlinkadvantage_le_full_decomposition). It is one `calc` over `gcongr`, but would produce an eight-term inequality; the two statements compose by hand. Issue #7's acceptance also asks for the "remaining lattice step" paragraph of `AnonymityFromSPR.lean`'s module docstring to be rewritten to point here — that file belongs to another work item and is left untouched. # Encodings: what is concrete in Lean and what is still a parameter :::info[Mirrored page] Source: [`lean/docs/encodings.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/encodings.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Scope: abstract encoding results for several formats. The commitment roundtrip does not prove account derivation or privacy. Current evidence and checker coverage: [ERC evidence](/spec/erc-evidence). Companion to `PqStealth/Invariants.lean` §3 (issue #10 in `improvements.md`). ## The two wire formats `TECHNICAL_SPEC.md` §3 fixes the meta-address as version(1) ‖ rho(32) ‖ pack23(t) ‖ ek and `DECISIONS.md` D-012 the reduced ZK-spend layout as version(1) ‖ commitment(32) ‖ ek Both are modelled in Lean as honest fixed-length byte strings — `Bytes n := Vector UInt8 n`, VCVio's `MLDSA.Bytes` — not as a record: * [`metaAddressEncode`](/lean/proofs/invariants#metaaddressencode) is literally `#v[version] ++ (rho ++ (packT t ++ packEk ek))` at type `Bytes (1 + (32 + (nt + nek)))`; * [`metaAddressDecode`](/lean/proofs/invariants#metaaddressdecode) splits at the fixed offsets `1`, `33`, `33 + nt` with [`splitBytes`](/lean/proofs/invariants#splitbytes), defined by `Vector.extract` + `Vector.cast`; * [`meta_address_roundtrips`](/lean/proofs/invariants#meta_address_roundtrips) / [`meta_address_commit_roundtrips`](/lean/proofs/invariants#meta_address_commit_roundtrips) prove `decode (encode …) = (version, rho, t, ek)` from the single byte-level lemma `splitBytes_append : splitBytes (a ++ b) = (a, b)`. The type is written right-nested (`1 + (32 + (nt + nek))`) on purpose: that is the association [`splitBytes`](/lean/proofs/invariants#splitbytes) peels from the front, and it avoids a `Vector.cast` on every use. The concrete corollaries [`meta_address_roundtrips_5633`](/lean/proofs/invariants#meta_address_roundtrips_5633) and [`meta_address_commit_roundtrips_1217`](/lean/proofs/invariants#meta_address_commit_roundtrips_1217) restate the same fact with every binder at a literal length (`Bytes 4416`, `Bytes 1184`, `Bytes 32`); the `: Bytes 5633` / `: Bytes 1217` ascription on the encoded argument typechecks by definitional unfolding of the numerals but, being elaboration-time only, does not survive into the stored type — the total is carried by the size theorems below. ## The lengths | Quantity | Lean | Value | |---|---|---| | `pack23(t)`, ML-DSA-65 | `packedTBytes mldsa65 = mldsa65.k * ringDegree * qBits / 8` | 4416 | | ML-KEM-768 `ek` | `MLKEM.Params.publicKeyBytes MLKEM.mlkem768 = 384 * k + 32` | 1184 | | Meta-address | [`metaAddress_size_mldsa65_mlkem768`](/lean/proofs/invariants#metaaddress_size_mldsa65_mlkem768) | **5633** | | commitment meta-address (`0x02`) | [`metaAddressCommit_size_mlkem768`](/lean/proofs/invariants#metaaddresscommit_size_mlkem768) | **1217** | `qBits = 23` is a literal (`q = 8380417 < 2^23`, the spec's `Q_BITS`); it is not derived from `Nat.log2 (modulus - 1) + 1` because `Nat.log2` is well-founded recursion and does not reduce by `rfl`. The `/ 8` is exact for every approved ML-DSA set since `8 ∣ 256`, so `packedTBytes p = p.k * 736`. The `ek` length is taken from VCVio (`MLKEM.Params.publicKeyBytes`) rather than written out, so the 5,633 stays tied to the upstream parameter record. ## What is still a parameter, and why `packT : T → Bytes nt` and `packEk : Ek → Bytes nek` are parameters carrying roundtrip hypotheses, exactly as [`stealth_pk_roundtrips`](/lean/proofs/invariants#stealth_pk_roundtrips) takes `enc.Laws`. This is forced by the pinned VCVio (`a5f474fd`), not a modelling shortcut: * `MLDSA.Encoding` (`LatticeCrypto/MLDSA/Encoding.lean`) keeps `EncodedPK`, `EncodedSK`, `EncodedSig` as abstract `Type`s. `pkEncode` packs `(rho, t1)`, the *rounded* high part — the meta-address must carry full-precision `t` (TECHNICAL\_SPEC §3), so `pkEncode` is the wrong encoder here even if it were byte-valued. * The concrete ML-DSA bit-packers (`LatticeCrypto/MLDSA/Concrete/Encoding.lean`: `simpleBitPackPoly`, `bitPackPoly`) return `ByteArray` — no length index — and carry no roundtrip lemma. There is no `pack23` upstream at all. * `MLKEM.Encoding.EncodedTHat` is likewise abstract, concretely `ByteArray`, and `mlkem768EncodingLaws` (`.lake/packages/VCVio/LatticeCrypto/MLKEM/Concrete/Instance.lean:133`) proves only the ciphertext/message laws — `byteDecode12Vec_byteEncode12Vec` is a field of the encoding bundle, but again at `ByteArray`, not `Bytes 1184`. So the honest statement is: **the outer layout is concrete and proved lossless; the two inner packers are assumed lossless.** Closing the remaining gap means either upstreaming length-indexed byte encoders with laws to VCVio, or writing a 23-bit little-endian bit-packer `Vector (Fin 8380417) (k*256) → Bytes (k*736)` with a roundtrip proof here. The latter is a self-contained mini-project (digit-level reasoning about `Nat.ofDigits`/`Nat.digits` with fixed padding) and was not attempted under issue #10. # Spending and account research :::info[Mirrored page] Source: [`docs/SPENDING_RESEARCH.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/SPENDING_RESEARCH.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: These implementations explore how a detected destination can authorize a transfer. They support the project's feasibility story but are separate from the key-exchange ERC's core receive/scan evidence. Start with [ERC evidence](/spec/erc-evidence) for that scope. The cohort's spend stretch goal — ERC-4337 carrying a post-quantum key exchange and a zero-knowledge ownership proof, measured for speed, gas and end-to-end behaviour — is closed in the [**stretch-goal report**](/research/stretch-goal-report). Read that first if you want the answer rather than the component experiments: it states what is measured, what each spend route reveals, and what is explicitly not claimed. | Track | Implementation and records | Security and deployment boundary | | --- | --- | --- | | Construction A | [Derivation/signing reference](/research/construction-a), [security analysis](/research/construction-a-security), Python blinded signer | Format `0x01`; stock signature-format compatibility does not complete related-key signature security; a revealed stealth key exposes the recipient's `rho` (D-027) | | Direct ML-DSA-44 committed key | [Profile record](/research/ml-dsa-commit-profile), `profiles.py` / `profiles.ts`, `MlDsa44CommitSigner7913`, forge + anvil tests | Format `0x02`, ML-DSA-44 only; key revealed at spend (linkable); on-chain key setup trusts a registrar; nothing deployed | | ERC-4337/7913 accounts | [TS client and E2E commands](/impl/js-client), vendored ZKNOX verifier | Verifier profiles and account address rules differ; raw ML-DSA-key hashes are not ECDSA EOAs | | Direct SPHINCS-C13 | D-018 in [decisions](/spec/decisions), C13 verifier and committed signer | Recipient key revealed at spend time; spend-time linkability remains | | C13 signature in ZK | [D-023 experiment](/research/zk-sphincs-frames), `noir/sphincs-c13-verify` | Hidden-key statement with a non-PQ-sound UltraHonk backend; recorded costs are specific to that testnet/toolchain | | Browser preimage ownership | D-025, `noir/preimage-ownership`, `ui/src/lib/preimage-prover.ts` | Proves knowledge of secret and opener; current backend is not PQ-sound; account/message binding needs separate review | | MLWE ownership circuit | [Noir research notes](/impl/noir), `noir/pq-stealth-ownership` | Separate lattice-relation PoC; not the current browser proof or a prerequisite for the ERC | | Future aggregation/native keys | [EIP-8288 assessment](/research/eip-8288-summary), [deployment experiments](/research/prefix-deploy-native-keys) | Prospective integrations; proposed dependency charges are not measured total spend costs | | Classical spending hybrid | [Hybrid notes](/spec/classical-spend-hybrid) | ML-KEM discovery with ECDSA authorization; spending is not post-quantum | ## Proof-reading path for spending specialists For Construction A, read its derivation reference and security analysis, then `Blinding`/`Invariants` → `Ownership` → `WidenedSigning` → `SpendSecurity` → `ConstructionASecurity`. The related-key bound concerns short ownership witnesses. The Fiat-Shamir-with-aborts signature composition remains open; the widened HVZK distance is currently the trivial `1`. For the commitment demo, inspect the preimage circuit and account authorization instead. Construction A's signing proofs do not verify this circuit, backend, or account. The sender knows the opener; proving only opener knowledge would not establish recipient authorization. ## Evidence to report for any route Record the precise key/profile and authorization statement, message and replay binding, who learns the key or witness, account/factory/verifier dependencies, and the proof backend's assumptions. Report hardware and toolchain alongside proving time and memory. Separate registration, announcement, account creation, verification, transfer, and total billed gas; distinguish initial and later spends. Existing measurements remain in their dated experiment notes. Narrowing the ERC scope leaves these research questions intact. It removes them from the key-exchange reading path; it does not make a deployed account secure by assumption or promise a transparent migration for funded accounts. # ML-DSA authorization under format 0x02 — the `ml-dsa-44-commit/v0` profile :::info[Mirrored page] Source: [`docs/ml-dsa-commit-profile.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/ml-dsa-commit-profile.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Implementation record for D-027 (2026-09-22). Python defines the bytes (`python/pq_stealth/profiles.py`); TypeScript mirrors them (`js-client/src/profiles.ts`); the contracts under `js-client/contracts/src` implement the account side. This is supporting implementation documentation: the human-written [ERC draft](/spec/erc-draft) decides what, if anything, becomes normative. Nothing here retires format `0x01`, allocates or reuses an ERC-5564 scheme ID, or establishes end-to-end post-quantum security of the protocol. ## 1. What an account profile is The wire format is unchanged: `0x02 || spend_key(32) || kem_ek(1184)` = 1,217 bytes with ML-KEM-768. The 32-byte `spend_key` is opaque. Which spending scheme, hash domains, chain, factory and account code interpret it is an **account profile**, chosen explicitly by the application before a payment is sent or scanned. A profile is never inferred from the commitment, guessed by trial verification, or left until deployment. Three identifiers, three roles: | Identifier | Role | Changed by this work? | | --- | --- | --- | | Meta-address version byte `0x02` | Encoding of the published key material | No | | ERC-5564 scheme ID | How an announcement is interpreted (KEM ciphertext as `ephemeralPubKey`, view tag in metadata) | No — no allocation, no reuse | | Account profile (`profiles.py`, `profiles.ts`) | The selected authorization and deployment behaviour for a `spend_key` | New: typed, explicit, fail-closed | A profile determines all of the following. The three implemented profiles: | Input | `sphincs-c13-commit/v0` | `preimage/v0` | `ml-dsa-44-commit/v0` | | --- | --- | --- | --- | | Meta-address format | `0x02` | `0x02` | `0x02` | | KEM | ML-KEM-768, existing encodings | same | same | | `spend_key` | the raw 32-byte C13 key | `keccak256("pq-stealth/preimage/key/v0" ‖ sk)` | `keccak256("pq-stealth/ml-dsa-44/key/v0" ‖ pk)`, `pk` the canonical 1,312-byte FIPS 204 ML-DSA-44 public key | | Authorization | C13 signature revealing the key | UltraHonk proof of `(sk, opener)` | pure ML-DSA-44 signature, empty context, over the 32-byte account digest; payload `pk ‖ opener ‖ sig` | | `open_domain` | `pq-stealth/sphincs-c13/open/v0` | `pq-stealth/preimage/open/v0` | `pq-stealth/ml-dsa-44/open/v0` | | `commit_domain` | `pq-stealth/sphincs-c13/commit/v0` | `pq-stealth/preimage/commit/v0` | `pq-stealth/ml-dsa-44/commit/v0` | | Deployment binding | `Stealth8141ZkFactory(commitment, verifier, frameCtx)`, salt 0 | same | same shape, `verifier` = `MlDsa44CommitFrameVerifier` | | Trust source | application-pinned / operator-supplied binding | same | same; plus the key registry's registrar (§4) | | Spend route status | deployed (D-018, D-023) | deployed on the frames testnet (D-025) | local contract path only; nothing deployed | Existing profiles keep their exact domain bytes and fixtures; the C13, preimage and `0x01` tests pass unchanged. Renaming a domain would move funds. **Trust boundary.** A profile name is a name. `select_profile` / `selectProfile` accept only the three known profiles, require a positive chain ID, validate the binding's shape (20-byte factory / verifier / frame context, 32-byte salt, non-empty creation code) and refuse to be used on another chain (`for_chain` / `bindingForChain`). They do not — cannot — authenticate that the factory, verifier or creation code are the intended ones. The application must obtain the binding from a source it trusts. No negotiation or registry mechanism is introduced; unknown profiles are rejected. ## 2. Derivation (exact bytes) `‖` is byte concatenation; domain strings are UTF-8 (ASCII) without length prefixes or terminators; Keccak-256 is Ethereum's, not SHA3-256. ```text spend_key = keccak256("pq-stealth/ml-dsa-44/key/v0" ‖ pk) pk: 1,312 B (ss, ct) = ML-KEM-768.Encaps(kem_ek) ct: 1,088 B opener = SHA-256("pq-stealth/ml-dsa-44/open/v0" ‖ ss) commitment = keccak256("pq-stealth/ml-dsa-44/commit/v0" ‖ spend_key ‖ opener) init_code = creation_code ‖ commitment ‖ leftpad32(verifier) ‖ leftpad32(frame_ctx) address = keccak256(0xff ‖ factory ‖ salt ‖ keccak256(init_code))[12:32] view_tag = SHA-256(ss)[0:1] ``` `spend_key` is hashed once, by the recipient, from the canonical key bytes; the outer commitment hashes that value together with the opener. The sender needs only the meta-address, the trusted binding and its own KEM result. The scanner needs the viewing secret, the public `spend_key` and the selected profile — never the signing key — and accepts a payment only after re-deriving the destination; a matching one-byte tag is a filter, not acceptance. Why ML-DSA-44 and not ML-DSA-65: the only ML-DSA verifier in this repository with an executable on-chain path is the vendored ZKNOX `ZKNOX_dilithium` (ETHDILITHIUM rev `df999ed`, NIST/SHAKE profile), whose signature slicing and matrix size are fixed at `k = l = 4` (D-006). An ML-DSA-65 profile needs its own verifier, its own domains and its own name; this profile refuses 1,952-byte keys and 3,309-byte signatures by length, and never labels the 44 verifier as 65\. The scheme's Construction A default remains ML-DSA-65 and is untouched. ## 3. Authorization A direct authorization payload is ```text payload = pk(1312) ‖ opener(32) ‖ sig(2420) = 3,764 bytes ``` The account/verifier (`MlDsa44CommitSigner7913`, and the reference `verify_authorization` / `verifyAuthorization`) performs, in order: 1. exact-length parse for ML-DSA-44 (`key` 32 B, payload 3,764 B); 2. `spend_key = keccak256(KEY_DOMAIN ‖ pk)`; 3. recompute the outer commitment and compare it with the account's bound commitment (the ERC-7913 `key`); 4. verify `sig` under `pk` over the account's 32-byte operation digest; 5. leave nonce, replay, chain, destination, value and calldata binding to the account: they are what the digest commits to. Message convention: **pure ML-DSA-44 (FIPS 204 Algorithm 2/3), empty context**, message = the 32-byte digest, so `M' = 0x00 ‖ 0x00 ‖ digest`. This is exactly what the vendored verifier's 3-argument `verify(bytes,bytes32,bytes)` hashes. No HashML-DSA prehash mode, no external-mu mode, and the digest is not hashed again anywhere. The digest is the account's: the EIP-8141 frame `sig_hash` (chain, sponsor, nonce, frames) for `Stealth8141ZkAccount`, the ERC-4337 `userOpHash` for `Stealth7913Account4337`. Anyone knowing the published `spend_key`, the shared secret and the opener still needs the recipient's ML-DSA-44 signing key: the vectors and tests exercise keys derived from `ss` and from the opener (they sign fine, but `keccak256(KEY ‖ pk')` is not the published `spend_key`) and a signature from another key over the recipient's `pk`. ## 4. On-chain integration and its honest status | Contract | Role | | --- | --- | | `ZKNOX_dilithium` (vendored, `lib/ETHDILITHIUM` @ `df999ed`) | The real ML-DSA-44 verifier. Expanded-key form: reads `(aHat, tr, t1)` from a `PKContract` pointer | | `TrustedMlDsa44KeyRegistry` (new) | Key setup: `keccak256(pk) → PKContract`. Recomputes `tr = SHAKE256(pk, 64)` and unpacks `t1` from the key bytes on chain; **trusts its registrar for `aHat = ExpandA(rho)`**. `register` binds once; registrar-only `replace` corrects a wrong binding (each binding is its own CREATE2 `PKContract`, salted by `pk` and `aHat`) | | `MlDsa44CommitSigner7913` (new) | ERC-7913 verifier: `key` = commitment, `signature` = payload; steps 1–4 above. Uniform `0xffffffff` for a bad payload or an invalid signature; **reverts** with `VerifierCallFailed` if the inner verify does not complete (out of gas, malformed return) | | `MlDsa44CommitFrameVerifier` (new) | `IProofVerifier` adapter so the unchanged `Stealth8141ZkAccount` / `Stealth8141ZkFactory` bind `(commitment, adapter, frameCtx)` | | `Stealth7913Account` / `Stealth7913Account4337` (existing) | Hold `verifier ‖ commitment` (52 B) as OpenZeppelin `SignerERC7913` signer bytes; exercised through ERC-1271 | **Verifier identity, established locally.** `ZKNOX_dilithium` at `df999ed` accepts stock FIPS 204 ML-DSA-44 signatures: the fixture signature is produced by `dilithium-py` 1.4.0 `ML_DSA_44.sign` (deterministic mode), reproduced byte for byte by `@noble/post-quantum` 0.6.1 `ml_dsa44` in TypeScript, verified by both libraries, and accepted on chain with `tr` and `t1` derived on chain from the canonical key bytes (`forge test --match-contract MlDsa44CommitAccount`, `npm run e2e-mldsa44`). D-006's "round-3 Dilithium2" wording therefore describes the parameter set (identical to ML-DSA-44) and not a different message or key format. **What is blocked: trustless key setup.** The verifier needs `aHat = ExpandA(rho)`, sixteen SHAKE128 polynomial expansions. With the vendored Solidity Keccak-f, one SHAKE256 over the 1,312-byte key costs ≈ 4.4 M gas, so a full expansion is ≈ 40 M gas — more than one transaction can do today and far more than a per-spend budget. The registry therefore does **not** verify `aHat`; a dishonest registrar could bind a matrix of its choosing to the recipient's `pk` and then spend from every account committed to it. Anyone can audit a registration off chain (`PKContract.getPublicKey().aHat == ExpandA(pk[0:32])`); nothing on chain does. Consequently: * the contract path is a **local, reviewed-registrar integration**, not a working trustless spend route and not a deployment; * the registrar's trust is **ongoing**: `replace` exists so that a registrar *mistake* does not strand funds (every account address pins the registry through the signer and adapter, so "deploy a new registry" is not a remedy for money already at a counterfactual address), which means a registrar compromised later can re-bind a key and spend. Both events are logged; a deployment that prefers immutability can retire the registrar address; * removing the trust needs either a staged on-chain expansion (≈ 16 transactions per recipient key), a cheaper SHAKE, or a verifier that takes the raw key — D-014's still-open "stateless raw-key verifier"; * no ML-DSA-44 verifier, registry, signer or factory of this profile is deployed anywhere; the vectors' binding is synthetic. The counterfactual address binds the policy: the account's `verifier` is the adapter, the adapter's signer is immutable, and a factory with any other verifier — or the same factory with another commitment — yields a different address (`testInitializationCannotSubstitutePolicy`). A fresh deployment cannot set a different owner. ### Measured costs Local only: forge 1.4.1 / anvil 1.4.1, solc 0.8.30, optimizer 10,000 runs, `evm_version = prague`, ETHDILITHIUM `df999ed`. Nothing here is a mainnet or testnet measurement, and nothing here says anything about ML-DSA-65. | Step | Gas | Where measured | | --- | --- | --- | | Verifier deploy (`ZKNOX_dilithium`) | 3,176,477 | anvil, tx receipt | | Registry / signer / adapter / factory deploy | 2,046,895 / 680,272 / 313,072 / 971,303 | anvil, tx receipts | | Key setup, once per recipient key (`register`: SHAKE256 `tr` + `t1` unpack + `PKContract`) | 10,791,042 (tx) / 11,794,233 (internal) | anvil / forge | | Account deploy (`createAccount`, no key material in initcode) | 704,195 | anvil, tx receipt | | `MlDsa44CommitSigner7913.verify` (commitment check + ZKNOX verify) | 14,966,788 internal; 15,038,028 as a tx with 3,764 B calldata | forge / anvil | | Complete spend (`executeFrame`: adapter + signer + verify + call) | 15,013,931 internal; 15,291,722 tx-level | forge / anvil | The per-spend cost is the ZKNOX verify (D-022's 14.9 M) plus ≈ 60 k for the commitment check and calldata. Because the signer reverts rather than returning `0xffffffff` when the inner verify runs out of gas, `eth_estimateGas` on `verify` reports the real cost (15,274,775 on anvil) instead of settling on a cheap failure path; callers of wrappers that swallow reverts (OpenZeppelin `ERC7913Utils`, `Stealth8141ZkAccount`) must budget that gas themselves. The previously circulated "Fireblocks 1.23 M" figure is not used here: no primary source, commit, parameter set, hash functions, key representation or reproducible benchmark for it was evaluated in this work, so it is not evidence. ## 5. Privacy boundaries Keep these apart; passing tests, distinct addresses and vector agreement are not privacy proofs. * **Receive time.** The announcement exposes the ciphertext, the tag and a CREATE2 address of a commitment; `spend_key` and the key stay hidden. The full-announcement composition argument for the commitment profile remains the open review item of [SECURITY\_ANALYSIS.md](/spec/security-analysis). * **Key disclosure at spend.** A direct ML-DSA spend reveals `pk`. Fresh openers give fresh commitments and addresses, but `keccak256(KEY ‖ pk)` is the recipient's published `spend_key`, so every spent address of one recipient is linkable to each other and to the meta-address from the first spend (`test_two_direct_spends_are_linkable`, the `linkage` block of the vectors). Direct verification does **not** preserve recipient anonymity after key disclosure. This is the same trade-off as the D-018 C13 commit signer. * **Construction A retained `rho` — confirmed.** `derive_stealth_pk` returns `pack_pk(rho, t1')`, and `rho` is the same 32 bytes the recipient publishes in its `0x01` meta-address (`python/tests/test_construction_a_rho.py`). Blinding hides `t`, not `rho`. Announcements reveal only `keccak256(stealth_pk)[12:]`, so receive-time privacy is unaffected — but any route that reveals the full stealth public key exposes `rho` and with it the recipient: an ML-DSA signature verified against `stealth_pk` (ERC-7913 route, D-014), a deployed `PKContract` (its `aHat = ExpandA(rho)`), pointer-signature key tables, the `zknox_*_demo.json` fixtures. D-018's sentence "blinded ML-DSA has no such identifying event" holds only while no stealth public key is revealed; after disclosure Construction A spends are linkable by `rho`. No algebraic repair is attempted here (changing `rho` independently of the matrix, or a common seed, needs its own analysis). * **Per-address key rotation is separate work.** Deriving the recipient's spending key from `ss` is unacceptable (the sender knows `ss`), and a sender cannot derive `H(pk_n)` from `H(pk_master)`; fresh recipient-controlled keys need an authenticated distribution or derivation protocol with its own privacy analysis. * **Signing security.** Stock ML-DSA-44 EUF-CMA under FIPS 204; no widened or related-key signing is involved in this profile. * **Proof-system soundness.** Not applicable to this profile. The D-025 browser demo (profile `preimage/v0`) proves knowledge of a spending secret and opener with UltraHonk, which is not post-quantum sound; it is a different route, not an ML-DSA-signature demo, and it is unchanged and still working. * **Account safety.** Verifier immutability, rotation-by-self-call, entry-point gating and digest binding are the account's properties (`Stealth8141ZkAccount` unchanged) and the registrar trust of §4 sits on top. ## 6. Conformance | Artifact | Command | | --- | --- | | Vectors `python/vectors/v0/mldsa44_commit_vectors.json` (profile, binding, two recipients, two payments, one cross-recipient miss, 2 valid + 12 invalid authorizations, linkage) | `python vectors/generate_mldsa44_commit_vectors.py` — byte-identical on regeneration (CI `cmp`) | | Python tests (`tests/test_profiles.py`, `tests/test_construction_a_rho.py`) | `pytest -q` | | TS replay + independent ML-DSA-44 verification | `npm run test:mldsa44` | | Contract path (forge, fixture `python/scripts/mldsa44_commit_7913_demo.json`) | `forge test --root contracts --match-contract MlDsa44CommitAccount -vv` | | Contract path (anvil, viem, cross-language CREATE2 over real creation code) | `npm run e2e-mldsa44` | Receive/scan conformance (vectors, `check_with_profile`) is separate from the optional spend-profile conformance (authorization vectors and contracts); an implementation may claim the former without the latter. ## 7. Open items * Trustless key setup or a raw-key ML-DSA-44 verifier (§4). Until then the registrar is a live trust assumption with a correction path, not a one-shot ceremony; a deployment must decide who holds that role and how it is retired. * Gas-exhaustion semantics across the stack: the signer reverts, but the OpenZeppelin and frame-account wrappers convert any revert into "not authorized"; an integrator under a gas cap cannot tell the two apart at those layers. * An ML-DSA-65 verifier and profile; nothing measured here transfers. * ERC-4337 binding: `Stealth7913Account4337` accepts the same signer bytes, but its constructor layout differs from the `Deployment` record, so no cross-language address derivation is provided for it. * Whether direct ML-DSA spending is offered in the ERC at all, given §5; the UI does not expose this route. * Construction A's `rho` exposure: decide whether the `0x01` form is kept with the linkability stated, or reworked with a separate analysis. # Construction A — derivation and spending reference :::info[Mirrored page] Source: [`docs/construction-a.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/construction-a.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Scope: supporting research for format `0x01`, retained after D-024 shifted the ERC focus to key exchange. This document preserves implementation details and dated spending experiments; it does not define the current ERC scope. Start with the [key-exchange technical reference](/spec/technical-spec). For the current proof boundaries, read the [Construction A security analysis](/research/construction-a-security). ## 1. Scheme overview Construction A from [plan.md](/overview/plan): ML-KEM (FIPS 203) establishes the shared secret; the stealth key is an additively blinded ML-DSA (FIPS 204) key. Compared to the `pq-sap` `v2` prototype this draft (a) adds a fresh error term per stealth key, (b) specifies all encodings, and (c) adds the signing path, which produces signatures accepted by any stock FIPS 204 verifier. ``` Recipient setup spending key: zeta -> (rho, s1, s2), t = A*s1 + s2 (FULL precision) viewing key: (ek, dk) = ML-KEM.keygen() meta-address: version || rho || pack23(t) || ek Sender (ss, R) = ML-KEM.encaps(ek) (s', e') = ExpandS(SHAKE256(ss, 64)) t' = A*s' + e' + t (t1', t0') = Power2Round(t', 13) # t0' discarded by sender stealth_pk = pack_pk(rho, t1') # genuine ML-DSA public key stealth_address = keccak256(stealth_pk)[12:32] view_tag = SHA-256(ss)[0 : VIEW_TAG_BYTES] announce(SCHEME_ID, stealth_address, R, view_tag); send assets Recipient scan (viewing key dk suffices) ss = ML-KEM.decaps(dk, R) view_tag mismatch -> skip (cheap path); else re-derive and compare address Spend / prove possession (requires spending secrets s1, s2) blinded key: s1' = s1 + s', s2' = s2 + e', t0' sign per FIPS 204 Alg. 7 with widened bounds (Section 4.2) ``` Correctness identity (the Lean 4 target in plan.md): `t' = A*s' + e' + t = A*(s1 + s') + (s2 + e')`, so `(s1', s2', t0')` is a working secret key for `stealth_pk` under the spec's rounding. ## 2. Parameters and sizes Default pairing ML-KEM-768 + ML-DSA-65 (NIST level 3); the spec is parameter-agile across `{512+44, 768+65, 1024+87}` (`pq_stealth/params.py`). All sizes below are measured by the test suite (`test_encoding.py`) and recorded in the vectors' `sizes` field. | Object | Size (default set) | Notes | |---|---|---| | Meta-address (format 0x01, Construction A) | **5,633 B** | `1 + 32 + 4416 + 1184`; one-time registry/ENS cost | | Meta-address (format 0x02, commitment; D-024) | **1,217 B** | `1 + 32 + 1184`; no ML-DSA public-key material | | Ephemeral pubkey [`R`](/lean/proofs/demo#r) | 1,088 B | the ML-KEM ciphertext (33 B in EC DKSAP) | | View tag | 1 B | longer tags safe in this family; default stays 1 | | Stealth address | 20 B | Ethereum-style, `keccak256(pk)[12:]` | | Stealth public key | 1,952 B | standard ML-DSA-65 pk | | Signature / possession proof | 3,309 B | standard ML-DSA-65 signature | `VIEW_TAG_BYTES = 1`. `Q = 8380417`, `Q_BITS = 23`, `d = 13`. ## 3. Encodings (normative) **Meta-address** (`pq_stealth/encoding.py`): `version(1) || rho(32) || pack23(t) || kem_ek`. `version = 0x01`. `pack23` packs each of the `k*256` coefficients of `t` (reduced to `[0, q)`) in 23 bits, little-endian bit order, matching FIPS 204's packing conventions. The meta-address MUST carry full-precision `t`, not the rounded `t1` of a standard public key: `Power2Round(A*s' + e' + t)` is only well-defined on the unrounded value. (The Rust `v2` prototype also keeps full `t` but never defined an encoding.) **Commitment meta-address, format `0x02`** (`pq_stealth/commit.py`, `js-client/src/commit-scheme.ts`, D-024): `version(1) || spend_key(32) || kem_ek` — **1,217 B** at ML-KEM-768. `spend_key` is a 32-byte commitment to the spending public key (the key itself for SPHINCS- C13, a hash otherwise). Derivation: `opener = SHA-256(open_domain || ss)`, `commitment = keccak256(commit_domain || spend_key || opener)`, stealth address = `CREATE2(factory, 0, initcode(commitment, …))` of the account bound to the commitment (a *deployment binding*, not part of the meta-address). This is the key-exchange-only form: no ML-DSA public-key material, no per-address key derivation, nothing about the spend scheme at receive time. Vectors: `vectors/v0/commit_vectors.json`; Lean: [`meta_address_commit_roundtrips_1217`](/lean/proofs/invariants#meta_address_commit_roundtrips_1217). Two spend-scheme domain sets exist: `pq-stealth/sphincs-c13/*` (the C13 key is the `spend_key`; D-018/D-023) and `pq-stealth/preimage/*` (`spend_key = keccak256("pq-stealth/preimage/key/v0" ‖ sk)`, spending proves knowledge of `sk`; D-025, the demo's client-side proof). **Announcement**: ERC-5564 `Announcement` event fields with `ephemeralPubKey = R` (1,088 B ML-KEM ciphertext) and `metadata[0] = view_tag` byte, per the existing convention. **Blinded secret key** (optional persistence format): `rho || bitpack(s1', s2', 5 bits/coeff over [-2η, 2η]) || bitpack(t0', 14 bits/coeff)`. The FIPS 204 secret-key encoding CANNOT hold a blinded key: `bit_pack_s` assumes coefficients in `[-η, η]`, but `‖s1'‖∞, ‖s2'‖∞ ≤ 2η`. Alternatively store nothing and re-derive from `(zeta seeds, ss)`. ## 4. Derivation and signing (normative) ### 4.1 Blinding `(s', e') = ExpandS(SHAKE256(ss, 64))` - the standard FIPS 204 eta-sampler over a 64-byte seed, producing the `l`-vector `s'` and `k`-vector `e'` with domain-separated nonces (`pq_stealth/blinding.py`). **Fresh error term (departure from pq-sap v2):** v2 reuses the recipient's `s2` in every stealth key, a potential linkability vector once any stealth key is revealed. Here each stealth key gets `e'` derived alongside `s'`; the sender still computes everything from public data. Cost: `‖s2'‖∞ ≤ 2η` enters the `r0` bound below. Construction A has its own conditional unlinkability analysis; see [the security analysis](/research/construction-a-security). **Retained `rho` (finding, D-027).** `stealth_pk = pack_pk(rho, t1')` keeps the recipient's matrix seed unchanged, and `rho` is published in the `0x01` meta-address. The announcement exposes only `keccak256(stealth_pk)[12:]`, so this does not affect receive-time unlinkability; but wherever the full stealth public key is revealed — an on-chain ML-DSA verify, a deployed `PKContract` (whose `aHat = ExpandA(rho)`), a pointer-signature key table, the demo fixtures — `rho` identifies the recipient among all registered meta-addresses. Blinding hides `t`, not `rho`; it does not by itself give unlinkability after key disclosure. Pinned by `python/tests/test_construction_a_rho.py`. No repair (changing `rho` independently of the matrix, or a common seed) is proposed here; either needs its own analysis. ### 4.2 Signing with the blinded key FIPS 204 Algorithm 7 over `(s1', s2', t0')` with the rejection bounds widened to `β' = τ·2η` (392 for ML-DSA-65, vs. standard `β = 196`): * `‖z‖∞ < γ₁ − β'` - stricter than the verifier's `γ₁ − β`, so signatures pass a stock verifier; acceptance probability drops accordingly. Measured cost is real, not cosmetic: the blinded signer runs ~6× the rejection rounds of stock ML-DSA-65 (mean ≈ 30 vs ≈ 5 at N=200, `benchmarks/op_bench.py`), dominated by the `r0` check since both secret vectors are now `2η`-normed. `2η` is a worst-case bound on `‖s1+s'‖∞`; a tighter (probabilistic) bound in the analysis would recover most of this, since the sum of two `η`-bounded terms rarely reaches `2η`. * `‖r0‖∞ < γ₂ − β'` - accounts for `‖c·s2'‖∞ ≤ β'`. * `‖c·t0'‖∞ < γ₂` and the ω hint bound are unchanged. Signatures are byte-identical in format to standard ML-DSA-65 and verify under dilithium-py, liboqs (tested), and any conformant on-chain verifier. **Security boundary:** accepted-`z` independence has been proved for the modeled cube sampler, but full transcript leakage and signature-layer composition remain unresolved; the widened HVZK bound is still `1`. See [the security analysis](/research/construction-a-security). ### 4.3 Message formatting (load-bearing) FIPS 204 external functions hash `M' = 0x00 || len(ctx) || ctx || M`. Any signer that hashes the raw message produces signatures that fail stock verification (this bug was hit and fixed during prototyping). Possession proofs bind the address via `ctx = "pq-stealth/pop/v0" || stealth_address` over the raw challenge; plain signatures use `ctx = ""`. ### 4.4 Signer randomization key `K' = SHAKE256("pq-stealth/sign-key/v0" || ss, 32)` replaces the keygen-time `K`; the deterministic variant sets `rnd = 0^32` (used for vectors). ## 5. Conformance vectors `python/vectors/v0/vectors.json`, deterministic (byte-identical on regeneration): recipient seeds, meta-addresses, viewing keys, announcements, expected stealth pk, and a deterministic possession proof. Negative cases: wrong view tag, wrong recipient, truncated ciphertext (MUST reject without raising), bit-flipped ciphertext (ML-KEM implicit rejection), plus a distinct-address unlinkability sanity case. Regenerate with `python vectors/generate_vectors.py`; the generator replays every case through the library before writing. ## 6. Library landscape (verified 2026-07) ### Python (reference implementation - this repo) | Library | Version | Role | Verdict | |---|---|---|---| | `kyber-py` | 1.2.0 | ML-KEM, pure Python | Top-level + derandomized internals (`_keygen_internal(d,z)`, `_encaps_internal(ek,m)`) - used for deterministic vectors | | `dilithium-py` | 1.4.0 | ML-DSA, pure Python | **Exposes every internal construction A needs**: `_expand_matrix_from_seed`, `_expand_vector_from_seed`, `power_2_round`, NTT `Matrix`/`Vector` algebra, `sample_in_ball`, `_pack_pk`/`_pack_sig`. No fork needed. | | `liboqs-python` | 0.16.0 | audited C backend | Opaque API only; used as cross-check verifier (test suite) and future fast-scanning backend | | `pqcrypto` | 0.4.0 | PQClean wrappers | Opaque API only; alternative cross-check | | `pycryptodome` | 3.23.0 | keccak256 | - | | `coincurve` | 21.0.0 | secp256k1 | DKSAP baseline for benchmarks (later phase) | Division of labor: pure-Python is the executable spec and vector generator; audited backends verify outputs; a production binding is future work. ### JavaScript (frontend - implemented in `js-client/`) * `@noble/post-quantum` 0.6.x (audited, pure TS/JS): ML-KEM-768 decaps and stock `ml_dsa65.verify` cover the scanning hot path and proof checking out of the box. The blinding layer is NOT expressible over its public API (the `internal` export is only FIPS 204's raw-`M'` interface; the NTT/ExpandA machinery lives in module closures). * `js-client/src/mldsa65.ts` therefore hand-ports the required FIPS 204 polynomial layer (~200 lines: ExpandA, ExpandS, NTT, Power2Round, pk packing; plain-Number arithmetic - products stay under 2^47). The conformance test replays all v0 vectors and asserts the JS-derived stealth pk is **byte-identical** to the Python reference; the vectors' possession proof verifies under noble. * End-to-end on-chain path (`npm run e2e`): a local ERC-5564 announcer on anvil (forge-built), vector announcements emitted as real transactions, logs fetched and scanned via viem - recipient A finds exactly the genuine payments, recipient B nothing. ## 7. The kohaku / ZKNOX spend route (stretch goal): compatibility finding Verified hands-on (2026-07-25, `ethereum/kohaku` `packages/pq-account` + `ETHDILITHIUM` at HEAD): the deployed ZKNOX verifiers implement a **level-2 Dilithium2 profile** (32-byte `c_tilde`, 2,420-byte signatures, round-3 `Dilithium2` from their vendored dilithium-py with FIPS-204-style `M' = 0x00‖len(ctx)‖ctx‖m` formatting and 64-byte `tr`) - **not FIPS 204 ML-DSA-65**, and not byte-exact ML-DSA-44 either. Measured on their own KAT vectors (forge, optimizer on, cancun): | Verifier | Gas per verify | |---|---| | `ZKNOX_dilithium` ("MLDSA" profile, SHAKE XOFs) | 8,176,453 | | `ZKNOX_ethdilithium` (keccak-PRNG XOFs, ETH-optimized) | 4,926,456 | Consequences for construction A: our blinding algebra carries over to their profile unchanged (same module structure; level-2 params η = 2, τ = 39, d = 13 - β' = 2τη = 156), but a spend demo through kohaku today requires instantiating the scheme at their profile, not at ML-DSA-65, and the ETH-optimized variant additionally swaps the XOFs (keccak-PRNG), which changes `ExpandA`/`ExpandS` and therefore the derived stealth keys. The pk is stored expanded (SSTORE2 via `PKContract`: `A_hat` + `tr` + `t1`, measured 22,400 B encoded) - one more reason the scheme ID stays parameter-agile. **Counterfactual CREATE2 demo (implemented and passing).** Construction A was instantiated at the ZKNOX profile (`python/scripts/zknox_counterfactual_demo.py`: master key with full `t`, ML-KEM-512 encapsulation, fresh-`e'` blinding, blinded possession signature - 31 rejection rounds at β' = 156, verified by their stock Python verifier) and driven on-chain (`js-client/test/e2e-counterfactual.test.ts`): * the account address is computed **fully off-chain** from the blinded key's expanded pk (CREATE2 over the factory's salt/initcode - the sender-names-the-address property Native UTXOs need), matches `factory.getAddress`, and matches the address actually deployed by `createAccount`; * the blinded-key signature **verifies through the on-chain `ZKNOX_dilithium` verifier**. | Operation (anvil, their artifacts) | Gas | |---|---| | Account deployment via factory (incl. 22.4 kB pk via SSTORE2) | 6,167,566 | | Blinded-signature verification (`ZKNOX_dilithium`) | ~8,151,911 | This upgrades the stretch goal from "route exists" to "route demonstrated end-to-end with construction A keys": receiving today, spending gated only on the account being funded/operated as a normal ERC-4337 account. **ERC-7913 update (2026-08-10, D-014).** Upstream ETHDILITHIUM at HEAD (`df999ed`) has replaced the `setKey` flow entirely: the verifiers now implement ERC-7913's `verify(bytes key, bytes32 hash, bytes signature) → bytes4`, where `key` is a 20-byte pointer to a deployed `PKContract` - a full ERC-7913 signer is `verifier || pkPointer` = 40 bytes. Two implications: (a) the 3-arg entrypoint hardcodes `M' = 0x00 || 0x00 || m` with [`m`](/lean/proofs/demo#m) a `bytes32`, so fixtures signed over longer or ctx-bearing messages (like the possession proof's ctx) do not verify through it - the message binding must live in the 32-byte hash; (b) per-account `setKey` (5.2 M gas on the deployed Sepolia v0.0.10) is superseded by one `PKContract` deployment per key. Demonstrated end-to-end in `js-client/test/e2e-7913.test.ts` against an OpenZeppelin `SignerERC7913` account (anvil, 2026-08-10): | Operation (vendored ETHDILITHIUM @ df999ed) | Gas | |---|---| | PKContract deploy (22.4 kB expanded pk, SSTORE2) | 5,324,168 | | `Stealth7913Account` deploy (signer = 40 B; initcode 2,952 B vs 49,152 B cap) | 620,750 | | ERC-7913 `verify` (blinded sig) | ~14,968,387 | Note the verify gas is ~1.8× the old table above: df999ed stores `t1` plain and recomputes `NTT(t1·2^d)` inside every `verify` - work the old `setKey` flow did once at key-setup time. **Correction (2026-09-02, D-022).** Profiled step by step (`js-client/contracts/test/DilithiumProfile.t.sol`), the per-verify `NTT(t1·2^d)` is 1.41 M of the 14.91 M; precomputing it at key setup (`contracts/src/ntt/`) gives 13,678,091 (−8 %), not the old 8.18 M. The largest single cost is SHAKE256 in Solidity (SampleInBall 0.94 M + the final `mu`/`c̃` hashes 4.05 M ≈ 34 %), which is exactly the gap to the keccak-XOF `ZKNOX_ethdilithium`. The 8,176,453 figure above was measured on an earlier upstream revision and does not reproduce at df999ed. Breakdown and consequences: `docs/research/prefix-deploy-native-keys.md` §3–4. ## 7b. Threat model, scheme variants, and the ZK direction The locked decisions and empirical findings from the design work - threat model (HNDL confidentiality vs. ownership, per pq.ethereum.org), the three scheme variants and what each protects, the EIP-3860 dual-PQ-account blocker, the ZKNOX level-2 key-size constraint, the cheap-vs-PQ on-chain tension, and the decode-in-reverse ZK ownership proof - are recorded as a dated ADR log in [`DECISIONS.md`](/spec/decisions). Headlines: * **Detection (ML-KEM) is the urgent, non-negotiable core** (HNDL protects confidentiality); spend-side PQ is configurable future-proofing that rides the account-abstraction rails Ethereum's own roadmap is building. * **On-chain today you get cheap OR post-quantum verification, not both** - Groth16 ~300k gas (classical), STARK ~5M (PQ), direct ML-DSA ~8M (PQ). The EF's J\* vector-math precompile closes this at the protocol level (~2029+). * **ZK ownership PoC** (`noir/`): prove the MLWE relation `A·s + e = t`, not a full ML-DSA signature - proved+verified in UltraHonk at 2.17M gates (full ML-DSA-65, schoolbook; ~100–200k projected with NTT). Cheap enough to be practical, but classical soundness until a PQ backend exists. ## 8. Deployment scope The raw hash of an ML-DSA public key is not an ECDSA-controlled EOA. Spending requires an account integration whose address rule and verifier match the selected profile. The account demonstrations above are evidence for those specific deployments, not universal spendability of raw format-0x01 outputs. Current commitment-account work is described in D-024/D-025. ## 9. Construction A security status Construction A has proved statements and explicit assumption records in `lean/PqStealth/ConstructionASecurity.lean` and its dependencies, documented in [construction-a-security.md](/research/construction-a-security). The signature layer is not yet composed, and the widened HVZK bound remains the trivial `1`: * **Widened-signature leakage** — `WidenedSigning.lean` bounds the accepted-`z` distribution and gives the ML-DSA-65 acceptance probability `(1047791/1048576)^1280`. * **ROM bad-query bound** — `BlindingROM.lean` and `BlindingEntropy.lean` bound the address-hash bad-query probability by `qH * betaAddr` per branch. * **SPR → MLWE/ANO-CCA** — `SPRTwoHop.lean` gives [`sprAdv_le_mlwe`](/lean/proofs/sprtwohop#spradv_le_mlwe): three named seeded-MLWE advantages plus `epsilonPrim` and `epsilonEnc`; ANO-CCA remains a separately labelled literature result (Maram–Xagawa ePrint 2022/1696). * **Related-key spend** — `SpendSecurity.lean` and `ConstructionASecurity.lean` prove the related-key spend bound in the search-witness model: a sender who knows all `q` blinding offsets forges a witness for some derived key with probability at most `q` times the master key's ownership-forgery advantage at bound `b + η`. The signature-layer losses [`CmaToNmaLossNN`](/lean/proofs/spendsecurity#cmatonmalossnn) and [`TruncationLossNN`](/lean/proofs/spendsecurity#truncationlossnn) are defined but not yet composed. * **Construction A/B decision** — D-021 in `DECISIONS.md` locks Construction A for the ERC-5564 sender-address flow and documents Construction B as a separate account-transfer protocol. The formal result covers serialized transcripts in the classical ROM. Timing, retry-count leakage, quantum random-oracle access, and side-channel analysis remain explicitly out of scope. # Construction A — security analysis :::info[Mirrored page] Source: [`docs/construction-a-security.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/construction-a-security.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Scope: format `0x01` and its blinded ML-DSA spending research. This analysis is retained as supporting evidence; it is not the proof of D-024 commitment-account privacy. Start with the [key-exchange security overview](/spec/security-analysis) and [ERC evidence map](/spec/erc-evidence). Every displayed bound below is either a theorem in `lean/PqStealth/ConstructionASecurity.lean` (or one of its dependencies) or an explicitly named assumption record. No claim is unconditional unless the statement says so. *** ## Notation * `Adv_unlink` — single-announcement unlinkability advantage for Construction A. * `Adv_unlink_q` — `q`-announcement unlinkability advantage against two fixed recipients. * `Adv_relatedSpend_q` — related-key ownership-witness advantage for `q` derived stealth keys; not signature EUF-CMA. * `qH` — number of adversarial queries to the address-hash random oracle. * `qS` — number of signing-oracle queries in the related-key game. * `epsilonBlindExpand` — bound on `ConstructionA.ExpandIsIdeal` (joint view tag / blinding pair). * `Adv_blindMLWE` — seeded decision-MLWE advantage against the blinding sampler. * `betaAddr` — point-mass bound for one address-point guess ([`BlindPointMassBound`](/lean/proofs/blindingentropy#blindpointmassbound)); for ML-DSA proven as `((2^d : ℝ≥0∞) / MLDSA.modulus) ^ (k * 256)` ([`blindPointMassBound_mldsa`](/lean/proofs/blindingentropy#blindpointmassbound_mldsa), ML-DSA-65: [`blindPointMassBound_mldsa65`](/lean/proofs/blindingentropy#blindpointmassbound_mldsa65)) when the primitive's `power2Round` is the concrete coefficient-wise `Power2Round` and `pack rho` is injective. * `epsilonPrim` — primitive-idealization loss in the SPR decomposition (ROM/PRF assumption). * `epsilonEnc` — encoding-regularity loss in the SPR decomposition. * `Adv_keyHop_b`, `Adv_ctHop_b` — seeded-MLWE advantages for the two SPR hops. * `Adv_keyRestore_b` — the key-restoration gap; by [`keyRestoration_le_mlwe_add_keyIdealization`](/lean/proofs/sprtwohop#keyrestoration_le_mlwe_add_keyidealization) it is at most the seeded-MLWE advantage of [`keyRestorationAdv`](/lean/proofs/sprtwohop#keyrestorationadv) plus [`keyIdealization`](/lean/proofs/sprtwohop#keyidealization) (the ROM/PRF step for key generation alone). * `Adv_maskIdealization` — distance between deterministic `expandMask` and independent mask draws. * `zetaWide` — [`widenedHvzkDistance`](/lean/proofs/widenedsigning#widenedhvzkdistance) for the widened ML-DSA identification scheme. * `delta` — commitment-regularity failure bound. * `Adv_spendForge(b)` — [`spendForgeryAdvantage`](/lean/proofs/ownership#spendforgeryadvantage) at shortness bound `b`: forging an ownership witness for the master stealth key (`Ownership.lean`; equal to a matrix-SIS advantage). * [`CmaToNmaLossNN`](/lean/proofs/spendsecurity#cmatonmalossnn) — classical-ROM CMA-to-NMA statistical loss (signature layer; defined, not composed). * [`TruncationLossNN`](/lean/proofs/spendsecurity#truncationlossnn) — loss from capping the signer at `maxAttempts` retries (signature layer). * [`UnboundedSigningAssumption`](/lean/proofs/spendsecurity#unboundedsigningassumption) — bridge from the production unbounded loop to the capped game. *** ## 1. Single-announcement unlinkability The Construction A unlinkability experiment is `StealthScheme.unlinkAdvantage` instantiated at `ConstructionA.scheme` (`ConstructionA.lean`). The faithful announcement model is [`StealthScheme.ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) (`KEMAnonymity.lean`), with the four-term decomposition ``` Adv_unlink ≤ sharedSecretHiding_true + auxKeyIndependence + KEM.anonAdvantage + sharedSecretHiding_false ``` stated as `ConstructionA.unlinkAdvantage_metaKem_le` (`ConstructionA.lean`) — it is the generic [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) bound instantiated at [`metaKem`](/lean/proofs/constructiona#metakem)/[`auxGen`](/lean/proofs/constructiona#auxgen), not an independent scheme theorem. The bound with each term replaced by its named hypothesis is `PqStealth.ConstructionA.unlinkAdvantage_full_bound` (`ConstructionASecurity.lean`). ### 1.1 Shared-secret hiding = IND-CPA [`sharedSecretHiding`](/lean/proofs/kemanonymity#sharedsecrethiding) is exactly the VCVio KEM IND-CPA advantage for the underlying ML-KEM instance. For ML-KEM-768 this is reduced to the SPR terms below; the IND-CPA advantages themselves remain named hypotheses until a sound KEM-level FO-to-MLWE theorem is available in the pinned VCVio tree. ### 1.2 From KEM anonymity to SPR `KEM.anonAdvantage_le_sprAdv` bounds the KEM anonymity term by two per-branch SPR advantages against a key-independent simulator. The SPR term decomposes in `SPRTwoHop.lean` as ``` KEM.sprAdv ≤ epsilonPrim + Adv_keyHop + Adv_ctHop + epsilonEnc + Adv_keyRestore ``` per branch, where the two MLWE hops are exact game identities and the three remaining terms are named assumption records ([`primitiveIdealizationBound`](/lean/proofs/sprtwohop#primitiveidealizationbound), [`encodingRegularityBound`](/lean/proofs/sprtwohop#encodingregularitybound), [`keyRestorationMLWE`](/lean/proofs/sprtwohop#keyrestorationmlwe)). The generic theorem is `PqStealth.sprAdv_le_mlwe` and the ML-KEM-768 instance is `PqStealth.mlkem768_sprAdv_le_mlwe` (both `SPRTwoHop.lean`). ### 1.2a Widened signing mask acceptance The widened identification scheme (`MLDSA.widenedIdentificationScheme`, `WidenedSigning.lean`) draws the mask `y` uniformly from the FIPS 204 cube `[-(γ₁-1), γ₁]` ([`sampleMaskCube`](/lean/proofs/widenedsigning#samplemaskcube)) and widens both response gates to `‖z‖∞ < γ₁ - β'` and `‖r₀‖∞ < γ₂ - β'` with `β' = 2·β`. Its key relation [`widenedValidKeyPair`](/lean/proofs/widenedsigning#widenedvalidkeypair) requires the public-key identity `t₁·2^d + t₀ = A·s₁ + s₂` and each secret vector to be a sum of two `η`-short vectors, which the blinded key `(s₁+s', s₂+e')` satisfies ([`widenedValidKeyPair_blinded`](/lean/proofs/widenedsigning#widenedvalidkeypair_blinded)). Under `Primitives.Laws` the scheme is complete (`MLDSA.widened_ids_complete`). The accepted-`z` probability is exact and independent of the secret: for any shift `‖δ‖∞ ≤ β'`, a cube-uniform mask passes the `z` gate with probability `((2·(γ₁-β')-1)/(2·γ₁))^(l·256)` (`MLDSA.cube_shift_accept_prob`, instantiated at `δ = c·s₁` as `MLDSA.widened_z_accepted_independent`). The ML-DSA-65 value is `MLDSA.mldsa65_widened_z_accept_prob`. All of these are sorry-free and guarded in `Axioms.lean`. ### 1.3 Blinding and ROM query bound [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) for Construction A is bounded in `BlindingROM.lean` and `BlindingEntropy.lean` by ``` auxKeyIndependence ≤ epsilonBlindExpand + Adv_blindMLWE + 2 * qH * betaAddr ``` where [`BlindPointMassBound`](/lean/proofs/blindingentropy#blindpointmassbound) captures the min-entropy of `pack rho (power2Round (u + t))` on a uniform mask `u`. The ROM part is proven sorry-free: for an adversary making at most `qH` address-oracle queries (`IsQueryBoundP … qH`) and a point-mass bound `beta ≠ ⊤`, `PqStealth.ConstructionA.probOutput_blindBadQuery_le` / [`blindBadProb_le_queryBound`](/lean/proofs/blindingentropy#blindbadprob_le_querybound) give `blindBadProb b ≤ qH * beta` per branch (tracking flag = point cached, at most `qH` cache keys, union bound over the keys — `ROMUpToBad` § query budget), and `PqStealth.ConstructionA.blindingAdvantageRO_le_queryBound` yields the `2 * qH * beta` term. The per-announcement bound used in the multi-announcement theorem is ``` Adv_unlink ≤ INDCPA_true + INDCPA_false + epsilonBlindExpand + Adv_blindMLWE + 2*qH*betaAddr + Σ b∈{false,true}, (epsilonPrim + Adv_keyHop_b + Adv_ctHop_b + epsilonEnc + Adv_keyRestore_b) ``` as `PqStealth.ConstructionA.unlinkAdvantageMulti_full_bound`. *** ## 2. q-announcement unlinkability For `q` announcements to the same two recipients, `MultiUnlink.lean` gives the generic hybrid bound [`unlinkAdvantageMulti_le_mul`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_mul). Instantiating the per-hybrid single-announcement bound above yields ``` Adv_unlink_q ≤ q * epsilonSingle ``` where `epsilonSingle` is the right-hand side of the single-announcement bound. This is `ConstructionA.unlinkAdvantageMulti_full_bound`. *** ## 3. Related-key spend security All of a recipient's stealth keys derive from one master ML-DSA key `(A, t = A·s₁ + s₂)`: announcement `i` adds a blinding offset `(s'ᵢ, e'ᵢ)` and the derived key is `tᵢ = t + A·s'ᵢ + e'ᵢ`. A malicious sender knows the offsets it created. The related-key game (`SpendSecurity.lean`, [`relatedSpendRun`](/lean/proofs/spendsecurity#relatedspendrun) / [`relatedSpendAdvantage`](/lean/proofs/spendsecurity#relatedspendadvantage)) therefore gives the adversary the master public key and all `q` offsets, and it wins by producing a valid spend witness — a short `(s, e)` with `A·s + e = tᵢ` — for any derived key `i`. This is the search-witness model of `Ownership.lean`; the signature-of-knowledge layer on top of it is the follow-up noted there (§4). The spend bound is proven, sorry-free: ``` Adv_relatedSpend_q ≤ Σᵢ Adv_relatedSpend_at(i) (union bound over the targeted key) ≤ q * max_i Adv_spendForge(b + η)[reductionᵢ] ``` where `reductionᵢ` ([`relatedSpendReduction`](/lean/proofs/spendsecurity#relatedspendreduction)) samples the offsets itself, runs the adversary, and subtracts offset `i` from the returned witness: a `b`-short witness for `tᵢ` minus an `η`-short offset is a `(b + η)`-short witness for `t` ([`ownershipValid_subtractOffset`](/lean/proofs/spendsecurity#ownershipvalid_subtractoffset), [`mldsaShort_subtractOffset`](/lean/proofs/spendsecurity#mldsashort_subtractoffset)). The theorems are [`relatedSpendAdvantage_le_sum`](/lean/proofs/spendsecurity#relatedspendadvantage_le_sum), [`relatedSpendAdvantage_le_mul`](/lean/proofs/spendsecurity#relatedspendadvantage_le_mul), [`relatedSpendAdvantageAt_le_spendForgery`](/lean/proofs/spendsecurity#relatedspendadvantageat_le_spendforgery), [`relatedSpendAdvantage_le_mul_spendForgery`](/lean/proofs/spendsecurity#relatedspendadvantage_le_mul_spendforgery), and at `Rq` with the ML-DSA range check [`mldsa_relatedSpendAdvantage_le`](/lean/proofs/spendsecurity#mldsa_relatedspendadvantage_le), re-exported as `PqStealth.ConstructionA.relatedSpendAdvantage_le_mul_capstone` (`ConstructionASecurity.lean`). `Adv_spendForge` is in turn a matrix-SIS advantage ([`mldsa_spendForgeryAdvantage_eq_sis_advantage`](/lean/proofs/ownership#mldsa_spendforgeryadvantage_eq_sis_advantage)). Not yet composed — the signature layer. The deployed spend is a Fiat-Shamir-with-aborts signature over the ownership relation, and its EUF-CMA reduction to the game above costs * [`CmaToNmaLossNN`](/lean/proofs/spendsecurity#cmatonmalossnn) — the classical-ROM statistical loss `FiatShamirWithAbort.cmaToNmaLoss`, as a nonnegative real under [`CmaToNmaAssumption`](/lean/proofs/spendsecurity#cmatonmaassumption) ([`CmaToNmaLossNN_val`](/lean/proofs/spendsecurity#cmatonmalossnn_val)); * `TruncationLossNN = qS * pAbort ^ maxAttempts` — the loss from capping signer retries; * [`UnboundedSigningAssumption`](/lean/proofs/spendsecurity#unboundedsigningassumption) — the bridge from the production `while True` signer to the capped game. These are defined in `SpendSecurity.lean` §6 but nothing consumes them: VCVio's `euf_cma_bound` is a placeholder at the pin, and the widened-scheme HVZK distance is still the trivial `1`. *** ## 4. Construction A / B decision **D-021 — Construction A retained; Construction B rejected for the ERC-5564 sender-address flow — LOCKED.** Construction A is normative because the sender computes the destination address from the recipient's meta-address and the shared secret. Construction B would require the recipient to participate in key generation and therefore changes who can compute the destination; it is documented only as a separate account-transfer protocol and does not share the Construction A scheme ID. The decision is contingent on the explicit assumptions listed below, not on any unconditional proof. *** ## 5. Assumptions The following are named assumption records in the Lean development. Each is quantified explicitly; none is claimed to follow from the standard model alone. 1. `ConstructionA.ExpandIsIdeal` / [`BlindExpandIdealizationBound`](/lean/proofs/constructiona#blindexpandidealizationbound) — XOF idealization for the joint `(viewTag, s', e')` derivation. 2. [`BlindPointMassBound`](/lean/proofs/blindingentropy#blindpointmassbound) — min-entropy of the address point `pack rho (power2Round (u + t))`. 3. [`primitiveIdealizationBound`](/lean/proofs/sprtwohop#primitiveidealizationbound) — ROM/PRF step in the SPR decomposition. 4. [`encodingRegularityBound`](/lean/proofs/sprtwohop#encodingregularitybound) — compress-and-encode regularity for uniform ML-KEM ring elements. 5. [`keyRestorationMLWE`](/lean/proofs/sprtwohop#keyrestorationmlwe) — named bound on the key-restoration gap, i.e. a seeded-MLWE advantage ([`keyRestorationAdv`](/lean/proofs/sprtwohop#keyrestorationadv), games `game0/game1_keyRestorationAdv`) plus the key-only ROM/PRF idealization [`keyIdealization`](/lean/proofs/sprtwohop#keyidealization). 6. `MaskIdealizationAdv` / [`widenedHvzkDistance`](/lean/proofs/widenedsigning#widenedhvzkdistance) — mask and transcript idealization for widened signing. 7. [`CmaToNmaAssumption`](/lean/proofs/spendsecurity#cmatonmaassumption) — sign conditions on the CMA-to-NMA loss parameters (signature layer, not yet composed). 8. [`UnboundedSigningAssumption`](/lean/proofs/spendsecurity#unboundedsigningassumption) — bridge from unbounded signer retries to the capped game (signature layer, not yet composed). *** ## 6. ANO-CCA strengthening (active attacks, literature) The repository's normative unlinkability result is the passive ANO-CPA/SPR-to-MLWE decomposition above. Maram and Xagawa, "Post-Quantum Anonymity of Kyber" (ePrint 2022/1696, `https://eprint.iacr.org/2022/1696`), prove an implicit-rejection ANO-CCA result for Kyber under its FO transform. Any use of this result for FIPS 203 ML-KEM-768 requires mapping the Kyber variant and oracle model; until that mapping is formalized, the ANO-CCA claim is kept in this separate literature section and is not claimed as a Lean corollary. *** ## 7. Exclusions The formal result covers serialized transcripts in the classical random-oracle model. It explicitly excludes: * Timing and retry-count leakage. * Quantum random-oracle access. * Disclosure of the stealth public key after receipt: the transcript model covers announcements, and `stealth_pk` carries the recipient's `rho`, so a spend or key deployment that reveals the full key is linkable to the meta-address (D-027, `construction-a.md` §4.1). No theorem here covers that post-disclosure setting. * Side-channel analysis of the reference implementation. * HNF absorption / ring-specific uniform-MSIS reductions for the ownership reshaping. These exclusions apply to this Construction A analysis. The ERC text remains human-written, and the commitment profile has a separate security review path. ## 8. Widened ML-DSA statement list Formal capstones in `lean/PqStealth/WidenedSigning.lean` (all sorry-free): * `MLDSA.sampleInBall_smul_widened_bound` — `‖c·(a+b)‖∞ ≤ 2·β` for `η`-short `a`, `b`. * `MLDSA.widenedValidKeyPair_blinded` — the blinded key satisfies the widened key relation. * `MLDSA.cube_shift_accept_prob` — cube-mask acceptance probability for any `β'`-short shift. * `MLDSA.widened_z_accepted_independent` — acceptance probability and independence of `z`. * `MLDSA.mldsa65_widened_z_accept_prob` — concrete ML-DSA-65 probability `(1047791/1048576)^1280`. * `MLDSA.widened_ids_complete` — widened IDS completeness under `Primitives.Laws`. * `MLDSA.widened_ids_hvzk` — widened IDS HVZK at `zetaWide = widenedHvzkDistance`; this bound is currently the trivial `1`, so the theorem fixes the statement shape only. # Spend forgery as matrix-SIS: the reshaping, its gaps, and the follow-up :::info[Mirrored page] Source: [`lean/docs/msis-reshaping.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/msis-reshaping.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Scope: Construction A spending research. Ownership/SIS proofs are not a prerequisite for the key-exchange ERC and do not prove the commitment account. Current signature-layer limits: [Construction A security](/research/construction-a-security). Background for `PqStealth/Ownership.lean`. ## The search core Spending a stealth account means proving knowledge of a short `(s, e)` with `A ·ᵥ s + e = t` — the signing-key relation from `Invariants.lean` — bound to the transaction. Forging a spend therefore means producing such a witness for a target `t` you were not given: an *inhomogeneous* Short Integer Solution instance. `Ownership.lean` does three things: 1. casts spend forgery as a VCVio `SIS.Problem`, with the honest side proved trivial (the blinded secret is always a valid witness); 2. reshapes that inhomogeneous instance into the *homogeneous* matrix-SIS shape VCVio's `SIS.matrixProblem` uses, via the standard `[A | I | −t]` augmentation, and proves the two advantages are **equal** — so the MSIS claim is a theorem about VCVio's predicate rather than a naming convention; 3. instantiates all of it at the ML-DSA ring `Rq` with the real centered infinity-norm bound, and shows the game-level validity predicate is exactly [`IsSigningKey`](/lean/proofs/invariants#issigningkey). Scope: this captures the SEARCH core (produce a witness = solve MSIS). The full signature-of-knowledge unforgeability — message binding via Fiat–Shamir, the forking-lemma extraction, HVZK — is the deeper reduction to `SelfTargetMSIS`; see the follow-up at the end. ## The `[A | I | −t]` augmentation VCVio's `SIS.matrixProblem` is the homogeneous problem: find a short nonzero `z` with `M ·ᵥ z = 0`. The ownership problem is inhomogeneous. The standard bridge is the augmentation `M = [A | I | −t]`, under which ``` M ·ᵥ (s, e, 1) = A ·ᵥ s + e − t ``` so kernel vectors whose last coordinate is `1` are exactly the ownership witnesses, with `e` absorbed by the identity block. Carrying the marker `1` in the shortness predicate ([`augmentedShort`](/lean/proofs/ownership#augmentedshort)) makes the correspondence a bijection on *valid* solutions, so the advantages are equal, not merely comparable. The marker also supplies the `z ≠ 0` conjunct of VCVio's matrix-SIS validity for free ([`augmentedWitness_ne_zero`](/lean/proofs/ownership#augmentedwitness_ne_zero)). [`readKey`](/lean/proofs/ownership#readkey) reads the ownership key back off an augmented matrix — `A` is the left block, `t` the negated last column — and is a left inverse of [`augmentedMatrix`](/lean/proofs/ownership#augmentedmatrix). That is what lets a forger for the homogeneous problem be built from an ownership forger ([`augmentedAdversary`](/lean/proofs/ownership#augmentedadversary)). [`spendForgeryAdvantage_eq_sis_advantage`](/lean/proofs/ownership#spendforgeryadvantage_eq_sis_advantage) is the resulting equality; [`spendForgeryAdvantage_le_augmentedSIS`](/lean/proofs/ownership#spendforgeryadvantage_le_augmentedsis) is its `≤` form for chaining; [`honest_augmented_witness_valid`](/lean/proofs/ownership#honest_augmented_witness_valid) is the non-vacuity check — a real ownership solution maps to a real kernel vector, so the equality is not between two unsatisfiable predicates. ## ℝ≥0∞ versus ℝ VCVio states search advantages in `ℝ≥0∞` (`SIS.advantage` is a `probOutput`), while every distinguishing advantage in this development (`ProbComp.boolDistAdvantage` and the KEM/MLWE bounds built on it) is `ℝ`. The `ℝ≥0∞` form is kept as the definition of [`spendForgeryAdvantage`](/lean/proofs/ownership#spendforgeryadvantage) — it is the one that is definitionally VCVio's — and [`spendForgeryAdvantageReal`](/lean/proofs/ownership#spendforgeryadvantagereal) is the `ℝ` view used when a spend bound has to be added to, or chained with, those advantages. Nothing is lost in the conversion: the advantage is a probability, hence finite ([`spendForgeryAdvantage_ne_top`](/lean/proofs/ownership#spendforgeryadvantage_ne_top)). ## What is proved and what is assumed [`spendForgeryAdvantage_eq_sis_advantage`](/lean/proofs/ownership#spendforgeryadvantage_eq_sis_advantage) is an exact **reshaping**: it puts spend forgery into VCVio's homogeneous matrix-SIS shape and shows the two advantages are equal, with the scoring function equal on the nose to `SIS.matrixProblem`'s ([`augmentedSISProblem_isValid_eq_matrixProblem`](/lean/proofs/ownership#augmentedsisproblem_isvalid_eq_matrixproblem), at `n = k`, `m = l + k + 1`). It is **not** yet a reduction to the standard uniform-MSIS assumption, because `(augmentedSISProblem keyGen isShort).sampleChallenge` is the scheme's distribution `keyGen >>= (pure ∘ augmentedMatrix)`, whereas `(SIS.matrixProblem k (l+k+1) isShort').sampleChallenge` is `$ᵗ Matrix (Fin k) (Fin (l+k+1)) R`. Two statements close that gap; neither is proved. * **HNF absorption.** A challenge of the shape `[A | I | −t]` is as hard as a uniform one. Precisely: for every `adv` there exists `adv'` with ``` SIS.advantage (augmentedSISProblem uniformKeyGen isShort) adv ≤ SIS.advantage (SIS.matrixProblem k (l+k+1) (augmentedShort isShort)) adv' ``` where `uniformKeyGen` samples `A` and `t` uniformly. The textbook proof column-reduces a uniform challenge into Hermite normal form, which over a module ring needs an invertible `k × k` block — the `Rq`-specific step. * **Target pseudorandomness.** The real `keyGen` is an MLWE sample: `A` is uniform but `t = A ·ᵥ s₁ + s₂` with `(s₁, s₂)` short. Replacing it by `uniformKeyGen` costs the MLWE distinguishing advantage. Precisely, for every `adv` the two advantages `SIS.advantage (augmentedSISProblem mlweKeyGen isShort) adv` and `SIS.advantage (augmentedSISProblem uniformKeyGen isShort) adv` differ by at most the advantage of the derived distinguisher against `LearningWithErrors.moduleMatrixProblem` — the same assumption the detection side of this development already uses. Chaining the proved equality with those two gives `spendForgeryAdvantage ≤ msisAdvantage + mlweAdvantage`, the bound the spec claims. ## The ML-DSA instance Everything above at `R := Rq`, with `isShort := mldsaShort bound` the real ML-DSA range check: every coordinate polynomial of `s` and of `e` has centered infinity norm at most the bound. The Boolean predicate the game uses is then decidably equivalent to [`IsSigningKey`](/lean/proofs/invariants#issigningkey) from `Invariants` ([`ownershipValid_mldsaShort_iff_isSigningKey`](/lean/proofs/ownership#ownershipvalid_mldsashort_iff_issigningkey)), so "the adversary wins the SIS game" and "the adversary produced a signing key it was never given" are the same statement. [`mldsa_spendForgeryAdvantage_eq_sis_advantage`](/lean/proofs/ownership#mldsa_spendforgeryadvantage_eq_sis_advantage) is the reshaping at that instance. `Rq` is nontrivial ([`rq_one_ne_zero`](/lean/proofs/ownership#rq_one_ne_zero), and the `Nontrivial Rq` instance built on it): the constant polynomial `1` differs from `0` in its degree-zero coefficient. That is needed for the `z ≠ 0` conjunct of matrix-SIS validity. ## Follow-up: signature-of-knowledge unforgeability What is proved is the *search* core: a spend is a witness for an MSIS instance, and forging one is solving that instance. The spend actually deployed (decision D-012) is a signature of knowledge — the ownership witness proved in zero knowledge and bound to the transaction — so full unforgeability is EUF-CMA of a Fiat–Shamir-with-aborts signature, not just hardness of the search problem. The route, none of which is implemented: * Package the ownership relation as a Sigma protocol (`VCVio/CryptoFoundations/SigmaProtocol.lean`): commitment `A ·ᵥ y`, challenge `c` from `sampleInBall`, response `z = y + c · s` with rejection sampling at the widened bound `2·beta` ([`beta_blinded_eq_two_beta`](/lean/proofs/invariants#beta_blinded_eq_two_beta)). * Prove special soundness and HVZK for it; the widened bound is exactly what the blinded secret needs ([`blinded_is_signing_key`](/lean/proofs/invariants#blinded_is_signing_key)). * Apply VCVio's Fiat–Shamir transform to get a signature of knowledge, with the transaction as the bound message. * Reduce EUF-CMA of the result to `SelfTargetMSIS.Problem` (`LatticeCrypto/HardnessAssumptions/ShortIntegerSolution.lean`), which is the assumption ML-DSA's own unforgeability rests on; the extraction step is the forking lemma over the random oracle already modelled by `SelfTargetMSIS.experiment`. # EIP-8288 and post-quantum stealth addresses :::info[Mirrored page] Source: [`docs/research/eip-8288-summary.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/eip-8288-summary.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Reviewed 2026-09-14. This is an integration assessment, not a route decision or ERC specification. The project's current scope is recorded in [D-024 and D-025](/spec/decisions). ## Proposal summary [EIP-8288](https://eips.ethereum.org/EIPS/eip-8288) is a Draft Core proposal extending EIP-8141 with signature and proof aggregation. Transactions declare dependencies as `(scheme, data_hash, verification_key_hash)` triples. Mempool participants aggregate witnesses into recursive STARKs; block validity requires a proof covering the block's dependencies. Accounts can inspect the declared dependencies through frame introspection. The direct schemes are leanSPHINCS and leanSTARK. Other signatures need ordinary on-chain verification or a client-side STARK wrapper. The draft charges 3,000 gas per leanSPHINCS dependency and 30,000 per leanSTARK dependency, and defines separate block-proof accounting. These are proposed charges, not measured total transaction costs. Its aggregate verification key is still `TBD`. Sources: [specification](https://eips.ethereum.org/EIPS/eip-8288#specification), [gas accounting](https://eips.ethereum.org/EIPS/eip-8288#gas-accounting), [signature alternatives](https://eips.ethereum.org/EIPS/eip-8288#why-lean-ethereum). ## What it means for this repository Our assessment: aggregation is a possible spending integration for D-024's key-exchange design. Receiving and scanning still use ML-KEM, the view tag, commitment derivation, and the deployment binding. Adoption of EIP-8288 is not necessary to demonstrate those operations against ERC-5564 announcements. | Existing route | Evidence and security boundary | Work needed for an aggregation experiment | | --- | --- | --- | | Construction A, blinded ML-DSA | Python/TS derivation and Lean results; signature-security composition remains incomplete | Implement and benchmark a compatible signature-verification proof; retain the existing security caveats | | Direct SPHINCS-C13 | Implemented spending; revealing the recipient key makes spends linkable | Establish compatibility with the exact leanSPHINCS profile before claiming a direct route | | C13 signature inside ZK | D-023 demonstrates a hidden-key statement with UltraHonk | Port the statement to a compatible PQ-sound, zero-knowledge proof backend | | Preimage ownership in the browser | D-025 demonstrates proving knowledge of the secret and opener; UltraHonk is not PQ-sound | Evaluate this smaller statement first with a compatible PQ-sound, zero-knowledge backend | Sources: [Construction A analysis](/research/construction-a-security), [C13 proof experiment](/research/zk-sphincs-frames), [D-024/D-025](/spec/decisions). These routes do not inherit one another's proofs. In particular, the Lean Construction A results are not an end-to-end proof of commitment-account security. ## Recommended integration experiment Start with D-025's preimage-ownership statement. This is a recommendation for future work; no EIP-8288 integration is claimed here. 1. Specify the exact public-input encoding for the account commitment and transaction authorization message. Identify the selected verification key and bind the expected statement to the account's authorization policy. 2. Prove the statement with a compatible backend. Check both quantum soundness and zero knowledge; wrapping the existing pairing-based proof alone does not fix its underlying soundness assumption. 3. Have account validation require the exact expected dependency. Test rejection of a changed message, commitment, verification key, or missing dependency, plus replay across nonces, accounts, and chains. 4. Inspect what a sender, mempool observer, aggregator, and chain observer can learn. A repeated public spending-key identifier may link accounts even if individual signatures are aggregated. Keep the spending secret and opener private throughout proving. 5. Measure proof generation, peak memory, witness/proof transport, aggregation latency, first-account deployment, subsequent spends, and total billed gas. Keep proposed protocol charges separate from measurements. A new backend or account can change the CREATE2 deployment binding and hence the destination address. Migration of already-funded accounts needs its own analysis; a backend swap must not be described as automatically transparent. ## Review deliverable Before claiming readiness, publish a reproducible fixture linking the existing commitment vectors to the public inputs, proof, dependency, account validation, and resulting transfer. Record the exact protocol revision, backend version, hardware, and negative-case results. Keep the ERC submission package focused on key exchange and address derivation while this integration is evaluated. # A SPHINCS- C13 spend authorized by a zero-knowledge proof, over frame transactions :::info[Mirrored page] Source: [`docs/research/zk-sphincs-frames.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/zk-sphincs-frames.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: *2026-09-03. Companion artifacts: `noir/sphincs-c13-verify/` (circuit + `generate_prover.py`), `js-client/contracts/src/frames/\{Stealth8141ZkAccount, Stealth8141ZkFactory\}.sol` + `frames/zk/SphincsC13HonkVerifier.sol` (bb-generated), `js-client/contracts/test/ZkAccount.t.sol`, `ui/scripts/\{deploy-frames-zk.mjs, e2e-frames-zk.ts\}`. Follows D-022 (`prefix-deploy-native-keys.md`) and D-018's open item "remove spend-time linkability by binding the SPHINCS- key through a ZK proof".* ## 1. Question and the user's framing Given that the direct ML-DSA verify costs 13.7–14.9 M gas and precompiles are not the way: prove the post-quantum verification inside a circuit and spend with the proof. The pairing-based backend (UltraHonk on BN254/KZG) is not quantum-sound, but the *statement* is fixed, so the backend can be swapped for a hash-based one later. The user's choice for the spike: **SPHINCS- C13 verification in-circuit**, pairing now / STARK later with the swap designed in, measured on the frames testnet. ## 2. What the circuit proves Public inputs: `message` (the frame tx `sig_hash`, TXPARAM 0x08) and the D-018 `commitment = keccak256("pq-stealth/sphincs-c13/commit/v0" ‖ pk ‖ opener)`, each as two 128-bit field elements (4 public inputs). Private: `pkSeed`, `pkRoot`, `opener`, the 3,688-B signature, and a chain-order hint. The circuit opens the commitment and runs the exact `SPHINCs-C13Asm.sol` verification (Verity-refined Yul, D-018): keccak-only tweakable hash over `seed ‖ ADRS ‖ payload`, FIPS 205 uncompressed ADRS, FORS+C forced-zero and WOTS+C constant-sum as **assertions**. Because the key is a witness, a spend reveals nothing about which recipient key signed it: the spend-time linkability of the commit signer (every spent address of a recipient becomes linkable once the commitment is opened on chain) is gone. The account address is `CREATE2(factory, 0, initcode(commitment, verifier, frameCtx))`, sender-computable from the meta-address key and the KEM shared secret exactly as before. **Circuit-shape decision.** WOTS+C makes the *total* chain length fixed (`L·(w−1) − target_sum = 43·7 − 208 = 93` steps per layer) while each chain's length is data-dependent. A naive circuit hashes 7 steps for every chain (301 per layer, 2.3× the work). Instead the prover supplies `chain_hint[layer][93]`, the chain each step belongs to; the circuit hashes the steps in that order with a per-chain running position and asserts every chain received exactly `7 − digit` steps. The digit-sum assertion guarantees `Σ(7 − digit) = 93`, so all 93 steps must land in real chains. Result: the same 335 keccak calls as the Solidity verifier. ## 3. Measured Toolchain: nargo 1.0.0-beta.19, bb 4.2.0 (aztec bundle), `noir-lang/keccak256` v0.1.2, forge 1.4.1, Apple silicon laptop. | item | value | |---|---| | keccak256 in UltraHonk, 64-B input, one call (incl. I/O) | 40,626 gates | | eight chained calls | 142,162 gates → **≈ 14.5 k gates per keccak-f** | | C13 verify circuit (335 keccak calls, 358 permutations) | **6,382,753 gates** (89,111 ACIR opcodes; 2^23 circuit) | | proving key | 18 s, 6.7 GB | | proof (EVM target, ZK) | 11,456 B, 4 public inputs; prove 50–56 s, peak 8.4–10.9 GB (§3b) | | `Stealth8141ZkAccount.executeFrame` with proof, forge | 3,526,632 gas; live 4,174,008 (§3b) | | bb Solidity verifier, 2^16 test circuit, 32 public inputs, ZK flavour | **2,989,292 gas**, runtime code **26,317 B** | | same, no-ZK flavour | proof 7,776 B (verifier not measured: the generator emits the ZK contract regardless) | | Python re-implementation of the verifier on the three fixture vectors | valid; 93 + 93 hint steps | ### 3b. Live run (frames testnet, chain 81410, 2026-09-03) Deployed (`ui/scripts/deploy-frames-zk.mjs`, `ui/public/frames-zk-deployment.json`): `ZKTranscriptLib` `0x48b90e15…2b361` (6,577 B, 10.4 M gas — bb's verifier calls it as an *external* library, so it is a second contract to link), `HonkVerifier` `0xF01ecC1d…5BBfb` (26,318 B, 40.9 M gas — **accepted: the frames testnet does not enforce EIP-170's 24,576 B**), `Stealth8141ZkFactory` `0x303CB317…9826`. `ui/scripts/e2e-frames-zk.ts`, fixture recipient, fresh ML-KEM-768 encapsulation: | step | result | |---|---| | receive: pay + announce, one 0x06 tx | `0xe1df05ee…b6cdb`, 295,489 gas | | scan: decapsulate, re-derive commitment → account | match, `0x9cF91490…ac4F7` | | C13 sign over the tx's `sig_hash` (Rust `signer-c13`) | 3.3 s | | witness (`nargo execute`) | 0.5 s | | UltraHonk prove, 2^23, EVM target (proving key 17 s of it) | **50.4 s**, peak 10.9 GB, proof 11,456 B | | spend: `[VERIFY(sponsor), DEFAULT(factory.createAccount), DEFAULT(account.executeFrame)]`, 11,908 B raw | `0x0fef8acd…2bc1e`, status 1, block 85365 | | frame receipts | sponsor VERIFY 100 · **account deploy 37,001** · **proof verify + transfer 4,174,008** | | total gas billed (execution + state) | 9,249,124 | Two lessons from the first attempt (`0x946ad48e…c0afd`, status 0): a 4 M execution-gas spend frame died at 3,938,060 = exactly 63/64 of its limit, i.e. out of gas inside the verifier staticcall — forge's 3.53 M undercounts what this chain charges for copying the 11 KB proof (SIGDATACOPY + ABI return), two cold contracts, and the EIP-8037 schedule; 9 M is comfortable and the total stays far under the 16.78 M cap. And the account deploy fits *inside* the spend transaction (3,099 B runtime, 37 k execution gas), unlike the ML-DSA route's 43 M type-2 deploy — the whole first spend is one frame transaction again. Two consequences stand out before any testnet number: * **The proof is cheaper than ML-DSA and dearer than SPHINCS- itself.** ~3 M for the Honk verify versus 13.7 M for ML-DSA on the EVM, but the direct C13 verify is 188 k. For SPHINCS- the ZK route is a *privacy* purchase (unlinkability), not a cost cut; for ML-DSA it would be a cost cut, but that is the MLWE-ownership statement of D-008, not this circuit. * **The bb verifier does not fit EIP-170.** 26.3 kB runtime against the 24,576-B limit. Mainnet needs EIP-7907 (removed from Fusaka, candidate for Glamsterdam, not final). The frames testnet runs Glamsterdam+Bogota; whether it enforces 170 is exactly what the deploy step tests. ## 4. Backend question: pairing now, STARK later The account holds the verifier as a **rotatable pointer** (`setVerifier`, callable only by the account itself, i.e. through an authorized `executeFrame` covered by `sig_hash`). The statement and its public-input layout are the contract; the backend is replaceable. What that does and does not buy: * Soundness of KZG/BN254 rests on discrete log. A CRQC forges proofs, so a Honk account is exactly as quantum-safe at rest as an ECDSA account: not at all. The swap must happen **before** a CRQC; after one, the attacker can authorize the swap too. Same posture as EIP-7702 accounts today, stated plainly. * A hash-based backend (FRI/STARK, or a binary-field system) is plausibly PQ-sound and transparent, but its EVM verifier is the cost: hash-heavy, typically 1–5 M+ gas with keccak Merkle paths and proofs of tens to hundreds of kB; with D-015's standard-hash policy there is no Poseidon shortcut. The verifier contract must also fit 170, which today means a wrapped/recursive proof. * **Wrapping does not transfer post-quantum soundness.** A PQ inner proof folded into a pairing-based outer proof (the SP1/Groth16 "EVM wrapper" pattern) is only as PQ as the outer proof. The on-chain verifier is the trust root. **Flock** (succinctlabs/flock, asked about 2026-09-03): "a prover and verifier for R1CS-over-GF(2) statements, built on a zerocheck + lincheck PIOP with a multilinear PCS (Ligerito) over the binary field F₂₁₂₈" — hash-based, transparent, plausibly PQ-sound, aimed at "batched hash statements (BLAKE3 and SHA-256 compressions)" with "a recursion tower that folds proofs into proofs"; 305 k SHA-256/s on a 32-core Threadripper; Rust API only; research stage; **no keccak encoder and no EVM verifier**. For this statement it would need a Keccak-f R1CS-over-GF(2) encoder (Keccak is Boolean-native: 38,400 ANDs per permutation, 358 permutations ≈ 14 M AND constraints, well inside its throughput) and, for on-chain use, either an EVM Ligerito verifier (none exists; proofs are large) or a wrapper — which brings back the paragraph above. Verdict: the right *shape* of prover for a hash-based migration target, not something that can be plugged into the account today; watch it alongside EIP-8288 (protocol-level STARK aggregation). ## 5. Where this leaves the routes | route | per-spend on-chain | linkability | PQ-sound at rest | status | |---|---|---|---|---| | direct C13 verify (D-018) | 188 k | key revealed on spend | yes (hash-based) | live | | C13 in-circuit, UltraHonk (this) | ≈ 3 M + call | none | **no** (until backend swap) | spike | | C13 in-circuit, hash-based backend | 1–5 M+, verifier TBD | none | yes | no EVM verifier yet | | ML-DSA direct (D-022) | 13.7 M | key revealed | yes (lattice) | live | | EIP-8164 native key (D-022) | 50 k | key in code at receive | yes | Draft, no client | ## 5b. Follow-up: prove the secret instead, in the browser (D-025) The C13 statement is too large for browser proving (bb.js is wasm32, 4 GB cap; the circuit needs 8–11 GB). The user's redirection for the demo — prove the *spending secret*, not a signature — makes the statement two keccak calls: `noir/preimage-ownership`, **40,626 gates**, same public-input layout, same account. Measured: witness ~50 ms, **UltraHonk prove 0.65–1.3 s with bb.js in WASM**, verifier 26,317 B, `executeFrame` 2,942,272 gas in forge; live on the frames testnet `0xa598ec41…1fe65` with frames \[100, 37,001, 3,544,107]. The demo UI proves in a web worker with no server component. Everything in §4 about the backend still applies; the statement shrank, the trust root did not move. ## 6. Open * EIP-170 on the frames testnet and on mainnet (EIP-7907). * Prover on a phone/browser: 6.4 M gates, 2^23, ~7 GB proving key — laptop-class today. The no-ZK flavour and a recursion step are the obvious levers. * Grindable [`R`](/lean/proofs/demo#r) (upstream's own review note) is unchanged by proving; the circuit inherits the C13 security posture as is. * Anonymity set: every ZK account shares one verifier and one commitment domain, so the set is "all ZK SPHINCS- stealth accounts", the same shape as D-022's "all 8164 accounts" remark. # ML-DSA spend without an EVM verifier: pre-frame deploy, native keys, pointer signatures :::info[Mirrored page] Source: [`docs/research/prefix-deploy-native-keys.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/prefix-deploy-native-keys.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: *2026-09-02. Companion artifacts: `python/pq_stealth/native_key.py` + `js-client/src/native-key.ts` (EIP-8164 crafted-authorization address derivation, cross-checked by `python/vectors/v0/native_key.json`), `js-client/contracts/src/ntt/` + `contracts/test/NttPrecompute.t.sol` (key-setup NTT spike) and `contracts/test/DilithiumProfile.t.sol` (where the verify gas goes). Decision record: D-022. Extends D-020 (frame transactions) and the pointer-signature PoC (`docs/pointer-signatures-poc.md`).* ## 1. Questions 1. Can the deployless `eth_call` trick — bytecode passed along with the call instead of being deployed (viem `deployless`, Multicall3-in-constructor, `eth_call` state-override `code`) — help ML-DSA verification? 2. Precompiles are not the way forward; what are the ways around, and can the verification be split? 3. What does "deploy the account in the EIP-8141 pre-frame" buy, combined with the Circle proposal "Proposed PQ upgrade for ecrecover" (ethresear.ch 25844, 2026-08-28)? Everything below is checked against the live EIP texts (8141, 8164, 7819, 7932, 7997\) and the thread as of 2026-09-02, and against measurements in this repo. ## 2. The deployless trick does not touch the cost that matters The trick is a *simulation* feature: `eth_call` with `to = null` runs initcode and returns what the constructor returns as if it were runtime code, and state overrides are a node-side option of `eth_call` / `eth_simulateV1`. Neither exists in a mined transaction. The on-chain analogue — run the verifier inside initcode and `REVERT`/`RETURN` the result so no code is deposited — removes the code deposit and nothing else: the same opcodes still execute. EIP-8141 closes the remaining doors: * a frame with `target = null` resolves to `tx.sender`; there is **no CREATE frame** and explicitly **no access list**. Bytecode can only enter a frame as `data` handed to a factory or to the account; * the account's verify runs through a `staticcall` into the ERC-7913 verifier (`Stealth8141Account.sol:55-60`), so it cannot `CREATE` an initcode-verifier; * transient storage is **wiped between frames** ("Discard the `TSTORE` and `TLOAD` transient storage between frames"), so intermediates cannot be carried across frames except through persistent `SSTORE`, which costs more than recomputing any ML-DSA intermediate; * the EIP-7825 cap applies to the **sum** of frame execution gas (`TX_MAX_GAS_LIMIT = 2^24 = 16,777,216`), so splitting one verify over several frames buys no headroom. What it *does* buy, marginally: (a) running a verifier on a chain where nobody deployed one, at ≈ 14.5 KB × calldata price (EIP-7623/7976 floor) ≈ 0.6–0.9 M per spend instead of a one-time deploy; (b) carrying the 22.4 KB expanded pk in frame data instead of a `PKContract` (≈ 0.9–1.3 M per spend vs 5.3 M once — break-even after four or five spends, so no for a long-lived account); (c) client-side pre-flight of a sponsored PQ spend via `eth_simulateV1` on a chain where the verifier is not yet deployed. None of these is a cost lever for the reference route. ## 3. Where the ML-DSA verify gas actually goes Measured on the blinded level-2 fixture `python/scripts/zknox_7913_demo.json` through the vendored ZKNOX `df999ed` verifier (NIST/SHAKE profile), forge, optimizer 10k runs, prague (identical under cancun): | step (`DilithiumProfile.t.sol`) | gas | share | |---|---|---| | 0 slice the 2,420-B signature into `cTilde/z/h` (memory copies) | 589,835 | 4 % | | 1 `dilithiumCore1` — unpack `z`, `h`, norm checks | 1,394,684 | 10 % | | 2 `sampleInBallNist` — **SHAKE256** in Solidity | 936,507 | 7 % | | 3 `nttFw(c)` | 258,376 | 2 % | | 4 expand + `<< d` + `nttFw(t1)` × 4 — **key-dependent only** | 1,405,488 | 10 % | | 5 `dilithiumCore2` — `expandMat`, NTT(z) × 4, `A·z − c·t1`, invNTT × 4, hints, w1 encode | 5,692,486 | 40 % | | 6 final **SHAKE256** — `mu = H(tr ‖ M')`, then `c̃ = H(mu ‖ w1Encode)` | 4,046,294 | 28 % | | sum (in-memory) | 14,323,670 | | | ERC-7913 `verify` via `PKContract` (call overhead, SSTORE2 read + `abi.decode` = 217,756) | 14,911,249 | | Two corrections to earlier notes: * **The "1.8× regression is exactly the per-verify `NTT(t1·2^d)`" explanation in D-014 / `construction-a.md §7` was an inference, not a measurement, and it is wrong.** The key-dependent step is 1.4 M (10 %). Moving it to key setup (`PKContractNtt` + `ZKNOX_dilithium_ntt`, `NttPrecompute.t.sol`) takes the ERC-7913 verify from **14,911,249 to 13,678,091** (−8 %) and raises the one-time `PKContract` deploy from 5,145,320 to 7,310,211. The upstream README's own 13.5 M for `Dilithium` at this revision agrees with the precomputed figure; the 8,176,453 in `construction-a.md §7` was measured on an earlier upstream revision with a different key path and is not reproducible at `df999ed`. * **SHAKE256 in the EVM is the largest single cost: ≈ 5.0 M of 14.9 M (34 %).** That is the whole difference to `ZKNOX_ethdilithium` (4.9 M): the keccak-PRNG profile replaces every SHAKE call by the `KECCAK256` opcode. It is also the one lever that leaves FIPS 204 (D-015's "standard hashes everywhere" is satisfied by keccak, the *scheme* is what stops being ML-DSA). ## 4. Ways around, ranked 1. **Key-setup NTT** (this spike): −1.2 M per spend, exact, FIPS-compatible. Cheap to keep; not a game changer. 2. **Keccak-XOF profile** (`ZKNOX_ethdilithium`, 4.9 M): −10 M per spend, but the derived stealth keys are no longer ML-DSA keys (ExpandA/ExpandS/SampleInBall change), so every FIPS 204 verifier and every future native scheme stops accepting them. Record as a named option only. 3. **Split across frames / transactions**: no. Frames share the 2^24 cap and lose transient storage; a cross-transaction split would have to persist intermediates (4 polynomials ≈ 3 KB ≈ 100 slots ≈ 2 M state gas plus a binding hash) — more than the compute it saves — and only matters when a single verify exceeds the cap, which 13.7 M does not. 4. **Optimistic verify** (claim → challenge window → finalize): moves the 13.7 M off the happy path at the price of latency and a watcher assumption on the account's own funds. An option for the ERC draft's Future section, not for the reference route. 5. **Proof-based** (Groth16/STARK of the verify, EIP-8288 aggregation): the durable EVM-side fix, already cited in `construction-a.md §7b`; EIP-8288 is still a stub. 6. **Protocol-native validation** (§6): the only shape that makes the EVM verify disappear. ## 5. Deploy in the EIP-8141 validation prefix — designator only What the spec allows in the prefix: the `deploy` frame must be the first frame, DEFAULT mode, targeting a factory; afterwards non-empty code must exist at `tx.sender`; the only writes allowed are `CREATE`/`CREATE2`/`SETDELEGATE` installing code at `tx.sender` and `SSTORE` to `tx.sender`; the budget across the whole prefix is `MAX_VERIFY_GAS = 100,000` execution and `MAX_VERIFY_STATE_GAS = 500,000` state. | deploy shape | exec | state | fits prefix? | |---|---|---|---| | SPHINCS- account, 3.4 KB code, measured in-frame on the frames testnet | 63 k | ≈ 5.7 M | no (state 11×) | | ML-DSA `Stealth8141Account` type-2 deploy on the frames testnet | ≈ 43 M total | — | no | | `SETDELEGATE` (EIP-7819) designator, 23 B, + 1 `SSTORE` (key pointer) | ≈ 30 k | ≪ 500 k | **yes** | So a pre-frame deploy works as a **delegation designator to a shared account implementation**, with the per-account key pointer in the sender's own storage. The stealth address becomes the EIP-7819 address `keccak(DESIGNATOR ‖ factory ‖ salt)[12:]` with `salt = H(signer)` — still counterfactual, still sender-computable. EIP-7819 is Draft but is referenced by the 8141 text. What it does **not** fix: `self_verify` in the prefix (13.7 M and even C13's 188 k exceed 100 k), so the prefix stays `[deploy, sponsor VERIFY]` and the PQ verify remains a post-prefix DEFAULT frame (today's `Stealth8141Account.executeFrame`). Net effect on the current route: the 43 M account deploy disappears; the `PKContract` (code deposit → state gas, outside the 7825 cap) can be created through the EIP-7997 factory in a post-prefix frame of the same transaction ahead of the verify frame (exec sum ≈ 0.5 M + 13.7 M \< 16.78 M, tight). Sponsorship remains mandatory. ## 6. EIP-8164 native keys — the ecrecover post's binding, and a second address form Facts (EIP-8164 "Native Key Delegation for EOAs", Draft 2026-02-17, Markou/Prestwich; no client implementation found, on no devnet): * account code becomes `0xef0101 ‖ pk` (ML-DSA-44 only, 1,315 B; `0xef0102…` reserved, FN-DSA named as the next candidate). ECDSA is **permanently** rejected for the account afterwards — which is the thread's main objection (TMerlini: the classical path must be retired) answered; * installed from a `native_key_authorization_list` tuple `[chain_id, pubkey, nonce, y_parity, r, s]` with `msg_hash = keccak256(0x07 ‖ rlp([chain_id, pubkey, nonce]))`; **any party may include it**; tuples are skipped, not reverted, on failure (7702 semantics); delegation bumps the authority nonce; costs `PER_NATIVE_AUTH_BASE_COST = 12,500` + `PER_EMPTY_ACCOUNT_COST = 25,000`; * transactions from the account carry the 2,420-B ML-DSA-44 signature and are validated by the client at admission for `ML_DSA_44_VERIFY_COST = 50,000` intrinsic gas. **Not an EVM precompile** — the same category as secp256k1 transaction validation. Whether that passes the "no precompiles" bar is a user decision (D-022 records the question, not an answer); * **crafted-signature keyless accounts are spec'd**: `r_seed = keccak256("nkd-v1" ‖ chain_id ‖ pk)`, [`r`](/lean/proofs/demo#r) = smallest valid secp256k1 x-coordinate ≥ `r_seed mod p`, `s = 1`; the account is "provably rootless" (ECDLP), with the explicit warning that the recovered ECDSA public key is quantum-exposed until the delegation lands; * native-key accounts execute no code and cannot use 7702 delegation or `APPROVE`; the draft defines its **own type `0x06`** envelope (a number clash with 8141 — draft artefact; an EIP-7932 update is said to reconcile 7932/8141/8164, unverified). Mapping onto the stealth scheme (Construction A is parameter-set generic; `ML-KEM-512+ML-DSA-44` is already in `python/pq_stealth/params.py`): 1. The sender derives the blinded ML-DSA-44 stealth pk exactly as today, then the **stealth address = ecrecover(msg\_hash(chain\_id, pk, 0), y = 0, r\_crafted, s = 1)**. No factory, no initcode, no code hash in the meta-address. The recipient recomputes it from the shared secret. `native_key.py` / `native-key.ts` implement this; the vector file pins the encoding choices the draft leaves open (chain\_id as 32-byte big-endian in `r_seed`, `y_parity = 0`, `r < n`). 2. The sender's payment transaction **carries the authorization tuple itself**. Funds and the `0xef0101 ‖ pk` code land in the same transaction, so ECDSA is dead from the first block the address holds value — quantum-safe at rest, unlike any keyless-ECDSA address that waits for its owner. Sender pays ≈ 37.5 k plus the designator storage. 3. The recipient's spend is a plain native-key transaction: 50 k verify, **no sponsor, no `PKContract`, no verifier contract, no `MAX_VERIFY_GAS` problem**. The 13.7 M EVM verify disappears. Caveats: * pre-quantum safe in flight only: with a CRQC an observer could recover the crafted address's ECDSA key from the mempool and front-run a competing authorization (nonce 0); the sender's tuple is then skipped and the funds go to an attacker-keyed account. Mitigations: private/builder inclusion; if 8164 folds into 8141 frames, an atomic DEFAULT frame that asserts `EXTCODEHASH(stealth) == keccak(0xef0101 ‖ pk)` and reverts the payment otherwise. Pre-CRQC this is moot; * parameter set: ML-DSA-44 (Level 1) versus the scheme's ML-DSA-65 default; * fingerprint: every stealth address carries native-key code, so the anonymity set is "all EIP-8164 accounts", not "all EOAs". The pk in code is the blinded stealth key, unlinkable to the master key (Construction A), the same key a spend reveals today — but revealed at receive time; * no smart-account features (batching, paymaster) on such accounts as drafted. ## 7. The ecrecover pointer proposal itself Sentinel `v = 27, s = 0`, `r = 0x00 ‖ sigIndex(11 B) ‖ verifierId(20 B)`, signature bytes read from the EIP-8141 signatures array (`pk ‖ sig`), binding via 8164 / 7932 / 8130, returns the claimed address. It is a **contract-consumer ABI hotfix** (permit, vaults, `ecrecover` call sites); it does not move value at a bare address — that still needs §6 or a contract account ("Where the value lives", `docs/pointer-signatures-poc.md`). So it is complementary to §6, not an alternative. `PointerSig.sol` already implements the contract-shim version with a different encoding (`v = 0x50/0x51`, table indices); re-encoding it to the post's form with the signature fetched through `FrameTxContext` (SIGPARAM/SIGDATACOPY) would make it a faithful shim of the proposed precompile semantics on the frames testnet. Thread status (2026-09-02): TMerlini (retire ECDSA — 8164 does), 71104 (new opcode instead), matt (prefers EIP-8151 account-code-restricted ecrecover), Helkomine (DoS). ## 8. Recommendation * **Tracked target: §6 + §7** — native key for value at the address, pointer signatures for contract authorizations. It is the only shape where the PQ spend costs tens of thousands of gas and needs no sponsor, and it removes the CREATE2-account machinery from the meta-address. It depends on two Drafts with no client code, so it cannot be demoed yet; the address derivation is implemented and vectored so the scheme is ready when a devnet is. * **Near-term on the frames testnet:** keep the sponsored post-prefix verify; adopt the key-setup NTT (−8 %); restructure the first spend as `[deploy via 7997 factory …]` only where state gas allows (C13 accounts). * **Do not** pursue the deployless trick, cross-frame or cross-transaction splitting, or an EVM ML-DSA precompile. # Classical-spend hybrid (secp256k1 + ML-KEM) :::info[Mirrored page] Source: [`docs/classical-spend-hybrid.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/classical-spend-hybrid.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: *Author: Skas Merkushin.* *Acknowledgments: the classical-spend hybrid design was discussed with Iván Mañús. Tooling note: parts of this document were drafted with assistance from Claude (Opus 5); the project discloses AI assistance at the repository level rather than listing a tool as a co-author.* An alternative construction in the same family as the ML-DSA scheme, offered as a complementary point in the design space, not a replacement. Same rail: ML-KEM key exchange, the same additive-blinding idea, the same view-tag, the same `Announcement`, the same ERC-6538 registry. The only thing that changes is **what gets blinded**: a secp256k1 spending key instead of an ML-DSA one. ## Idea in one line Blind a secp256k1 key with a tweak derived from the ML-KEM shared secret. The stealth address is a normal EOA, so **spending is a plain ECDSA transaction, deployable today**: no protocol-level PQ signature support, no ERC-4337, no ZK circuit. Confidentiality is already post-quantum: linking a stealth address to its recipient means breaking ML-KEM, not ECDH. This is exactly the DKSAP baseline that `benchmarks/scan_bench.py` measures (`ss = v·R`, then `P = K + H(ss)·G`), with the classical ECDH key exchange replaced by ML-KEM encapsulation. Nothing else moves. ## Where it sits This construction is a **companion scheme in the same family**, not a variant of the ML-DSA scheme. Both derive a per-payment tweak from an ML-KEM shared secret and add it to the recipient's published key; they differ only in the group the spending key lives in: | | ML-DSA scheme | Classical-spend hybrid | |---|---|---| | Spending key | ML-DSA, blinded `t' = A·s' + e' + t` | secp256k1, blinded `P = K + t·G` | | Stealth address | `keccak(ML-DSA pk)[12:]` | `keccak(uncompressed pubkey)[12:]` (EOA) | | ERC-7913 signer | `verifier \|\| key` pointer (over 20 B) | 20-byte address, empty key (`ecrecover` base case) | | Key exchange / view key | ML-KEM | ML-KEM (identical) | | View tag | `sha256(ss)[:n]` | `sha256(ss)[:n]` (identical) | | Signature / spend | ML-DSA (FIPS 204) | ECDSA (secp256k1) | | **Spendable on-chain today** | No (needs protocol PQ signatures) | **Yes** (plain tx) | | Quantum posture | Fully PQ (spend included) | PQ confidentiality; classical spend | The ML-DSA scheme is the destination: a fully post-quantum stealth output. The classical-spend hybrid is the bridge you can deploy while that destination waits on protocol-level PQ signatures (EIP-8141). They share a recipient's ML-KEM viewing key, so a wallet can publish both under one registry and let the sender pick. ## Construction Recipient keys: * **spending key** `(k, K = k·G)`: a normal secp256k1 keypair. * **viewing key** `(kem_dk, kem_ek)`: an ML-KEM keypair, identical in role to the ML-DSA scheme's viewing key. Meta-address (versioned, same shape as the ML-DSA scheme, smaller because there is no lattice `t` to carry): ``` meta_address = version(1) || spend_pub(33, compressed) || kem_ek ``` Send (public inputs only): 1. `ss, R = ML-KEM.encaps(kem_ek)`: shared secret and ciphertext. 2. `t = KDF(ss) mod n`: the tweak scalar (`n` = secp256k1 group order; the mapping avoids `0`, see `classical/blinding.py`). 3. `P = K + t·G`: the one-time stealth public key. 4. address `= keccak(uncompressed(P)[1:])[12:]`, view tag `= sha256(ss)[0]`. 5. announce `(address, R, view_tag)`: the same `Announcement` as the ML-DSA scheme. Scan (viewing key only): 1. `ss = ML-KEM.decaps(kem_dk, R)`; discard on the view-tag miss. 2. recompute [`P`](/lean/proofs/demo#p), `address`; keep on match. Spend (spending key only): * `p = (k + t) mod n` is the private key for the stealth EOA. Because `p·G = k·G + t·G = K + t·G = P`, a standard ECDSA signature from `p` controls the address. No new machinery. ## What it gives up, what it keeps | Property | Classical-spend hybrid | |---|---| | Confidentiality (unlinkability) | **Post-quantum**, rests on ML-KEM | | Holding funds at rest | Only an address hash is on-chain; no long-lived key exposure | | Spending | Classical secp256k1 ECDSA, short exposure at spend time only | | Harvest-now-decrypt-later on *who paid whom* | Resisted (the link is an ML-KEM ciphertext) | | A future quantum adversary forging a *spend* | Not resisted: the spend is classical, deferred to EIP-8141 | The trade is deliberate: give up post-quantum resistance on the spend signature (a short, one-time exposure that EIP-8141 upgrades later) to gain same-day deployability with unmodified wallets and no on-chain verifier. ## On-chain distribution: the same registry, no off-chain channel The hybrid uses the ERC-6538 registry the ML-DSA scheme already targets (`MetaPublic`: "published ... via ERC-6538 registry or ENS"). The registry maps `(registrant, schemeId) -> bytes` and is agnostic to the blob's schema, so: * The ML-DSA scheme registers under the scheme ID declared in its own ERC (here, `2`). * The hybrid registers under **its own distinct scheme ID in the same canonical registry**. Because the registry is content-agnostic, no new contract is needed; the mapping for the hybrid's ID is declared in the hybrid's own ERC rather than this one. * Both coexist; the sender reads by `schemeId`. No off-chain meta-address sharing on either path. The same registry deployment serves every scheme on every network: the reuse is free. **ERC placement.** This document treats the hybrid as a separate ERC candidate (`docs/classical-spend-hybrid.md`). Keeping it out of this ERC keeps the ML-DSA scheme single-scheme and the conformance checker simple, while the shared registry and account model let wallets publish both meta-addresses side-by-side. For the **announcement**, the hybrid rides the same rail: the ERC-5564-style `Announcement` (`stealth_address`, `ephemeral_pub_key = R`, `view_tag`). An announcer-less variant that carries the announcement blob in the payment transaction's calldata is possible and removes the dedicated announcer, at the cost of a chain-wide calldata scan; it is a deployment choice, not a change to the scheme, and is out of scope for the reference here. ## A linkability corner that does not exist here The ML-DSA scheme adds a fresh error term `e'` per stealth key, on purpose: reusing the recipient's `s2` across stealth keys (as in pq-sap v2) leaks a linkability signal, so `derive_blinding` derives `(s', e')` together to close it. The hybrid has no error term to reuse. The tweak is a single scalar `t = KDF(ss) mod n`, each `ss` fresh per payment, and `P = K + t·G` is a uniform point given `t`. The `s2`-reuse vector simply is not present, not because it was patched, but because the classical group has nothing analogous. Simpler by construction, not by cutting a corner. ## ERC-7913 compatibility: the same account, the 20-byte case The stealth identity does not have to be a bare EOA. ERC-7913 represents a signer as `verifier || key`, and OpenZeppelin's `SignatureChecker.isValidSignatureNow(bytes signer, ...)` resolves it by length: fewer than 20 bytes fails, exactly 20 bytes falls back to `ecrecover` (or ERC-1271), and more than 20 bytes dispatches to the verifier contract. There is no separate secp256k1 verifier in that set (only P256, RSA, WebAuthn), precisely because a secp256k1 key is the 20-byte, empty-key base case handled by the `ecrecover` fallback. That is exactly where this scheme lands. The blinded stealth key is a plain secp256k1 key, so its ERC-7913 signer is the 20-byte stealth address with an empty key, and a spend is a plain ECDSA signature over the 32-byte hash the account validates. The same account harness the ML-DSA scheme drives (an OpenZeppelin `SignerERC7913` account, `Stealth7913Account` in the ML-DSA scheme's `construction-a.md` section 7 and `DECISIONS.md` D-014) accepts this scheme with no change: the hybrid is the 20-byte leaf, the ML-DSA scheme is the longer `verifier || pointer` leaf, and both resolve to the same counterfactual CREATE2 account address derived from the signer. One account model, one identity rule, two signer forms. This is what makes the migration in place rather than a re-anchor. An account can start life with the 20-byte secp256k1 signer, spendable today through `ecrecover` at ecrecover cost, and later swap in a post-quantum verifier signer without changing the account model or the way its address is derived. The hybrid is not a detour off the ERC-7913 path: it is that path's base case. The bare-EOA form in the Construction section stays the minimal reference (spend with no contract at all). The `SignerERC7913` account is the form that shares an identity rule with the ML-DSA scheme and migrates in place, at the cost of an account deployment per stealth output. The on-chain demonstration of this route lives alongside the reference, mirroring the ML-DSA scheme's own ERC-7913 test. ## Upgrade path The recipient's ML-KEM viewing key is shared between both schemes. When protocol-level PQ signatures land (EIP-8141), or sooner through the ERC-7913 account above, a wallet can move new payments to the ML-DSA scheme without rotating its viewing identity, and sweep any hybrid funds with a final classical spend. The hybrid is the interim; the ML-DSA scheme is where it goes. ## Status Reference implementation, unaudited, for research and discussion. The spend flow is real and exercised end-to-end (secp256k1 via `coincurve`); the ML-KEM half reuses the same `kyber-py` backend as the ML-DSA scheme. # Noir PoC — address-ownership proof (decode-in-reverse MLWE) :::info[Mirrored page] Source: [`noir/README.md`](https://github.com/Skanislav/pq-sap/blob/main/noir/README.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: This page documents the separate MLWE ownership circuit in `pq-stealth-ownership/`. The current browser demo uses `preimage-ownership/` (D-025); `sphincs-c13-verify/` is another signature experiment. None is required to implement key exchange. See [spending research](/research/spending). Proof of concept for the **application-layer ZK** direction of the scheme: prove control of a stealth address by proving the *MLWE decode-in-reverse relation* — knowledge of a **short** secret `(s, e)` with `A·s + e = t (mod q)` — instead of proving a full FIPS-204 ML-DSA signature. This is exactly what a spend needs (control of the address), and it skips the parts of signature verification that are murderous in-circuit (SHAKE hashing, rejection sampling, hints). Circuit: `src/main.nr`. Witness generator: `generate_prover.py`. ## Verified end to end At a toy size (N=8, K=2, L=2) the full flow runs with Barretenberg (UltraHonk): `nargo execute` solves the witness, `bb prove` produces a proof, and `bb verify` returns **"Proof verified successfully."** The message `msg` (a userOpHash) is folded into a returned binding tag, so a proof authorizes exactly one transaction. ## Constraint counts (measured) | Config | K·L poly-muls | ACIR opcodes | Backend gates | |---|---|---|---| | toy — N=8, K=2, L=2 | 4 | 977 | 5,834 | | unit — N=256, K=1, L=1 | 1 | 15,617 | 102,538 | | **ML-DSA-65 — N=256, K=6, L=5** | 30 | 89,857 | **2,168,651** | The count is dominated by the schoolbook convolution (K·L·N² ≈ 2M multiply-adds). Two things bring a production circuit far below this: * **NTT domain.** Doing `A·s` as pointwise products on NTT-transformed operands turns each poly-mul from O(N²)=65,536 into O(N log N)≈2,048 — a \~32× cut on the dominant term, projecting to **~100–200k gates**. (Not implemented here; the schoolbook version is simpler and its gate count is an honest upper bound.) * **A is public.** The verifier never recomputes `A` from `rho` in-circuit; it's a public input, so ExpandA's SHAKE cost stays out of the circuit entirely. **Why this matters:** proving full ML-DSA *verification* in-circuit is dominated by SHAKE (ExpandA hashes the whole K×L×N matrix; the challenge and sample-in-ball hash more), which runs to millions–tens-of-millions of gates on its own. The decode-in-reverse relation avoids all of it. This PoC is the evidence that the ownership statement is cheap enough to be practical — the number the spec needs to justify the Direction-A (gas/privacy) scheme variant. ## Honest scope of this PoC The constraint *structure and scale* match the real scheme; a production circuit differs in details that change correctness, not gate count: * **Cyclic, not negacyclic.** Real ML-DSA is `Z_q[X]/(X²⁵⁶+1)` (sign flip on wrap-around). This PoC uses plain cyclic convolution to keep the arithmetic sign-clean. Same N² product structure. * **Non-negative range, not centered.** Coefficients are checked in `[0, 2η]` rather than `[-η, η]`. Same number of range checks. * **Quotient-hint reduction.** `mod q` is enforced by the exact field identity `A·s + e = q·quot + t` with a prover-supplied `quot` (both sides \< 2⁴⁰ ≪ the BN254 field, so the field equation *is* the integer relation). * **Soundness of the proof itself is the backend's.** UltraHonk (BN254) is **not** post-quantum. Proving a lattice statement with a classical SNARK gives a classically-sound proof — see `docs/DECISIONS.md` (D-007): genuine PQ soundness needs a FRI/STARK or lattice backend, which is the frontier, not this PoC. ## Run ```sh # toy config: fast prove + verify (edit globals N=8,K=2,L=2 in src/main.nr) python3 generate_prover.py 8 2 2 8 nargo execute bb write_vk -b target/pq_stealth_ownership.json -o target bb prove -b target/pq_stealth_ownership.json -w target/pq_stealth_ownership.gz -o target bb verify -k target/vk -p target/proof -i target/public_inputs # full ML-DSA-65 gate count (globals N=256,K=6,L=5) python3 generate_prover.py 256 6 5 8 nargo compile bb gates -b target/pq_stealth_ownership.json ``` # POC: (v, r, s) as pointers — ABI-compatible PQ signatures :::info[Mirrored page] Source: [`docs/pointer-signatures-poc.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/pointer-signatures-poc.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Prototype of the "re-interpret the 65-byte ECDSA tuple" hotfix (Circle, Aug 2026 PQ call). Code: `docs/pointer-sig-poc/PointerSig.sol` (→ `ETHDILITHIUM/src/`), tests: `docs/pointer-sig-poc/PointerSig.t.sol` (→ `ETHDILITHIUM/test/`). `ETHDILITHIUM/` is a gitignored clone of ZKNoxHQ/ETHDILITHIUM; copy the two files in to run. **Extended version (2026-08-27, CI-tested):** `js-client/contracts/src/PointerSig.sol` — the same registry adapted to the vendored ETHDILITHIUM `df999ed` (key entries are `PKContract` addresses, which is what its ERC-7913 `verify` takes) plus two SPHINCS- C13 versions, `v = 0x52` and `0x53`; `npm run e2e-pointer-sig`. See [SPHINCS- C13 variants](#sphincs--c13-variants-2026-08-27) below, and read [Where the value lives](#where-the-value-lives) first. ## Encoding | field | classic | `v = 0x50` (pq) | `v = 0x51` (hybrid) | |---|---|---|---| | [`v`](/lean/proofs/demo#v) | 27 / 28 | version | version | | [`r`](/lean/proofs/demo#r) | curve scalar | index into **key table** (ML-DSA pk) | index into key table (entry binds EOA + ML-DSA pk) | | `s` | curve scalar | index into **signature table** | index of blob `abi.encode(ecdsaSig, pqSig)` | `PointerSigRegistry.recover(digest, v, r, s) → address` is a drop-in for `ecrecover`. Consumers keep their `(v, r, s)` ABI; `PointerSigVault.withdrawWithSig` is the example — one call site changed. * Key table: `registerKey(expandedPk)` stores the ML-DSA-44 key via SSTORE2; the entry's address is `keccak256(pk)[12:]` (pure PQ) and `msg.sender` (hybrid binding). * Signature table: `publishSignature(bytes)` — anyone can publish; a blob only has effect if it verifies against the digest at use time, so replay across digests fails (tested). * Verification: ZKNOX `ZKNOX_ethdilithium.verify(pk, digest, sig)` (ERC-7913 interface, Dilithium2 / ETH-keccak profile). ## Measured (forge, default profile) | op | gas | |---|---| | classic withdraw (unchanged path) | ~110k (whole test) | | `publishSignature` (2420-byte ML-DSA sig, SSTORE2) | ~566k | | `withdrawWithSig` pq (lookup + ML-DSA verify) | ~4.9M | | hybrid (ECDSA + ML-DSA) | ~ same + ecrecover | ## Caveats this POC does not solve * **Where the value lives (stated late — it was implicit in the demo's `depositFor`).** [`recover`](/lean/proofs/dksap#recover) authorizes actions *inside contracts that call it*. The address a PQ branch returns (`keccak256(pk)[12:]`, or `keccak256(r)[12:]` below) is not an account: native ETH or tokens transferred to it directly are unspendable, because no EVM rule lets a hash-based or lattice key move them. The pq address is meaningful only as an owner key in pointer-aware contracts (this vault; a token whose permit/transfer-by-signature path adopted [`recover`](/lean/proofs/dksap#recover)). Consequence for the stealth scheme: this is a **spend-authorization shim for value already held by adopting contracts**, not a stealth-receive mechanism — an ERC-5564 payment to a bare stealth address still needs that address to be a contract account (the CREATE2 ERC-7913 route, D-014/D-018). See [Where the value lives](#where-the-value-lives). * Two transactions per PQ spend (publish sig, then use it) unless a relayer/4337 bundler batches them; the `s` index is predictable (`signatureCount()`) so batching is feasible. * Table indices are global and monotonic; production would scope keys per account and allow inline signature blobs (e.g. `v=0x52`, `s` = calldata offset) to skip storage. * Verifier is the level-2 Dilithium2 profile, not ML-DSA-65; ~22 kB key storage per registration. * [`v`](/lean/proofs/demo#v) values 0/1 (EIP-2098 yParity) are untouched; 0x50/0x51 are arbitrary POC constants. ## Run ``` cd ETHDILITHIUM && python3 -m venv pythonref/myenv && pythonref/myenv/bin/pip install -r pythonref/requirements.txt forge test --match-contract PointerSigTest -vv ``` ## Live run on anvil (real transactions) Copy `docs/pointer-sig-poc/PointerSigDemo.s.sol` and `demo_anvil.sh` into `ETHDILITHIUM/script/`, then `cd ETHDILITHIUM && bash script/demo_anvil.sh`. Boots anvil, broadcasts every step from anvil account #0, reads state back with `cast`. Per-tx gas measured (anvil, solc 0.8.30, optimizer 10k runs): | tx | gas | |---|---| | deploy `ZKNOX_ethdilithium` | 2,450,564 | | deploy `PointerSigRegistry` | 988,104 | | deploy `PointerSigVault` | 438,068 | | `registerKey` (expanded ML-DSA-44 pk → SSTORE2, one-time per key) | 4,943,054 | | `publishSignature` (2420 B ML-DSA sig → SSTORE2) | 625,420 | | `withdrawWithSig` **pq** (`v=0x50`, r=0, s=0) | 4,943,553 | | `withdrawWithSig` **classic** (`v=27/28`) | 67,518 | Result: recipient balance 0.35 ETH (0.25 pq + 0.1 classic) through the same `withdrawWithSig(owner,to,amount,v,r,s)` entry point; `keyCount=1`, `signatureCount=1`. ## SPHINCS- C13 variants (2026-08-27) A SPHINCS- C13 public key (D-018) is `pkSeed[0:16] || pkRoot[0:16]` — **exactly one word** — so the trick loses its worst part: [`r`](/lean/proofs/demo#r) carries the key itself, there is no key table and no registration transaction. Home: `js-client/contracts/src/PointerSig.sol` (registry + vault, adapted to the vendored ETHDILITHIUM `df999ed`), test `js-client/test/e2e-pointer-sig.test.ts` (`npm run e2e-pointer-sig`, in CI), fixture `python/scripts/sphincs_c13_7913_demo.json` (`pointer` section: the two vault digests, signed with upstream's Rust signer against the deploy order the test pins). | field | `v = 0x52` (SPHINCS-) | `v = 0x53` (SPHINCS-, committed) | |---|---|---| | [`r`](/lean/proofs/demo#r) | **the C13 public key** (32 B) | `keccak256("pq-stealth/sphincs-c13/commit/v0" ‖ pk ‖ opener)` | | `s` | index of the 3,688-B C13 signature in the signature table | index of the blob `pk(32) ‖ opener(32) ‖ c13sig` | | address returned | `keccak256(r)[12:]` | `keccak256(r)[12:]` | | who can compute the address | anyone with the key | **the stealth sender** — `pk` from the meta-address, `opener = SHA-256("…/open/v0" ‖ ss)` from the KEM shared secret | | what a spend reveals | — | `pk` (opens the commitment): every spent address of the recipient becomes linkable to the recipient; unspent ones stay hidden (D-018) | `0x53` is therefore a stealth address in plain address shape — no CREATE2 account, no initcode — *for value that lives in pointer-aware contracts* (next subsection). It composes unchanged with the hybrid binding idea, and both branches reuse the D-018 byte conventions (`js-client/src/sphincs.ts`). ### Where the value lives This is the constraint the original POC left implicit (its demo already parks the ETH inside `PointerSigVault.depositFor(owner)`), and it decides what these branches are for. `registry.recover` only authorizes actions inside contracts that call it. `keccak256(r)[12:]` is not an account: ETH or tokens sent to it directly are stuck — no EVM rule lets a SPHINCS- (or ML-DSA) key move them, and nothing short of native post-quantum transaction signatures changes that. So: * **Spend authorization for value already held by adopting contracts** — a vault, an ERC-20 whose `permit`/transfer-by-signature path swapped `ecrecover` for [`recover`](/lean/proofs/dksap#recover), an escrow: this is what pointer signatures do, and for C13 they do it at ~2× the classic cost (table below). * **Stealth receive of arbitrary value** — an ERC-5564 payment to a bare stealth address: **not this.** The stealth address must be a contract account (the CREATE2 ERC-7913 route, D-014/D-018) for the recipient to be able to move whatever lands there. A stealth *payment into* a pointer-aware token/vault (`depositFor(keccak256(commitment)[12:])`) works, but that is a payment into a contract, and the announcer flow would have to be that. ### Measured (anvil, viem transactions, `e2e-pointer-sig`) | tx | gas | |---|---| | deploy `PointerSigRegistry` (ML-DSA + C13 dispatch) | 1,319,404 | | deploy `PointerSigVault` | 438,068 | | `withdrawWithSig` **classic** (`v=27/28`) | 92,555 | | `publishSignature` (3,688-B C13 sig → SSTORE2) | 898,534 | | `withdrawWithSig` **0x52** ([`r`](/lean/proofs/demo#r) = C13 key, no key table) | **182,799** | | `publishSignature` (`pk ‖ opener ‖ sig`, 3,752 B) | 895,264 | | `withdrawWithSig` **0x53** ([`r`](/lean/proofs/demo#r) = commitment) | **183,237** | | ML-DSA `PKContract` deploy (one-time per key) | 5,324,156 | | ML-DSA `registerKey(pkContract)` (hashes the 22.4 kB key for the pq address) | 396,634 | | [`recover`](/lean/proofs/dksap#recover) **0x50** (ML-DSA, `df999ed` verifier) | ~15,186,908 (estimate) | Relative to the same vault's classic withdraw: C13 costs **2.0×**; the ML-DSA branch on the vendored `df999ed` costs ~164× (its `verify` recomputes `NTT(t1·2^d)` per call, D-014). Publishing the C13 signature is the dominant one-time cost per spend (~0.9 M) — the same two-transaction shape as the original POC; the inline variant (`s` = calldata offset, consumer forwards `msg.data[s:]`) would remove it at the price of one consumer-side line. Negative cases exercised: signature bound to its digest (replay for another amount, replay after the nonce advanced), commitment/opening mismatch, bare signature presented to the commit branch, wrong key word, bad index, unknown version, hybrid without its EOA half. # Lean tooling and maintainer workflow :::info[Mirrored page] Source: [`lean/docs/tooling.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/tooling.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: This material supports the proof library. It is not part of the key-exchange wire specification and does not add ERC conformance requirements. For the review path, use [ERC evidence](/spec/erc-evidence). | Material | Purpose | | --- | --- | | [Lean overview](/lean) | Current module classification and build checks | | [VCVio pin](/lean/vcvio-pin) | Dependency upgrade procedure and historical validation records | | [Upstream notes](/lean/vcvio-upstream) | Framework gaps and proposed contributions | | [Study notes](/lean/lean-study-notes) | Lean/VCVio techniques and learning material | | [Engineering lessons](/lean/etheorem-lessons) | Comparisons with other formalization projects | | [Improvement log](/lean/improvements) | Historical proof/tooling backlog; not the current ERC critical path | | `scripts/gen_browser.py` | Generates module pages and the theorem index from source | | `scripts/check_citations.py` | Resolves supported source-range citations | | `scripts/check_sizes.py` | Checks selected byte sizes; currently excludes commitment-vector replay | The generated browser is a source reference across **all** tracks, including spending. Listing a declaration there does not mean it is needed for the ERC or that it establishes an end-to-end security claim. Edit source docstrings or essays and regenerate; do not edit `lean/docs-proofs/` directly. Full build and axiom-audit policy remains unchanged. An ERC-only Lean target would require a later source-level dependency split; the present documentation classification must not be used to bypass the existing checks. # Tracking the VCVio pin :::info[Mirrored page] Source: [`lean/docs/vcvio-pin.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/vcvio-pin.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Standing checklist for issue #21 in `improvements.md`. VCVio is young and the plan's rule is "pin a commit and don't float on main", so a bump is a deliberate, reviewed act — not something a `lake update` should do behind our backs. ## The current pin | | | |---|---| | Package | `Verified-zkEVM/VCVio` | | Revision | `a5f474fd0e9a26266cc599d100267411690dfeb7` | | Upstream date | 2026-07-15 | | Toolchain | `leanprover/lean4:v4.32.0` | | Validated | 2026-07-25 (week-1 gate: `lake build LatticeCrypto` in the VCVio checkout, green) | | Transitive deps | frozen in `lake-manifest.json` (mathlib `v4.32.0`, loom2, PolyFun, …) | `lakefile.toml` also sets `autoImplicit = false`, `relaxedAutoImplicit = false` and `linter.missingDocs = true` package-wide (issue #14), matching VCVio's own package options. ## Bump procedure 1. **Edit the pin.** Change `rev` in `lean/lakefile.toml`. Leave the comment above it in place and update the date it quotes. 2. `lake update VCVio` — refreshes `lake-manifest.json`. Check the diff: a VCVio bump usually drags mathlib and the toolchain with it. If `lean-toolchain` moves, install the new toolchain first. 3. `lake exe cache get` — mathlib oleans. VCVio itself is **not** in the cache and compiles from source; a cold build is hours, not minutes. 4. `lake build` — must be green with no `warning:` or `error:` from `PqStealth/`. 5. **The axiom guard re-runs itself.** There is no separate command: the 114 `#guard_msgs (whitespace := lax) in #print axioms …` blocks in `PqStealth/Axioms.lean` are part of the build, and a changed axiom list or a `sorry` anywhere in a headline theorem's dependency cone is a compile error. If a guard fails, do not edit the expected list to make it pass until you understand which upstream change moved it. 6. **Diff two upstream files for things to adopt:** * `LatticeCrypto/MLKEM/Security.lean` — the three `sorry`s at the pin (`kpke_ind_cpa_security`, `kpke_delta_correct`, `ind_cca_security`). If any of them becomes a proof, `docs/spr-two-hop.md` and `MLKEM.lean`'s "missing upstream lemma" section need rewriting. * `VCVio/CryptoFoundations/KeyEncapMech.lean` — at the pin it stops at `IND_CPA_*` / `IND_CCA_*` and has no anonymity notion at all. If a KEM-level anonymity / ANO-CCA definition lands, adopt it in place of our `KEM.AnonExp` / `KEM.anonAdvantage` (`KEMAnonymity.lean`) rather than keeping a parallel one. 7. Record the new pin and validation date in `README.md` ("Pin" section) and in the table above. **Cadence:** once a month, or whenever upstream announces KEM anonymity or proves one of the ML-KEM security placeholders. ## The three upstream asks Things we carry locally only because VCVio does not export them. Each is a small upstream PR; when one lands, delete our copy at the next bump. (The full list, eleven items, is in `vcvio-upstream.md`; item 8 there was retracted on 2026-08-19 — VCVio's programming-oracle bridge exists at the pin.) 1. **Ship `DecidableEq` next to `mlkem768Encoding`.** `LatticeCrypto/MLKEM/Concrete/Instance.lean` sets every encoded type to `ByteArray`, but as a plain `def`, so the equality instance does not transport. We re-derive all three by hand in `MLKEM.lean` (`instDecidableEqMlkem768Encoded{THat,U,V}`, each `inferInstanceAs (DecidableEq ByteArray)`). They belong beside the encoding. 2. **De-privatize `byteEncode_size`.** `.lake/packages/VCVio/LatticeCrypto/MLKEM/Concrete/Encoding.lean:242`, `private theorem byteEncode_size (d : Nat) (f : Rq) : (byteEncode d f).size = 32 * d`. It is exactly the fact we need for the [`v`](/lean/proofs/demo#v) half of the FIPS 203 ciphertext layout; because it is `private` we take that byte count from the standard instead of proving it, and `MLKEM.lean`'s docstring lists it under "Assumed". Making it (or a public corollary) visible closes the only non-cryptographic assumption in that file. 3. **A KEM-level [`kem_ind_cpa_security`](/lean/proofs/mlkem#mlkemkem_ind_cpa_security).** `Security.lean` supplies `kpke_ind_cpa_security`, which is about K-PKE, not the KEM, and is `sorry` at the pin. The statement we want is recorded verbatim in `MLKEM.lean` under "The missing upstream lemma" — it elaborates as written against the pinned VCVio: ``` theorem MLKEM.kem_ind_cpa_security {params : Params} (ring : NTTRingOps) (encoding : Encoding params) (prims : Primitives params encoding) [DecidableEq encoding.EncodedTHat] [DecidableEq encoding.EncodedU] [DecidableEq encoding.EncodedV] [SampleableType SharedSecret] : ∃ mlwe : LearningWithErrors.Problem (TqMatrix params.k params.k) (TqVec params.k) (TqVec params.k), ∀ cpaAdv : (MLKEM.asKEMScheme ring encoding prims).IND_CPA_Adversary, ∃ mlweAdv : LearningWithErrors.Adversary mlwe, KEMScheme.IND_CPA_Advantage ProbCompRuntime.probComp cpaAdv ≤ |LearningWithErrors.advantage mlwe mlweAdv| ``` The gap between the two is the T-transform half of Fujisaki–Okamoto. The day it lands, [`mlkem768_unlinkAdvantage_le_indCpa`](/lean/proofs/mlkem#mlkem768_unlinkadvantage_le_indcpa) composes with it in a one-line `calc`. ## What in our tree is sensitive to a bump Ordered by how loudly it fails. * **`PqStealth/Axioms.lean` — 114 frozen `#print axioms` messages.** Failing loudly is the whole point, but note the *shape* is fragile too: the blocks use a 3-line form with `#print axioms` indented onto a continuation line. That is structurally incompatible with mathlib's `style.commandStart` linter — with `weak.linter.mathlibStandardSet = true` the linter's own warning is captured by `#guard_msgs` and turns every block into an error. Verified on this toolchain; that is why the lakefile enables `linter.missingDocs` but not the mathlib standard set. If a bump turns those linters on by default, reformat `Axioms.lean` to one-line commands rather than suppressing the linter. * **`PqStealth/ROMUpToBad.lean` and `SPRTwoHop.lean` — the round-4 VCVio reach.** `ROMUpToBad` builds on `ProgramLogic/Relational/ProgrammingOracle.lean` (`QueryImpl.withProgramming`, `withCachingTrackingPolicy`, the engine `tvDist_simulateQ_run_le_probEvent_output_bad`, `relTriple_simulateQ_run'`) and re-proves upstream's `private` per-step agreement lemma; its `simp only` lists name `withProgramming_apply`, `withCachingTrackingPolicy_apply`, `StateT.run_mk`, `QueryImpl.withCaching_apply`. `SPRTwoHop` imports `VCVio.ProgramLogic.Tactics` and uses `by_equiv` / `rvcstep swap left` / `rvcgen` for the branch reorder — the only tactic-layer use in the tree, so a tactic-surface change upstream shows up there first. * **Three VCVio modules that entered the cone in round 3.** `OracleComp/QueryTracking/RandomOracle/Basic.lean` (`OracleSpec.randomOracle`, used by `BlindingROM.lean`) and `OracleComp/QueryTracking/RandomOracle/DeferredSampling.lean` (+ its import `ProbeEps.lean`; `evalDist_bind_const_neverFails`, used by `MultiRecipient.lean`). Neither is reached by VCVio's own root import, so a bump that moves or renames them fails at *import resolution*, not at a lemma name. [`dksapRomImpl`](/lean/proofs/dksaporacle#dksapromimpl) (and `BlindingROM.romImpl`) mirror VCVio's `PRF.prfIdealQueryImpl` line for line — if that handler's shape changes, copy the change across. * **`PqStealth/Demo.lean` — 8 `#guard_msgs` around `#eval`.** Frozen numeric output. Only breaks if `ZMod 23` arithmetic or `Repr` output changes. * **The pinned `simp only` lists (issue #18).** After the round-2 pass there is no bare `simp` left in `PqStealth/`, so every list is explicit and a rename upstream is a named-lemma error rather than a silent proof change. The exposed ones are the VCVio-internal names: `probOutput_bind_const`, `probFailure_of_liftM_PMF`, `probOutput_map_const`, `bind_map_left` in [`dksap_perfectlyComplete`](/lean/proofs/dksap#dksap_perfectlycomplete) (`DKSAP.lean`), and `support_bind` / `probFailure_bind_eq_zero_iff` / `bind_map_left` in `Controls.lean`. * **`PqStealth/SharedSecretHiding.lean` — the `evalDist` normal-form proofs.** [`evalDist_uniformSample_bind_const`](/lean/proofs/reorder#evaldist_uniformsample_bind_const), [`evalDist_bind_pull_front`](/lean/proofs/sharedsecrethiding#evaldist_bind_pull_front), [`indCpaGame_eq_evalDist_normalForm`](/lean/proofs/sharedsecrethiding#indcpagame_eq_evaldist_normalform), [`evalDist_rorGame_eq_normalForm`](/lean/proofs/sharedsecrethiding#evaldist_rorgame_eq_normalform) reorder independent samples underneath `evalDist` using `evalDist_bind_bind_swap` / `evalDist_bind_congr'`. These are the deepest reach into VCVio's monadic API in the tree; if the `evalDist` API or the `OracleComp` bind normal form is reworked upstream, this file is where the work is. * **`PqStealth/MLKEM.lean` — everything that names concrete ML-KEM-768 internals.** The three `DecidableEq` instances become redundant (and possibly ambiguous) the moment upstream ships its own; the `Ciphertext` infiniteness argument depends on the encoded types staying `ByteArray`; the 1088-byte simulator depends on the FIPS 203 layout VCVio implements. * **Reduction lemmas that name VCVio hypotheses:** `probOutput_bind_bijective_uniform_cross` (`DKSAP.lean`), `probOutput_bind_add_right_uniform` (`ConstructionA.lean`), `KEMScheme.PerfectlyCorrect ProbCompRuntime.probComp` ([`perfectlyComplete_ofKEMFull`](/lean/proofs/kemanonymity#perfectlycomplete_ofkemfull)), `SIS.matrixProblem` (`Ownership.lean`). * **Known lint findings we do not act on** (they resurface if the linter set widens): mathlib's non-default `explicitVarsOfIff` wants [`ownership_iff_signing`](/lean/proofs/invariants#ownership_iff_signing) and [`ownershipValid_mldsaShort_iff_isSigningKey`](/lean/proofs/ownership#ownershipvalid_mldsashort_iff_issigningkey) to take implicit binders — we keep them explicit because both are headline statements cited from `docs/`; and `linter.unusedSectionVars` flags `[DecidableEq G]` on the three DKSAP soundness theorems in `Soundness.lean`, whose fix would be an `omit`, which the tree deliberately does not use. # VCVio: what this development found missing or wrong upstream :::info[Mirrored page] Source: [`lean/docs/vcvio-upstream.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/vcvio-upstream.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Everything below was discovered while building `lean/PqStealth/` against `Verified-zkEVM/VCVio @ a5f474fd` (2026-07-15, Lean `v4.32.0`; see `vcvio-pin.md` for the pin and bump procedure). Each item says what we carry locally because of it, where it bit, and what the upstream change would be. Nothing here has been filed upstream yet (status as of 2026-08-19). Grouped by what the change would cost on VCVio's side. ## A. Small, mechanical — one PR each 1. **`DecidableEq` next to `mlkem768Encoding`.** `LatticeCrypto/MLKEM/Concrete/Instance.lean` sets every encoded type (`EncodedTHat`, `EncodedU`, `EncodedV`) to `ByteArray`, but through a plain `def concreteEncoding`, so instance search cannot see through it. Consequence: `MLKEM.asKEMScheme concreteNTTRingOps mlkem768Encoding mlkem768Primitives` does not elaborate in a downstream file without hand-derived instances. We carry three in `PqStealth/MLKEM.lean` (`instDecidableEqMlkem768Encoded\{THat,U,V\} := inferInstanceAs (DecidableEq ByteArray)`). Upstream: `@[reducible]` on `concreteEncoding`, or ship the three instances beside `mlkem768EncodingLaws`. 2. **De-privatize `byteEncode_size`.** `.lake/packages/VCVio/LatticeCrypto/MLKEM/Concrete/Encoding.lean:242`: `private theorem byteEncode_size (d : Nat) (f : Rq) : (byteEncode d f).size = 32 * d`. It is exactly the fact needed for the [`v`](/lean/proofs/demo#v) half of the FIPS 203 ciphertext layout (`32·dv = 128` bytes at ML-KEM-768). Because it is `private` we take that count from the standard; it is the only non-cryptographic assumption in `PqStealth/MLKEM.lean`. Upstream: make it (or a public corollary) visible. 3. **`SampleableType` on `R_q` / `T_q`.** VCVio never constructs uniform sampling on its own polynomial rings (the MLDSA files take it as a hypothesis). Any MLWE statement over the concrete ring needs it; `PqStealth/SPRTwoHop.lean` adds [`instSampleableTypeRq`](/lean/proofs/sprtwohop#instsampleabletyperq), [`instSampleableTypeTq`](/lean/proofs/sprtwohop#instsampleabletypetq) and a `NeZero` instance for the ML-KEM modulus. Upstream: provide the instances in `LatticeCrypto/MLKEM/Arithmetic.lean` (or wherever `Rq`/`Tq` live). 4. **[`LearningWithErrors.advantage_eq_boolDistAdvantage`](/lean/proofs/sprtwohop#learningwitherrorsadvantage_eq_booldistadvantage).** The bridge `advantage = (game0).boolDistAdvantage (game1)` is what lets a decision-MLWE term enter a `boolDistAdvantage_triangle` chain at all. We carry it as `PqStealth.LearningWithErrors.advantage_eq_boolDistAdvantage` (`SPRTwoHop.lean`); it belongs in `LatticeCrypto/HardnessAssumptions/LearningWithErrors.lean`. 4b. **Four small `boolDistAdvantage` lemmas** (`CryptoFoundations/SecExp.lean` has only `boolDistAdvantage_triangle`): [`boolDistAdvantage_comm`](/lean/proofs/games#probcompbooldistadvantage_comm), [`boolDistAdvantage_congr`](/lean/proofs/multiunlink#probcompbooldistadvantage_congr) (transport along `𝒟[p] = 𝒟[p']`), [`boolDistAdvantage_le_sum_hybrids`](/lean/proofs/multiunlink#probcompbooldistadvantage_le_sum_hybrids) (telescoping over `ℕ`-indexed hybrids) and [`boolBiasAdvantage_bind_uniformBool_branch`](/lean/proofs/games#probcompboolbiasadvantage_bind_uniformbool_branch) (the bit-indexed-branch form of `boolBiasAdvantage_bind_uniformBool_eq_boolDistAdvantage`). All carried under `PqStealth.ProbComp.*` (`Games.lean`, `MultiUnlink.lean`). ## B. Medium — real proving, but well-specified 5. **A KEM-level [`kem_ind_cpa_security`](/lean/proofs/mlkem#mlkemkem_ind_cpa_security) for `MLKEM.asKEMScheme`.** VCVio has `kpke_ind_cpa_security` (about K-PKE, and `sorry` at the pin) and nothing about the KEM's `KEMScheme.IND_CPA_Advantage`. The exact statement we need is recorded verbatim in `PqStealth/MLKEM.lean` ("The missing upstream lemma") and elaborates as written: ```lean theorem MLKEM.kem_ind_cpa_security {params : Params} (ring : NTTRingOps) (encoding : Encoding params) (prims : Primitives params encoding) [DecidableEq encoding.EncodedTHat] [DecidableEq encoding.EncodedU] [DecidableEq encoding.EncodedV] [SampleableType SharedSecret] : ∃ mlwe : LearningWithErrors.Problem (TqMatrix params.k params.k) (TqVec params.k) (TqVec params.k), ∀ cpaAdv : (MLKEM.asKEMScheme ring encoding prims).IND_CPA_Adversary, ∃ mlweAdv : LearningWithErrors.Adversary mlwe, KEMScheme.IND_CPA_Advantage ProbCompRuntime.probComp cpaAdv ≤ |LearningWithErrors.advantage mlwe mlweAdv| ``` The gap to `kpke_ind_cpa_security` is the T-transform half of Fujisaki–Okamoto. When it lands, [`mlkem768_unlinkAdvantage_le_indCpa`](/lean/proofs/mlkem#mlkem768_unlinkadvantage_le_indcpa) composes with it in a one-line `calc`. 6. **Un-`sorry` `LatticeCrypto/MLKEM/Security.lean`.** `kpke_ind_cpa_security`, `kpke_delta_correct`, `ind_cca_security` are placeholders at the pin. Downstream prose easily mistakes them for theorems (we did, and had to scrub "VCVio reduces … to MLWE" from our docs). A note worth passing along from `SPRTwoHop.lean`: the two MLWE hops their K-PKE proof needs are the same two we proved, but **seeded on `rho`** — a reduction must output a real encapsulation key and cannot invert `SampleNTT`, so the announced `TqMatrix`-sampled problem shape is unusable for the reduction adversary. The seeded shape ([`keyHopProblem`](/lean/proofs/sprtwohop#keyhopproblem), [`ctHopProblem`](/lean/proofs/sprtwohop#cthopproblem) in our tree) is the one that composes. 7. **KEM anonymity (ANO-CPA / ANO-CCA) in `CryptoFoundations/KeyEncapMech.lean`.** VCVio stops at IND-CCA. Recipient unlinkability of a stealth scheme is key privacy, not message privacy, so we define the anonymity game ourselves (`PqStealth.KEM.AnonExp`, the hidden bit picks the public key). Upstream should own that definition; `vcvio-pin.md` says to adopt theirs the moment it exists. References: Grubbs–Maram–Paterson (EC'22), Maram–Xagawa (PKC'23). ## C. Framework-level — the ones that blocked round 3 8. **RETRACTED (2026-08-19): the lazy-RO switching lemma exists.** We had recorded that only `StateSeparating/IdenticalUntilBad.lean` (two `QueryImpl.Stateful` handlers, explicit `σ × Bool` flag) was available. At the pin VCVio also has `ProgramLogic/Relational/ProgrammingOracle.lean`: `tvDist_simulateQ_randomOracle_withProgramming_le_probEvent_bad`, `programming_collision_bound[_qP_qH_β]`, built on the generic engine `tvDist_simulateQ_run_le_probEvent_output_bad` (`ProgramLogic/Relational/SimulateQ.lean`) and `QueryImpl.withProgramming` / `withCachingTrackingPolicy` (`OracleComp/QueryTracking/ProgrammingOracle.lean`). What it does *not* cover is our spec shape `unifSpec + hashSpec` with uniform forwarding (`unifFwdImpl + randomOracle`, the same shape as `Examples/BR93.lean`, whose up-to-bad step is `sorry` at the pin). We instantiated the engine for that shape in `PqStealth/ROMUpToBad.lean` ([`programmedROImpl`](/lean/proofs/romuptobad#programmedroimpl), [`trackingROImpl`](/lean/proofs/romuptobad#trackingroimpl), the two `relTriple_simulateQ_run'` projections, and the averaged [`boolDistAdvantage_run'_cacheQuery_run'_empty_le`](/lean/proofs/romuptobad#booldistadvantage_run_cachequery_run_empty_le)); both `DKSAPOracle` and `BlindingROM` now close their identical-until-bad steps with it. Remaining upstream asks from this: (a) the per-step agreement lemma `probOutput_withProgramming_eq_withCachingTrackingPolicy_of_not_bad_output'` is `private` — we had to re-prove it; (b) a `unifSpec + hashSpec` (`unifFwdImpl + so`) variant of the bridge, or a general lemma that a uniform-forwarding component can be peeled off, would make ours unnecessary; (c) the symmetric half of the fundamental lemma (`Pr[flag | programmed run] = Pr[flag | tracking run]`) is not stated. 9. **Reachability of the random-oracle modules.** `OracleComp/QueryTracking/RandomOracle/Basic.lean` (`OracleSpec.randomOracle`) and `OracleComp/QueryTracking/RandomOracle/DeferredSampling.lean` (+ its `ProbeEps` import) are not reachable from VCVio's root import; we had to build them into the shared packages dir by hand. An import-graph fix. Also: programmability ("run against a cache that already maps `x ↦ y`") is something we reconstructed ad hoc in `DKSAPOracle.lean`; a small API for it next to `randomOracle` would help. 10. **`Vector`/`Bytes` uniform-projection lemma.** "The first byte of a uniform 32-byte string is a uniform byte" is not provable in-tree at the pin, so `PqStealth/Soundness.lean`'s 1-byte view-tag bound carries `viewTag` uniformity as a hypothesis. A lemma [`probOutput_map_get_uniformSample_vector`](/lean/proofs/soundness#proboutput_map_get_uniformsample_vector) (or similar) in `OracleComp/Constructions/SampleableType.lean` would discharge it. ## D. Non-code 11. **`mathlibStandardSet` linters vs. `#guard_msgs in #print axioms`.** With `weak.linter.mathlibStandardSet = true`, the `style.commandStart` warning produced by a `#print axioms` on a continuation line is captured by `#guard_msgs` and turns every audit block into an error. Verified on this toolchain. It discourages exactly the build-enforced axiom audit a downstream user should adopt. Worth a report; the workaround is one-line commands. ## Suggested filing order 1, 2, 3, 4, 4b and the three residual asks under 8 as issues (each with the failing snippet) — clear, small asks with a concrete proposed change. 5, 6, 7 as one discussion thread — they are roadmap items for VCVio's ML-KEM security story and our seeded-hop finding is useful input to it. 9, 10, 11 as low-priority issues. # Lean 4 study notes for the PqStealth development :::info[Mirrored page] Source: [`lean/docs/lean-study-notes.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/lean-study-notes.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Condensed from the two official books, read 2026-08-17 as preparation for the improvement pass on `lean/PqStealth/` (see `improvements.md`): * *Functional Programming in Lean* — https://lean-lang.org/functional\_programming\_in\_lean/ * *Theorem Proving in Lean 4* — https://lean-lang.org/theorem\_proving\_in\_lean4/ The two digests below are meant to be read next to the code. The items that map directly onto this repo are: `abbrev` vs `def` for transparency to instance search and `simp`; `#guard_msgs` + `#print axioms` for build-checked axiom audits; `simp only` over bare `simp` for proofs that must survive dependency bumps; `set_option autoImplicit false` (or the lakefile equivalent) so a typo in a binder is an error rather than a fresh implicit; `section`/`variable` scoping rules (only variables mentioned in a theorem's *statement* are included, instance binders follow their dependencies — hence the `omit ... in` dance in `DKSAPClassical.lean`); module docstrings `/-! -/` for `doc-gen4`; and `termination_by`/`decreasing_by` when recursion is not structural. *** # Functional Programming in Lean — study digest Source: https://lean-lang.org/functional\_programming\_in\_lean/ (current Verso edition; chapters below follow its TOC). Assumes Haskell/OCaml/Rust background; focus is on what is *Lean-specific*. ## 1. Getting to Know Lean **Commands.** `#eval e` evaluates (needs `Repr`/`ToString`/`ToExpr` for the result — a partial application errors with "Could not synthesize a `ToExpr`, `Repr`, or `ToString` instance for type String → String"). `#check e` shows a type without evaluating; `#check f` shows the signature with binder names, `#check (f)` shows the plain arrow type; `#check @f` shows *all* implicit/instance args. `example : T := e` type-checks without naming (good for functions/`do` blocks that `#eval` can't print). **Definitions.** `def x : T := e`; functions `def add1 (n : Nat) : Nat := n + 1`. `:=` defines, `=` is propositional equality. Names are usable only after their definition (no forward refs). Curried: `Nat → Nat → Nat` = `Nat → (Nat → Nat)`; ASCII `->` works. Type ascription is `(e : T)`. **`def` vs `abbrev` (gotcha).** `def NaturalNumber : Type := Nat` then `def n : NaturalNumber := 38` fails: `failed to synthesize OfNat NaturalNumber 38` — `def`s are *not* unfolded during instance search. Use `abbrev N : Type := Nat` (reducible) or `(38 : Nat)`. Same for bounds predicates and `Prop`-valued definitions used with `simp`/`decide`. **Literals.** Untyped numerals default to `Nat`; `Nat` subtraction truncates: `(1 - 2 : Nat) = 0`; use `Int`. `0.0` is `Float`, `0` isn't. Numeric literals go through the `OfNat α n` class (see ch. 3). **Structures.** ```lean structure Point where x : Float y : Float deriving Repr def origin : Point := { x := 0.0, y := 0.0 } -- or ⟨0.0, 0.0⟩ (needs known type) def p2 : Point where x := 1; y := 2 -- `where` form #eval { origin with x := 3 } -- functional update, original unchanged ``` Generates `Point.mk` (rename with `structure Point where point :: x : Float ...`) and accessors `Point.x`. Brace/anonymous-constructor syntax needs the expected type known: `{ x := 0.0, y := 0.0 : Point }` or `(⟨1, 2⟩ : Point)`; otherwise "invalid \{...} notation, expected type is not known". **Generalized dot notation (very Lean).** `e.f a b` calls `T.f` where `T` is the *head type of `e`*, inserting `e` as the *first explicit argument of type `T`* (not necessarily the first parameter): `def Point.modifyBoth (f : Float → Float) (p : Point)`, then `p.modifyBoth Float.floor`. Works for any namespace: `"a".append "b"`, `(4 : Nat).double` after `def Nat.double`. **Inductive types & matching.** ```lean inductive Nat where | zero : Nat | succ (n : Nat) : Nat def isZero (n : Nat) : Bool := match n with | Nat.zero => true | Nat.succ k => false ``` Constructors live in the type's namespace (`Nat.succ`); use leading-dot `.succ k` when the expected type is known. Nat literal patterns: `| 0 => .. | n + 1 => ..` (right of `+` must be a literal; `2 + n` fails). Structural recursion is checked; termination is *required*: `evenLoops n` with unchanged arg fails "failed to infer structural recursion". Non-structural (e.g. `div (n - k) k`) needs `termination_by`/`decreasing_by`/`partial`. **Polymorphism.** Type args are ordinary (explicit) params: `def replaceX (α : Type) (p : PPoint α) ...`; make them implicit with `{α : Type}`; or omit entirely — *auto-bound implicits*: any unbound lowercase/Greek identifier in a signature becomes an implicit param. Supply explicitly by name: `List.length (α := Int)`. Built-ins: `Option` (`none`/`some`; `Option (Option Int)` is fine, unlike nullables), `Prod` (`α × β`, `(a, b)`, right-assoc), `Sum` (`α ⊕ β`, `.inl`/`.inr`), `Unit` (`()`), `Empty`. Naming: `f?` returns `Option`, `f!` panics, `fD` takes a default. Gotchas: constructors can't take `Type` args unless the type is universe-bumped ("Invalid universe level"), no negative occurrences (`(MyType → Int) → MyType` rejected), and `#eval []` can't infer `α`. **Conveniences.** Pattern-matching definitions (`def length : List α → Nat | [] => 0 | _ :: ys => ...`), simultaneous match `match n, xs with | 0, ys => ..` (matching a *tuple* `(n, xs)` breaks termination checking!), `let x := e` / `let (a, b) := e` / `let rec`, `fun x => ..`, `fun | 0 => .. | n+1 => ..`, `(· + 1)` section syntax (each `·` a param, left to right), `if let some x := e then .. else ..`, `s!"x = {x}"` (needs `ToString`), namespaces (`namespace N ... end N`, `open N`, `open N in cmd`, `open N (f g)`, `def N.f` defines into `N`). Strings: `String` is UTF-8 bytes + cached length; `.drop`/`.trimAscii`/`.dropWhile` return `String.Slice` (zero-copy) — call `.copy` to get a `String`; pattern arg can be a `Char`, `String`, or `Char → Bool`. *Not covered by the book but standard:* `section ... end` scopes `open`/`variable`; `variable (α : Type) [Monad m]` declares params auto-added to following defs that mention them; `universe u`. ## 2. Hello, World! `def main : IO Unit := IO.println "hi"`; run with `lean --run File.lean`. `main` may also be `IO UInt32` or `List String → IO UInt32`. `IO α` = "a description of effects that, when executed, throws or returns α"; evaluation ≠ execution. `do` blocks: `let x ← act` runs an action, `let x := e` binds a pure value, bare `act` sequences. Nested actions `(← act)` are hoisted to the enclosing `do` — but *not* out of `if` branches ("Nested action must be nested inside a `do` expression"), so both branches' effects run unconditionally if you hoist manually. IO actions are values: `def twice (a : IO Unit) : IO Unit := do a; a`, lists of actions, `pure ()`. `#eval` *executes* IO actions (stdin is empty; may re-run on edits). Layout is whitespace-sensitive; braces/semicolons possible but unidiomatic. **Projects.** `lake new foo` → `Main.lean`, `Foo.lean`, `Foo/Basic.lean`, `lakefile.toml`, `lean-toolchain`; `lake build`, `lake exe foo`. Modules (`Foo/Basic.lean` = `import Foo.Basic`) are separate from namespaces. `partial def` for possibly-non-terminating functions (e.g. reading a stream) — excluded from proofs but doesn't infect callers; still needs an inhabited return type. `IO.FS.Stream`, `IO.getStdin/Stdout/Stderr`, `IO.FS.Handle.mk`, `System.FilePath` (single-field structure: `⟨"path"⟩`, `String` coerces to it), `ByteArray`, `USize`. `if`/`match` as *statements* in `do` get implicit `do` per branch. ## 3. Interlude: Propositions, Proofs, and Indexing `Prop` is a universe of propositions; a proposition is a type, a proof a term of it. `theorem name : 1 + 1 = 2 := rfl` (definitional equality); tactics: `by decide` (decision procedure on closed terms), `by simp`, `by grind`. Connectives: `True.intro`, `And.intro`/`∧`, `Or.inl/inr`, `A → B`, `¬A = A → False`. Evidence as arguments: `def third (xs : List α) (ok : xs.length > 2) : α := xs[2]` and call `third xs (by decide)`. Indexing forms: `xs[i]` (needs a proof in scope, else "failed to prove index is valid"), `xs[i]?` (`Option`), `xs[i]!` (panic; needs `Inhabited α`), `xs[i]'h`. Gotcha: `xs [1]` with a space is function application. `simp` won't unfold a `def`-defined `Prop`; use `abbrev`. ## 4. Overloading and Type Classes ```lean class Plus (α : Type) where plus : α → α → α instance : Plus Nat where plus := Nat.add def sum [Add α] [Zero α] : List α → α | [] => 0 | x :: xs => x + sum xs ``` Classes are structures; methods live in namespace `Plus`; `[C α]` is an *instance-implicit* found by search (not unification like `{}`); instances are anonymous by default and the most recent wins; instances may take `[Add α]` premises → recursive search. Operators desugar to *heterogeneous* classes: `+`→`HAdd.hAdd`, `*`→`HMul`, `++`→`HAppend`, `==`→`BEq.beq`, `<`→`LT.lt`, `&&&`/`|||`/`^^^`/`<<<`; homogeneous `Add`, `Mul`, `Append`, `Neg`, `AndOp`/`OrOp` (since `And`/`Or` are logic). Literals: `OfNat α n` (parameter is a *value*, e.g. `instance : OfNat Pos (n + 1)`), `Zero`, `One`. `outParam` marks class params determined by search (`class HPlus (α β : Type) (γ : outParam Type)`); without it, an unknown `γ` metavariable blocks search. `@[default_instance]` lets `HPlus.hPlus (5 : Nat)` default to `Nat → Nat`. Debug with `set_option trace.Meta.synthInstance true`. Standard classes: `BEq` (Bool `==`) vs `=` (Prop; needs `Decidable` to use in `if`) — `instance : Decidable (x < y) := inferInstance ...`; `Ord`/`compare : α → α → Ordering` (`.lt/.eq/.gt`); `Hashable`/`mixHash`; `ToString`, `Repr`; `Functor` (`<$>`, laws: identity/composition); `deriving BEq, Hashable, Repr, Inhabited, Ord` (inline or `deriving instance BEq for T`; no handler for `ToString`). Arrays: `#[1,2]`, `xs.size`, `GetElem coll idx (elem : outParam) (inBounds : outParam)`; you can index by custom types (`GetElem (List α) Pos α (fun l n => l.length > n.toNat)`). Coercions: `Coe α β` (`Coe Pos Nat`, `Nat`→`Int`, `α`→`Option α`, chained), `CoeDep α x β` (per-value), `CoeSort` (structure-as-type), `CoeFun` (structure-as-function; `instance : CoeFun Adder (fun _ => Nat → Nat)`); force with `↑e`. Coercions fire only on a *type mismatch* with enough type info — `def x : Option Nat := 392` fails (`OfNat` error, not a mismatch), and they don't apply inside `e.field` targets. Only define `Coe α β` if it's total. Instances can be written `⟨...⟩`, `{ .. }`, or `where`. ## 5. Monads Pattern: `Option`, `Except ε α` (`.error`/`.ok`), `WithLog`, `State σ α := σ → σ × α` all share `pure`/`andThen`. `infixl:55 " ~~> " => andThen` declares operators. Class: ```lean class Monad (m : Type → Type) where pure : α → m α bind : m α → (α → m β) → m β -- x >>= f ``` Laws: `bind (pure v) f = f v`, `bind v pure = v`, associativity. `Id` monad; when the monad is ambiguous pass `(m := Id)` ("typeclass instance problem is stuck"). `mapM`. `do` desugars: `let x ← E; rest` ⇒ `E >>= fun x => rest`; bare `E; rest` ⇒ `E >>= fun () => rest`; `let x := E` is a plain let; nested `(← E)` allowed. Real `IO` = `EIO IO.Error` = `EStateM`-style world-passing (`RealWorld` erased). `nomatch e` for uninhabited cases (`Empty`). Also: `def f (x y : α)` shared binder types, leading `.ctor`, or-patterns `| .a | .b => ..` (only common vars usable; result is duplicated per branch, so a shadowed outer name can silently be picked in one branch). Custom `Reader ρ α := ρ → α`, `Many` (lazy nondeterminism via `Unit → Many α` thunks). ## 6. Functors, Applicative Functors, and Monads **Structure inheritance.** `structure Monster extends MythicalCreature where vulnerability : String` — stored as field `toMythicalCreature`; `Monster.mk ⟨true⟩ "sunlight"` (nested anonymous ctor!); `troll.small` works via dot-notation inserting `toMythicalCreature`, but `MythicalCreature.small troll` is a type error (no subtyping; "upcast" *erases* fields). Multiple inheritance with diamonds: first parent's copy is stored, others' fields copied; `#print` shows it. Default field values `large := size == .large` are only defaults — invariants need a `Prop` field/subtype. Classes extend classes the same way with default methods. **Applicative.** `class Applicative f extends Functor f where pure; seq : f (α → β) → (Unit → f α) → f β`; `E1 <*> E2 = Seq.seq E1 (fun () => E2)` (thunk = laziness). `Validate ε α` accumulates errors (`NonEmptyList`), unlike `Except`; but a type with both `Applicative` and `Monad` must have `seq` agree with the bind-derived one — so `Validate` should *not* be a monad. Laws: identity, composition, homomorphism, interchange. `Alternative f extends Applicative f` with `failure`, `orElse` (`<|>`, lazy second arg via `Unit →`), `guard`; `*>`, `<*`; `OrElse`, `SeqRight` classes. Subtypes: `{x : Nat // x > 0}` = `Subtype`, values `⟨n, proof⟩`; `if h : p then .. else ..` (dependent if) binds evidence `h`; `by simp`, `by decide`, `by simp [*]` inline. Gotcha: `[Decidable (p v)]` must come *after* [`v`](/lean/proofs/demo#v) and `p` in the binder list. **Universes.** `Prop : Type`, `Type = Type 0 : Type 1 : ...`; `Sort 0 = Prop`, `Sort (u+1) = Type u`. No cumulativity. Universe-polymorphic defs: `inductive MyList (α : Type u) : Type u`, `Sum (α : Type u) (β : Type v) : Type (max u v)`; levels: `0`, `u`, `max u v`, `u + 1`, `imax`. Function type lives in max of both, *except* anything returning `Prop` is in `Prop`. Errors: "Invalid universe level in constructor … not less than or equal to the inductive type's resulting universe level". Full library classes are `Functor (f : Type u → Type v) : Type (max (u+1) v)` with `map`, `mapConst`; `Pure`, `Seq`, `SeqLeft`, `SeqRight`, `Bind`; `Monad extends Applicative, Bind` with defaults so only `pure`+`bind` are needed. ## 7. Monad Transformers `ReaderT ρ m α := ρ → m α`; `abbrev ConfigIO := ReaderT Config IO` (must be `abbrev`, or library instances are hidden). Classes: `MonadReader ρ m` (`read`), `MonadWithReader` (`withReader f act`), `MonadLift m n` (`monadLift`) — Lean *auto-inserts* lifts on type mismatch, so plain `IO` actions work inside `ReaderT Config IO`; `.run cfg` unwraps. `OptionT m α := m (Option α)`, `ExceptT ε m α := m (Except ε α)`, `StateT σ m α := σ → m (α × σ)`; each has `.mk`/`.run` (needed to steer inference — cryptic "expected α but got Option α" otherwise), a `Monad` instance and `MonadLift`. Universe gotcha: `ExceptT` needs `ε` and `α` in the *same* `Type u`. Effect classes: `MonadExcept ε m` (`throw`, `tryCatch`, `try .. catch | pat => ..`), `MonadState σ m` (`get`, `set`, `modify`, `modifyGet`), `MonadStateOf`/`getThe σ`/`modifyThe σ` (`semiOutParam`) for stacks with several states; `export MonadReader (read)` re-exports. Order matters: `StateT σ (ExceptT ε Id)` rolls state back on exception; `ExceptT ε (StateT σ Id)` keeps it (like most imperative langs); `StateT`/`ReaderT` commute, `StateT`/`ExceptT` don't. **Imperative `do`.** `if c then act` (no else ⇒ `pure ()`), `unless c do act`, `return e` (early exit of *the current* `do` — implemented via `ExceptT`), `let mut x := 0; x := x + 1` (via `StateT`; only mutable in the same block, not in nested `let rec`/`fun`/`let y := do ..`; `let y ← do ..` *is* the same block), `for x in xs do ..` (`ForIn`/`ForM`), `for h : i in 0...xs.size do xs[i]`, ranges `a...b`, `a...=b`, `a<...b`, `*...b`, parallel `for x in xs, y in ys`, `break`/`continue`, `repeat`, `while c do`, `Id.run do ..` for pure code. Pipes: `x |> f`, `f <| x`, `xs |>.reverse |>.drop 1`. ## 8. Programming with Dependent Types ```lean inductive Vect (α : Type u) : Nat → Type u where | nil : Vect α 0 | cons : α → Vect α n → Vect α (n + 1) def Vect.zip : Vect α n → Vect β n → Vect (α × β) n | .nil, .nil => .nil | .cons x xs, .cons y ys => .cons (x, y) (zip xs ys) -- no missing cases ``` Params (before `:`) are fixed across constructors and don't raise the universe; indices (after `:`) vary and force `Type (u+1)`-style bumps; params must precede indices; Lean auto-promotes uniform post-colon args to params ("Mismatched inductive type parameter" if you vary a named param). Dependent pattern matching refines the expected type per branch; matching a variable-indexed value can leave you unable to pick any constructor. Universe design pattern: codes + `abbrev asType : Code → Type` interpretation, then recursion over codes (type-class search cannot enumerate `BEq t.asType`; write `def beq (t) : t.asType → ..`). `mutual ... end` for mutual recursion. Typed-queries example: `abbrev Row : Schema → Type` computed by cases (`[]`, `[c]`, `c1 :: c2 :: cs` — functions must mirror the same case split), `HasCol`/`Subschema` as `Type`-valued families, evidence built with `by repeat constructor`, `Bool` coerced to `Prop`, `macro`. Pitfalls: definitional equality is by *computation*, so `Nat.plusL 0 k` reduces but `Nat.plusR 0 k` is stuck (recursion on the 2nd arg) — implementation leaks into the interface; fix with propositional equality and rewriting `proof ▸ e` (`plusR_zero_left k ▸ ys`), `congrArg`, `rfl`. Idiomatic Lean prefers subtypes + separate propositions over heavy indexed families. ## 9. Interlude: Tactics, Induction, and Proofs `theorem t (k : Nat) : k = Nat.plusR 0 k := by induction k with | zero => rfl | succ n ih => simp [Nat.plusR]; assumption`. Tactics: `induction x with | ctor a ih => ..` (or `case succ n ih => ..`; unnamed hyps get `n✝`), `rfl`, `unfold f`, `rw [h]`/`rw [← h]`, `simp [f, g]`, `exact h` (name-brittle), `assumption`, `skip`, `intro`, `cases h`, `constructor`, `apply`, `funext`, `omega`, `simp +arith`, `fun_induction f`, `grind [facts]` (uses hypotheses; all-or-nothing), `sorry`. Combinator `t1 <;> t2` applies `t2` to all goals of `t1` ("tactic golf": `induction t <;> grind [BinTree.mirror, BinTree.count]`). Induction on any inductive type gives one IH per recursive field. ## 10. Programming, Proving, and Performance Tail calls: only *self* tail calls are eliminated; use accumulators (`sumHelper (soFar : Nat)`); non-tail `sum` overflows around 200k elements. Prove equivalence with `funext xs; induction xs`; generalize the accumulator by putting it *after* the colon (`(n : Nat) → n + NonTail.sum xs = Tail.sumHelper n xs`) so the IH is ∀-quantified. Arrays/termination: `if h : i < arr.size then .. arr[i] ..` supplies the bounds proof; `termination_by arr.size - i` (or `termination_by?` for a suggestion); `have : measure_decreases := by grind [...]` right before the recursive call; `decreasing_by` for a custom tactic; test first with `partial`; `Nat.le` is an inductive relation (`refl`/`step`), `n < m := n + 1 ≤ m`; `div` needs `(ok : k ≠ 0)`. `Fin n` = `{val : Nat, isLt : val < n}` (literals wrap modulo `n`!), `Array.find : .. → Option (Fin arr.size × α)`. Insertion sort: `arr.swap i j`, `Fin` for indices, `fun_induction insertSorted <;> grind`; arrays mutate in place when RC = 1 — check with `dbgTraceIfShared "msg" arr` (compiled code, not `#eval`; read input from stdin so the compiler can't constant-fold). Special runtime reps: `Nat`/`Int` (bignums), `UIntN`/`USize`, `Char`, `String`, `Array` (avoid `Array.mk`/`.toList`, `String.toByteArray` in hot code — linear conversions), types and proofs erased, single-data-field structures (subtypes, `Fin`) unboxed, nullary constructors are constants. ## 11. Next Steps Pointers to *Theorem Proving in Lean 4*, the Lean reference manual, *Metaprogramming in Lean 4*, Zulip, Mathlib community, Software Foundations (Rocq), *Type-Driven Development with Idris*, *The Little Typer*. ### Cross-cutting gotchas for Haskell/OCaml/Rust folks * Whitespace/indentation-sensitive layout; `def` bodies via `:=`; no top-level type inference of arguments (annotate binders). * Termination is mandatory; `partial` opts out (and bars proofs); `termination_by`/`decreasing_by`/`have` are the escape hatches. * `{}` = unification-solved implicit, `[]` = instance-search implicit, `()` explicit; `@f` exposes everything; auto-bound implicits for free type variables. * Type classes ≈ Haskell classes but params can be values, `outParam` controls search direction, instances are anonymous, latest wins; `deriving` limited set. * `Prop` vs `Bool`: `=`/`<` are `Prop`, `==` is `Bool`; `if` on a `Prop` needs `Decidable`; `decide`/`simp`/`omega`/`grind` discharge obligations inline; `def`s are opaque to these — use `abbrev`. * Structures: no subtyping; `extends` nests; dot notation is name-based and inserts coercions/projections; `⟨⟩` needs a known type. * `do` is much richer than Haskell's (`let mut`, `for`, `return`, `←` nested), but block boundaries and `:=`-vs-`←` matter. * Universes are explicit and non-cumulative; indices vs parameters change universe levels. *** # Theorem Proving in Lean 4 — Study Digest Source: https://lean-lang.org/theorem\_proving\_in\_lean4/ (Verso edition, chapters 2–12). Everything below is from the book unless marked **\[not in TPIL]**. *** ## 2. Dependent Type Theory **Concepts.** Every term has a type; types are terms too. Universe hierarchy: `Prop = Sort 0`, `Type = Type 0 = Sort 1`, `Type u = Sort (u+1)`, `Type u : Type (u+1)`. `Prop` is impredicative and proof-irrelevant. Dependent function ("Pi") type `(a : α) → β a` generalises `α → β`; dependent pair (Sigma) `(a : α) × β a` / `Σ a : α, β a` generalises `α × β`. Arrows associate right; application associates left; `fun x y => ..` curries. ```lean universe u def F (α : Type u) : Type u := Prod α α -- or def F.{u} ... #check @List.cons -- {α : Type u_1} → α → List α → List α #eval (fun x : Nat => x + 5) 10 -- 15 (compiled) #reduce (fun x : Nat => x + 5) 10 -- kernel reduction, can differ in speed def compose (g : β → γ) (f : α → β) (x : α) := g (f x) -- auto-bound implicits α β γ def h (x : Nat) := let y := x + 1; y * y -- let is definitionally unfolded (unlike have) section variable {α : Type u} (x : α) -- inserted as params only where used def ident := x end namespace Foo ... end Foo ; open Foo ``` **Implicit args.** `{α : Type}` inserted eagerly; `@f` makes all explicit (`@id Nat 1`); `(e : T)` ascription steers elaboration; `_` = "please infer". `⦃x⦄` (strict/weak implicit) only inserted before a following explicit arg. `[C α]` = instance implicit. **Gotchas.** `#check ident` shows signature; `#check (ident)` shows the type with metavariables. Numerals default to `Nat`. Unused vars → linter warning; prefix `_`. `set_option autoImplicit false` to disable auto-bound implicits. *** ## 3. Propositions and Proofs **Curry–Howard.** `p : Prop`, a proof is `t : p`. `theorem` = `def` but irreducible and only variables in the *statement* become params. Any two proofs of `p` are definitionally equal. | Connective | Intro | Elim | |---|---|---| | `p → q` | `fun hp => ..` | application | | `p ∧ q` | `And.intro`, `⟨hp, hq⟩` | `h.left/h.1`, `h.right/h.2` | | `p ∨ q` | `Or.inl`, `Or.inr` | `Or.elim h f g`, `h.elim` | | `¬p` (= `p → False`) | `fun hp => ..` | `hnp hp : False`, `absurd hp hnp : q` | | `p ↔ q` | `Iff.intro`, `⟨mp, mpr⟩` | `h.mp`, `h.mpr` | | `True`/`False` | `True.intro`/`trivial` | `False.elim`, `h.elim` | Precedence: `¬` > `∧` > `∨` > `→` > `↔`. Anonymous constructor `⟨_, _⟩` works for single-constructor types (`And`, `Iff`, `Exists`, structures) — **not** `Or`; nested `⟨a, b, c⟩` right-associates. ```lean example (h : p ∧ q) : q ∧ p := have hp : p := h.left suffices hq : q from ⟨hq, hp⟩ show q from h.right open Classical theorem dne (h : ¬¬p) : p := (em p).elim id (fun hnp => absurd hnp h) example (h : ¬¬p) : p := byContradiction fun h1 => h h1 example : p := byCases (fun hp : p => ..) (fun hnp : ¬p => ..) ``` **Gotchas.** `sorry` closes anything (warning; equivalent to an axiom `sorryAx`; shown by `#print axioms`). `_` in a proof gives an error listing the missing goal. Peirce, `¬(p ∧ q) → ¬p ∨ ¬q`, `(p → q) → ¬p ∨ q` need `Classical`. *** ## 4. Quantifiers and Equality `∀ x : α, p x` *is* `(x : α) → p x`; intro = `fun x => ..`, elim = application. `∃ x, p x` is `Exists (fun x => p x)`, an inductive `Prop`. ```lean example (h : ∃ x, p x ∧ q x) : ∃ x, q x ∧ p x := match h with | ⟨w, hpw, hqw⟩ => ⟨w, hqw, hpw⟩ example : (∃ x, p x ∧ q x) → ∃ x, q x ∧ p x := fun ⟨w, hpw, hqw⟩ => ⟨w, hqw, hpw⟩ example (h : ∃ x, p x) : q := Exists.elim h (fun w hw => ..) -- Exists.elim only to Prop ``` **Equality.** `Eq.refl a`, `rfl`, `h.symm`, `Eq.trans h₁ h₂` / `h₁.trans h₂`, `Eq.subst h hp : p b` (`h : a = b`, `hp : p a`), macro `h ▸ e` (rewrites in either direction, better heuristics), `congrArg f h`, `congrFun h a`, `congr h₁ h₂`. `rfl` proves anything definitional: `2 + 3 = 5`, `(fun x => f x) a = f a`. Higher-order unification for the motive is undecidable — supply `(motive := ..)` if `▸`/`Eq.subst` fails. **calc.** Chain any relations with `Trans` instances (mix `=`, `<`, `≤`, `↔`…): ```lean calc a = b := h1 _ = c + 1 := h2 _ < d := Nat.lt_of_.. ``` Also `show t from e`, `show t; exact e` in tactics. Impredicativity: `∀ p : Prop, ..` is again a `Prop`. *** ## 5. Tactics (core chapter) `by tac₁; tac₂` or newline-separated. Focus with `case tag => ..`, bullets `·`/`.` (indentation-sensitive; error if goal not closed), or `{ }`. `apply` tags subgoals by parameter names (`left`/`right`, `mp`/`mpr`, `zero`/`succ`). `#print thm` shows the term. **Basic** * `intro h` / `intro x y h₁ h₂` / `intro ⟨w, hw⟩` / `intro | ⟨w, .inl h⟩ => .. | ⟨w, .inr h⟩ => ..`; `intros` (auto-named, *inaccessible* names `a✝` — use `rename_i h1 _ h2` or `unhygienic`). * `apply e` (unify conclusion, new goals for args); `exact e` (must close; preferred); `refine ⟨_, ?_⟩` **\[not in TPIL]** — `?_` opens named goals. * `assumption`, `rfl` (any reflexive relation, defeq), `contradiction` (finds `h : ¬p`/`h : p`, `False`, distinct constructors `7 = 4`), `exfalso`/`trivial`/`left`/`right` **\[Std; not in TPIL]**. * `revert x` (inverse of intro, drags dependents), `generalize h : 3 = x` (may lose provability without the label `h`), `show t` (restate goal up to defeq: `show Nat.succ n = Nat.succ n`), `have h : t := e` / `have := e` (label `this`), `let a := 3 * 2` (unfoldable, unlike `have`), `sorry`. * Structured: `case left => ..`, `next => ..`, `all_goals t`, `any_goals t` (succeeds if ≥1 works), `focus`, `t₁ <;> t₂` (t₂ on *all* goals produced), `first | t₁ | t₂`, `try t` (= `first | t | skip`), `repeat t` — **`repeat (try t)` loops forever**. **Destructing / constructing** ```lean cases h with | inl hp => .. | inr hq => .. -- structured (order free) cases h -- unstructured; then `case inl h => ..` or `<;>` cases n with | zero => .. | succ m => .. cases m + 3 * k -- generalises expression (does not revert hyps mentioning it) cases Nat.lt_or_ge m n with | inl hlt => .. | inr hge => .. -- non-hyp term: `have`s it first rcases h with ⟨x, hx | hx⟩; obtain ⟨w, hw⟩ := h -- [Std/Mathlib; not in TPIL] pattern forms of cases constructor -- first applicable constructor (And.intro, Iff.intro, Exists.intro w/ metavar witness) exists x -- explicit witness (Mathlib: `use x`) match h with | ⟨_, Or.inl _⟩ => .. | .. -- match works in tactic blocks; also `intro | pat => ..` induction n with | zero => rfl | succ n ih => rw [Nat.add_succ, ih] induction x, y using Nat.mod.inductionOn with | ind x y h ih => .. | base x y h => .. injection h with h' -- constructor injectivity; closes goal on constructor clash funext (a, b) (c, d) -- funext with patterns ``` `cases`/`induction`/`constructor` also *define data* (`def swap_pair : α × β → β × α := by intro p; cases p; constructor <;> assumption`). `cases` reverts+reintroduces dependent hyps; `induction` only on local variables (generalize first). **Rewriting** ```lean rw [h₂, h₁] -- LHS→RHS, first match in traversal order; closes `t = t` with rfl afterwards rw [← h] -- reverse; `<-` ascii rw [Nat.add_comm b] -- instantiate args to pick the subterm; `Nat.add_comm _ b` rw [Nat.add_zero] at h -- in hypothesis; `at h ⊢`, `at *` rw [h] at t -- also rewrites types of data (Tuple α n → Tuple α 0) ``` `rw` fails under binders (`fun x => 0 + x`) — use `simp`, `funext`, or `conv`. Motive-not-type-correct errors → `simp only`, `cases`, or generalize the dependency. **Simplifier** ```lean simp -- @[simp] set + rfl closes; propositional rewriting (p ∧ q ↦ q given hp) simp [h₁, ←h₂, f, *] -- extra lemmas, reverse, unfold defs, all hyps simp at h ; simp at * -- location; order of `only [..]` `at` matters — check docstring simp only [List.reverse_append] at h -- no default set simp [-reverse_mk_symm] -- remove one lemma simp +contextual -- use `x = 0` inside then-branch of `if x = 0 ..` simp +arith -- linear arithmetic normalisation simp_all -- simplify hyps and goal together, repeatedly (used with fun_cases) @[simp] theorem foo .. -- global attribute (persists in importing files, cannot be removed) attribute [local simp] Nat.mul_comm Nat.mul_assoc Nat.mul_left_comm -- section-scoped; ordered rewriting handles AC without looping ``` `simp` failing = "simp made no progress" error; `unfold f` and `simp [f]` unfold definitions; equation lemmas `f.eq_1` etc. `unfold` is the way to see one step of a well-founded definition (`conv => lhs; unfold div`). **Other** * `split` — case-split nested `if`/`match` in goal (`split <;> first | contradiction | rfl`) or `split at h`. * `decide` — evaluate a `Decidable` instance (`example : 10 < 5 ∨ 1 > 0 := by decide`). * `omega`, `norm_num`, `ring`, `aesop`, `ext`, `subst`, `exact?`, `apply?`, `simp?` **\[not in TPIL; Std/Mathlib]** — `subst h` (h : x = e) replaces the variable; `ext`/`funext` for function/set equality; `omega` closes linear Nat/Int arithmetic; `ring` commutative (semi)ring identities. * Extensible tactics: `syntax "triv" : tactic` + `macro_rules | `(tactic| triv) => `(tactic| assumption)`; multiple rules tried in order. * Mixing: `exact ⟨hp, by simp⟩`; term-mode `have`/`show`/`match` inside tactic args and vice-versa. *** ## 6. Interacting with Lean * `#guard_msgs in cmd` asserts expected messages; `#check`, `#check @f`, `#print f`, `#print axioms f`, `#eval`, `#eval!` (allow sorry), `#reduce`. * `import A.B.C` (transitive; must be at file top). `Init` auto-imported. * Sections & `variable`: only variables actually mentioned become params (for theorems: only those in the statement, plus explicit `include`). * Namespaces: `def Foo.bar` ≡ `namespace Foo def bar end Foo`; `_root_.x`; `protected def` (no short alias — `Nat.rec`); `open Nat (succ zero)`, `open Nat hiding gcd`, `open Nat renaming mul → times`, `export Nat (succ add)`, `open Foo in cmd` (one-command scope). * Attributes: `@[simp]`, `attribute [simp] foo`, `attribute [local simp] ..`, `attribute [-instance] inst`, `attribute [local instance] i`. Attributes persist across imports unless `local`/`scoped`. * Notation: `infixl:65 " + " => HAdd.hAdd`, `infixr`, `prefix:100`, `postfix:max`, general `notation:65 lhs:65 " ~ " rhs:66 => ..`; higher number binds tighter; longest-parse rule; ambiguity resolved at elaboration. * Coercions `↑x`; auto-inserted `Nat → Int` etc. * `set_option pp.explicit true`, `pp.universes`, `pp.notation false`, `pp.all`, `pp.proofs`, `linter.unusedVariables false`, `autoImplicit false`, `trace.Meta.synthInstance true`, `synthInstance.maxHeartbeats`. Options are always scoped to the section/file; `set_option .. in cmd`. * Library naming: `Nat.succ_ne_zero`, `Nat.le_of_succ_le_succ` (conclusion\_of\_hyps), camelCase defs, CamelCase types; dot notation `xs.map f`, `h.symm`, `p.add q` (fills first explicit arg of matching type). * Implicit lambdas auto-inserted for expected `{α} → ..`; disable with `@fun`. Sugar `(· + 1)`, `(f · 1 ·)`, `(·.1)`. Named args `f (z := z) x`, `Eq.subst (motive := fun x => ..) h`, `..` for remaining explicit args in patterns (`Term.lambda (name := n) ..`, `Nat.add_assoc ..`). Default args `(y : Nat := 1)`. *** ## 7. Inductive Types ```lean inductive Weekday where | sunday | monday .. deriving Repr inductive Prod (α : Type u) (β : Type v) where | mk (fst : α) (snd : β) : Prod α β inductive Nat where | zero : Nat | succ : Nat → Nat inductive Vect (α : Type u) : Nat → Type u where -- parameter α, index Nat (family) | nil : Vect α 0 | cons : α → {n : Nat} → Vect α n → Vect α (n + 1) inductive Eq {α : Sort u} (a : α) : α → Prop where | refl : Eq a a structure Subtype {α : Sort u} (p : α → Prop) where val : α ; property : p val -- {x // p x} mutual inductive Even : Nat → Prop .. inductive Odd : Nat → Prop .. end inductive Tree (α) where | mk : α → List (Tree α) → Tree α -- nested ``` Each type gets `T.rec` (motive, minor premises, major premise), `T.recOn`, `T.casesOn`, `T.noConfusion` (constructors injective & disjoint), `T.below`/`brecOn` (course-of-values). Constructors live in namespace `T`. Positivity required. `match` compiles to `casesOn`/`rec` (`T.match_1` auxiliaries). `Prod`/`Sum`/`Option`/`Sigma`/`Subtype`/`Inhabited` and `False`/`True`/`And`/`Or`/`Exists`/`Eq` are all inductives — the Prop versions of `Empty`/`Unit`/`Prod`/`Sum`/`Sigma`. **Universe/elimination rules.** For non-Prop types the universe must be ≥ every constructor argument's universe. Inductive Props eliminate only into `Prop`, **except singleton elimination**: one constructor whose args are all Props or indices (`Eq`, `And`, `Acc`) can eliminate into any `Sort` — that's why `Eq.rec`/`▸` can cast data. `Or`, `Exists`, `Nonempty` cannot produce data (hence `Classical.choice` is an axiom). Proof idioms: `cases d <;> rfl` for enumerations; `theorem subst (h : a = b) (h₂ : p a) : p b := match h with | rfl => h₂`; `nomatch h` for impossible hyps; `Nat.noConfusion h`. *** ## 8. Induction and Recursion Equation compiler: `def f : Nat → Nat | 0 => .. | n+1 => ..` (patterns: vars, constructors, literals, `n+2`, `[]`, `a :: as`, `(m, n)`, wildcards `_`; multiple args separated by commas; overlapping patterns → first match wins; missing cases → error listing them). Params before the colon are fixed; those after participate in matching. Structural recursion holds **definitionally** (`rfl` works); `where`/`let rec` create auxiliaries `f.loop`. `Inhabited`/`default` or `Option` for "incomplete" cases. ```lean theorem zero_add : ∀ n, add zero n = n | zero => rfl | succ n => congrArg succ (zero_add n) -- induction = recursion def fib : Nat → Nat | 0 => 1 | 1 => 1 | n+2 => fib (n+1) + fib n -- #reduce fast (brecOn), #eval slow ``` **Well-founded recursion.** When structural fails Lean tries WF: `termination_by x y => (x, y)` (lexicographic via `WellFoundedRelation`, `sizeOf` default), `decreasing_by simp_wf; ..` (default `decreasing_tactic` uses `assumption`, so a `have : x - y < x := ..` before the call suffices). Underlying `Acc`, `WellFounded.fix` (must be `noncomputable` if used directly). WF definitions do **not** reduce definitionally — `div 8 2 = 4` needs `simp [div]`/`unfold`/equation lemmas, not `rfl`. `decreasing_by sorry` = axiom; `#print axioms unsound` shows `sorryAx`. **Functional induction.** `fun_induction ack <;> simp [*]` and `fun_cases f <;> simp_all` generate cases following the *function's* recursion, with hypotheses ruling out earlier branches. `induction .. using ack.induct` older form. **Mutual recursion**: `mutual def even .. def odd .. end`; mutual theorems `mutual theorem .. end` on nested types (`Term`/`List Term`). **Dependent pattern matching.** On indexed families, unreachable cases may be omitted (`def head : Vect α (n+1) → α | cons a as => a`). Inaccessible patterns `.(f a)` or `_` mark terms determined by other args (avoid splitting on the index). Auto-bound `{n}` discriminants are added implicitly. `match h₀, h₁ with | ⟨x, px⟩, ⟨y, qy⟩ => ..`, `fun ⟨x, px⟩ ⟨y, qy⟩ =>`, `let (m, n) := p; ..` are all the same machinery. *** ## 9. Structures and Records ```lean structure Point (α : Type u) where mk :: x : α ; y : α deriving Repr #check @Point.rec ; Point.mk 1 2 ; p.x ; { x := 1, y := 2 : Point Nat } ; ⟨1, 2⟩ { p with y := 3 } ; { p, q with x := 6 } -- record update (left-to-right precedence) structure ColorPoint (α) extends Point α where c : Color -- inheritance; fields flattened, `toPoint` projection structure RGP (α) extends Point α, RGBValue where no_blue : blue = 0 def Point.add (p q : Point Nat) := .. -- p.add q ; p.smul 3 = Point.smul 3 p (first arg of type Point) ``` Implicit fields `{α : Type u}`; unspecified fields inferred or error; `structure` = single-constructor inductive plus projections, namespace, `rec`. *** ## 10. Type Classes ```lean class Add (α : Type) where add : α → α → α -- @Add.add : {α} → [self : Add α] → α → α → α instance : Add Nat where add := Nat.add instance [Add α] : Add (Array α) where add x y := Array.zipWith (· + ·) x y -- chaining def double [Add α] (x : α) := Add.add x x instance (priority := default + 1) i1 : Foo where .. -- later/higher-priority tried first (inferInstance : Add Nat) ; instance : Inhabited (Set α) := inferInstanceAs (Inhabited (α → Prop)) class HMul (α β) (γ : outParam Type) where hMul : α → β → γ -- outParam: synth even if γ unknown; semiOutParam @[default_instance] instance : HMul Int Int Int .. -- resolves stuck `?m` problems; numerals: OfNat α n, nat_lit 2 local instance / scoped instance (active inside/`open Foo` or `open scoped Foo`) / attribute [-instance] i export Inhabited (default) ``` Resolution = Prolog-like backtracking; debug with `set_option trace.Meta.synthInstance true`. `Decidable p` (`isTrue h | isFalse h`) is a `class inductive` in `Type`-like strength: `if p then a else b` = `ite` needs `[Decidable p]`; `if h : c then t else e` = `dite`. Instances for `∧ ∨ ¬ = < ≤` on `Nat`/`Int`. `open Classical` gives low-priority `propDecidable` (noncomputable). Tactic `decide` and function `decide p : Bool`, `of_decide_eq_true`. Coercions: `instance : Coe Bool Prop`, `CoeDep`, `CoeSort` (`(a b : S)` for a `Semigroup` structure), `CoeFun` (morphisms as functions), force with `↑`. Warning: class-typed defs should be `@[instance_reducible]`/`abbrev`. *** ## 11. Conversion Mode `conv => ..` (or `conv at h`, `conv in pat => ..`) navigates: `lhs`/`rhs`, `arg i`, `congr`/`args`, `intro x` (under binders), `enter [1, x, 2]`, `pattern b * c`, `skip`, then `rw [..]`, `simp [..]`, `unfold`, `whnf`, `rfl`, `done`, `trace_state`, `tactic => ..`/`apply e` to escape. Structure with `·`. Use it to rewrite one specific occurrence or under `fun`; usually `simp`/`funext` suffice. *** ## 12. Axioms and Computation CIC core (universes, Pi, inductives) + three additions: `axiom propext : (a ↔ b) → a = b`; quotients (`Quot`, `Quot.mk`, `Quot.ind`, `Quot.lift` are primitive; `axiom Quot.sound : r a b → Quot.mk r a = Quot.mk r b`) which imply `funext`; `axiom Classical.choice : Nonempty α → α`. `propext`/`Quot.sound` block kernel reduction (`#reduce val` stuck, `#eval` fine) but not codegen; `choice` needs `noncomputable`. `Classical.choose h`, `choose_spec`, `indefiniteDescription`, `epsilon`; Diaconescu: `choice + propext + funext ⊢ em`; `Classical.em`, `propComplete : a = True ∨ a = False`, `Decidable p` for all `p` (classical). Setoid/`Quotient`: `Quotient.mk`, `.lift`, `.ind`, `.sound`, `.exact`, `lift₂`, `≈`; idiom: define on representatives, prove `respects`, lift. `#print axioms thm` lists `propext`, `Quot.sound`, `Classical.choice`, `sorryAx`. Set extensionality: `funext fun x => propext (h x)`. *** ## Quick gotcha list * `theorem` bodies are irreducible; use `def`/`abbrev` if you need unfolding; `def` returning a Prop triggers a linter. * `have` forgets the value, `let` keeps it. `let rec`/`where` need to be closed over local vars. * `rw` is syntactic (up to instances) — `n + 1` vs `Nat.succ n` may need `show`/`simp`. `rw` closes goals with `rfl` automatically; `simp` closes with `rfl`/`True`. * `intros` names are inaccessible; prefer explicit `intro`. * `cases e` on a non-variable expression won't touch hypotheses mentioning `e` — `revert` first or `generalize h : e = x`. * `constructor` on `∃` leaves the witness as a metavariable — fine if later `exact` fixes it, else `exists w`. * `simp` attribute is global once added; use `attribute [local simp]` or `simp only`. * WF-recursive defs don't compute by `rfl`; structural ones do. `#reduce` uses kernel (fast for `fib` via `brecOn`); `#eval` compiled. * Anonymous constructor works only for one-constructor types; `Or` needs `Or.inl`/`.inr` (or `left`/`right`). * `open Nat` inside `namespace Hidden` where `Hidden.Nat` exists → ambiguity linter. * `sorry`/`decreasing_by sorry` = axiom; check with `#print axioms`. # `CommitmentAnonymity.lean` — module design :::info[Mirrored page] Source: [`lean/docs/commitment-anonymity-design.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/commitment-anonymity-design.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Status: design doc for the future Lean module that closes the one instantiation-specific gap of the format-`0x02` announcement-privacy story — the [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) ROM bound. This is a *design*, not a proof: no `CommitmentAnonymity.lean` exists yet, and nothing here changes the sorry-free state of `PqStealth/`. The module is planned Lean future work; the doc exists so the module lands as a port of a settled design rather than a from-scratch exploration, and so reviewers of [commitment-announcement-privacy.md](/research/commitment-announcement-privacy) can check the §3 sketch against a proof plan. Companion note: `docs/research/commitment-announcement-privacy.md` (the instantiation + ROM sketch this module must discharge); `announcement-model.md` (the [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) model and the terms). ## 1. What the module must prove The generic chain (`KEMAnonymity.lean:236`) bounds unlinkability of `StealthScheme.ofKEMFull kem auxGen` by ``` Adv_unlink ≤ ssHiding(true) + auxKeyIndependence + KEM.anonAdvantage + ssHiding(false) ``` and everything except [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) (`KEMAnonymity.lean:228`) is already discharged generically or named. The commitment profile's [`auxGen`](/lean/proofs/constructiona#auxgen) is (§1 of the companion note): ``` PK := (spend_key, kem_ek) — both public Aux := Tag × Addr auxGen ss pk := ( SHA-256(ss)[0:1], CREATE2(keccak256(COMMIT ‖ pk.spend_key ‖ SHA-256(OPEN ‖ ss))) ) ``` The module's goal statement, in the shape of [`blindingAdvantageRO_le_queryBound`](/lean/proofs/blindingentropy#blindingadvantagero_le_querybound) (`BlindingEntropy.lean:107`): > There is a query-bounded ROM game whose advantage bounds > [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) from above, and that advantage is at most > `2 · qH · 2⁻²⁵⁶`. The exact target, mirroring how `ConstructionA` handles its own gap: a **standalone abstraction** first (like [`blindGameRO`](/lean/proofs/blindingrom#blindgamero), `BlindingROM.lean:103`), with the identification of the abstraction's advantage with the concrete [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) term left to the instantiation layer — the module proves the ROM half sorry-free and states the bridge explicitly. Design principle carried over from `BlindingROM.lean:20-22`: the ROM game is "NOT identified with it here" — the abstraction boundary is drawn on purpose, so the generic [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) layer never needs to know about oracles. ## 2. Why the commitment instance is *simpler* than Construction A (and what that means for the design) `ConstructionA`'s aux term needs three layers: [`blindingProblem`](/lean/proofs/constructiona#blindingproblem) / [`mlweAdvOfUnlinkAdv`](/lean/proofs/constructiona#mlweadvofunlinkadv) (seeded-MLWE hop), the [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal) XOF idealization, and the point-mass machinery (`BlindingEntropy.lean`), because the address point `pack rho (power2Round (u + t))` has *low min-entropy* per coordinate — the fiber bound [`card_power2Round_fiber_le`](/lean/proofs/blindingentropy#card_power2round_fiber_le) (`BlindingEntropy.lean:162`) gives point mass `(2^d/q)^(k·256)` per address-point guess, and the whole [`BlindPointMassBound`](/lean/proofs/blindingentropy#blindpointmassbound) hypothesis exists to price that. The commitment address point is different in kind: the secret input is a **uniform 256-bit string** (`ss'` from [`randAuxBranch`](/lean/proofs/kemanonymity#randauxbranch), `KEMAnonymity.lean:203`) and the recipient's input (`spend_key`) is a **public constant in the preimage**. Consequences for the design: * **No MLWE hop.** The module imports nothing from `SPRTwoHop` or `ConstructionA`. No [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal). * **No fiber combinatorics.** The point-mass bound is the trivial one: a uniform 256-bit string hits any fixed point with probability `2⁻²⁵⁶`. The analog of [`BlindPointMassBound`](/lean/proofs/blindingentropy#blindpointmassbound) is one line of `probEvent_uniformSample`, not a coordinate-wise `Finset.card` argument. * **The hash composite is the oracle.** What must be random is the composite `spend_key, ss' ↦ address` — exactly the "composite as ONE function" reading `BlindingROM.lean` adopts for `hashAddr ∘ pack`. One oracle, not a chain of two. The design risk is therefore *not* proof difficulty but **faithfulness of the abstraction**: the ROM game must be obviously the game the commitment profile plays. That is why the interface is parameterized on byte-level objects (below) rather than reusing Construction A's [`Prims`](/lean/proofs/constructiona#prims). ## 3. Interface: what the module is stated over Following `BlindingROM.lean`'s parameterization style (`Prims R Rho Bytes T1 Tag Addr K k l`) but replacing the lattice-flavored parameters with byte-level ones: ```lean namespace PqStealth.CommitmentAnonymity variable {Bytes SS Tag Addr SpendKey : Type} variable [DecidableEq Bytes] [SampleableType SS] [SampleableType Addr] variable (openOracle : Bytes →ₒ SS) -- SHA-256(OPEN ‖ ·), idealized variable (auxOracle : Bytes →ₒ Addr) -- composite commit+CREATE2, idealized variable (tagOracle : SS →ₒ Tag) -- SHA-256(·)[0:1], idealized variable (preimage : SpendKey → SS → Bytes) -- COMMIT ‖ spend_key ‖ opener ``` Design decisions, with reasons: 1. **Three accessible hash interfaces and one joint budget — plus an explicit bridge obligation (review F3/F4).** The tag hash, opener hash, and commitment/CREATE2 composite are separate oracle types in the interface (`SS →ₒ Tag`, `Bytes →ₒ SS`, `Bytes →ₒ Addr`). The adversary-facing game must expose all three and charge them to one `qH` budget: the published tag lets an adversary filter candidate secrets before trying the opener/address path. With only an outer-oracle budget, a tag-matching candidate has conditional point mass `2⁻²⁴⁸`, so `qH·2⁻²⁵⁶` would not follow. This is the `addrSpec := unifSpec + (Bytes →ₒ Addr)` pattern (`BlindingROM.lean:38`) extended with the two domain-separated SHA-256 interfaces. **What this interface does not yet model:** the adversary's queries to the *real* intermediate hashes — the concrete commitment hash and the concrete keccak256 initcode hash — on adversarially chosen inputs. The composite abstraction hides that access; a faithful instantiation argument must either simulate those queries in the reduction or prove the composite inherits per-hash ROM uniformity under composed queries. That bridge is **open work**, recorded here and in the companion note §3; theorems proved over this interface are statements about the abstract game, not yet about the deployed commitment/CREATE2 chain. 2. **`SpendKey` is a public constant, not a secret.** The interface does not model `spend_key` generation — it is a parameter handed to the game, exactly like `rho : Bool → Rho` in [`blindGameRO`](/lean/proofs/blindingrom#blindgamero) (`BlindingROM.lean:103`). The recipient *pair* appears as `spend_key : Bool → SpendKey`, and the module says nothing about how the pair was drawn (the single-profile condition lives at the [`unlinkSetup`](/lean/proofs/games#unlinksetup) level; see §6). 3. **`ss'` uniform by construction.** The game draws `ss' ← ($ᵗ SS)` itself — this is the *post-[`randAuxBranch`](/lean/proofs/kemanonymity#randauxbranch)* game, matching `BlindingROM`'s "with the mask already idealized" stance (`BlindingROM.lean:81-84`). The IND-CPA hop that gets here is already performed and priced by the generic chain ([`sharedSecretHiding`](/lean/proofs/kemanonymity#sharedsecrethiding), `SharedSecretHiding.lean:187`); the module must not (and does not) redo it. No circularity, per the companion note §3 assumption 2. 4. **The deployed tag is public and query-accounted (review F4).** The view tag is a deterministic function of `ss'` alone — the [`taggedAux`](/lean/proofs/soundness#taggedaux) shape (`Soundness.lean:305`) — but the announced byte is immediately visible to the adversary. **Branch-independence alone is not the harmless property.** A tag function that leaked the secret (e.g. `tagOf ss = ss`) would let the adversary compute both candidate destinations through the public hashes. For the deployed tag, the game uses the accessible domain-separated `tagOracle`; its eight-bit candidate filtering is charged to the joint budget from decision 1. A future soundness instantiation also needs the separate assumption that this truncated oracle is uniform on uniform secrets. Any concrete profile must analyze its actual tag jointly with the opener and address derivation. ## 4. The game, and the four planned theorems The game mirrors [`blindGameRO`](/lean/proofs/blindingrom#blindgamero) (`BlindingROM.lean:103`) branch-for-branch: ```lean /-- Branch b: draw uniform ss', query the tag and opener oracles, build the address via the composite, then hand (tag, addr) to the adversary. -/ noncomputable def commitGameRO (spendKey : Bool → SpendKey) (b : Bool) (adv : Tag × Addr → ROMComp Bool) : ProbComp Bool := do let ss' ← ($ᵗ SS) let tag ← tagOracle ss' -- public tag-oracle query let opener ← hashOpenRO ss' -- inner-oracle query let addr ← auxRO (preimage (spendKey b) opener) -- composite query adv (tag, addr) ``` Planned theorem ladder (names provisional, statement shapes final): 1. **`commitGameRO_eq`** — the [`blindGameRO_eq`](/lean/proofs/blindingrom#blindgamero_eq) (`BlindingROM.lean:119`) analog: after the challenger's queries, both branches are the SAME computation run from caches that differ at one point — the outer-oracle entry at `(spend_key_b, opener)` for the branch's own uniform draw. Proof: [`run_hashAddrRO_empty`](/lean/proofs/blindingrom#run_hashaddrro_empty) (`BlindingROM.lean:67`) applied twice (inner then outer); the branch enters only through the public `spend_key` prefix of the preimage. 2. **`commitAdvantageRO_eq_zero_of_no_query`** — the degenerate identical-until-bad case (`BlindingROM.lean:134` analog): an adversary that never queries the oracles sees a uniform address on both branches; advantage `0`. This is the module's **positive control**, the role [`auxKeyIndependence_eq_zero_of_pk_independent`](/lean/proofs/constructiona#auxkeyindependence_eq_zero_of_pk_independent) plays in `announcement-model.md` §"what the negative control pins down" — a sanity check that the game measures the right thing. 3. **`commitAdvantageRO_le_commitBadProb`** — identical-until-bad against VCVio's programming-oracle engine, via `ROMUpToBad`'s [`tvDist_run'_roImpl_cacheOr_le_probEvent_bad`](/lean/proofs/romuptobad#tvdist_run_roimpl_cacheor_le_probevent_bad) (`ROMUpToBad.lean:298`): branch distance ≤ probability the adversary queries the branch's address point. Direct reuse of [`blindingAdvantageRO_le_blindBadProb`](/lean/proofs/blindingrom#blindingadvantagero_le_blindbadprob)'s proof shape. 4. **`commitBadProb_le_queryBound`** — the union bound closing the gap under a joint tag/opener/composite query budget: `Pr[bad] ≤ qH · 2⁻²⁵⁶` per branch, then **`commitAdvantageRO_le_queryBound : commitAdvantageRO ≤ 2 · qH · 2⁻²⁵⁶`**. The `BlindingEntropy.lean:94-121` proof replays with two simplifications: the point-mass hypothesis [`BlindPointMassBound`](/lean/proofs/blindingentropy#blindpointmassbound) is replaced by the trivial uniform-draw bound (`probEvent_uniformSample`, one line — the fiber combinatorics of [`card_power2Round_fiber_le`](/lean/proofs/blindingentropy#card_power2round_fiber_le) vanish because the secret is a uniform string, not a rounded lattice mask), and no `β ≠ ⊤` side condition is needed beyond the same `toReal` hygiene. ## 5. What the module deliberately does NOT do Mirroring the companion note §5, so the axiom audit stays honest and no claim creeps past what is proved: * **No identification with [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) inside the module.** Like `BlindingROM.lean`'s "Assumed / NOT closed" header note, the bridge from `commitGameRO` to the concrete [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) (`KEMAnonymity.lean:228`) of `ofKEMFull commitKem commitAuxGen` is stated as an explicit TODO comment + docstring, not proved. The bridge needs the instantiation layer (concrete `K := Fin 256 → Bool`-shaped shared secret, `SpendKey := Bytes`, the composite-as-one-function reading of keccak256 ∘ CREATE2) which belongs with the ERC profile layer, not the generic ROM module. * **No ROM model of SHA-256 or keccak256 themselves** — the oracles are VCVio lazy random oracles ([`roImpl`](/lean/proofs/romuptobad#roimpl), `ROMUpToBad.lean:49`), the same idealization `BlindingROM` uses. D-015's discipline (concrete primitives un-modelled) is unchanged. * **No MLWE, no [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal), no imports from `ConstructionA` / `SPRTwoHop`** — the module is dependency-light: `ROMUpToBad`, `Reorder` (if the sample-swap step needs it, as `BlindingEntropy.lean:72-77` does), and core `VCVio.OracleComp`. This keeps the build cone small and the axiom audit surface minimal. * **No statement about profile narrowing or multi-recipient composition** — the composites (`MultiUnlink.lean:281`, `MultiRecipient.lean:358`) apply verbatim once the single-payment bound is an [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) bound, and the narrowing channel is auxiliary information outside [`auxGen`](/lean/proofs/constructiona#auxgen) (companion note §5). ## 6. Hypothesis hygiene (audit honesty) Per the repo's testing discipline: a new theorem with a hypothesis bundle needs an inhabitance witness so the audit stays honest. The module's hypotheses are byte-level parameters, and the witnesses are trivial to state: * `IsQueryBoundP` (the adversary's joint tag/opener/composite-oracle budget, extending the `BlindingEntropy.lean:40` shape): witnessed by the constant-`false` adversary, which makes no queries. * The `SampleableType`/`DecidableEq` instances: witnessed by `Bytes := Fin 32 → Bool`-style finite types (or VCVio's byte-vector types) in the module's docstring example, keeping the statements computational where `#eval`-able. * The single-profile/common-binding condition: NOT a hypothesis of any theorem in this module — it lives at the [`unlinkSetup`](/lean/proofs/games#unlinksetup) layer (both recipients drawn from one keygen, one profile), stated in the companion note §3 assumption 3 and left to the ERC text. Keeping it out of the module is what keeps the module generic over `SpendKey`. ## 7. Build and verification plan (when a toolchain is available) 1. `lake build PqStealth.CommitmentAnonymity` first — the module imports only `ROMUpToBad` (+ possibly `Reorder`), so the incremental cone is small; no VCVio cold-build risk beyond what `BlindingROM` already requires. 2. Zero-warnings/sorries policy: the module lands either fully proved or with NO `sorry` and the bridge explicitly out of scope (§5) — the `Axioms.lean` `#guard_msgs #print axioms` audit must show no new axioms. If a step fails, the fallback is to ship theorems 1-2 (the abstraction + positive control, which need only [`run_hashAddrRO_empty`](/lean/proofs/blindingrom#run_hashaddrro_empty)-style lemmas) and mark 3-4 as the open tail — better a smaller proved surface than a sorry. 3. Update `lean/README.md`'s module table and regenerate `lean/docs-proofs/` (`scripts/gen_browser.py`) in the same change set; the citation checker (`scripts/check_citations.py`) must stay green — every `X.lean:N` in this doc resolves on `main` at `7d5e15e`. 4. `check_sizes.py` untouched — the module proves no byte sizes. 5. The companion note's §5 "future work" paragraph is the sentence to strike when this lands; the SECURITY\_ANALYSIS.md row's "Remaining boundary" column (this branch's edit) is the second strike-through. ## 8. Estimated shape \~250-350 lines of Lean, roughly: interface + game ~80, theorem 1 ~40 (a `bind_congr` chain like `BlindingROM.lean:127-129`), theorem 2 ~25, theorems 3-4 ~120 (the `BlindingEntropy.lean:42-103` skeleton with the point-mass step replaced by `probEvent_uniformSample`), plus the docstring header carrying the "NOT identified with [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) here" boundary note. The dominant risk is VCVio `OracleComp` plumbing, not mathematics; the identical-until-bad engine is fully generic (`ROMUpToBad` states everything over an arbitrary `hashSpec`), and the commitment instance is strictly simpler than the one `BlindingEntropy` already discharged. # What etheorem teaches us (and what it doesn't) :::info[Mirrored page] Source: [`lean/docs/etheorem-lessons.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/etheorem-lessons.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Survey of [`github.com/etheorem/etheorem`](https://github.com/etheorem/etheorem) at commit `39be608f` (2026-08-21), read as "a mature Lean 4 Ethereum codebase — what should we borrow?". Paths below are into that repository unless prefixed with `PqStealth/` or `docs/`. ## 1. What it is A **consensus-layer** implementation: SSZ (`packages/SizzLean`), the beacon state transition and fork choice for the Fulu / Gloas (ePBS) / Heze (FOCIL) forks (`packages/EthCLSpecs`, `packages/EthCLLib`), plus crypto packages (`LeanSha256`, `LeanPoseidon`, FFI wrappers `LeanHazmat{Sha256,Bls,Kzg}` over OpenSSL, blst, c-kzg). Single developer, LGPL-3.0-only, Lean `v4.29.1` (we are on `v4.32.0`), Mathlib quarantined into the one proof package that needs it (`packages/LeanPoseidonProofs`), no VCVio. About 39 kLoC across 256 files; ~500 `native_decide` uses almost all in test modules; 0 `sorry` (banned by `just lint`) and exactly 5 `axiom`s, each with a docstring explaining the trust step. It is **not** an execution-layer implementation. There is no Keccak-256, no secp256k1 / ECDSA / `ecrecover`, no RLP, no ABI, no EVM, no Merkle-Patricia trie. So it cannot supply the primitives our Lean tree leaves uninterpreted — `Prims.hashAddr` (keccak), `viewTag` (SHA-256 truncation), `expandA`/`expandBlind` (SHAKE), the inner byte packers (`PqStealth/ConstructionA.lean`, `docs/encodings.md`). The value is in engineering discipline, not primitives. ## 2. Borrowed, landed in this tree | etheorem artefact | our version | status | |---|---|---| | `scripts/check_citations.py` — resolves every `File.lean:N-M` cited in docs against the declaration it names; `--fix` rewrites. Their motivation: 16 of 37 ledger rows were stale, worst off by 187 lines. | `lean/scripts/check_citations.py` (own code, same idea) | First run found 7 of 33 citations in `docs/improvements.md` stale, including one file that no longer exists (`MLKEMInstance.lean`) and a range past end-of-file. Fixed; 35/35 clean. | | `scripts/check_constant_tiers.py` + `constant_tiers.json` — pins spec constants against a vendored ground truth, because "a misfiled preset value silently shapes an SSZ cap → wrong root, green build". | `lean/scripts/check_sizes.py` | Compares the sizes Lean proves by `rfl` (`metaAddress_size_mldsa65_mlkem768 = 5633`, `Bytes 1184`, `metaAddressCommit_size_mlkem768 = 1217`) against `python/vectors/**/vectors.json`. First Lean ↔ implementation link in the repo; the `1217` vs `1218` layout difference is recorded as expected, not drift. | Run both with `python3 lean/scripts/check_citations.py && python3 lean/scripts/check_sizes.py` (stdlib Python only, no build needed). ## 3. Borrowable, not yet taken ### 3a. `LeanSha256` — an executable `viewTag` `packages/LeanSha256/LeanSha256/Core.lean` is a pure-Lean, Mathlib-free, kernel-reducible FIPS 180-4 SHA-256 (`hash : ByteArray → ByteArray`), validated against NIST CAVP vectors with `native_decide` and carrying ~23 structural theorems (`ch_eq_fips`, `pad_size_multiple_of_64`, `hash_size_eq_32`, …). It is mirrored to a standalone repo (`github.com/etheorem/LeanSha256`) for Reservoir. **Probe (2026-08-21):** copied the package, changed only `lean-toolchain` to `v4.32.0`, `lake build LeanSha256` succeeded unmodified in ~2 s, and `hash "abc"` starts `ba 78 16 bf` (the FIPS KAT). So it is a cheap `[[require]]` if we ever want to * instantiate `viewTag` concretely instead of as a [`Prims`](/lean/proofs/constructiona#prims) field, and * check the `view_tag` values in `python/vectors/v0/vectors.json` from Lean (`#guard_msgs`-wrapped `#eval`, the `Demo.lean` pattern). Cost: a second git dependency beside VCVio (pin it like `docs/vcvio-pin.md`), and an LGPL-3.0 licence entry. Using it changes nothing in the proofs: the view-tag soundness theorems (`Soundness.lean`) take the tag function as a parameter and would simply be specialised. Tracked as issue #22 in `docs/improvements.md`. ### 3b. The `UIntWide` proof technique for our inner packers `packages/SizzLean/SizzLean/Proofs/UIntWide.lean` proves the `BitVec 128/256 ↔` little-endian `ByteArray` codec roundtrip and size bounds with **only the three kernel axioms** (no `bv_decide`, no `native_decide`) by `Nat` induction on the digit expansion: 1. `size_natToLEBytes` — the writer appends exactly `w` bytes; 2. `get!_natToLEBytes` — byte `i` is `⌊n / 256ⁱ⌋ mod 256`; 3. `readNatLEAux_value` — the Horner reader over any buffer with those digits rebuilds `n mod 256^w` (one induction with the accumulator generalised, digit step is `Nat.mod_mul`); 4. compose. `docs/encodings.md` leaves `packT : Vector (Fin 8380417) (k·256) → Bytes (k·736)` and `packEk` as *parameters with roundtrip hypotheses*. Our packer is 23-bit fields, not byte-aligned, so the file is not a drop-in, but the lemma path (size → digit characterisation → reader-value induction → compose) is exactly the shape of the missing proof. Rewrite from the idea rather than copying the LGPL file. ### 3c. The explicit FFI-equals-spec axiom `packages/SizzLean/SizzLean/Hasher/Sha256Equiv.lean`: ```lean axiom sha256Hash_eq_spec : @LeanHazmat.Sha256.sha256Hash = LeanSha256.hash ``` One fast `@[extern]` implementation, one slow pure-Lean spec, one named axiom identifying them, and a docstring saying a `@[csimp]` theorem could replace it later without disturbing dependents; `#print axioms` keeps it auditable. This is the pattern to use if we ever want an executable ML-KEM or Keccak in Lean via `liboqs`/XKCP without proving the C: it fits our `Axioms.lean` guard directly (the axiom's name would appear in the frozen list, visibly). ### 3d. Proof-coverage ratchet `packages/EthCLLib/EthCLLib/Internal/ProofLedger.lean` defines an attribute `@[characterizes f]` (registered with `applicationTime := .afterTypeChecking`, so the handler rejects a tag whose theorem statement never mentions `f`), and `scripts/ProofCoverage.lean` reads the compiled `.olean` environment — not source — to grade coverage and `--check` fails CI if the number moves in *either* direction. Our `Axioms.lean` ratchets the *axiom set* of 95 theorems; it does not record *which ERC claim each theorem discharges*. A lighter analogue for us: a claim ledger (ERC section → theorem name) validated by a script that checks each name resolves. Worth doing once the residual terms in `docs/improvements.md` settle. ### 3e. Hygiene we could copy cheaply * `just lint` bans `sorry`, bare `#eval`, `#check`, `#print` from committed source by `git grep`. We keep `#guard_msgs`-wrapped `#eval`/`#print axioms` on purpose, so our rule would be "no `sorry`, no unwrapped `#eval`". * Every module opens with `/-! # Module: purpose -/` and a *"Lean idioms used here (annotated on first appearance)"* section (`dif_pos`, `ByteArray.get!`, …). Our readers are cryptographers first; this is a good habit for the files that use VCVio-specific idioms. * `set_option autoImplicit false` in every file (we do it once in `lakefile.toml`, which is the same thing); `maxHeartbeats` bumps are local and commented. * CI calls `just ` for every step so local and CI cannot drift. * A `CLAUDE.md` agent brief (26 KB) with an explicit *Don'ts* list and a writing-style section. We have none under `lean/`; ours lives in the `vcvio` skill and memory. Note their `ai_coauthor_guard.yml` rejects commits with an AI `Co-authored-by:` trailer — the opposite of our convention; listed for completeness, not adoption. ## 4. Confirmed not useful to us BLS12-381 / KZG FFI (`blst`, `c-kzg-4844`), Poseidon2, SSZ, beacon state transition, fork choice, the pyspec conformance harness (`packages/EthCLSpecs/PySpecTests/`, pytest-xdist against a long-lived Lean server — a nice pattern, but our conformance surface is seven JSON cases), and anything game-based: there is no probabilistic reasoning in the repository. ## 5. Modelling choices worth noting for comparison * `UInt256` is `BitVec 256` (`SizzLean/Spec/Interp.lean`); buffers are `ByteArray`; fixed-width roots/keys are `Vector UInt8 32/48`. We use `Vector UInt8 n` (`Bytes n`) for the wire layout and `ByteArray` only where VCVio's concrete ML-KEM forces it — the same split. * Their central SSZ theorems are stated over a `BasicSupported` subset strictly smaller than `Supported`, with the restriction proved and documented rather than hidden. Same spirit as our "what is NOT proved" sections. * Spec arithmetic stays in `UInt64` to match upstream overflow semantics, with `Nat` only as a documented escape hatch. # PqStealth (Lean) — proposed improvements, drafted as GitHub issues :::info[Mirrored page] Source: [`lean/docs/improvements.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/improvements.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Historical proof and tooling backlog. Priorities here predate the key-exchange focus and are not the current ERC critical path. See [ERC evidence](/spec/erc-evidence) and [tooling](/lean/tooling). Prepared 2026-08-17 after (a) reading *Functional Programming in Lean* and *Theorem Proving in Lean 4* (digest in `lean-study-notes.md`) and (b) a full read of the 13 modules in `lean/PqStealth/` against the pinned VCVio (`a5f474fd`). Build state at time of writing: `lake build` green, 2768 jobs, sorry-free. Each entry is written so it can be pasted into a GitHub issue as-is (title → issue title, body → issue body). Suggested labels: `lean`, plus one of `model-gap` (the formal model says less than the prose claims), `proof` (new theorem work), `infra` (build/CI/tooling), `cleanup` (idiom/hygiene, no new theorems). Priority is my estimate of value ÷ effort. Line references are to the current tree; VCVio references are relative to `lean/.lake/packages/VCVio/`. ## Status (2026-08-18, branches `lean-improvements`, `lean-simplify`, `lean-round2`) Landed, all sorry-free and axiom-guarded in `PqStealth/Axioms.lean` (67 theorems), full `lake build` green (2779 jobs, no warnings). Names below are the post-refactor ones. Simplification refactor landed on `lean-simplify`: 13 modules (12 content modules plus the axiom audit) and 2,579 lines, down from 17 modules and 3,685 lines — 1,334 code / 827 prose / 418 blank, against 1,590 / 1,638 / 457 before. [`KEM`](/lean/proofs/kemanonymity#kem) is now VCVio's `KEMScheme ProbComp` outright, the unlinkability adversary is a function, every hidden-bit game is indexed by the bit instead of duplicated per branch, the module essays moved to `docs/*.md`, and no `omit … in` remains. No theorem was weakened; the generic-`(ring, encoding, prims)` ML-KEM restatements were dropped as subsumed by the ML-KEM-768 capstones, which have strictly fewer hypotheses, and the missing [`mlkem768_unlinkAdvantage_le_indCpa`](/lean/proofs/mlkem#mlkem768_unlinkadvantage_le_indcpa) was added. | # | Outcome | |---|---| | 1 | Done — [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) scan recomputes the aux data (`MetaPriv = SK × PK`); correctness is VCVio's `KEMScheme.PerfectlyCorrect`; [`perfectlyComplete_ofKEMFull`](/lean/proofs/kemanonymity#perfectlycomplete_ofkemfull). | | 2 | Done — `MLKEM.lean`: `DecidableEq` instances, `mlkem768_unlinkAdvantage_le{,_full_decomposition,_le_indCpa}` with no instance hypotheses on ML-KEM types. Sharper than the issue text: `SampleableType (Ciphertext mlkem768 …)` is provably **uninhabited** (`ByteArray` is infinite), so the generic ML-KEM capstone now takes an explicit `sim`. Upstream asks for VCVio recorded in the module docstring. | | 3 | Done (model + sanity lemma + reduction adversary) — `ConstructionA.lean`; [`auxKeyIndependence_eq_zero_of_pk_independent`](/lean/proofs/constructiona#auxkeyindependence_eq_zero_of_pk_independent), [`stealthAddr_eq_blinded_pk`](/lean/proofs/constructiona#stealthaddr_eq_blinded_pk), seeded-MLWE [`mlweAdvOfUnlinkAdv`](/lean/proofs/constructiona#mlweadvofunlinkadv), [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t). **Correction to the issue text:** the blinding term is not bounded by MLWE alone — `rho` sits outside the mask, so closing it needs the address hash as a random oracle (documented; follow-up below). | | 3-follow-up | Partial — `BlindingROM.lean`: `hashAddr ∘ pack` as VCVio's lazily sampled `randomOracle` over `unifSpec + (Bytes →ₒ Addr)` ([`romImpl`](/lean/proofs/blindingrom#romimpl), mirroring VCVio's `prfIdealQueryImpl`), the ROM blinding game ([`blindTraceRO`](/lean/proofs/blindingrom#blindtracero)/[`blindGameRO`](/lean/proofs/blindingrom#blindgamero)/[`blindingAdvantageRO`](/lean/proofs/blindingrom#blindingadvantagero)), and three theorems: [`run_hashAddrRO_empty`](/lean/proofs/blindingrom#run_hashaddrro_empty) (from the empty cache the announced address is uniform **whatever** was hashed — the step the mask cannot supply, and the one that kills the `rho` dependence [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t) had to share), [`blindGameRO_eq`](/lean/proofs/blindingrom#blindgamero_eq) (the branches are the same computation from caches differing at one point) and [`blindingAdvantageRO_eq_zero_of_no_query`](/lean/proofs/blindingrom#blindingadvantagero_eq_zero_of_no_query). **Round 4: (b) is proved ([`blindingAdvantageRO_le_blindBadProb`](/lean/proofs/blindingrom#blindingadvantagero_le_blindbadprob), via `ROMUpToBad`). Originally not proved, THREE items:** (a) [`blindGameRO`](/lean/proofs/blindingrom#blindgamero) is a standalone abstraction of the branch comparison — the KEM, ciphertext and tag derivation are dropped and `tg`/`rho`/`t` are free parameters — and is NOT identified with the two games [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) compares; that identification is not even stated in Lean, and needs the bind commutation plus [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal) that `ConstructionA`'s gap list already names. (b) the identical-until-bad step (`BoundedByBadQuery`) and (c) `Pr[bad] ≤ mlwe + q_H·β` (`BadQueryBounded`), both stated as `Prop` targets with the reduction [`mlweAdvOfBlindAdvRO`](/lean/proofs/blindingrom#mlweadvofblindadvro) type-checked. VCVio's `StateSeparating/IdenticalUntilBad.lean` wants `σ × Bool`-shaped handlers with an explicit bad flag, which [`romImpl`](/lean/proofs/blindingrom#romimpl) is not; and the `Pr[bad]` bound needs a query bound plus min-entropy of `pack ∘ power2Round`, which is uninterpreted here. | | 4 | Done (two recipients, `q` challenges) — `MultiUnlink.lean`: [`UnlinkExpMulti`](/lean/proofs/multiunlink#unlinkexpmulti), [`unlinkAdvantageMulti_le_sum`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_sum) (`≤ ∑ k ∈ range q, unlinkAdvantage (hybridAdv k (q-k-1))`) and [`unlinkAdvantageMulti_le_mul`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_mul) (`≤ q * ε`), over the general [`boolDistAdvantage_le_sum_hybrids`](/lean/proofs/multiunlink#probcompbooldistadvantage_le_sum_hybrids). **Loss factor `q`, linear** — the two recipients are fixed, so only the announcements are hybridised. | | 4-follow-up | Done — `MultiRecipient.lean`: the `n`-recipient game ([`UnlinkExpN`](/lean/proofs/multirecipient#unlinkexpn), the adversary NAMES the pair after seeing all `n` meta-addresses), [`pairGuessAdv`](/lean/proofs/multirecipient#pairguessadv), [`unlinkAdvantageN_le_sum`](/lean/proofs/multirecipient#unlinkadvantagen_le_sum) and [`unlinkAdvantageN_le_mul`](/lean/proofs/multirecipient#unlinkadvantagen_le_mul) (`≤ n·(n−1)·ε`). **Correction to the follow-up text:** it is NOT a conditioning argument — gating each reduction on the named pair makes the gated games disjoint, so their probabilities add ([`sum_probOutput_unlinkBranchNAt`](/lean/proofs/multirecipient#sum_proboutput_unlinkbranchnat)) and `\|∑ xⱼ\| ≤ ∑ \|xⱼ\|` finishes it; no uniform draw over pairs anywhere. The one new fact is exchangeability of `n` i.i.d. key generations ([`evalDist_pubKeysN_embedPair`](/lean/proofs/multirecipient#evaldist_pubkeysn_embedpair), from a `Fin.cons` induction). One explicit hypothesis: the adversary names two distinct recipients (the round-3 text also listed `keygen`/`announce` never failing; for `ProbComp` that is VCVio's `probFailure_of_liftM_PMF`, so those hypotheses were vacuous and were removed in round 4). Composition with the `q`-challenge hybrid and with [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) stays on paper. | | 5 | Done — `Games.lean` [`falsePositiveRate`](/lean/proofs/games#falsepositiverate) / [`SoundWithin`](/lean/proofs/games#soundwithin); `Soundness.lean`. **Correction to the issue text:** DKSAP's false-positive rate is *exactly* `1 / |F|` for **every** hash `h` ([`dksap_falsePositiveRate_eq`](/lean/proofs/soundness#dksap_falsepositiverate_eq)) — it is neither `0` nor a hash-collision term; the leak is recipient 1's uniform spending scalar. For [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull), [`falsePositiveRate_ofKEMFull_le`](/lean/proofs/soundness#falsepositiverate_ofkemfull_le) gives `ε + decapsRoR` with `ε` a tag-collision bound and [`decapsRoR`](/lean/proofs/soundness#decapsror) a named real-or-random term (KEM IND-CPA again, unbounded here, exactly as [`sharedSecretHiding`](/lean/proofs/kemanonymity#sharedsecrethiding) is); [`soundWithin_ofKEMFull_oneByteTag`](/lean/proofs/soundness#soundwithin_ofkemfull_onebytetag) is the ERC's `1/256`. | | 6 | Done — [`sharedSecretHiding_eq_indCpaAdvantage`](/lean/proofs/sharedsecrethiding#sharedsecrethiding_eq_indcpaadvantage) (one theorem, `∀ b`), [`unlinkAdvantage_ofKEMFull_le_indCpa`](/lean/proofs/sharedsecrethiding#unlinkadvantage_ofkemfull_le_indcpa), [`mlkem768_unlinkAdvantage_le_indCpa`](/lean/proofs/mlkem#mlkem768_unlinkadvantage_le_indcpa). **Finding:** VCVio's `kpke_ind_cpa_security` / `kpke_delta_correct` / `ind_cca_security` are `sorry` at the pinned commit and concern K-PKE, so KEM-IND-CPA → MLWE stays paper-level; the missing lemma is stated in `MLKEM.lean` (elaborates as written). Stale prose claiming otherwise was scrubbed from the Lean files and README; `docs/DECISIONS.md` D-013 still says "→ MLWE \[Lean bridge + VCVio]" and should be corrected. | | 7 | Done in part (both lattice hops; two non-lattice terms named, not bounded) — `SPRTwoHop.lean`. **Correction to the issue text:** the result is NOT `SPR ≤ 2·MLWE`; it is the four-term `sprAdv ≤ primitiveIdealization + advantage keyHopProblem (keyHopAdv) + advantage ctHopProblem (ctHopAdv) + simulatorGap` ([`sprAdv_le_two_hop_decomposition`](/lean/proofs/sprtwohop#spradv_le_two_hop_decomposition), and [`mlkem768_sprAdv_le_two_hop_decomposition`](/lean/proofs/sprtwohop#mlkem768_spradv_le_two_hop_decomposition) at [`mlkem768KEM`](/lean/proofs/mlkem#mlkem768kem) / [`mlkem768UniformCiphertext`](/lean/proofs/mlkem#mlkem768uniformciphertext) with no instance hypotheses). Both `LearningWithErrors.Problem`s are **seeded** on `rho` (a matrix-sampled problem, as `kpke_ind_cpa_security` announces, is unusable by a reduction that must output a public key), and each hop's game gap is PROVED equal to its reduction adversary's `LearningWithErrors.advantage` — `game0/game1_{key,ct}HopProblem`, all `simp only [bind_assoc, pure_bind, …]` because the games draw challenge key → ciphertext → other key. New bridge [`LearningWithErrors.advantage_eq_boolDistAdvantage`](/lean/proofs/sprtwohop#learningwitherrorsadvantage_eq_booldistadvantage); new instances [`instNeZeroMlkemModulus`](/lean/proofs/sprtwohop#instnezeromlkemmodulus) / [`instSampleableTypeRq`](/lean/proofs/sprtwohop#instsampleabletyperq) / [`instSampleableTypeTq`](/lean/proofs/sprtwohop#instsampleabletypetq) (**fourth upstream ask**). Unbounded: [`primitiveIdealization`](/lean/proofs/sprtwohop#primitiveidealization) (`G`/`PRF_η`/`SampleNTT` ideal — ML-KEM derives `s, e` from one `sigma` and the encryption coins from `G(m ‖ H(ek))`, i.e. from the challenge key) and [`simulatorGap`](/lean/proofs/sprtwohop#simulatorgap), split by [`simulatorGap_le`](/lean/proofs/sprtwohop#simulatorgap_le) into [`encodingRegularity`](/lean/proofs/sprtwohop#encodingregularity) (lossy compress-then-encode of uniform ring elements vs uniform bytes; needs the `private` `byteEncode_size`) and [`keyRestoration`](/lean/proofs/sprtwohop#keyrestoration). Implicit rejection provably irrelevant: no decapsulation appears in any game. **Not done:** the acceptance clause "rewrite `AnonymityFromSPR.lean`'s remaining-lattice-step paragraph" — that file was owned by another work item; needs reassigning. Details in `docs/spr-two-hop.md` § "What is now in Lean". | | 8 | Done (a–c, d documented) — [`IsShortPair`](/lean/proofs/invariants#isshortpair), honest [`IsSigningKey`](/lean/proofs/invariants#issigningkey), [`blinded_is_signing_key`](/lean/proofs/invariants#blinded_is_signing_key); `[A | I | -t]` reshaping with [`spendForgeryAdvantage_eq_sis_advantage`](/lean/proofs/ownership#spendforgeryadvantage_eq_sis_advantage) scored by exactly `SIS.matrixProblem`'s predicate; [`spendForgeryAdvantageReal`](/lean/proofs/ownership#spendforgeryadvantagereal); `honest_witness_relation` removed. Uniform-challenge gap (HNF absorption + MLWE pseudorandomness of `t`) documented. | | 9 | Done (a) / partial (b) — new `DKSAPOracle.lean` over `unifSpec + (G →ₒ F)`. **(a)** [`dlogAttack`](/lean/proofs/dksaporacle#dlogattack) makes `IsTotalQueryBound … 2` and `¬ IsTotalQueryBound … 1` — **exactly two queries, and neither statement mentions the announcement list** (the HNDL retroactivity claim, now a theorem); [`dlogImpl`](/lean/proofs/dksaporacle#dlogimpl) simulates the oracle by a real [`dlog`](/lean/proofs/demo#dlog) with `hdlog : ∀ y, dlog y • g = y`, giving [`dlogAttack_key_recovery`](/lean/proofs/dksaporacle#dlogattack_key_recovery) (output list = the recipient's own spending scalars, exactly) and the `List.Forall` form [`dlogAttack_forall_key_recovery`](/lean/proofs/dksaporacle#dlogattack_forall_key_recovery); [`dksapAnnounce_mem_announce_support`](/lean/proofs/dksaporacle#dksapannounce_mem_announce_support) keeps the input shape honest. **(b)** ROM game over VCVio's lazy `randomOracle`: [`dksapROIdeal_boolDistAdvantage_eq_zero`](/lean/proofs/dksaporacle#dksaproideal_booldistadvantage_eq_zero) (**the idealized RO game is perfectly unlinkable, adversary holding the oracle**), [`dksap_unlinkAdvantageRO_le_hashedDHRO`](/lean/proofs/dksaporacle#dksap_unlinkadvantagero_le_hasheddhro), [`dksapRORun_eq`](/lean/proofs/dksaporacle#dksaprorun_eq) (real game = ideal game against a cache programmed at the DH point), the bad event [`dksapROBad`](/lean/proofs/dksaporacle#dksaprobad)/[`dksapROBadProb`](/lean/proofs/dksaporacle#dksaprobadprob), and the CDH reduction [`cdhOfUnlinkAdvRO`](/lean/proofs/dksaporacle#cdhofunlinkadvro) type-checked against `DiffieHellman.CDHAdversary`. **Update (round 4):** `hashedDHRO ≤ dksapROBadProb` IS proved ([`hashedDHRO_le_dksapROBadProb`](/lean/proofs/dksaporacle#hasheddhro_le_dksaprobadprob), via `ROMUpToBad` — VCVio's `tvDist_simulateQ_run_le_probEvent_output_bad` engine instantiated for `unifSpec + hashSpec`); **gap:** `Pr[bad] ≤ q_H · Adv_CDH` remains open, stated precisely in `docs/dksap-asymmetry.md`. | | 10 | Done (outer layout concrete; inner packers still parameters) — `Invariants.lean` §3: `Bytes n = Vector UInt8 n`, [`splitBytes`](/lean/proofs/invariants#splitbytes)/[`splitBytes_append`](/lean/proofs/invariants#splitbytes_append), [`metaAddressEncode`](/lean/proofs/invariants#metaaddressencode)/`Decode` at `Bytes (1 + (32 + (nt + nek)))`, `meta_address_roundtrips{,_5633}`, the commitment-layout `meta_address_commit_roundtrips{,_1217}` (D-024), and the length theorems `metaAddress_size_mldsa65_mlkem768 = 5633` / `metaAddressCommit_size_mlkem768 = 1217` (`ek` length from VCVio's `MLKEM.Params.publicKeyBytes`). **Correction to the issue text:** `pkEncode` cannot be used — it packs the rounded `t1`, and `EncodedPK`/`EncodedTHat` are abstract `Type`s whose concrete instances are `ByteArray`; `mlkem768EncodingLaws` covers only ciphertext/message. So `packT`/`packEk` stay parameters with roundtrip hypotheses; gap written up in `docs/encodings.md`. | | 11 | Done (untested by nature) — `.github/workflows/lean.yml`. | | 12 | Done — `PqStealth/Axioms.lean`, 95 `#guard_msgs (whitespace := lax) in #print axioms` blocks; wrong list ⇒ build error (verified). | | 13 | Done — `Controls.lean`: leaky scheme advantage `= 1 − Pr[key collision]` exactly, leaky KEM, dead KEM not complete, tag-ignoring scan complete-but-not-sound. | | 14 | Done except the doc-gen facet (deferred with #16's license headers) — `lakefile.toml` `[leanOptions]`: `autoImplicit = false`, `relaxedAutoImplicit = false`, `linter.missingDocs = true`. No binder needed fixing (the tree never relied on auto-bound implicits; checked with a probe declaration). Batteries `#lint in PqStealth` plus the non-default `docBlameThm`: **0 errors in 244 declarations, 16 linters**, after naming three anonymous `DecidableEq` instances in `MLKEM.lean` (`defsWithUnderscore`) and adding eleven missing docstrings. **Not adopted:** VCVio's `weak.linter.mathlibStandardSet` — its `style.commandStart` linter is structurally incompatible with the 3-line `#guard_msgs … in #print axioms` form (`docs/vcvio-pin.md`). | | 15 | Done — `Demo.lean` `#eval`s guarded; build is silent. | | 16 | Done except the license headers (deferred until the repo goes public). Every module has a `/-! … -/` docstring after the imports, in proved/assumed/docs-pointer form, with no change-log wording; the root carries the module map. | | 17 | Closed by the simplification refactor — `abbrev KEM (PK SK C K : Type) := KEMScheme ProbComp K PK SK C` (`KEMAnonymity.lean:30`); both bridges deleted. | | 18 | Done — no bare `simp` left in `PqStealth/`: [`dksap_perfectlyComplete`](/lean/proofs/dksap#dksap_perfectlycomplete)'s terminal `simp [...]` and the seven `simpa` sites in `Demo`/`Controls`/`Soundness` are `simp only` / `simpa only` with `simp?`-generated lists (16 names for the first, 1–3 for the rest). It is a smaller job than the issue text implies: the simplification refactor had already fixed the second example it cites ([`meta_address_roundtrips`](/lean/proofs/invariants#meta_address_roundtrips), `Invariants.lean`). **No `@[simp]` projection lemmas added:** unfolding [`ofKEM`](/lean/proofs/kemanonymity#stealthschemeofkem)/[`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) costs one name today and would cost three as projections, so the "shorter lists in ≥2 proofs" measure is not met. | | 19 | Closed by the simplification refactor — zero `omit [...] in` and zero `(F := F)` in the tree. | | 20 | Done — README rewritten around the module map and the decomposition block; [`KEM`](/lean/proofs/kemanonymity#kem) is an `abbrev` for VCVio's `KEMScheme ProbComp`; reading order updated. | | 21 | Done — `docs/vcvio-pin.md`: the pin table, the bump procedure (the axiom guard *is* the build), the two upstream files to diff, the three upstream asks (`DecidableEq` beside `mlkem768Encoding`, de-privatize `byteEncode_size`, a KEM-level [`kem_ind_cpa_security`](/lean/proofs/mlkem#mlkemkem_ind_cpa_security)), and what in this tree is bump-sensitive. | | 22 | Open — `docs/etheorem-lessons.md` landed with `scripts/check_citations.py` (35 citations clean after fixing 7 stale ones here) and `scripts/check_sizes.py` (6 rows, Lean `rfl` sizes vs `vectors.json`); the `LeanSha256` `viewTag` instantiation and CI wiring remain. | Open: #16 (license headers); #22 (`LeanSha256` view tag, CI wiring); the named residual terms — #7's [`primitiveIdealization`](/lean/proofs/sprtwohop#primitiveidealization) (ROM/PRF derandomisation of ML-KEM's coins) and [`simulatorGap`](/lean/proofs/sprtwohop#simulatorgap) (encoding regularity), #9(b)'s `q_H` guessing step (the lazy-RO switching lemma is now closed via `ROMUpToBad`), `BlindingROM`'s identification with [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) and its `Pr[bad]` bound (its identical-until-bad step is closed); the upstream VCVio asks (`DecidableEq` beside `mlkem768Encoding`, de-privatize `byteEncode_size`, `SampleableType` on `R_q`/`T_q`, a KEM-level IND-CPA lemma). *** ## P0 — model gaps (the formal statement is weaker than it reads) ### 1. Detection is vacuous on the concrete ML-KEM instance: `scan` always returns `true` **Labels:** `lean`, `model-gap`, `proof` · **Priority:** P0 **Context.** [`StealthScheme.ofKEM`](/lean/proofs/kemanonymity#stealthschemeofkem) and [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) define detection as "decapsulation returns `some`" (`PqStealth/KEMAnonymity.lean:109-111`, `:170-172`). VCVio's concrete ML-KEM has implicit rejection, so its `decaps` is `fun dk c => return some (decapsInternal …)` (`.lake/packages/VCVio/LatticeCrypto/MLKEM/KEM.lean:98`) — it *never* returns `none`. Hence `mlkemStealthScheme … |>.scan sk c = pure true` for every `sk`, `c`: every recipient "detects" every announcement. This is exactly the failure mode `Falsification.lean` warns about ("if the detection test were trivially true, the proof would still go through"), and it also means the view tag, which is what real scanning tests, is not modelled on the recipient side at all (`ofKEMFull.scan` ignores the `Aux` component). The unlinkability theorems are unaffected (they never run `scan`), but the README's "detection completeness" bullet for the KEM-based scheme is empty on ML-KEM, and no *soundness* statement (a non-recipient does not detect) can even be stated. **Proposal.** 1. Make `scan` recompute the auxiliary data and compare: `scan sk (c, aux) := do let some k ← kem.decaps sk c | pure false; pure (aux == auxGen k pk)`. This needs the recipient's own `pk` on the scan side — either carry it in the secret (`SK × PK`, mirroring the real meta-address wallet state) or add a `pkOf : SK → PK` field to [`KEM`](/lean/proofs/kemanonymity#kem). It also needs `[DecidableEq Aux]` (or `[BEq Aux]`). 2. Prove [`PerfectlyComplete`](/lean/proofs/games#perfectlycomplete) for [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) from a KEM correctness hypothesis (VCVio's `KEMScheme.PerfectlyCorrect`, `.lake/packages/VCVio/VCVio/CryptoFoundations/KeyEncapMech.lean:51`), and note the δ-correct variant (`.lake/packages/VCVio/LatticeCrypto/MLKEM/Security.lean:70 kpke_delta_correct`) for the concrete instance. 3. Add the negative control (see #13): a variant whose scan drops the tag comparison is *not* sound. 4. Re-check [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) and [`cipherOf`](/lean/proofs/kemanonymity#stealthschemeunlinkadvcipherof) still go through (they should — the announcement side is unchanged). **Acceptance.** `mlkemStealthScheme.scan` is not the constant `true`; `PerfectlyComplete (ofKEMFull kem auxGen)` proved under `kem` correctness; README updated. *** ### 2. Discharge the free instance hypotheses on the ML-KEM capstones **Labels:** `lean`, `model-gap`, `proof` · **Priority:** P0 **Context.** `mlkem_unlinkAdvantage_le` and `mlkem_unlinkAdvantage_le_full_decomposition` are stated under section variables `[SampleableType SharedSecret]`, `[SampleableType (Ciphertext params encoding)]`, and three `DecidableEq` instances on the encoding types (`PqStealth/MLKEM.lean:32-41`, `AnonymityFromSPR.lean:60`). Checked against the pinned VCVio with `lake env lean` on a scratch file (2026-08-17): * `SampleableType SharedSecret` — **resolves** (`Vector Byte 32` via `instSampleableTypeVector`, `UInt8` via a generic `FinEnum` instance). The variable is redundant and can be dropped. * `DecidableEq mlkem768Encoding.EncodedTHat/EncodedU/EncodedV` — **fails** on the concrete instance (`MLKEM.Concrete.mlkem768Encoding`, `.lake/packages/VCVio/LatticeCrypto/MLKEM/Concrete/Instance.lean:119`). The underlying types are `ByteArray`, but `concreteEncoding` is a plain `def`, so instance search cannot see through it — the FPIL `def` vs `abbrev` gotcha, on VCVio's side. * `SampleableType (Ciphertext mlkem768 mlkem768Encoding)` — **fails**; `Ciphertext` is a two-field structure with no instance, and its components have none either. Consequence: `mlkemStealthScheme concreteNTTRingOps mlkem768Encoding mlkem768Primitives` does not currently elaborate ("failed to synthesize DecidableEq mlkem768Encoding.EncodedTHat"), so the "instantiated on real ML-KEM" claim holds only for the abstract `(ring, encoding, prims)` triple, and the uniform-ciphertext simulator `$ᵗ (Ciphertext …)` is uniform only relative to an instance nobody has built. (VCVio's own `.lake/packages/VCVio/LatticeCrypto/MLKEM/Security.lean:118` carries the same hypotheses, so this is inherited, but for our headline theorems it should be closed.) **Proposal.** * Drop `[SampleableType SharedSecret]` from both variable blocks. * Provide the concrete `DecidableEq`s: either `instance : DecidableEq mlkem768Encoding.EncodedU := by unfold mlkem768Encoding concreteEncoding; infer_instance` in a small `PqStealth/MLKEM768.lean`, or upstream a `@[reducible]`/`abbrev` on `concreteEncoding` to VCVio (preferred; open the issue there too). * `instance [SampleableType encoding.EncodedU] [SampleableType encoding.EncodedV] : SampleableType (Ciphertext params encoding)` as the product sampler through `Ciphertext.mk`; then the concrete one via the fixed-length byte representation (this is the "uniform ciphertext bytes" simulator the SPR docstring talks about; if the encoded type is a raw `ByteArray`, sample a `Vector Byte n` and convert). * State one theorem `mlkem768_unlinkAdvantage_le …` at `concreteNTTRingOps`/`mlkem768Encoding`/`mlkem768Primitives` (`.lake/packages/VCVio/LatticeCrypto/MLKEM/Concrete/NTT.lean:324 concreteNTTRingOps`, `.lake/packages/VCVio/LatticeCrypto/MLKEM/Concrete/Instance.lean:119 mlkem768Encoding`, `.lake/packages/VCVio/LatticeCrypto/MLKEM/Concrete/Instance.lean:147 mlkem768Primitives`) with **no** instance hypotheses. **Acceptance.** A theorem about `mlkem768` whose only hypotheses are the adversary and [`auxGen`](/lean/proofs/constructiona#auxgen); `#print axioms` unchanged; the scratch check above becomes an `example` in the tree. *** ### 3. Connect the algebraic core to the game layer: instantiate [`auxGen`](/lean/proofs/constructiona#auxgen) with construction A **Labels:** `lean`, `model-gap`, `proof` · **Priority:** P0 **Context.** The two layers never meet. `Blinding.lean`/`Invariants.lean` prove facts about `A *ᵥ s' + e' + t`; the game layer takes an opaque `auxGen : K → PK → Aux`. Nothing in Lean says the stealth address in the announcement *is* `keccak(pack(rho, Power2Round(A·s'+e'+t)))` with `(s',e')` derived from the shared secret, so [`blinded_key_correctness`](/lean/proofs/blinding#blinded_key_correctness) is never used by any security statement, and the term the model was extended to express, [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) (`KEMAnonymity.lean:226-230`), is a named real number with no theorem about it. **Proposal.** 1. Define `constructionA.auxGen (ss : SharedSecret) (pk : MetaPub) : ViewTag × StealthAddr` over VCVio's `MLDSA` types (`Rq`, `Power2RoundOps`, `Encoding.pkEncode`), with the `(s', e')` derivation abstracted as a function `expand : SharedSecret → (Fin l → Rq) × (Fin k → Rq)` (the SHAKE expansion is not in scope to model). 2. Prove the sanity lemma `auxKeyIndependence kem auxGen adv = 0` whenever [`auxGen`](/lean/proofs/constructiona#auxgen) ignores its `PK` argument (constant in `pk`) — this pins the term's meaning and is a cheap positive control. 3. State the blinding hop as a reduction to VCVio's decision-MLWE (`.lake/packages/VCVio/LatticeCrypto/HardnessAssumptions/LearningWithErrors.lean:132 moduleMatrixProblem`, `.advantage`): an adversary distinguishing `randAuxBranchTrue` from the [`cipherOf`](/lean/proofs/kemanonymity#stealthschemeunlinkadvcipherof) game yields an MLWE distinguisher for `(A, A·s'+e')`. Even just the reduction *adversary* (type-checked, as was done for IND-CPA in `SharedSecretHiding.lean`) is progress; the advantage equality is the real target. 4. Use [`blinded_key_correctness`](/lean/proofs/blinding#blinded_key_correctness) in the completeness proof of the resulting concrete scheme (recipient recomputes the same stealth key) — that is where the identity finally earns its place in the chain. **Acceptance.** `PqStealth/ConstructionA.lean` (or similar) importing both `Blinding` and `KEMAnonymity`; at least (2) proved and (3) type-checked. *** ### 4. Multi-recipient / multi-announcement unlinkability (hybrid argument) **Labels:** `lean`, `model-gap`, `proof` · **Priority:** P1 **Context.** [`UnlinkExp`](/lean/proofs/games#unlinkexp) (`Games.lean:137-157`) has exactly two recipients and one challenge announcement. plan.md's deliverable is "unlinkability across payments": many announcements to many recipients, adversary sees all meta-addresses and all announcements. The standard hybrid argument gives `Adv_{n,q} ≤ n·q·Adv_{2,1}` (or a tighter `q`-fold), and the loss factor matters for the parameter write-up. **Proposal.** Define `UnlinkExpMulti n q` with the adversary choosing `(i₀, i₁)` per challenge (or the "left-or-right" many-challenge form), and prove the hybrid bound against [`unlinkAdvantage`](/lean/proofs/games#unlinkadvantage). VCVio has the `MultiTarget` hardness scaffolding (`VCVio/CryptoFoundations/HardnessAssumptions/MultiTarget.lean`) that may give the loop for free. **Acceptance.** A theorem [`unlinkAdvantageMulti_le_mul`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_mul) with an explicit loss factor; README/DECISIONS updated with the factor. *** ### 5. Detection soundness (false-positive statement) **Labels:** `lean`, `model-gap`, `proof` · **Priority:** P1 (blocked by #1) **Context.** Only completeness ([`PerfectlyComplete`](/lean/proofs/games#perfectlycomplete)) is defined. Nothing states that a *non*-recipient does not detect (up to the view-tag false-positive rate 2⁻⁸ per byte of tag). This is the property the scanning benchmarks and the ERC's tag-length rationale rely on. **Proposal.** `SoundnessExp`: keygen two recipients, announce to 1, scan with 0; prove `Pr[= true | …] ≤ ε` where `ε` comes from [`auxGen`](/lean/proofs/constructiona#auxgen)'s tag being uniform when the shared secret is (KEM IND-CPA again, plus a counting argument on `Fin 256`). **Acceptance.** [`dksap`](/lean/proofs/dksap#dksap) (exact `0` false positives modulo hash collisions of `h`) and [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) (tag-length bound) both instantiated. *** ## P1 — closing documented gaps in the proved chain ### 6. Prove the "documented glue": `sharedSecretHiding* = IND_CPA_Advantage indCpaAdv*` **Labels:** `lean`, `proof` · **Priority:** P1 **Context.** `SharedSecretHiding.lean:86-114` builds the VCVio `IND_CPA_Adversary`s and the docstring says the advantage equality is "documented glue" (sample reordering, `ProbCompRuntime.probComp` bridge). Until this equality is proved, the hop "ssHiding → MLWE via VCVio" is a paper step, not a Lean one, although VCVio's `kpke_ind_cpa_security` (`.lake/packages/VCVio/LatticeCrypto/MLKEM/Security.lean:99`) is sitting there waiting to be composed. **Proposal.** Prove `sharedSecretHidingTrue kem auxGen adv = IND_CPA_Advantage ProbCompRuntime.probComp (indCpaAdvTrue kem auxGen adv)` (and `False`). The proof is a bind-reordering of independent samples — `bind_comm`-style lemmas (`VCVio/EvalDist/Monad/Basic.lean`) plus `probOutput_bind_eq_tsum` and `tsum_comm`. Then compose with VCVio's KEM IND-CPA → K-PKE IND-CPA → MLWE chain to get an MLWE bound on the ssHiding terms for `mlkem`. **Acceptance.** Two equalities proved; a corollary bounding `sharedSecretHidingTrue (mlkem …)` by an MLWE advantage. *** ### 7. Formalize `SPR(K-PKE) ≤ 2·MLWE` (the two-hop) **Labels:** `lean`, `proof` · **Priority:** P1 **Context.** `AnonymityFromSPR.lean:15-25` documents the key hop (`t = A·s+e` → uniform) and the ciphertext hop (`(u,v)` is an MLWE sample over `[A | t]`) as paper work. Both land on `LearningWithErrors.advantage`. This is the last lattice step of the unlinkability chain and the one item that would make the reduction end-to-end in Lean at the CPA level. **Proposal.** Prove `sprAdvTrue (mlkem …) ($ᵗ Ciphertext) adv ≤ mlwe₁ + mlwe₂` against `KPKE.encrypt`'s definition in `LatticeCrypto/MLKEM/KPKE.lean`, reusing whatever `kpke_ind_cpa_security` already proves about the ciphertext distribution (its key hop is probably identical). Record the ANO-CCA / implicit-rejection caveat as a separate issue rather than blocking on it. **Acceptance.** Theorem in Lean; module docstring's "remaining lattice step" paragraph rewritten to point at it. *** ### 8. Spend side: honest MSIS statement and the SoK route **Labels:** `lean`, `proof`, `model-gap` · **Priority:** P1 **Context.** * [`IsSigningKey`](/lean/proofs/invariants#issigningkey) (`Invariants.lean:81-84`) is literally the same definition as [`IsOwnershipWitness`](/lean/proofs/invariants#isownershipwitness), so [`ownership_iff_signing`](/lean/proofs/invariants#ownership_iff_signing) is `Iff.rfl`. A signing key is a *short* `(s₁,s₂)`; the bridge should carry `cInfNorm sᵢ ≤ η` and use [`blinded_norm_bound`](/lean/proofs/invariants#blinded_norm_bound) for the `2η` version. * [`ownershipSISProblem`](/lean/proofs/ownership#ownershipsisproblem) (`Ownership.lean:46-51`) uses VCVio's *generic* `SIS.Problem` with an abstract `isShort : … → Bool`, so "spendForgeryAdvantage is bounded by MSIS hardness" is by naming, not by a theorem: nothing links it to `SIS.matrixProblem` (`.lake/packages/VCVio/LatticeCrypto/HardnessAssumptions/ShortIntegerSolution.lean:86`) at `Rq` with the actual norm bound. * `spendForgeryAdvantage : ℝ≥0∞` while every other advantage in the development is `ℝ` (`Ownership.lean:56-60`); mixing the two blocks composing bounds. **Proposal.** (a) Strengthen [`IsSigningKey`](/lean/proofs/invariants#issigningkey) with the norm bound and reprove the bridge ([`blinded_is_ownership_witness`](/lean/proofs/invariants#blinded_is_ownership_witness) + [`blinded_norm_bound`](/lean/proofs/invariants#blinded_norm_bound)). (b) Instantiate [`ownershipSISProblem`](/lean/proofs/ownership#ownershipsisproblem) at `R := Rq`, `isShort w := decide (cInfNorm-bound)` and prove it *equals* (or reduces to) `SIS.matrixProblem`/the module variant so the MSIS claim is a theorem. (c) Return `ℝ` (`.toReal`) or provide both. (d) Open a follow-up for the SoK unforgeability via `SelfTargetMSIS` + `VCVio/CryptoFoundations/SigmaProtocol.lean`/`FiatShamir` (the ZK-spend path from D-012). **Acceptance.** `spendForgeryAdvantage_le_msis` (now named [`spendForgeryAdvantage_le_augmentedSIS`](/lean/proofs/ownership#spendforgeryadvantage_le_augmentedsis), since the challenge is still the scheme's key distribution) stated against VCVio's concrete MSIS problem; advantage types uniform. *** ### 9. DKSAP: real discrete-log oracle and query counting; DDH+ROM positive proof **Labels:** `lean`, `proof` · **Priority:** P2 **Context.** `DKSAP.lean` takes the dlog answers as hypotheses (`hM : xM • g = m • g`) rather than as oracle queries, so the "2 queries per victim regardless of payment count" statement (the HNDL retroactivity argument) is not expressible; and `DKSAPClassical.lean` bounds unlinkability by two `hashedDH*` terms that are *defined*, not reduced to DDH with `h` a random oracle. **Proposal.** (a) Restate the attack in `OracleComp (unifSpec + (G →ₒ F))` with VCVio's query-tracking (`VCVio/OracleComp/QueryTracking/QueryCost.lean`) to prove the query bound. (b) Model `h` as a random oracle (VCVio has ROM machinery in `GPVHashAndSign.lean`/`PRF.lean`; check for a reusable `randomOracle` spec) and prove `hashedDHTrue ≤ DDH advantage` via `VCVio/CryptoFoundations/HardnessAssumptions/DiffieHellman.lean` — this is the most publishable single item ("DKSAP is unlinkable under DDH in the ROM", which nobody has machine-checked). **Acceptance.** Either half landing is a separate PR. **Round 4.** The standard-model half is done without the ROM: [`hashedDH_le_ddh_add_es`](/lean/proofs/dksap#hasheddh_le_ddh_add_es) bounds each [`hashedDH`](/lean/proofs/dksap#hasheddh) term by `DiffieHellman.ddhDistAdvantage` of [`ddhReductionDKSAP`](/lean/proofs/dksap#ddhreductiondksap) plus `EntropySmoothing.advantage` of [`esReductionDKSAP`](/lean/proofs/dksap#esreductiondksap) (VCVio's hashed-ElGamal shape), so [`dksap_unlinkAdvantage_le_ddh_add_es`](/lean/proofs/dksap#dksap_unlinkadvantage_le_ddh_add_es) is "DKSAP is unlinkable under DDH + entropy smoothing of `h`" with explicit reductions. *** ### 10. Byte-level encoding roundtrip for the meta-address **Labels:** `lean`, `model-gap` · **Priority:** P2 **Context.** [`metaAddressEncode`](/lean/proofs/invariants#metaaddressencode)/[`metaAddressDecode`](/lean/proofs/invariants#metaaddressdecode)/[`meta_address_roundtrips`](/lean/proofs/invariants#meta_address_roundtrips) (`Invariants.lean:168-186 metaAddressEncode`) is a three-field record with `version : Nat`; the roundtrip theorem is `simp` on a structural record and says nothing about the wire format `version ‖ rho ‖ pack23(t) ‖ ek` (TECHNICAL\_SPEC, 5,633 B) or about the reduced ZK-spend layout (`version ‖ commitment ‖ ek`, D-012). **Proposal.** Model the meta-address as `Bytes n` built with VCVio's `MLDSA.Encoding.pkEncode` and the ML-KEM `EncapsulationKey` encoder, and prove decode∘encode = id from their `Laws` (VCVio has `mlkem768EncodingLaws`, `.lake/packages/VCVio/LatticeCrypto/MLKEM/Concrete/Instance.lean:133`). Use `UInt8`/`Fin 256` for `version`. **Acceptance.** A roundtrip theorem whose statement mentions concrete byte lengths. *** ## P1 — infrastructure (the build should enforce what the README claims) ### 11. CI: build + axiom audit on every push **Labels:** `lean`, `infra` · **Priority:** P1 **Context.** There is no `.github/workflows` for the Lean package. The README's job counts drift (2715/2717/2762/2768 across README, DECISIONS, memory), "sorry-free" and "only three axioms" are checked by hand. **Proposal.** Workflow: `elan` toolchain from `lean-toolchain`, `lake exe cache get`, `lake build`, then build the axiom-guard module (#12). Cache `.lake/packages` and mathlib oleans keyed on `lake-manifest.json`. Remove job counts from prose (or generate them). **Acceptance.** Green badge; a `sorry` or a new axiom fails CI. *** ### 12. Build-checked axiom audit with `#guard_msgs in #print axioms` **Labels:** `lean`, `infra` · **Priority:** P1 **Context.** TPIL ch. 6/12: `#guard_msgs in cmd` asserts the exact messages a command emits, and `#print axioms` lists `propext`, `Classical.choice`, `Quot.sound`, `sorryAx`. Today the audit is a scratch file someone runs. **Proposal.** `PqStealth/Axioms.lean` (imported by the root) with one ```lean /-- info: 'PqStealth.unlinkAdvantage_ofKEMFull_le' depends on axioms: [propext, Classical.choice, Quot.sound] -/ #guard_msgs in #print axioms unlinkAdvantage_ofKEMFull_le ``` block per headline theorem (the ~17 listed in memory/DECISIONS). Verified 2026-08-17 with `lake env lean` on a scratch file: the block above passes silently on the current tree with exactly that message text. Any `sorry` anywhere upstream changes the message and breaks the build. Also consider `leanOptions = [{ name = "warningAsError", value = true }]` scoped to this package if it does not fire on dependency warnings. **Acceptance.** `lake build` fails when a `sorry` is introduced into any headline theorem's dependency cone. *** ### 13. Positive/negative controls for the game layer (extend `Falsification.lean`) **Labels:** `lean`, `proof`, `infra` · **Priority:** P1 **Context.** `Falsification.lean` covers one completeness claim (DKSAP). The unlinkability game has no control at all: nothing shows that a scheme which *leaks* the recipient is caught by [`unlinkAdvantage`](/lean/proofs/games#unlinkadvantage). **Proposal.** (a) [`leakyScheme`](/lean/proofs/controls#leakyscheme): announcement includes `pk`; the trivial adversary comparing to `pk1` has `unlinkAdvantage = 1/2` (or `1` in the bias normalisation used by `boolBiasAdvantage` — check `SecExp.lean`) — proved. (b) After #1: [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) variant whose scan ignores the tag is not sound; a KEM with `decaps := none` gives a scheme that is not complete. (c) A [`KEM`](/lean/proofs/kemanonymity#kem) whose ciphertext *is* the public key has anonymity advantage `1` (shows [`anonAdvantage`](/lean/proofs/kemanonymity#anonadvantage) has teeth). Same recipe as [`dksapBroken_not_perfectlyComplete`](/lean/proofs/controls#dksapbroken_not_perfectlycomplete): `probOutput_eq_one_iff_forall`, exhibit the witness. **Acceptance.** Three theorems, all in the build. *** ### 14. `lakefile.toml`: `autoImplicit = false`, linters, doc-gen **Labels:** `lean`, `infra`, `cleanup` · **Priority:** P2 **Context.** FPIL/TPIL both flag auto-bound implicits: a typo in a binder silently becomes a fresh universe-polymorphic variable. VCVio sets `autoImplicit = false` globally in its lakefile; our `lakefile.toml` sets no `leanOptions`, so the package compiles with auto-implicits on. Also no `#lint`, no doc build. **Proposal.** ```toml [leanOptions] autoImplicit = false relaxedAutoImplicit = false linter.missingDocs = true ``` Fix whatever fails (expect a few `{n}` binders). Add a `docs` facet with `doc-gen4` (`lake -R -Kenv=doc build PqStealth:docs`) once #16 lands so the module docstrings actually render. Run mathlib's `#lint` once and fix the reported items (unused arguments, `Iff.rfl`-style simp candidates, naming). **Acceptance.** Options in the lakefile; `lake build` green; a `docs/` target exists. *** ### 15. `Demo.lean` prints during every build **Labels:** `lean`, `cleanup` · **Priority:** P2 **Context.** `Demo.lean:98-103` has six bare `#eval`s, so every `lake build` ends with six `info:` lines. FPIL: `#eval` is for the interactive loop; checked expectations belong in `#guard_msgs in #eval` or `example … := by decide`. **Proposal.** Wrap each `#eval` in `/-- info: 22 -/ #guard_msgs in #eval M` (keeps them runnable *and* asserted), or move the walkthrough into a `lean_exe pqstealth-demo` with a `main : IO Unit` so `lake exe` runs it and `lake build` is silent. Keep the three `example … := by decide` — they are the actual test. **Acceptance.** `lake build` output ends at `Build completed successfully` with no `info:` lines. *** ## P2 — cleanup / idiom (from the books; no new theorems) ### 16. Module docstrings and file headers **Labels:** `lean`, `cleanup` · **Priority:** P2 **Context.** Every file opens with a plain `/- … -/` comment (`Blinding.lean:1-8`, etc.); TPIL/VCVio convention is a `/-! … -/` module docstring after the imports so `doc-gen4` renders it, plus copyright/authors header if the repo goes public with the ERC. Some docstrings still describe history ("added 2026-07-31", "the earlier model …") — VCVio's guide asks for intrinsic descriptions. **Proposal.** Convert headers to `/-! -/`, move them below the imports, add license header, strip change-log wording (it lives in DECISIONS.md). Root `PqStealth.lean` gets a docstring listing the module map (and loses the stray blank line at line 5). *** ### 17. Deduplicate [`KEM`](/lean/proofs/kemanonymity#kem) against VCVio's `KEMScheme ProbComp` **Labels:** `lean`, `cleanup` · **Priority:** P2 **Context.** `KEM PK SK C K` (`KEMAnonymity.lean:44-47`) has fields identical to `KEMScheme ProbComp K PK SK C`; we maintain two bridges (`KEM.toKEMScheme`, `KEM.ofKEMScheme`) with no roundtrip lemmas. FPIL: `abbrev` is transparent to instance search and `simp`, `def` is not. **Proposal.** Either `abbrev KEM PK SK C K := KEMScheme ProbComp K PK SK C` and delete both bridges (dot-notation `kem.keygen` keeps working), or keep the structure and add `@[simp] theorem toKEMScheme_ofKEMScheme` / `ofKEMScheme_toKEMScheme`. Prefer the abbrev unless the argument order matters for readability (it can be preserved with the abbrev). *** ### 18. Simp-set and proof-robustness hygiene **Labels:** `lean`, `cleanup` · **Priority:** P2 **Context.** TPIL: `simp` with the default set depends on every upstream `@[simp]` attribute, so bare terminal `simp` calls ([`dksap_perfectlyComplete`](/lean/proofs/dksap#dksap_perfectlycomplete), `DKSAP.lean:119-121`; [`meta_address_roundtrips`](/lean/proofs/invariants#meta_address_roundtrips), `Invariants.lean:147`) are the first thing to break on a VCVio/mathlib bump. Conversely the seven-name `simp only [...]` unfold list in [`unlinkAdvantage_ofKEM_eq_anonAdvantage`](/lean/proofs/kemanonymity#unlinkadvantage_ofkem_eq_anonadvantage) (`KEMAnonymity.lean:123-126`) suggests missing `@[simp]` projection lemmas for [`ofKEM`](/lean/proofs/kemanonymity#stealthschemeofkem)/[`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull)/[`unlinkSetup`](/lean/proofs/games#unlinksetup)/[`anonSetup`](/lean/proofs/kemanonymity#anonsetup). **Proposal.** Run `simp?` on the bare `simp`s and pin the lemma lists; add `@[simp]` lemmas for the scheme constructors' fields (`ofKEM_keygen`, `ofKEMFull_announce`, …) and the four `*Setup`/`*Branch*` unfoldings; try `rfl` where the two experiments are definitionally the same computation. Consider `attribute [local simp]` blocks per section instead of global attributes (TPIL §5: global `@[simp]` cannot be removed downstream). *** ### 19. Section/variable hygiene in the DKSAP files **Labels:** `lean`, `cleanup` · **Priority:** P3 **Context.** TPIL §6: a `variable` is included in a theorem only if mentioned in the statement; instance binders follow their dependencies. `DKSAPClassical.lean` needs `omit [DecidableEq G] in` three times (`:85`, `:108`, `:120`) because `[DecidableEq G]` is declared in the same section as theorems that do not need it, and `dksapIdeal (F := F) g` is repeated eight times because [`F`](/lean/proofs/demo#f) only occurs in the discarded [`MetaPriv`](/lean/proofs/constructiona#metapriv) type. **Proposal.** Split the section: instance-free probability lemmas in one section, the scheme + `hashedDH*` in another; make [`F`](/lean/proofs/demo#f) an explicit argument of [`dksapIdeal`](/lean/proofs/dksap#dksapideal) (`def dksapIdeal (F) (g : G)`) so call sites read `dksapIdeal F g`. Same pattern check for `Games.lean`'s `variable (adv : UnlinkAdv …)`. *** ### 20. Small duplications and naming **Labels:** `lean`, `cleanup` · **Priority:** P3 * [`honest_witness_valid`](/lean/proofs/ownership#honest_witness_valid) (`Ownership.lean:81-87`) is [`blinded_is_ownership_witness`](/lean/proofs/invariants#blinded_is_ownership_witness) restated; keep one, or make the second a `@[simp]`-oriented corollary with a reason in the docstring. * [`IsOwnershipWitness`](/lean/proofs/invariants#isownershipwitness)/[`IsSigningKey`](/lean/proofs/invariants#issigningkey) are `def`s returning `Prop`; the mathlib linter flags Prop-valued `def`s that are used with `simp` — either `abbrev` or add `@[simp] theorem …_iff` unfoldings (and see #8 for the semantic fix). * Experiments are named [`CorrectExp`](/lean/proofs/games#correctexp)/[`UnlinkExp`](/lean/proofs/games#unlinkexp)/[`AnonExp`](/lean/proofs/kemanonymity#anonexp) (UpperCamel, mirroring VCVio) while values elsewhere are lowerCamel (`rorGameTrue`, `randAuxBranchTrue`); pick one convention for `ProbComp Bool`-valued definitions and apply it (mathlib: lowerCamel for terms, UpperCamel for `Prop`/`Type`). * README `lean/README.md:8, :97`: drop the job counts (see #11) and add the reading order (Demo → DKSAP → Blinding → Games → KEMAnonymity) that the handbook artifact already documents. *** ### 21. Track the VCVio pin **Labels:** `lean`, `infra` · **Priority:** P3 **Context.** VCVio is pinned at `a5f474fd` (2026-07-15) with a documented "don't float on main" policy. Its `MLKEM.ind_cca_security` is a self-flagged placeholder, and KEM anonymity is absent — both are things upstream may land. A bump is also when #18's `simp` fragility bites. **Proposal.** A standing issue with the bump checklist: update `rev` in `lakefile.toml` + `lake update VCVio`, `lake exe cache get`, `lake build`, re-run the axiom guard (#12), diff `LatticeCrypto/MLKEM/Security.lean` and `KeyEncapMech.lean` for new anonymity/ANO-CCA definitions to adopt, and record the new pin date in README. Cadence: once per month or when upstream announces KEM anonymity. *** ### 22. Executable `viewTag` via `LeanSha256`; keep docs and vectors in step **Labels:** `lean`, `infra` · **Priority:** P3 **Context.** `docs/etheorem-lessons.md` surveys the etheorem consensus-layer Lean codebase. Two of its tooling ideas are already here (`scripts/check_citations.py`, `scripts/check_sizes.py`; the first run found 7 stale citations in this file). The remaining concrete opportunity is its `LeanSha256` package: pure Lean, Mathlib-free, CAVP-validated, and it builds unmodified on our `v4.32.0` toolchain (probe 2026-08-21, `hash "abc"` gives the FIPS KAT). Our spec's view tag is a SHA-256 truncation, currently the uninterpreted `Prims.viewTag`. **Proposal.** (a) Add `LeanSha256` as a second pinned `[[require]]` (LGPL-3.0-only; record the licence and pin beside `docs/vcvio-pin.md`). (b) Define a concrete `viewTag` from `LeanSha256.hash` and specialise the `Soundness.lean` tag theorems to it — no proof changes, they take the tag function as a parameter. (c) Check the `view_tag` fields of `python/vectors/v0/vectors.json` from Lean with `#guard_msgs`-wrapped `#eval`, the `Demo.lean` pattern — the first value-level (not size-level) Lean ↔ implementation check. (d) Run the two scripts in CI next to `lake build`. *** ## Suggested order 1. \#12 axiom guard and #15 demo noise (an afternoon; makes every later PR self-checking), then #11 CI. 2. \#1 scan fix + #13 controls (the model-integrity items). 3. \#2 concrete instances, then #6 glue equality — these turn two "documented" arrows into Lean ones cheaply. 4. \#3 construction-A bridge (biggest conceptual gain: the algebra finally feeds the games). 5. \#7, #8, #5 — the remaining theorem work, in the order the security write-up needs them. 6. Cleanup batch #14, #16–#20 as a single PR when convenient. ## Creating the issues Once reviewed, each `### N. Title` block above maps to one `gh issue create --title "" --label lean,<label> --body-file <block>`. No issues have been created; this file is the review copy. # DKSAP: sound classically, totally broken given a discrete-log oracle :::info[Mirrored page] Source: [`lean/docs/dksap-asymmetry.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs/dksap-asymmetry.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Scope: classical comparison research, outside the commitment-format implementation path. Start with [ERC evidence](/spec/erc-evidence) for the current proposal. Background for `PqStealth/DKSAP.lean`, `PqStealth/Controls.lean` (the broken variant) and `PqStealth/Demo.lean`. DKSAP is scheme 1 of ERC-5564: the SECP256K1 dual-key stealth address protocol, the only stealth scheme actually deployed today and the baseline this project measures against. It is modelled as an instance of the same [`StealthScheme`](/lean/proofs/games#stealthscheme) abstraction used for the ML-KEM construction, so the two live in one framework and the comparison is structural rather than rhetorical. Put the two halves together and the same scheme, in one framework, is sound under a classical assumption and totally broken given a discrete-log oracle. That is a statement about the quantum transition rather than about DKSAP being weak. ## The group model Matching VCVio's `DiffieHellman` file: [`F`](/lean/proofs/demo#f) is the scalar field (exponents), `G` the group (elliptic curve points), and `a • g` is the additive rendering of `gᵃ`. Keeping the group abstract is deliberate — the attack uses only the group law, so nothing depends on the curve. A recipient holds `(m, v)` and publishes `(M, V) = (m • g, v • g)`: [`m`](/lean/proofs/demo#m) is the spending key, [`v`](/lean/proofs/demo#v) the viewing key. The sender draws an ephemeral [`r`](/lean/proofs/demo#r), publishes `R = r • g`, and derives the stealth key `P = M + h(r • V) • g`; the recipient recomputes the same scalar as `h(v • R)` and checks the resulting key against [`P`](/lean/proofs/demo#p). `h : G → F` abstracts the hash that turns the shared secret point into a scalar. The announcement is the pair `(R, P)`; [`P`](/lean/proofs/demo#p) stands in for the on-chain address, whose hashing is irrelevant to everything proved. [`dksap_derivation_agrees`](/lean/proofs/dksap#dksap_derivation_agrees) is the identity the whole scheme rests on — `r • (v • g) = v • (r • g)` — and also the reason the attack works once [`v`](/lean/proofs/demo#v) is known. ## The break: key recovery, not forgery What is proved, in `DKSAP.lean`: 1. [`dksap_perfectlyComplete`](/lean/proofs/dksap#dksap_perfectlycomplete) — the instance really is DKSAP: a recipient always detects a payment addressed to them. This is what rules out a vacuous model. 2. [`dksap_recover_eq_honest`](/lean/proofs/dksap#dksap_recover_eq_honest) — an adversary holding the discrete logs of the PUBLISHED meta-address recomputes the recipient's honest stealth spending key exactly. 3. [`dksap_key_recovery`](/lean/proofs/dksap#dksap_key_recovery) — that recovered scalar is a valid secret key for the announced stealth public key. ### Why key recovery rather than a distinguishing game The question this answers is "is DKSAP forgeable by a quantum adversary". The answer is stronger than forgeability: the adversary recovers the actual signing key, and key recovery implies UNIVERSAL forgery — sign anything, spend anything. Stating it that way also keeps the result equational; there is no probability slack anywhere. Phrasing it instead as a distinguishing game ("advantage = 1") would be strictly weaker AND harder, since it would inherit an address-collision term. ### The injectivity hypothesis [`dksap_recover_eq_honest`](/lean/proofs/dksap#dksap_recover_eq_honest) assumes `Function.Injective (· • g)`. Injectivity says the generator's orbit hits each point once, so a discrete log pins down the exact scalar rather than merely some scalar with the same image. It holds in the prime-order group DKSAP is instantiated over (`F = ZMod p`). It is genuinely needed: the recovered viewing scalar is fed to `h`, so an answer that is only correct up to the group law would derive a different shared secret. VCVio assumes the sibling condition `Function.Bijective (· • g)` in the same situation, and the classical half of `DKSAP.lean` uses that bijectivity form. ### What the discrete-log oracle abstracts, and what it does not Shor's algorithm is not formalized, and cannot be: the verification framework this project builds on models classical probabilistic computation only (no quantum computation, no QROM), and carries no elliptic curves at all. What Shor CONTRIBUTES to this attack is exactly one thing — discrete logarithms become computable — and that is what is assumed, in the standard way, by taking the oracle's answers as hypotheses (`xM • g = M`). Every step after that is ordinary algebra. So this is a proof about a scheme relative to an assumption about the adversary, NOT a proof about a quantum algorithm. The load-bearing premise is that the meta-address is **published** (ERC-6538 registers `(M, V)` on-chain). An unpublished meta-address leaves an unspent stealth address protected by hash preimage resistance — though any output that has been spent exposes its public key and falls anyway. ### Honest scope of the contrast with the ML-KEM scheme The ML-KEM construction in this development contains no group element, so this adversary gains nothing against it. That is an asymmetry, and it is NOT proved to be a separation: relative to a discrete-log oracle, the hardness of the lattice assumptions is an ASSUMPTION, not a theorem. The honest statement is that the DKSAP break is unconditional given the oracle, while the ML-KEM bounds hold under assumptions that the oracle is not known to affect. Two further scope notes. The break concerns the key exchange and the spending key derived from it; it says nothing about how value is spent in practice today, where the accompanying design still relies on a classical account-abstraction route. And the two consequences differ in urgency: deanonymization is retroactive (announcements recorded now can be opened later), whereas theft requires the funds to still be there when the capability exists. ## The other half: why it is a sound design classically On its own the break is a weak claim — in a world where every discrete log is available, every discrete-log-based scheme dies, and showing one of them die proves little about the design. The classical half of `DKSAP.lean` supplies the contrast: DKSAP is unlinkable, and all of that unlinkability rests on a single assumption about the hashed Diffie–Hellman value. ### The shape of the argument The sender derives the stealth key as `M + h(r • V) • g`. Replace the scalar `h(r • V)` by a uniformly random one and the announcement becomes independent of the recipient outright — proved, not assumed. So the entire question is whether the real scalar is distinguishable from uniform, which is the hashed Diffie–Hellman assumption (implied by DDH with `h` modelled as a random oracle). This mirrors how the ML-KEM side is organized: the structural hops are proved, and one named hardness assumption carries the weight. The classical assumption is stated, not proved, exactly as MLWE is on the other side. ### The idealized scheme `dksapIdeal F g` draws the shared scalar uniformly at random instead of deriving it as `h(r • V)`; everything else is unchanged. It is a proof device, not a scheme anyone could run — a recipient cannot recompute a random scalar, so detection does not work — and it is only ever used as the middle game of a hop. [`dksapIdeal_unlinkAdvantage_eq_zero`](/lean/proofs/dksap#dksapideal_unlinkadvantage_eq_zero): the idealized scheme is perfectly unlinkable. Not "negligibly" — exactly zero. With a uniform scalar the announced stealth key is a uniformly distributed group element whatever the recipient's key was, so the two branches are the same distribution and no adversary, however powerful, does better than guessing. The substitution behind it ([`dksapIdeal_announce_indep`](/lean/proofs/dksap#dksapideal_announce_indep)) is `s ↦ d + s`, where `d` is the discrete log of `M1 − M0`; it is a bijection of the scalar field, so it carries the uniform distribution to itself. [`dksapIdeal_branch_indep`](/lean/proofs/dksap#dksapideal_branch_indep) lifts that to a whole announcement, ephemeral key included. ### The classical security statement `hashedDH g h adv b` is the hashed-DH advantage on branch `b`: distinguishing an announcement whose scalar is the real `h(r • V)` from one whose scalar is uniform. Under DDH with `h` a random oracle this is negligible; it is the classical assumption the scheme rests on, and it is exactly what a discrete-log oracle destroys. [`dksap_unlinkAdvantage_le_hashedDH`](/lean/proofs/dksap#dksap_unlinkadvantage_le_hasheddh): DKSAP's unlinkability advantage is bounded by the two per-branch hashed-DH advantages and nothing else — the idealized middle game contributes exactly zero, so there is no residual slack in the reduction. [`hashedDH_le_ddh_add_es`](/lean/proofs/dksap#hasheddh_le_ddh_add_es) / [`dksap_unlinkAdvantage_le_ddh_add_es`](/lean/proofs/dksap#dksap_unlinkadvantage_le_ddh_add_es): **the hashed-DH term from named assumptions.** [`hashedDH`](/lean/proofs/dksap#hasheddh) is a game gap, so by itself it is a definition; on each branch it is at most VCVio's decisional Diffie–Hellman advantage `DiffieHellman.ddhDistAdvantage g (ddhReductionDKSAP h adv b)` plus the entropy-smoothing advantage of `h`, `EntropySmoothing.advantage F g (fun _ => h) (esReductionDKSAP g adv b)` (VCVio's `HardnessAssumptions/EntropySmoothing.lean`; hash key `Fin 1` since `h` is unkeyed). The two reductions are explicit: the DDH one plants `A` as the target's viewing key, `B` as the ephemeral key and hashes `T`; the ES one uses its sample as the shared scalar. This is exactly VCVio's own hashed-ElGamal argument (`Examples/ElGamal/Hash.lean`) — DKSAP's announcement is hashed ElGamal with the spending key as the message — and the proof is the same four-game hop: real = DDH-real, DDH-random = hash-of-uniform-point = ES-real, ES-ideal = ideal. The intermediate games are written in the sampling order of VCVio's experiments, so each hop is a permutation of independent draws (`Reorder.evalDist_pull₃ … ₆`) plus one `mul_comm`. Read alongside [`dksap_key_recovery`](/lean/proofs/dksap#dksap_key_recovery), this is the point of the whole exercise: the same scheme is unlinkable under DDH + entropy smoothing of `h`, and has its keys recovered by two discrete-log queries. ## Negative control A proof closed by `simp` can be correct about something other than what was intended, and a completeness theorem is especially exposed to this — if the model were vacuous, or the detection test trivially true, the proof would still go through and nothing would look amiss. The usual defence is to break the scheme on purpose and check that the same proof script stops working. That is worth doing interactively, but it is a poor regression test: a failing tactic script is not checked by the build, and it silently stops being evidence the moment a tactic gets stronger. The durable version is to state the negation as an ordinary theorem and prove it. Then the build itself guarantees that the broken variant really is broken, and hence that the completeness theorem next door has content. [`dksapBroken`](/lean/proofs/controls#dksapbroken) drops the recipient's spending key from the detection test — the single most plausible way to get this scheme wrong — and [`dksapBroken_not_perfectlyComplete`](/lean/proofs/controls#dksapbroken_not_perfectlycomplete) proves it fails. ## The runnable demo Everything else in the development is abstract: the group `G`, the scalar field [`F`](/lean/proofs/demo#f) and the hash `h` are type variables, which is what makes the theorems apply to any curve. The cost is that nothing can be executed and inspected. `Demo.lean` fixes a tiny concrete instance so the scheme — and the key-recovery attack against it — can actually be RUN with `#eval`. It is a sanity check on the model and an entry point for reading the development, not a cryptographic instantiation: the group is the additive group of `ZMod 23`, in which discrete logarithms are just division. That is precisely why the attack is executable there, and it is also why the instance offers no security whatsoever. The point being demonstrated: the abstract theorem [`dksap_key_recovery`](/lean/proofs/dksap#dksap_key_recovery), applied to these concrete numbers, says the recovered scalar is the recipient's actual spending key — and you can watch that happen. ## Oracle attack: the discrete-log oracle made real, and its query bound Background for `PqStealth/DKSAPOracle.lean` §1. `DKSAP.lean` takes the oracle's answers as hypotheses (`hM : xM • g = m • g`). That is enough for the equational break, but it cannot express the *cost* of the attack, and the cost is the whole retroactivity argument. `DKSAPOracle.lean` restates the attack as a computation with a real oracle so the cost becomes a theorem. ### The interface `scalarSpec F G = unifSpec + (G →ₒ F)` — VCVio's `+` on specs, so `unifSpec ⊂ₒ scalarSpec F G` and any `ProbComp` lifts in unchanged. One oracle, taking a group element to a scalar. The same interface serves the ROM section below; only the IMPLEMENTATION differs, which is exactly the point being made: the hash and the discrete log have the same signature, and the scheme's fate turns on which one the adversary is holding. `dlogAttack h (M, V) l` queries the oracle at [`M`](/lean/proofs/demo#m) and at [`V`](/lean/proofs/demo#v) — twice, before it has looked at `l` at all — and then maps [`recover`](/lean/proofs/dksap#recover) over the announcement list. ### The query bound * `dlogAttack_isTotalQueryBound : IsTotalQueryBound (dlogAttack h MV l) 2` * `dlogAttack_not_isTotalQueryBound_one : ¬ IsTotalQueryBound (dlogAttack h MV l) 1` Together: **exactly two queries**. Neither statement mentions `l.length`. That is the machine-checked form of "two queries per victim regardless of payment count": a capability that is bought once deanonymizes an entire history, so an announcement recorded today is opened by an oracle that arrives in twenty years. Harvest-now-decrypt-later is not a metaphor here — it is the query bound. VCVio's `IsTotalQueryBound` is the structural (`PFunctor.FreeM.IsRollBound`) predicate, so both facts are pathwise statements about every execution, not expectations. The upper bound is `⟨_, fun _ => ⟨_, fun _ => trivial⟩⟩` and the lower bound instantiates the continuation at `0 : F`; the pair is the honest rendering of "exactly `n`" at this layer. VCVio's weighted `QueryCost` layer (`Queries[ oa in runtime ]`) was NOT used: it is stated over direct-style `HasQuery.Program`, which would force the attack into typeclass-abstracted form for no gain here. ### Correctness under simulation `dlogImpl dlog` answers group-oracle queries by an actual function `dlog : G → F` and uniform queries uniformly. The Shor hypothesis is now exactly one equation, `hdlog : ∀ y, dlog y • g = y`, supplied to the theorems rather than baked into the oracle. * [`simulateQ_dlogAttack`](/lean/proofs/dksaporacle#simulateq_dlogattack) — under [`dlogImpl`](/lean/proofs/dksaporacle#dlogimpl) the attack is deterministic; it consumes no randomness at all. * [`dlogAttack_key_recovery`](/lean/proofs/dksaporacle#dlogattack_key_recovery) — its output list is *exactly* `l.map (fun RP => m + h (v • RP.1))`, the recipient's own spending scalars. Proved from [`dksap_recover_eq_honest`](/lean/proofs/dksap#dksap_recover_eq_honest), so the injectivity discussion above carries over unchanged. * [`dlogAttack_forall_key_recovery`](/lean/proofs/dksaporacle#dlogattack_forall_key_recovery) — the `List.Forall` form: for a payment history given by ephemeral scalars `rs`, every recovered scalar is a valid secret key for that announcement's stealth address ([`dksap_key_recovery`](/lean/proofs/dksap#dksap_key_recovery)). * [`dksapAnnounce_mem_announce_support`](/lean/proofs/dksaporacle#dksapannounce_mem_announce_support) — the announcements the attack is fed are ones [`dksap`](/lean/proofs/dksap#dksap) actually emits. Without this the multi-announcement statement could be about a shape no sender ever produces. ## DDH/ROM bound: what is in Lean Background for `PqStealth/DKSAPOracle.lean` §2. [`hashedDH`](/lean/proofs/dksap#hasheddh) in `DKSAP.lean` names the gap between the real derived scalar and a uniform one; in the standard model it is bounded by DDH plus entropy smoothing of `h` ([`hashedDH_le_ddh_add_es`](/lean/proofs/dksap#hasheddh_le_ddh_add_es)). This section instead moves the whole game into the random-oracle model, where the same gap becomes a bad-query probability, and reports honestly how far that argument is machine-checked. ### The model The hash `h : G → F` is replaced by the second summand of `scalarSpec F G`, implemented by VCVio's lazy random oracle (`OracleSpec.randomOracle` = `uniformSampleImpl.withCaching`, state `(G →ₒ F).QueryCache`). `dksapRomImpl F G` forwards uniform queries and hands hash queries to that oracle. The adversary [`UnlinkAdvRO`](/lean/proofs/dksaporacle#unlinkadvro) is a function of the two meta-addresses and the challenge announcement into `OracleComp (scalarSpec F G) Bool` — it holds the same oracle the sender used, which is the whole content of the ROM. Probabilities are taken only after `simulateQ … |>.run' ∅` lands back in `ProbComp`; `IsUniformSpec` is deliberately never declared for [`scalarSpec`](/lean/proofs/dksaporacle#scalarspec). * `dksapRORun g adv b` — real branch: the shared scalar is the oracle at the DH point `r • V_b`. * `dksapROIdealRun g adv b` — idealized branch: a fresh uniform scalar and *no* oracle query. * [`unlinkAdvantageRO`](/lean/proofs/dksaporacle#unlinkadvantagero), `hashedDHRO g adv b` — the ROM counterparts of [`unlinkAdvantage`](/lean/proofs/games#unlinkadvantage) and [`hashedDH`](/lean/proofs/dksap#hasheddh). ### What is proved [`dksapROIdeal_boolDistAdvantage_eq_zero`](/lean/proofs/dksaporacle#dksaproideal_booldistadvantage_eq_zero): **the idealized game is perfectly unlinkable in the ROM** — exactly zero, with the adversary holding the random oracle throughout. The reason it is cheap is structural: the ideal announcement never touches the oracle, so keygen and the two scalars peel out of the simulation as ordinary sampling ([`dksapROIdealRun_eq`](/lean/proofs/dksaporacle#dksaproidealrun_eq), over VCVio's `roSim.run'_liftM_bind`), and what remains is literally the shape that [`dksapIdeal_branch_indep`](/lean/proofs/dksap#dksapideal_branch_indep) already handles, with the continuation being "run the adversary under the random oracle". The `s ↦ d + s` substitution does the rest. [`dksap_unlinkAdvantageRO_le_hashedDHRO`](/lean/proofs/dksaporacle#dksap_unlinkadvantagero_le_hasheddhro): the same two-hop decomposition as the plain model, with the idealized middle game again contributing exactly zero. [`dksapRORun_eq`](/lean/proofs/dksaporacle#dksaprorun_eq): the real game IS the ideal game run against a cache already programmed at the DH point. This holds because the sender's query is the *first* oracle query, so it hits an empty cache and is a fresh uniform sample that is then written in ([`dksapRomImpl_run'_scalarQuery_bind`](/lean/proofs/dksaporacle#dksapromimpl_run_scalarquery_bind)). Both games therefore end up in identical shape, with identical announcements, differing only in the starting cache. [`dksapROBad`](/lean/proofs/dksaporacle#dksaprobad) / [`dksapROBadProb`](/lean/proofs/dksaporacle#dksaprobadprob): **the bad event.** The adversary is run against the oracle *programmed* at the DH point (VCVio's `QueryImpl.withProgramming`, whose `Bool` flag is set the first time the programmed point is queried), and the event is that flag. `ROMUpToBad.badQueryGame` packages it: prefix, point, value, adversary. [`hashedDHRO_le_dksapROBadProb`](/lean/proofs/dksaporacle#hasheddhro_le_dksaprobadprob) and [`dksap_unlinkAdvantageRO_le_badProb`](/lean/proofs/dksaporacle#dksap_unlinkadvantagero_le_badprob): **identical until bad, closed.** `hashedDHRO g adv b ≤ dksapROBadProb g adv b`, hence `unlinkAdvantageRO ≤ dksapROBadProb true + dksapROBadProb false`. The engine is VCVio's `tvDist_simulateQ_run_le_probEvent_output_bad`, instantiated in `PqStealth/ROMUpToBad.lean` for the `unifSpec + hashSpec` shape (uniform queries forwarded, hash queries programmed / tracked), with two relational projections (`relTriple_simulateQ_run'`): the programmed run from `∅` is the plain random oracle run from `∅` overridden at the point, and the tracking run is the plain random oracle run from `∅`. [`boolDistAdvantage_run'_cacheQuery_run'_empty_le`](/lean/proofs/romuptobad#booldistadvantage_run_cachequery_run_empty_le) then averages over the prefix. [`cdhOfUnlinkAdvRO`](/lean/proofs/dksaporacle#cdhofunlinkadvro): **the reduction to CDH**, type-checked against VCVio's `DiffieHellman.CDHAdversary F G`. It plants the CDH challenge `A` as the ephemeral key and `B` as the target's viewing key, runs the idealized game under a logging random oracle ([`loggingROImpl`](/lean/proofs/dksaporacle#loggingroimpl) — VCVio's `appendInputLog`, a `preInsert`, over the lazy `randomOracle`), and returns one logged query point uniformly at random. The planting closes *because the idealized announcement needs neither [`r`](/lean/proofs/demo#r) nor `v_b`*: it uses a fresh scalar. In the real game it would not close, and that asymmetry is why the bad event is defined on the ideal side. ### The gap, precisely Not proved: `dksapROBadProb ≤ q_H · Adv_CDH(cdhOfUnlinkAdvRO adv b)`. Two pieces. The reduction runs the *tracking* side of the identical-until-bad pair (the oracle at the DH point is a fresh sample, so [`r`](/lean/proofs/demo#r) and `v_b` are not needed), whereas [`dksapROBadProb`](/lean/proofs/dksaporacle#dksaprobadprob) measures the flag on the *programmed* side; that the two flag probabilities coincide is the symmetric half of the fundamental lemma (the runs agree until the flag fires, and the flag is monotone), not yet stated here. The second needs the standard guessing argument: the reduction must pick WHICH of the adversary's hash queries is the DH point, costing a factor `q_H` (an `IsQueryBoundP … Sum.isRight q_H` bound on the adversary). [`cdhOfUnlinkAdvRO`](/lean/proofs/dksaporacle#cdhofunlinkadvro) is written to make that argument possible — it logs the queries and samples an index — but the inequality itself is not proved. So the honest summary of the positive side: **"DKSAP is unlinkable under DDH in the ROM" is set up but not closed.** What is machine-checked is the ROM model, the perfect unlinkability of its idealized game, the two-hop decomposition, the reprogramming identity that puts real and ideal in one shape, the bad event, and the CDH reduction. What is missing is named above and is a general fact about lazy random oracles, not about DKSAP. # PQ stealth addresses and authenticated UTXO discovery :::info[Mirrored page] Source: [`docs/research/authenticated-utxo-discovery.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/authenticated-utxo-discovery.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Research integration note, 2026-09-14. This describes a proposed composition, not an implemented native-UTXO integration or a normative ERC profile. Our ML-KEM scheme targets recipient privacy: the viewing-key holder discovers which one-time destinations belong to them. The [authenticated UTXO discovery proposal](https://ethresear.ch/t/an-evaluation-of-authenticated-utxo-discovery-with-eip-8304-and-utxo-proof-tables/25828) combines EIP-8304 recipient-range completeness with UTXO Proof Tables (UPT) authenticating output fields. Current spent-state proofs answer the separate question of whether those outputs remain unspent. These layers complement one another; an output opening commitment is distinct from our recipient commitment. EIP-8304 is a Draft Core EIP and UPT is a research proposal, so both remain mutable dependencies rather than available protocol guarantees. The proposed flow is: 1. Alice uses Bob's meta-address to derive a fresh destination `S` and publishes the KEM ciphertext and view tag through ERC-5564. 2. Alice creates a UTXO for `S`, subject to a compatible authorization route. Our current CREATE2 account binding does not establish native-UTXO spending compatibility by itself. 3. Bob obtains a complete, authenticated announcement stream and scans locally to discover `S`. 4. Bob queries UTXO creation events for `S`, verifies their complete recipient ranges and corresponding UPT opening proofs, then checks current spent state. The key gap is announcement discovery. A complete UTXO query for `S` cannot recover another destination whose announcement was withheld. A candidate design uses [EIP-8304](https://eips.ethereum.org/EIPS/eip-8304) to prove a complete announcer-address range and authenticate event classification. Its standard index covers addresses and topics, so the ciphertext and metadata in log data also need receipt proofs or another authenticated payload mechanism. The wallet must cover the intended history range and anchor proofs to independently authenticated canonical headers and roots. Commitments do not ensure that providers retain or serve the underlying bytes. For an ERC-5564 announcement, the wallet must join index entries at the same `(block, transaction, log)` position: the announcer address; `topics[0]` as the `Announcement` event signature; `topics[1]` as the expected scheme ID; and `topics[2]` as `leftpad32(S)`. It must then verify the ciphertext and metadata from that same receipt-log position. Separately valid index entries from different logs are not evidence of one announcement. This composition does not make the initial scan sublinear: the current scanner decapsulates each candidate ciphertext before checking its view tag. Nor does query authentication provide query privacy: submitting discovered destinations to one RPC can let that provider group them. A privacy-preserving retrieval strategy remains separate work. See the [receive/scan reference](/spec/technical-spec) and [security boundaries](/spec/security-analysis). A useful joint prototype would connect an authenticated ERC-5564 scan to the UPT wallet, including exact event-to-output binding, canonical-history handling, and a compatible spending adapter. Neither the proposal's experimental discovery pipeline nor this composition establishes end-to-end PQ security; our concrete commitment-profile privacy argument and spending assurance remain separate obligations in the [ERC evidence map](/spec/erc-evidence). # The commitment format in the announcement-privacy model :::info[Mirrored page] Source: [`docs/research/commitment-announcement-privacy.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/commitment-announcement-privacy.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Status: research note feeding the security-analysis write-up (SECURITY\_ANALYSIS.md review priority 2). It instantiates the proved generic [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) announcement-privacy chain at the D-024 commitment profile (format `0x02`), states exactly which assumptions that instantiation needs, and contrasts it with the Construction A (`0x01`) instantiation the chain was originally built for. Analysis only; no protocol change is proposed. Every theorem cited is sorry-free in `lean/PqStealth/` (guarded by the `Axioms.lean` audit); line numbers are from `main` at `7d5e15e`. The ROM argument of §3 is a sketch for review, not a theorem: it rests on a composite-oracle abstraction whose bridge to the real commitment/CREATE2 instantiation is **not proved**, and its `2^-175` figure is an estimate under that abstraction. Its `qH` is a joint budget over the tag, opener, and address-composite oracle calls; §3 and §5 state the boundary precisely. Classical ROM only: quantum oracle access, ANO-CCA (active) and the MLWE reduction of the KEM terms remain outside this note exactly as in the rest of the development. ## 1. The instantiation The commitment derivation (`python/pq_stealth/commit.py`, D-024; wire format `0x02 ‖ spend_key(32) ‖ kem_ek`, 1,217 B at ML-KEM-768) is a deterministic function of the KEM shared secret `ss` and the recipient's *public* meta-address material: ``` opener = SHA-256(OPEN_DOMAIN ‖ ss) (32 B) commitment = keccak256(COMMIT_DOMAIN ‖ spend_key ‖ opener) (32 B) address = CREATE2(factory, salt, initcode(commitment, …)) (20 B) view_tag = SHA-256(ss)[0:1] (1 B) announcement = (stealth_address, ct, view_tag) ``` The generic announcement-privacy model (`KEMAnonymity.lean`) is `StealthScheme.ofKEMFull kem auxGen` with `auxGen : K → PK → Aux`, announcement `(ct, auxGen ss pk)`, and a scan that recomputes aux from the decapsulated secret and the recipient's own public key (`KEMAnonymity.lean:145-155`; why the second argument is load-bearing, and why the scan must recompute rather than test decapsulation, is in `lean/docs/announcement-model.md`). The format-`0x02` instantiation is: ``` PK := CommitMetaPublic = (spend_key, kem_ek) — both public SK := kem_dk — the ML-KEM decapsulation key alone K := 32-byte shared secret Aux := Tag × Addr — view tag + stealth address auxGen ss pk := ( SHA-256(ss)[0:1], CREATE2(keccak256(COMMIT ‖ pk.spend_key ‖ SHA-256(OPEN ‖ ss))) ) ``` Two structural facts make this the faithful instance of the generic model: * **The view tag is a function of the shared secret alone** — exactly the `taggedAux viewTag rest` shape (`Soundness.lean:305`). This establishes the syntactic shape required by the generic detection theorem; applying [`soundWithin_ofKEMFull_oneByteTag`](/lean/proofs/soundness#soundwithin_ofkemfull_onebytetag) additionally requires its `hUnif` hypothesis, here a classical-ROM assumption that truncated `SHA-256` of a uniform secret is uniform. That concrete assumption is not formalized. * **The address is a deterministic function of `(ss, spend_key)`** — the recipient dependence enters through the *public* `spend_key` slot of [`auxGen`](/lean/proofs/constructiona#auxgen)'s second argument and nowhere else. There is no recipient-side lattice mask to model at receive time: unlike Construction A, whose announced key is `hashAddr (pack rho (power2Round (u + t)))` with `rho` and `t` the recipient's own meta-address material and only `(s', e')` from the secret, the commitment address contains **no recipient secret-derived structure at all**. The account's spending scheme is behind the commitment hash — that is the point of D-024 (and D-018 for C13). The generic chain supplies the result shapes needed by this manual mapping: the four-term bound [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) (`KEMAnonymity.lean:236`), the IND-CPA identification of the hiding terms ([`sharedSecretHiding_eq_indCpaAdvantage`](/lean/proofs/sharedsecrethiding#sharedsecrethiding_eq_indcpaadvantage), `SharedSecretHiding.lean:187`), the multi-payment hybrid [`unlinkAdvantageMulti_ofKEMFull_le`](/lean/proofs/multiunlink#unlinkadvantagemulti_ofkemfull_le) (`MultiUnlink.lean:281`), and the n-recipient pair-guessing bound [`unlinkAdvantageN_ofKEMFull_le`](/lean/proofs/multirecipient#unlinkadvantagen_ofkemfull_le) (`MultiRecipient.lean:358`). No checked `commitKem` / `commitAuxGen` wrapper yet instantiates these definitions for the deployed profile, so this note is not itself a concrete Lean instantiation. The generic chain fixes the *shape* of the intended bound; [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) (`KEMAnonymity.lean:228`) is the term whose value depends on [`auxGen`](/lean/proofs/constructiona#auxgen)'s concrete structure. ## 2. The single-payment bound, instantiated At the commitment [`auxGen`](/lean/proofs/constructiona#auxgen), the four-term decomposition (`KEMAnonymity.lean:236-241`) reads ``` Adv_unlink ≤ ssHiding(true) + auxKeyIndependence + KEM.anonAdvantage + ssHiding(false) ``` with each term now concrete: | Term | What it is at format `0x02` | Status | | --- | --- | --- | | `ssHiding(b)`, one per branch | Exactly the ML-KEM-768 IND-CPA advantage of the reduction adversary ([`sharedSecretHiding_eq_indCpaAdvantage`](/lean/proofs/sharedsecrethiding#sharedsecrethiding_eq_indcpaadvantage), `SharedSecretHiding.lean:187`) | Named KEM term; the FO-to-MLWE bridge is the same open upstream lemma as for Construction A ([`mlkem768_sprAdv_le_mlwe`](/lean/proofs/sprtwohop#mlkem768_spradv_le_mlwe), `SPRTwoHop.lean:605`, covers the anonymity side) | | [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) | **The only commitment-specific term.** With the secret idealized to a fresh uniform `ss'` ([`randAuxBranch`](/lean/proofs/kemanonymity#randauxbranch), `KEMAnonymity.lean:203`), aux is `(SHA-256(ss')[0:1], CREATE2(keccak256(‖spend_key‖SHA-256(ss'))))` — a deterministic function of a uniform 256-bit string and a public constant | Bounded in §3 under ROM assumptions on SHA-256 and keccak256 — **no lattice assumption enters** | | `KEM.anonAdvantage` | ML-KEM-768 ciphertext anonymity (key privacy) | Decomposed to seeded-MLWE plus named assumption records via [`anonAdvantage_le_sprAdv`](/lean/proofs/anonymityfromspr#anonadvantage_le_spradv) (`AnonymityFromSPR.lean:49`) and [`mlkem768_sprAdv_le_mlwe`](/lean/proofs/sprtwohop#mlkem768_spradv_le_mlwe) (`SPRTwoHop.lean:605`) | **What disappears relative to Construction A.** The `0x01` instantiation needs the seeded-MLWE blinding hop (`ConstructionA.lean`'s [`blindingProblem`](/lean/proofs/constructiona#blindingproblem) / [`mlweAdvOfUnlinkAdv`](/lean/proofs/constructiona#mlweadvofunlinkadv), with its [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal) XOF-idealization assumption), and where a proof-grade ROM bound is wanted, the point-mass machinery of `BlindingROM.lean` / `BlindingEntropy.lean` — which exists to handle a lattice address point of low min-entropy: at ML-DSA-65 (`k = 6`, `ringDegree = 256`) the point-mass is `betaAddr = (2¹³/q)^(6·256) ≈ 2^-15358` per address-point guess ([`blindPointMassBound_mldsa65`](/lean/proofs/blindingentropy#blindpointmassbound_mldsa65); the `^2048` form is ML-DSA-**87**, `k = 8`), and the ROM bound is `2·qH·betaAddr` ([`blindBadProb_le_queryBound`](/lean/proofs/blindingentropy#blindbadprob_le_querybound), `BlindingEntropy.lean:94`). The commitment address point is a full 256-bit hash *preimage-slot*: the recipient's input to the address hash is a public constant (`spend_key`), and the only secret input is the uniform `ss'`. The aux term is therefore a plain ROM argument with point mass `2^-256` — **no MLWE hop, no [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal), no lattice point-mass**. The commitment profile is not merely "less proved" than the supporting Construction A track; its aux argument is *structurally cleaner*, and that contrast is what the ERC security-considerations text needs to convey. (§5 records that the ROM bound itself is a sketch, not a theorem.) ## 3. [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) at format `0x02` — ROM bound sketch The intermediate game `randAuxBranch kem auxGen adv b` (`KEMAnonymity.lean:203`) replaces the real `ss` by a fresh uniform `ss'` but keeps building aux from recipient `b`'s public key. The adversary sees `(ct, tag, addr)` plus both meta-addresses `(spend_key₀, ek₀)`, `(spend_key₁, ek₁)` — the full public view, per [`unlinkSetup`](/lean/proofs/games#unlinksetup). The term asks: can aux built from `spend_key_b` and uniform `ss'` be told from aux built from `spend_key_{1−b}` and uniform `ss'`? The view tag is part of the public view; it is not learned through an oracle query. The ROM sketch must therefore give the adversary access to the domain-separated tag hash as well as the opener and address composite, and `qH` below counts **all** such calls jointly. This prevents the tag from creating an uncharged filtering step: an adversary can hash candidate secrets, keep those whose first byte matches the announced tag, and only then query the opener/address path. Subject to that joint budget, the argument is the ROM argument for a hash chain whose secret input is uniform and whose recipient-dependence enters via a public constant in the preimage: 1. **Inner hash (opener).** Model `ss' ↦ SHA-256(OPEN_DOMAIN ‖ ss')` as a random oracle. For a fresh `ss'` never queried before, `opener` is a fresh uniform 256-bit string independent of everything the adversary has seen — the uniform-output property, the same shape as BlindingROM's [`run_hashAddrRO_empty`](/lean/proofs/blindingrom#run_hashaddrro_empty) (`BlindingROM.lean:67`): uniform output *whatever* the input hashed. The adversary may program/observe queries on the inner oracle too. A query on exactly the challenger's `ss'` (probability `2^-256` per candidate before the public tag is used) is the event that can correlate the branches. 2. **Outer hash (commitment) and CREATE2 chain (address).** Model the composite `(spend_key, opener) ↦ address` — i.e. `keccak256(0xff ‖ factory ‖ salt ‖ keccak256(initcode))` with `initcode` carrying `keccak256(COMMIT ‖ spend_key ‖ opener)` — as one random oracle `Bytes → Addr`, exactly the "composite as ONE function" reading `BlindingROM.lean` adopts for Construction A's `hashAddr ∘ pack`. For a fresh `(spend_key, opener)` pair not previously queried, the address is a fresh uniform `2^160`-point. The recipient's `spend_key` sits in the *preimage*: distinguishing branches is distinguishing oracle outputs on two public prefixes — impossible without querying the oracle on a point whose preimage involves the branches' differing secret draw. 3. **Bad event and budget, defined precisely (review F3/F4).** Two distinct events must not be conflated: * **(a) Hidden-preimage query:** the adversary queries an oracle at a preimage whose secret slot is the challenger's `ss'`. Per-query probability is `2^-256` against a uniform 256-bit secret it has not seen, when the query is charged to the joint budget. * **(b) Unrelated colliding output:** some query happens to produce the challenge *address* (a 160-bit target) without touching the hidden preimage — e.g. programming the outer oracle, or querying a different preimage that happens to land on the same address. Event (a) is bounded by `qH · 2^-256` only when `qH` includes tag-hash candidate tests as well as opener and composite calls. If it counted only outer-composite calls, tag-filtered candidates would instead have conditional point mass `2^-248`, so the stated `2^-256` per-outer-query step would be invalid. Event (b) does not identify the hidden preimage and is not a branch discriminator under the composite-oracle abstraction. The earlier draft's `2^-160` union-bound term remains absent because of that abstraction, not because it is dominated by `2^-256`. ``` auxKeyIndependence ≤ 2·qH·2^-256 ``` — *under the composite-oracle abstraction and the joint query budget*; the factor two accounts for the two comparison branches. The two branches share the same hidden `ss'`, so this is not a fresh `2^-160` address guess per branch. **The composite-oracle abstraction is a stronger assumption, stated as such (review F3).** Modeling commitment-hash + CREATE2 as one ideal oracle `Bytes → Addr` ignores that the adversary can query the *intermediate* hashes (the real commitment hash, the real keccak256 initcode hash) on adversarially chosen inputs. A faithful reduction would have to simulate those intermediate oracles and argue the composite inherits their uniformity — the natural plan mirrors `BlindingROM.lean` (one composite oracle, identical-until-bad, query-budget bound), but **no such module exists in the Lean tree**. Until `CommitmentAnonymity.lean` lands, §3 is a **sketch under an unproved abstraction bridge**, and the `2^-175` figure below is an *estimate under that abstraction*, not a theorem-derived bound. Assumptions, stated precisely: 1. **ROM on SHA-256 (opener) and on the keccak256 composite (commitment + CREATE2 chain), modeled as ONE composite oracle.** Stronger than the standard per-hash ROM the development carries elsewhere: the abstraction hides the adversary's ability to query the real intermediate hashes. Labeled here as an **unproved bridge** (see above); discharging it is the recorded open obligation (`CommitmentAnonymity.lean`, future work). 2. **Joint query budget, including the public tag oracle.** `qH` bounds every adversarial query to `SHA-256(·)` used for the announced tag, `SHA-256(OPEN_DOMAIN ‖ ·)` used for the opener, and the modeled address composite. The domains keep outputs independent in the classical ROM; the shared budget prevents tag filtering from being free. 3. **`ss'` uniform by construction.** In the [`randAuxBranch`](/lean/proofs/kemanonymity#randauxbranch) game the challenger draws `ss'` uniform — that is *by construction*, not an assumption. Getting there from the real game is the IND-CPA step, which the four-term decomposition already performs and prices into the `ssHiding` terms — so the aux argument consumes, rather than duplicates, that assumption. No circularity. 4. **Both recipients share the deployment binding (and profile).** The factory, creation code, salt, verifier, and frame context enter the address preimage as public constants *common to both branches*. If the two recipients use different bindings or profiles, their aux distributions differ for public reasons — that channel is auxiliary information outside the [`auxGen`](/lean/proofs/constructiona#auxgen) model ([`unlinkSetup`](/lean/proofs/games#unlinksetup) draws both recipients from one `keygen`), and SECURITY\_ANALYSIS.md's profile-narrowing paragraph covers it. The ERC text must state the single-profile/common-binding condition as a property. 5. **The tag function is the deployed one-byte tag.** `tagOf = SHA-256(ss)[0:1]` is public and branch-independent once `ss'` is fixed. Branch-independence alone is NOT a generic harmless property: a tag function that leaked the secret (e.g. `tagOf(ss) = ss`) would let the adversary compute both candidate destinations through the public hashes. The deployed tag only offers an eight-bit candidate filter; §3 charges its oracle evaluations to the joint budget. The generic [`taggedAux`](/lean/proofs/soundness#taggedaux) machinery makes no claim about other tag choices; **each profile's tag must be analyzed jointly with its opener and address derivation**. **Reading the estimate (review F3).** Under the composite-oracle abstraction above, the commitment-side channel contributes at most `2·qH·2^-256` — with a generous `qH = 2^80`, `2^-175` per payment pair. This is an **estimate under an unproved abstraction**, not a bound discharged by a Lean theorem; the four-term decomposition itself is proved, but this aux term is the sketched one (see §3 and §5). The dominant terms of the format-`0x02` privacy bound are the KEM terms — ML-KEM IND-CPA (twice) and ML-KEM ciphertext anonymity — which remain named MLWE-level terms exactly as for Construction A. **The privacy story for format `0x02` is: the KEM carries all the weight; the commitment side adds only the ROM assumptions already on the books.** That sentence is what SECURITY\_ANALYSIS.md's review priority 2 asks for ("justify its auxiliary-data independence under explicit hash assumptions"), and it is the argument the ERC's security-considerations section should carry. ## 4. Multi-payment and multi-recipient composition The multi-challenge hybrid is proved generically for every [`auxGen`](/lean/proofs/constructiona#auxgen) and adversary: [`unlinkAdvantageMulti_ofKEMFull_le`](/lean/proofs/multiunlink#unlinkadvantagemulti_ofkemfull_le) (`MultiUnlink.lean:281`) gives `Adv_unlink_q ≤ q · ε_single` for `q` announcements between the same two recipients (linear loss — [`unlinkAdvantageMulti_le_mul`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_mul), `MultiUnlink.lean:241`), and [`unlinkAdvantageN_ofKEMFull_le`](/lean/proofs/multirecipient#unlinkadvantagen_ofkemfull_le) (`MultiRecipient.lean:358`) gives `Adv ≤ n·(n−1)·ε_single` when the adversary names the challenge pair among `n` published meta-addresses. Both are *instantiations of proved theorems*, not paper composites — the per-hybrid adversary's four-term sum is the §2 bound each time. A concrete reading for the write-up — *conditional on the §3 abstraction*: with `qH = 2^80`, the aux term at `q` observed announcements is `q · 2^-175`. Even a full-chain window of `q = 2^20` announcements to the pair keeps it at `2^-155`; a `2^40` window at `2^-135`. These figures inherit the §3 sketch's status: proved decomposition, sketched aux term. Whatever window the threat model picks, the commitment-side channel stays negligible against the KEM terms — the statement the security-analysis write-up should make quantitative, with the window `q` stated explicitly (`announcement-model.md`'s guidance: the single-challenge target must be `ε ≤ 2^-k / q` for a `k`-bit claim). **Correction to the in-tree reading.** `lean/docs/announcement-model.md` says the composition of `MultiUnlink`/`MultiRecipient` with [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) is "on paper, not in Lean" (§ multi-challenge: "the composition is nevertheless legitimate"; § `n`-recipients: "that composition is not in Lean"). That was true when the essay was written; it is stale now: [`unlinkAdvantageMulti_ofKEMFull_le`](/lean/proofs/multiunlink#unlinkadvantagemulti_ofkemfull_le) (`MultiUnlink.lean:281`) and [`unlinkAdvantageN_ofKEMFull_le`](/lean/proofs/multirecipient#unlinkadvantagen_ofkemfull_le) (`MultiRecipient.lean:358`) discharge exactly those instantiations. The essay is updated in this branch's companion commit. ## 4b. Detection soundness (the tag side) The scan-side theorem is generic and conditional. Given `hUnif` — that the one-byte tag of a uniform secret is uniform — the false-positive rate of [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) is at most `1/256 + decapsRoR` ([`soundWithin_ofKEMFull_oneByteTag`](/lean/proofs/soundness#soundwithin_ofkemfull_onebytetag), `Soundness.lean:373`). [`decapsRoR`](/lean/proofs/soundness#decapsror) is the same named real-or-random IND-CPA-shaped term as in the unlinkability chain (a distinguisher that tells the recipient's real decapsulated secret from a fresh uniform one inside [`FalsePositiveExp`](/lean/proofs/games#falsepositiveexp); `Soundness.lean` names it rather than sweeping it into the statement). The commitment profile has the required [`taggedAux`](/lean/proofs/soundness#taggedaux) *shape* (`Soundness.lean:305`), but no Lean definition establishes `hUnif` for truncated `SHA-256`; that is a classical-ROM assumption here. No lattice term appears on the soundness side either. The `n`-fold union bound for a scanner sweeping `n` announcements is on paper (not in Lean), as the essay already records. ## 5. What is and is not discharged **Discharged — generic, sorry-free result shapes (not a checked `0x02` instantiation):** * The four-term decomposition and its IND-CPA identification (`KEMAnonymity.lean:236`, `SharedSecretHiding.lean:187`). * The multi-payment and n-recipient composites (`MultiUnlink.lean:281`, `MultiRecipient.lean:358`). * Detection soundness conditional on `hUnif` (`Soundness.lean:373-377`), with [`taggedAux`](/lean/proofs/soundness#taggedaux) at `Soundness.lean:305` supplying only the required shape. * Detection completeness ([`perfectlyComplete_ofKEMFull`](/lean/proofs/kemanonymity#perfectlycomplete_ofkemfull), `KEMAnonymity.lean:160`). **Instantiation-specific — argued here, not proved:** * The concrete `commitKem` / `commitAuxGen` wrapper connecting the deployed profile to the generic Lean types and experiments. * The [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) ROM bound (§3). The shape is [`run_hashAddrRO_empty`](/lean/proofs/blindingrom#run_hashaddrro_empty)'s uniform-output property plus the [`blindBadProb_le_queryBound`](/lean/proofs/blindingentropy#blindbadprob_le_querybound) union bound, but the commitment [`auxGen`](/lean/proofs/constructiona#auxgen) has a different input structure (public constant in the preimage vs a masked lattice term), so it needs its own module in the Lean tree (`CommitmentAnonymity.lean`, future work; the natural plan mirrors `BlindingROM.lean`: one composite oracle, identical-until-bad, query-budget bound). Until it lands, §3 is a review artifact for the ERC's security-considerations text — the *assumption list* is rigorous, the `2^-175` figure is an estimate under the stated composite-oracle abstraction (an unproved bridge), **not an established bound**. Per review F3: do not quote `2^-175` as a derived quantity; quote it as "estimated under the §3 abstraction, open proof obligation". * Uniformity of `SHA-256(ss)[0:1]` for uniform `ss`, the `hUnif` premise of the generic detection theorem; this note treats it as a classical-ROM assumption. **Explicitly not claimed:** * No reduction of the ML-KEM IND-CPA or anonymity terms to MLWE for this format — the FO bridge is the same open upstream lemma as for Construction A (`SPRTwoHop.lean`'s open item). No ANO-CCA for active attacks; the chain proves the passive game. Quantum ROM access remains outside, as everywhere in the development. * No theorem covers *profile narrowing*: two recipients on different profiles (or deployment bindings) have differently-distributed aux for public reasons. The model's [`unlinkSetup`](/lean/proofs/games#unlinksetup) draws both recipients from one `keygen`, i.e. the same profile; the narrowing channel is auxiliary information, covered by SECURITY\_ANALYSIS.md's existing paragraph and stated as a property in the ERC text. * Nothing here covers spending: the account behind the commitment has its own authorization and disclosure story (D-027's linkage analysis, the widened-`z` route-class channel for `0x01` blinded signing). Receive-time privacy and spend-time linkability are separate assurance tracks, exactly as SECURITY\_ANALYSIS.md frames them. ## Reproduce Numbers in §2–§4 from exact arithmetic (no dependencies). The `aux_term` line is the §3 sketch's estimate **under the composite-oracle abstraction** — it reproduces the arithmetic, not the proof (review F3): ```python from fractions import Fraction as F beta_commitment = F(1, 2**256) # hidden-preimage point mass (§3 event (a)) qH = 2**80 # generous joint oracle-query budget aux_term = 2 * qH * beta_commitment # = 2^-175 (under the §3 abstraction) assert aux_term == F(1, 2**175) # Construction A contrast (ML-DSA-65): lattice point-mass # mldsa65.k = 6, ringDegree = 256, so the exponent is 6 * 256 = 1536: # betaAddr = (2^13/q)^1536, q = 8380417 ≈ 2^-15358 # (blindPointMassBound_mldsa65; needs the seeded-MLWE hop + ExpandIsIdeal) # ML-DSA-87 (k = 8) is the ^2048 form, ≈ 2^-20477. # Compare in log space: the value underflows a float. import math assert math.log2(F(2**13, 8380417)) * 1536 < -15357 ``` Cross-checks: the four-term shape and [`randAuxBranch`](/lean/proofs/kemanonymity#randauxbranch)/[`cipherOf`](/lean/proofs/kemanonymity#stealthschemeunlinkadvcipherof) definitions are `KEMAnonymity.lean:203/228/236`; the [`taggedAux`](/lean/proofs/soundness#taggedaux) + one-byte-tag chain is `Soundness.lean:305/373`; `1/256` is the machine-checked tag bound's own constant; the `q`-fold linear loss is `MultiUnlink.lean:241`. # Cost report — measured L1/L2 gas and client-side scanning :::info[Mirrored page] Source: [`docs/research/cost-report.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/cost-report.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: *2026-09-30. Assembles the measured cost numbers for the scheme into one report (the EPF project definition's "benchmarks + cost report" deliverable). Every figure below is either measured on-chain (forge e2e / Sepolia fork), produced by a committed benchmark harness, or computed by the gas model anchored to a measured number — each cites its source. Dated assumptions are stated where they are used; nothing here is a per-rollup fee quote.* Companion material: the harness-level detail and methodology lives in [`python/benchmarks/README.md`](https://github.com/Skanislav/pq-sap/blob/main/python/benchmarks/README.md) (scan, KEM sweep, view tags, registry curve, EIP-8304 PoC); the data-cost model is [`python/benchmarks/onchain_cost.py`](https://github.com/Skanislav/pq-sap/blob/main/python/benchmarks/onchain_cost.py) (D-011); the decision records behind the framing are D-011 (cost is data), D-025 (account routes), D-028 (key-exchange layer). ## 1. Headline Categories are kept separate throughout (review F6): **setup/deployment** (one-time), **recurring verification** (per spend), **whole-transaction** (sponsor-visible), and **client-side**. Every row names its profile/parameter set, verifier revision, toolchain, and measurement boundary. | Cost (category) | Value | Exact provenance | | --- | --- | --- | | Announcement, L1 (recurring, per payment) | **67,580 gas** (EIP-7623 floor, binding) | measured, Sepolia fork e2e, forge/anvil, 2026-08-14, scheme ID `2`; the model on the committed vectors gives 67,550 (−0.04%, calldata byte split) | | vs EC-DKSAP baseline (same boundary) | **2.5×** (27,342 gas, standard regime) | `onchain_cost.py` (modeled standard-regime baseline) | | Key-exchange wrapper overhead (recurring marginal) | **2,065 gas** (~3% of the floor) | `test_announce_overhead_gas` (D-028), forge, CI toolchain | | Announcement, L2 blob regime (recurring) | **~$0.0042** marginal L1 data | `onchain_cost.py` L2 model, anchors dated 2026-07-28 | | Meta-address registration (one-time setup) | 3.79 M gas naive / **~1.13 M via SSTORE2** (5,633 B, `0x01`) | modeled, D-011; `0x02` is 1,217 B → ~264k SSTORE2 | | Account deployment (one-time setup) | **620,750 gas** (ERC-7913 pointer route) | e2e-7913, anvil, 2026-08-10 | | Signature verification (recurring, per spend — see §5 for the profile split) | 4.93 M (ZKNOX\_ethdilithium, keccak-PRNG variant) – **13.68 M** (ERC-7913 route at df999ed, with key-setup NTT; 14.91 M without) | ETHDILITHIUM KAT gas report at rev df999ed; e2e-7913. The 8.18 M figure in older text is an earlier upstream revision and does not reproduce | | Whole-spend transaction (recurring) | ~8.4 M (`handleOps`, blinded-key sig) | Sepolia fork, outer-tx measured | | Scanning (client-side) | **44.3 µs/announcement** steady-state (Python client); **22.6 µs** native | `scan_bench.py` / `registry_curve.py`; 0x3327 Rust harness (1,809 ms / 80,000) | The one-line story: **the scheme is data-heavy, not compute-heavy.** Detection (scanning) is competitive with — and natively *faster* than — the EC scheme ERC-5564 deploys today; the post-quantum premium is bytes on-chain (a 1,088 B ciphertext where DKSAP posts a 33 B point), which the EIP-7623 floor prices directly on L1 and which EIP-4844 blobs dissolve on L2. ## 2. Announcement cost, L1 The canonical `ERC5564Announcer.announce(uint256,address,bytes,bytes)` call with our scheme ID `2` announcement (1,088 B ML-KEM-768 ciphertext, 1-byte view tag) costs **67,580 gas measured** on the Sepolia fork (2026-08-14): 1,316 B calldata, 1,114 nonzero / 202 zero bytes, 4,658 calldata tokens. The data-cost model reconstructs that calldata shape and gives **67,550** on today's committed vectors (1,113 nonzero / 203 zero bytes, 4,655 tokens) — a −0.04% residual from the zero/nonzero split of the encoded arguments, not a difference in regime: * standard cost would be 52,446 (21,000 base + calldata + LOG4 execution); * the **EIP-7623 calldata floor** `21,000 + 10 × 4,658 tokens = 67,580` is binding — post-Pectra a PQ announcement is priced as *bytes*, not compute (D-011's core finding). The EC baseline (DKSAP, 33 B compressed point) **models** at **27,342 gas** in the *standard* regime — its calldata is zero-dominated, so the floor never engages. It has no on-chain measurement of its own (`onchain_results.json` records `measured_gas: null`); the 67,580 measurement belongs to the PQ row. PQ is thus **2.5× on L1** (the fixed 21,000 base compresses the raw 33× data ratio). At the D-011 anchors (L1 base 8 gwei, ETH $3,200 — dated 2026-07-28) that is ~$1.73 per PQ announcement vs ~$0.70 for DKSAP; the figure scales linearly with basefee, and both rows are dominated by bytes, not by anything the scheme can optimize away without shrinking the ciphertext. **Wrapper overhead (D-028).** Routing the announcement through `StealthKeyExchange.announce` (shape-check: supported ciphertext length, metadata present; byte-for-byte forward to the singleton under scheme ID 2) costs **2,065 gas over a direct singleton call** — ~3% of the floor (`test_announce_overhead_gas`, steady-state on the CI toolchain). The shape-check contract is optional for wallets; the announcer stays the deployed singleton and nothing about the scheme changes. ## 3. Announcement cost, L2 Rollups post transaction data to L1, so an L2 announcement's marginal cost is L1 data cost. Two regimes (marginal L1 data cost per announcement; dated anchors ETH $3,200, L1 base 8 gwei, blob base 1 gwei — 2026-07-28; each rollup adds its own compression and margin on top, which floats, so we do not quote per-rollup fees): | Announcement | calldata regime | blob regime (EIP-4844) | | --- | --- | --- | | PQ (1,316 B) | ~$1.35 | **~$0.0042** | | DKSAP (~130 B compressed) | ~$0.30 | ~$0.0009 | Blobs price data at ~1 gas/byte against the blob basefee instead of ~40 gas/nonzero-byte against the execution basefee — a ~**320×** cut. The PQ announcement costs **sub-cent on any blob-posting L2**, and the ~4.5× modeled cost ratio over DKSAP persists in both regimes (both announcements benefit from the regime change; the absolute gap falls to fractions of a cent). Stealth-address activity already lives on L2s (D-011's live-registry survey: the mainnet canonical announcer has 202 announcements *ever*; the volume is Fluidkey on Base/Gnosis and Umbra on OP/Polygon) — so the deployment story is: the L1 data tax is real but the L2 landing is where the scheme is cheap, and there the footprint "largely dissolves" (D-011). ## 4. Meta-address registration (one-time per recipient) Storing the 5,633 B Construction A meta-address (`0x01`) in ERC-6538-style storage at a naive per-word SSTORE is ~**3.79 M gas** (177 words) vs ~62k for the 33 B EC meta-address (~61×) — the meta-address, not the announcement, is the big on-chain write. SSTORE2 / code-blob storage (~200 gas/byte to deploy) brings it to **~1.13 M gas** and is the pattern the contracts use (`StealthKeyExchange` ek-registry, D-028). The commitment form (`0x02`, D-024) shrinks the meta-address 5,633 → **1,217 B** (4.6×): ~850k naive / ~**264k gas** via SSTORE2 — one-time registration lands in the same ballpark as a modest contract deployment, not a small fortune. Meta-address distribution and the ERC-6538 registry are out of the proposal's scope (D-014): naming services or off-chain sharing can carry the meta-address and the deployment binding entirely off-chain, in which case this row is optional for every recipient. ## 5. Spend-side on-chain costs (account routes, D-025) The deployed-verifier reality is ZKNOX's ETHDILITHIUM profile (level-2 round-3 Dilithium, not ML-DSA-65 — issue #30). All figures measured (forge/anvil e2e, 2026-08-10; ETHDILITHIUM KAT gas report at rev df999ed): | Operation | Category | Gas | Profile / verifier revision | Notes | | --- | --- | --- | --- | --- | | ERC-7913 account deploy (pointer route) | setup, one-time | **620,750** | ERC-7913 pointer route, df999ed verifier | initcode 2,952 B; keys behind SSTORE2 pointer (D-020/D-025) | | PKContract deploy (22.4 kB expanded pk) | setup, one-time per stealth key | 5,324,168 | ZKNOX PKContract | expanded-key storage route | | Account deploy via old factory+embedded-key | setup, one-time (superseded) | 6,167,566 | pre-D-025 route | the route D-025 replaced | | `ZKNOX_ethdilithium.verify` | recurring verification | **4,926,456** | ETHDILITHIUM keccak-PRNG variant — **not a FIPS ML-DSA measurement** | see caveat below | | `ZKNOX_dilithium.verify` | recurring verification | 8,176,453 | level-2 Dilithium2-shape, SHAKE | issue #30 territory: raw-key ML-DSA-65 verifier is open | | ERC-7913 verify at df999ed | recurring verification | ~14.97 M | ERC-7913 route, df999ed | stores `t1` plain, recomputes `NTT(t1·2^d)` per verify | | C13 `verify` (direct scheme) | recurring verification | **188,092** | SPHINCS-C13 direct signature, tx-level | hash-based; **its own soundness is that of the C13 parameter set** — the classical-only UltraHonk *wrapper* limitation (D-025) is a separate statement about a different route and does not transfer here | | 4337 `handleOps` spend | whole-transaction | ~8.4 M | blinded-key sig, level-2 profile | Sepolia fork, outer-tx measured | **Keccak-PRNG caveat (review F6):** `ZKNOX_ethdilithium` is a keccak-PRNG *variant* of the level-2 profile. It is **not** interchangeable with a FIPS ML-DSA measurement, and its 4.93 M figure must not be read as "the cost of ML-DSA verification" — it is the cost of that specific variant's verify at rev df999ed on the stated toolchain. The SHAKE-based `ZKNOX_dilithium` row (8.18 M) is the closer-to-standard measurement; a genuine FIPS ML-DSA verifier figure does not exist in this report (issue #30 records the raw-key ML-DSA-65 verifier as open work). Per-spend execution gas spans **single-digit millions to ~15 M depending on the verifier revision and route** (review F6: the earlier "single-digit-millions" framing contradicted the ~14.97 M ERC-7913 row in this same table). At the dated anchors (8 gwei, ETH $3,200) a spend's signature verification alone is ~$126 (ZKNOX\_ethdilithium, 4.93 M) to ~$209 (ZKNOX\_dilithium, 8.18 M), and the ERC-7913 route at df999ed (~14.97 M) is \~$383; at 30 gwei those become ~$473 / ~$785 / ~$1,437. The honest framing: **spend is the expensive side of the scheme, and it is L1-only expensive** — detection (the every-payment path) sits at the 67,580 data floor, while each spend pays millions to ~15 M of *execution* gas for PQ signature verification, which L2s price at a fraction of L1. Spend is a one-shot operation per payment, not per-recipient per-announcement, and the account deploy (620,750) is one-time per stealth address. The ZK-ownership route (D-025 preimage proof, UltraHonk backend) changes the profile, not the story: verification drops to the verifier's fixed cost but **that specific backend is classical-only sound** (UltraHonk is BN254/KZG) — a PQ-sound STARK verifier is ~5 M gas (D-007 measured/cited: STARK ~5 M vs Groth16 \<300 k which is classical). Route choice is the parameter-level decision (#30/#31) and does not move the detection numbers. **Security-assumption bookkeeping (review F6), kept separate:** the *direct* C13 route (188,092) verifies a hash-based signature on-chain; its soundness assumptions are those of the SPHINCS-C13 parameter set — post-quantum under the hash-function security of its components, independent of any proof system. The classical-only limitation above belongs to the *UltraHonk-wrapped* preimage route (D-025), a different route whose statement happens to include C13 inside a zk proof. The two must not share a security label; the table row for direct C13 now carries its own. ## 6. Client-side scanning Scan cost is decaps cost — one decapsulation per announcement, view-tag pre-filter, full derivation only on tag match (~1/256): * **Python client steady-state: 44.3 µs/announcement** (linear across 2.5k–160k announcements, R² = 0.999, `registry_curve.py`) vs 27.5 µs for the DKSAP baseline on libsecp256k1 — a fitted **1.61×** ratio that includes the ~N/256 false-positive derivations. The decaps+view-tag loop alone is \~19 µs/announcement (liboqs ML-KEM-768 decaps 17.2 µs native), i.e. **the pure lattice loop is faster than the EC baseline**. * **Native: 22.6 µs** (the 0x3327/pq-sap Rust harness we reproduced; 1,809 ms for 80,000 announcements, `python/benchmarks/README.md`) vs 27.5–27.9 µs DKSAP — against the scheme ERC-5564 actually deploys, native lattice scanning is *moderately faster*, not the paper's 3×-vs-Curvy headline (D-011's honesty note: DKSAP is the only EC baseline we carry). An earlier native run is quoted as 23.8 µs in older text; 22.6 is the figure arithmetic gives on the committed 80k row, and both land on the same conclusion. * **Today's registries are tiny**: the mainnet canonical announcer's entire history (202 announcements) scans in ~9 ms; at 1M announcements the projection is ~44 s single-core, embarrassingly parallel. Per-operation microbenchmarks (`op_bench.py`): `gen_meta_address` ~8 ms, `send` ~10 ms, scan hit ~11 ms, stock `verify` ~8 ms (pure-Python reference; wallets bind native crypto). The one real slowdown is **blinded signing**: widening both secret vectors to `2η` shrinks the acceptance region, costing \~4.5× ML-DSA's rejection rounds (mean 24.8 vs 5.5, median 22 vs 4; heavy-tailed — max seen 162 rounds), i.e. ~20 ms best / ~120 ms median in pure Python. A tighter probabilistic norm bound than worst-case `2η` would recover most of it and helps the default L3 set most (η=4 there; the penalty is not monotone in the security level — `param_sweep.py`). ## 7. Assumptions and caveats * **Price anchors are dated 2026-07-28** (ETH $3,200, L1 base 8 gwei, blob base 1 gwei, D-011) and scale linearly; the gas *quantities* are protocol constants plus measured execution, so they do not age, only the USD conversions do. * USD conversions here were computed from the measured gas at 8 gwei and, for the spend rows, 8/30 gwei — illustrative, not fee quotes. * Per-rollup fees float with each rollup's compression and margin; we quote only marginal L1 data cost. * The gas model's dispatch overhead was fit once (700 gas) against the measured 67,580 and is fixed; the model is anchored, not tuned per use. * Spend-side verification figures are the **deployed level-2 profile** (ZKNOX/ETHDILITHIUM at rev df999ed, forge/anvil, solc 0.8.30 / EVM `prague`), **not ML-DSA-65**: the keccak-PRNG variant (4.93 M) and the SHAKE level-2 shape (8.18 M) are separate measurements of separate verifier builds and are not interchangeable FIPS ML-DSA figures (#30 is the raw-key ML-DSA-65 verifier, open). Route and parameter-level selection (#30/#31) is a spec-freeze decision and will move the spend rows; it does not move §2–§4. * Whole-spend rows (`handleOps` ~8.4 M) are outer-transaction measurements including EntryPoint overhead, on Sepolia fork replay — not pure verification cost and not comparable to per-verify figures without subtracting the wrapper (§5 table lists both categories separately). * Blinded-signing round counts are stochastic; means/medians are over N=200 (mean) and N=50 (per-level) signatures (`op_bench.py`, `param_sweep.py`). ## 8. Reproduce Every number above is regenerable from `python/` (venv with the `[dev,audit,bench]` extras) and `js-client/`: ```sh # data-cost model (reproduces the measured 67,580 gas announcement) python benchmarks/onchain_cost.py --json benchmarks/onchain_results.json # scan steady-state + EC baseline + registry curve python benchmarks/scan_bench.py --sizes 5000,20000,80000 --reps 3 --json benchmarks/results.json python benchmarks/registry_curve.py --reps 3 --json benchmarks/registry_curve.json # per-op + blinded-signing rounds + security-level sweep python benchmarks/op_bench.py --reps 20 --json benchmarks/op_results.json python benchmarks/param_sweep.py --n 20000 --reps 5 --sign-reps 50 --json benchmarks/param_sweep.json # on-chain: wrapper overhead + account routes (from js-client/) npm run test-contracts # includes test_announce_overhead_gas (2,065) npm run e2e-7913 # account deploy 620,750, PKContract 5,324,168 npm run e2e:fork # announce 67,580 on the canonical announcer, handleOps ~8.4 M ``` Raw JSON outputs are committed alongside the harnesses in `python/benchmarks/*.json`; the on-chain measurements are recorded in the fork-replay fixtures (`js-client/test/`) so they reproduce offline. Per-route spend costs and the two end-to-end runs are assembled, with what each route reveals stated next to what it costs, in the [stretch-goal report](/research/stretch-goal-report). # Composing the scheme-2 spec into ethereum/ERCs, and where Lean evidence can live :::info[Mirrored page] Source: [`docs/research/erc-lean-composition.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/erc-lean-composition.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Reviewed 2026-10-29 against `ethereum/ERCs` master `4878022` (626 `ERCS/*.md`, 221 `assets/erc-*` directories) and `ethereum/EIPs` master. This note records a survey and a submission plan. It does not write the ERC text: under [D-024](/spec/decisions) that stays human-written, and the local draft stays empty. ## Verdict 1. `ethereum/ERCs` contains **no Lean and no formal-method artifact of any kind**, now or at any point in its history. The single occurrence of the word "Lean" in the repository is a citation title in `ERCS/erc-7573.md`: *"A Proposal for a Lean and Functional Delivery versus Payment across two Blockchains"* (SSRN `10.2139/ssrn.4628811`) — where "Lean" is the software-engineering adjective, not the proof assistant. 2. The nearest real precedent lives in `ethereum/EIPs`, not `ERCs`: EIP-8450 (leanXMSS) states *"specified and implemented in the leanVM repository, with a Lean 4 proof of its classical security"* in prose, ships no artifact, and adds *"That target is not a proved bound."* EIP-8222 ("Lean Staking") is about the leanVM proving system; also prose only. 3. Consequence for us: Lean evidence is admissible as **prose plus a citation object**, not as a vendored proof development. The ERC CI never builds anything in `assets/`, and its link linter rejects every external URL, so a vendored Lean tree would be unverified by CI, unbuildable for most reviewers, and would need a CC0 relicensing of the copies. ## What the gate actually enforces (measured, not quoted) Local eipw (`eipw-lint-js` 0.11.0, run with the repository's own `config/eipw.toml`), plus markdownlint and codespell with the ERC configs: | Probe | Result | | --- | --- | | Extra H2 (`## Formal verification`) | `error[markdown-order-section]: body has extra section(s)` | | External link to a GitHub blob URL | `error[markdown-rel-links]: non-relative link or image` | | Missing `## Rationale` | `error[markdown-req-section]: body is missing section(s)` | | `csl-json` footnote without `DOI` | `error[markdown-json-cite]: "DOI" is a required property` | | `csl-json` footnote, `"type": "software"`, DOI + URL | **0 reports** | | `../assets/eip-N/vectors/x.json` link inside `ERCS/*.md` | **0 reports** | | `markdownlint-cli2` 0.18.1 + `config/.markdownlint.yaml` on `erc-8441.md` | 0 errors (MD013 off); the CI job does not cover this path at all | | `codespell` 2.4.3 + `config/.codespell-whitelist` on `lean/PqStealth/*.lean` | 4 hits: `FO` ×1, `hEnc` ×3 | Rules that follow directly: * **Sections.** H2 set must be a subset of the template's nine (Abstract, Motivation, Specification, Rationale, Backwards Compatibility, Test Cases, Reference Implementation, Security Considerations, Copyright). Every extra topic — proof scope, profile selection, deployment binding — must be an **H3 inside `## Specification` or `## Security Considerations`**, never a new H2. * **Links.** No external URLs except the allowlisted standards bodies. Internal references use `./eip-5564.md`; assets use `../assets/eip-N/...` **with the `eip-` prefix even in the ERCs repo** — the site build renames `assets/erc-N/` to `assets/eip-N/` during the EIPs merge, and `ERCS/*.md` contains 687 such links and zero `../assets/erc-` ones. On GitHub the `eip-` form 404s; in the rendered site it is the only form that resolves. * **One legal external pointer.** A `csl-json` footnote with `DOI` and `URL` is the sole mechanism that passes the linter and still resolves to something outside the repository. `"type": "software"` is accepted. * **The `markdownlint` job never sees `ERCS/`.** Its changed-file filter is `grep -E -x '[^/]+\.md|EIPS/eip-[0-9]+\.md'`, which matches only slash-free paths, so `ERCS/erc-N.md` and everything under `assets/` fall outside it. Markdown in `assets/` is still rendered as freestanding markdown, where MD041 wants a `#` H1 on line 1 — a Jekyll display rule, not a lint the PR will be failed on. * **codespell** runs over every changed file whose name is not in its skip list; `.lean` is not skipped, and an inline `codespell:ignore` comment does not suppress a hit inside `.lean` (verified; a whitelist entry does). Our tree would need `FO` and `hEnc` handled before any Lean source could ship. * **CI jobs:** `save-pr`, `htmlproofer`, `link-check`, `codespell`, `eipw-validator`, `markdownlint` (+ GitGuardian and label bots). `htmlproofer` does follow `assets/` links in the built site (its incremental path is disabled for any non-`ERCS/erc-*.md` change). Nothing runs `lake`, `forge`, or `npm` over `assets/`. ## Precedent to copy: ERC-8441 (merged 2026-10-29) `ethereum/ERCs#2059` merged as `4878022`, three files: `ERCS/erc-8441.md` (735 lines) and two vector JSONs under `assets/erc-8441/vectors/`. All CI checks green; the only change outside `ERCS/`+`assets/` contemplated was a `config/.codespell-whitelist` entry for the author's first name. Its shape is the one to mirror for our scheme ID: * a comparison table of scheme 1 vs 3 sizes and gas in `## Specification`; * vectors split per spec section, each row carrying `claim` / `given` / `expect` / `wrong` (the wrong output it distinguishes); * an explicit `## Backwards Compatibility` paragraph declaring the deviation from ERC-5564's `n`/`2n` meta-address length rule; * `## Security Considerations` opening with a "what this protects and does not protect" list. Two review threads from that PR apply directly to us: * SergeevDmitry: changing the combiner later changes the view tag and the derived address, so a replacement needs a **new scheme ID**, not an update. Our derivations must be frozen inside scheme 2 for the same reason. * The maintainer's reply rejects EIP-8141/8288/8355 dependencies for now — supporting evidence for keeping spending out of our normative text. ## Composition plan for our spec ### Identifier `schemeId` **2** is still unclaimed. The merged corpus declares only 1 (`erc-5564`) and 3 (`erc-8441`); a grep over all 323 open PR heads found no `schemeId 2` claim; no Ethereum Magicians thread uses that identifier. ERC-5564 recommends monotonic IDs, so 2 is both free and consistent. A claim under 323 open PRs is only as good as the last fetch, so the text must still re-check at PR time — but nothing else in this plan depends on the number. ### Files | Path | Content | Source | | --- | --- | --- | | `ERCS/erc-<n>.md` | Human-written text; H3s for profile table, deployment binding, wire format | author | | `assets/erc-<n>/vectors/commit.json` | Commitment-format vectors | [`python/vectors/v0/commit_vectors.json`](https://github.com/Skanislav/pq-sap/blob/main/python/vectors/v0/commit_vectors.json) | | `assets/erc-<n>/vectors/mldsa44.json` | Optional: ML-DSA-44 profile vectors | [`mldsa44_commit_vectors.json`](https://github.com/Skanislav/pq-sap/blob/main/python/vectors/v0/mldsa44_commit_vectors.json) | | `assets/erc-<n>/profile-v0.md` | Byte-level profile: domains, lengths, CREATE2 binding | [`TECHNICAL_SPEC.md`](/spec/technical-spec) §2–§4 | | `assets/erc-<n>/check.py` | Dependency-free checker (stdlib + vendored keccak-256) | planned, see [gap analysis](/research/erc-submission-gap-analysis) §5 | | `assets/erc-<n>/lean/` | Optional: the dependency-free model only, 4 files / 765 lines | [`js-client/contracts/lean/`](https://github.com/Skanislav/pq-sap/tree/main/js-client/contracts/lean) | | `assets/erc-<n>/EVIDENCE.md` | Claim → declaration → file → assumption table | [`ERC_EVIDENCE.md`](/spec/erc-evidence) condensed | ### Methods ERC-5564 requires us to define ERC-5564: *"The specification of a new stealth address scheme MUST specify the implementation for each of these methods"* (`generateStealthAddress`, `checkStealthAddress`, `computeStealthKey`). ERC-8441 could honor all three because spending stays ECDSA. Our commitment flow cannot, and the text has to say so rather than silently omit: | Method | Our answer | | --- | --- | | `generateStealthAddress(meta)` | Sender side of [`commit.py`](https://github.com/Skanislav/pq-sap/blob/main/python/pq_stealth/commit.py) `send_commit`: encapsulate `kem_ek`, derive opener and commitment, return the account address and `ct` as `ephemeralPubKey` | | `checkStealthAddress(addr, epk, ...)` | Scanner side: decapsulate, compare the tag, re-derive the commitment and address **with the profile's deployment binding**; returns `false` only for a mismatch, fails on malformed keys | | `computeStealthKey(...)` | **Not applicable**: there is no stealth private key. Spending is the account profile's authorization (D-027), so the text must state the method is undefined for scheme 2 and that its absence is what makes spending swappable | Other deviations to declare in `## Backwards Compatibility`, mirroring 8441: * meta-address is 1,217 B (`0x02 || spend_key(32) || ek(1184)`), not `n` or `2n`; * `ephemeralPubKey` is the ML-KEM ciphertext alone (1,088 B), and the view tag is `SHA-256(ss)[0:1]` — the opener and tag hash `ss` only, with no transcript binding (D-029's recorded open obligation); * the address is a CREATE2 account, not `keccak256(pubkey)[12:]`, so the deployment binding is part of the scheme, not an implementation detail. ### Where the Lean work attaches Three routes, in increasing cost: 1. **Prose + revision hash** (EIP-8450 precedent). Under `## Security Considerations`, an H3 naming the game, the proved declarations, and the named residual assumptions, with the repository revision in backticks. No artifact, no CI cost, no relicensing. Reviewers fetch by hash. 2. **`csl-json` software citation.** Archive the proof tree to get a DOI, then a footnote with `"type": "software"`, `DOI`, `URL`, `version`. Verified to pass eipw with the repository's own config. Choose the archive licence deliberately; the citation is not a vendored file, but the archived copy is public. 3. **Vendor the dependency-free model only.** `js-client/contracts/lean/` is 765 lines, no dependencies, and builds in 3.8 s here (`lake build`, 7 jobs) — fast enough that a reviewer on a laptop can replay it, unlike the 25-module, 7,535-line `lean/PqStealth/` development, which needs VCVio plus mathlib's \~8.6k oleans (per [`lean/docs/tooling.md`](/lean/tooling)). It would ship as sources plus a `README.md` with the exact pin and the axiom-audit transcript; ERC CI would not build it, so the text must not imply that it does, and `FO`/`hEnc` need renaming or a whitelist entry first. Do not ship `lean/PqStealth/` under `assets/`. It is unreplayable in a reviewer environment, and per-file CC0 relicensing would need an explicit human decision. ## Human decisions this plan waits on 1. Ethereum Magicians thread (none exists for scheme 2) — it is a hard blocker, since `discussions-to` must point at a live thread. 2. ERC number at PR time; `schemeId 2` is free but a same-day check is cheap. 3. Whether the profile spec and the checker ship in the first PR or later. 4. CC0 relicensing of every file placed under `assets/` (this repository is MIT). 5. Lean evidence route 1, 2, or 3, and if 2, the archive. ## Reproduction ```sh # survey git clone --filter=blob:none --no-checkout https://github.com/ethereum/ERCs.git git log --all --diff-filter=A -- '*.lean' 'lakefile*' 'lean-toolchain' '*.thy' '*.tla' # empty grep -rIl '\bLean\b' . --exclude-dir=.git # erc-7573.md only # gate npm i eipw-lint-js@0.11.0 markdownlint-cli2 smol-toml # then lint(files, config/eipw.toml) /tmp/.../markdownlint-cli2 --config config/.markdownlint.yaml ERCS/erc-8441.md codespell --ignore-words=config/.codespell-whitelist <changed files> # the model we could ship cd js-client/contracts/lean && lake build # 7 jobs, ~4 s, no dependencies ``` # Hash-based key exchange (SHA-256 / BLAKE3 commitments) as an ML-KEM alternative: why the lattice is load-bearing :::info[Mirrored page] Source: [`docs/research/hash-based-key-exchange.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/hash-based-key-exchange.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: **Research question.** Our discovery layer is "just a KEM" since the ZK-spend reframe (D-012): the sender must establish a secret with a recipient **non-interactively, from public data only**, and the scheme is otherwise hash-only (D-015). Can that one remaining structured-assumption component — ML-KEM — be replaced by a commitment scheme built from SHA-256 or BLAKE3, so the whole scheme rests on hashes? The question is being used as the argument for (or against) keeping a lattice assumption for key exchange, so it needs a precise answer, not a preference. **Date:** 2026-08-25. Sources: theory papers (cited inline), primary parameter tables, and a new measured benchmark `python/benchmarks/hash_kex_bench.py` (M1 Max, → `hash_kex_20260825.json`). Companion measurements of the structured-KEM design space are in D-012 / `discovery_kem_20260801.json`. *** ## Verdict in one paragraph **No — and it is a theorem, not an engineering gap.** A commitment scheme is the wrong shape for key exchange (the opening stays with the committer; a KEM needs the *recipient* to open something the *sender* made from public data, which is a trapdoor, i.e. public-key structure). Any key exchange whose only assumption is a hash function — SHA-256, BLAKE3, SHA3, it makes no difference — has a provable polynomial ceiling: honest parties doing *n* hash calls can be broken with *O(n²)* hash calls (Barak–Mahmoody, CRYPTO 2009, tightening Impagliazzo–Rudich 1989), and Merkle puzzles already achieve that ceiling. Worse for a *post-quantum* project: against a quantum eavesdropper Merkle's scheme has **no gap at all** (Brassard et al., CRYPTO 2011), and the best known classical-party hash-only schemes only approach *n^\{3/2}*. Turned into numbers on this machine, the classical-attacker 128-bit setting needs 2^64 hash calls per party and a **738 EB recipient public key**; even a 2^80 attacker budget — about 17 minutes of the Bitcoin network — costs a 44 TB key and 64 GPU-seconds per payment, and buys zero quantum security. The hash-only designs that *do* work in practice (pre-shared-secret tag chains; published one-time address lists) either need a secret established some other way — our tag-chain design already does this, seeded by **one** ML-KEM handshake — or give up unlinkability, which is the entire point of a stealth address. So hashes **amortize** the KEM and own everything downstream of it (KDF, tags, ZK spend, D-015); they cannot replace it. The honest comparison for "should discovery use a lattice" is ML-KEM against the *other structured* PQ KEMs (HQC — NIST's non-lattice backup — NTRU, Frodo, McEliece, CSIDH), which D-012 measured and ML-KEM wins on scan time and footprint. **Recommendation: keep ML-KEM-768 as the discovery KEM; state in the ERC that a hash-only handshake is impossible in the non-interactive setting and that tag chains + an optional out-of-band handshake are the hash-only opt-out for pairs that have a prior channel.** ## 1. What a hash-based commitment can and cannot do A commitment `c = H(x ‖ r)` gives *hiding* (c reveals nothing about x) and *binding* (the committer cannot open c to x' ≠ x). Both properties serve one party — the committer — who keeps the opening `(x, r)`. Nothing in the primitive lets a *second* party who holds only public information recover x. A stealth-address discovery step needs exactly that: the sender publishes one message, and the recipient — who has never interacted with the sender — must recover a secret from it using a private key of their own. That "open with a private key" operation *is* a public-key trapdoor. Rephrasing it as "the recipient committed to something earlier" does not help: the recipient's commitment is public, and whatever the sender can compute from it, the eavesdropper can compute too, because both hold the same inputs and the same hash. The formal version of this intuition is a black-box separation: * **Impagliazzo–Rudich (STOC 1989).** Secret-key agreement cannot be built from one-way functions (equivalently, from a random oracle) in a black-box way; any *n*-query protocol falls to an adversary making polynomially many queries (their bound: ~n^6). * **Barak–Mahmoody, "Merkle Puzzles Are Optimal — An O(n²)-Query Attack on Any Key Exchange from a Random Oracle" (CRYPTO 2009).** Every key-exchange protocol in the random-oracle model in which the honest parties make at most *n* oracle queries is broken by an adversary making O(n²) queries. Merkle's 1974 puzzles need Ω(n²) to break, so the quadratic gap is tight. ([paper](https://www.iacr.org/archive/crypto2009/56770369/56770369.pdf), [Springer](https://link.springer.com/chapter/10.1007/978-3-642-03356-8_22)) Two things about these results matter for the argument being made: 1. **They are about the access model, not the hash.** SHA-256 vs BLAKE3 vs SHA3-256 is a throughput and precompile question (§5); it is irrelevant to whether key exchange exists. A hash-only KEM with a superpolynomial security gap would be a non-black-box construction nobody has, and would be a landmark result in complexity theory — not a design option for an ERC. 2. **"Commitment scheme" is inside the theorem's scope.** Hash commitments, Merkle trees, hash chains, XMSS/SPHINCS-style structures are all *n*-query random-oracle protocols. Any protocol composed from them is covered. ### 1.1 The quantum adversary makes it strictly worse Brassard, Høyer, Kalach, Kaplan, Laplante, Salvail, "Merkle Puzzles in a Quantum World" (CRYPTO 2011, [arXiv:1108.2316](https://arxiv.org/abs/1108.2316), [IACR](https://www.iacr.org/archive/crypto2011/68410385/68410385.pdf)): * Merkle's original scheme with **classical honest parties is broken completely by a quantum eavesdropper** — Grover over the puzzle set finds the sender's puzzle in effort proportional to the honest parties' own effort. Gap exponent 1: no security. * They build a *classical*-party scheme a quantum adversary cannot break with effort proportional to the honest parties' (concretely, ~N^\{7/6} in the body), and a family of classical protocols whose quantum-adversary cost approaches **N^\{3/2}** but never reaches it. * With **quantum honest parties** (wallets running quantum computers — out of scope for a wallet standard for the foreseeable future) a scheme reaches N^\{5/3}, and a family approaches N². So the proposition "hash-based key exchange as the post-quantum alternative" inverts the motivation: hash-only key exchange is the one family of key exchange whose security *drops* when the attacker becomes quantum, and the exponent it drops to is worse than the classical Merkle gap that was already unusable. ## 2. What the gap theorems cost, measured (`hash_kex_bench.py`) The bench measures hash rates here and converts each gap exponent *e* (attacker = n^e) into the honest work *n* needed to force attacker work 2^W, then into wall-clock and storage. Model: one Merkle puzzle = one hash call and 40 bytes; the recipient's "public key" is the puzzle set (n puzzles); the sender solves one puzzle (n hash calls); attacker rates assumed 2^24.5 H/s per native core, 2^34 per GPU, 2^70 for the 2026 Bitcoin network. Local Python-bound SHA-256 measured at 2^21.6 calls/s. | attacker model | gap | W = 2^80 | W = 2^128 | |---|---|---|---| | classical Eve, Merkle 1974 (Barak–Mahmoody optimal) | n² | n=2^40: 64 GPU-s per payment, **44 TB** recipient key, 1.8e15 gas if on-chain | n=2^64: **34 GPU-years** per payment, **738 EB** key | | quantum Eve, classical parties, Merkle 1974 | n¹ | n=2^80 — broken | n=2^128 — broken | | quantum Eve, classical parties, BHKKLS'11 concrete | n^\{7/6} | n=2^68.6: 809 GPU-yr | n=2^109.7 | | quantum Eve, classical parties, BHKKLS'11 family limit (unreachable) | n^\{3/2} | n=2^53.3: 184 GPU-h, 454 PB | n=2^85.3 | | quantum Eve, *quantum* honest parties, BHKKLS'11 | n^\{5/3} | n=2^48: 4.6 GPU-h, 11 PB | n=2^76.8 | Reference row, measured (D-012): **ML-KEM-768** — 1,184-B key, 1,088-B ciphertext, 17.2 µs decaps, 1.38 s per 80k-announcement scan, 67,700-gas announcement, NIST level 3 against classical *and* quantum attackers. Reading the table: W = 2^80 is not a security level (it is ~17 minutes of the Bitcoin network's hash rate), and it already requires a recipient key 3.7×10^10 times larger than ML-KEM-768's. The only row with quantum security for classical wallets that is not "broken" is n^\{7/6}, at 2^68.6 hashes per payment for the weak target. There is no parameter choice, hash function, or implementation trick that moves these rows: they are the exponents from §1 with numbers plugged in. A Merkle-root-on-chain / puzzles-off-chain variant removes the gas column and changes nothing else — the sender still needs the whole puzzle set and the honest work is the same. ## 3. Hash-only designs that do exist, and what each gives up The impossibility is specifically about **non-interactive agreement from public data**. Relaxing that is where every hash-only "stealth" construction lives. Each is listed with the property it trades away. | design | how it works | what it gives up | status for us | |---|---|---|---| | **A. Pre-shared-secret tag chain** (symmetric stealth) | S and R share `cs`; payment *i* carries `tag_i = SHA-256(cs ‖ i)` and an address whose secret ratchets from `cs`. Scan = exact-tag lookups, O(counterparties) not O(registry). | Requires `cs` to be *established*: by one KEM handshake (HNDL then hits that handshake, so the KEM assumption still protects every relationship's root secret) or **out-of-band** (in person, an existing secure channel). Non-interactivity for first contact. | **Already our design** — `poseidon2-stark-discovery.md` §2.2, SHA-256 per D-015. Announcement ~56 B vs 1,109 B; the 1,088-B ciphertext is paid once per relationship. This is what "hash-based" legitimately buys: amortization. | | **B. Published one-time address list** | R publishes N hash commitments / one-time public keys (Merkle root + leaves); S picks an unused leaf; ownership by hash-based signature or STARK preimage. | **Unlinkability.** The list is R's identity; every address on it is publicly R's. Also leaf exhaustion and sender collisions. Delivering the list privately instead collapses to A. | Not a stealth address — it is "publish N addresses", the thing ERC-5564 exists to avoid. | | **C. Interactive on-chain handshake** | S posts a commitment, R responds, S opens… | Nothing: with a public transcript and only a hash, the two parties hold exactly the eavesdropper's information — the §1 theorems apply to any number of rounds. Also breaks unilateral sending and needs R online. | Dead regardless of round count. | | **D. Merkle puzzles** | §2. | Everything (§2), and all quantum security (§1.1). | Dead. | | **E. Hash-based ownership** (SLH-DSA / XMSS signatures; STARK hash-preimage proof) | Spend authorizes with hashes only. | Nothing — this is where hashes are the right tool. | **Already the plan** (D-008 / D-012 / D-015). | The honest way to say "the scheme can be lattice-free" is therefore: *for a sender–recipient pair that established `cs` out-of-band, every payment is hash-only end to end; for everyone else the first contact needs a PQ KEM, and no hash construction can change that.* That opt-out is worth a sentence in the ERC; it is not an alternative KEM. ## 4. The comparison that is actually open: ML-KEM vs other structured PQ KEMs If the concern behind "consider hashes" is *assumption diversity* (a structural break in module lattices), the alternatives are the other structured families, all measured at matched level in D-012 (`discovery_kem_20260801.json`, 2026-08-01, M1 Max, liboqs): | KEM | assumption | level | pk B | ct B | decaps µs | scan 80k s | announce gas | |---|---|---|---|---|---|---|---| | ML-KEM-512 | Module-LWE | L1 | 800 | 768 | 11.6 | 0.9 | 54,900 | | NTRU-HPS-2048-509 | NTRU | L1 | 699 | 699 | 95.9 | 7.7 | 52,250 | | FrodoKEM-640-AES | LWE (unstructured) | L1 | 9,616 | 9,752 | 429.0 | 34.3 | 413,350 | | **HQC-1** (NIST backup, selected 2025-03-11) | quasi-cyclic codes | L1 | 2,241 | 4,433 | 2,373 | 190 | 201,140 | | BIKE-L1 | quasi-cyclic codes | L1 | 1,541 | 1,573 | 5,117 | 409 | 87,160 | | Classic-McEliece-348864 | Goppa codes | L1 | 261,120 | 96 | 18,008 | 1,441 | 28,020 | | **ML-KEM-768 (default)** | Module-LWE | L3 | 1,184 | 1,088 | 17.2 | 1.4 | 67,700 | | NTRU-HPS-2048-677 | NTRU | L3 | 930 | 930 | 52.4 | 4.2 | 61,680 | | FrodoKEM-976-AES | LWE (unstructured) | L3 | 15,632 | 15,792 | 750 | 60 | 654,190 | | HQC-3 | quasi-cyclic codes | L3 | 4,514 | 8,978 | 7,114 | 569 | 382,870 | | Classic-McEliece-460896 | Goppa codes | L3 | 524,160 | 156 | 43,697 | 3,496 | 30,490 | | CSIDH-512 (lit.) | isogeny NIKE | ~L1, disputed | 64 | 64 | ~80,000 | ~6,400 | 26,740 | HQC is the standardized hedge against a lattice break — NIST selected it on 2025-03-11 exactly for mathematical diversity ([NIST IR 8545](https://csrc.nist.rip/external/nvlpubs.nist.gov/nistpubs/ir/2025/NIST.IR.8545.pdf), [FedScoop](https://fedscoop.com/nist-backup-algorithm-general-encryption-quantum-cyberattacks-pqc/)); its draft FIPS is not yet out, and its current parameter sizes are from [pqc-hqc.org](https://pqc-hqc.org/) (HQC-1 2,241/4,433 B; HQC-3 4,514/8,978 B). For *this* scheme it costs a 4× ciphertext (3× the announcement gas) and a 140× slower scan at L1. The disciplined way to hedge the lattice assumption is therefore a **hybrid discovery KEM (ML-KEM ‖ HQC, KDF over both secrets)** as an optional parameter set — additive cost, secure if *either* holds — not a hash construction. Frodo (unstructured LWE) is the "lattice-but-conservative" pole at a 15.7 kB meta-address; NTRU is the cheap same-family hedge. None of this changes D-012's ranking: ML-KEM wins discovery on decaps latency and footprint. ## 5. Where SHA-256 and BLAKE3 do belong (and which one) Hashes own every non-KEM site of the design, and D-015 already fixes the policy. The bench adds the scan-side numbers so the choice can be made on facts: | site | primitive | measured here | |---|---|---| | KEM-internal KDF / domain separation | SHAKE256 / SHA3-256 (FIPS 203 internals, unchanged) | inside the 17.2 µs decaps | | view tag over the 32-B shared secret | SHA-256 | 0.28 µs (BLAKE3 0.43, SHA3-256 0.57, keccak 2.83 — Python binding) | | tag-chain PRF `tag_i = SHA-256(cs ‖ i)` | SHA-256 (D-015) | 0.28 µs per candidate tag | | KDF over the whole 1,088-B ciphertext | SHA-256 | 0.70 µs (BLAKE3 1.68, SHA3 2.22) | | bulk (indexer, accumulator, prover-internal) | BLAKE3 permitted (D-015) | SHA-256 2.4 GB/s vs BLAKE3 1.8 GB/s single-thread here; BLAKE3 wins multi-threaded and in Flock-class provers (82k vs 42k compressions/s/core, D-015) | | stealth address | keccak256 (EVM address rule) | 2.83 µs (pycryptodome; negligible) | | ownership / spend | STARK hash-preimage (SHA-256) or SLH-DSA | D-008 / D-012 | Per-announcement hashing is 0.3–1.7 µs against a 17.2 µs decaps: **the hash choice cannot move scan time by more than ~5%**; the KEM decides it. SHA-256 keeps its edge for anything that touches the chain (precompile `0x02` at 60 + 12/word gas; BLAKE3 has no precompile — only the BLAKE2b [`F`](/lean/proofs/demo#f) function of [EIP-152](https://eips.ethereum.org/EIPS/eip-152), and the 2023 [Magicians proposal](https://ethereum-magicians.org/t/eip-add-precompile-for-blake2s-blake3/12407) for a BLAKE3 precompile never became an EIP), hardware acceleration, and alignment with EIP-8304 and the EF's own post-Poseidon direction. Quantum margin is not a differentiator: both are 256-bit, Grover halves preimage resistance to 128 bits, collision stays ≥ 2^85 (BHT) and in practice 2^128 — fine at every parameter set we ship. ## 6. What this settles, and what it does not **Settled.** * A hash-based commitment scheme cannot be the discovery KEM; no hash function choice changes that (§1). Hash-only key exchange is *less* post-quantum than classical, not more (§1.1). * The measured cost of the best hash-only key exchange is 10–20 orders of magnitude off in key size alone (§2). There is no parameter regime to explore. * Hashes already do everything they can in the design: tag chains amortize the KEM to once per relationship (§3-A), and spend/KDF/tags are hash-only (D-015, §5). * The lattice choice for discovery is justified against the real alternatives — structured non-lattice KEMs — on measured scan and footprint numbers (D-012, §4). **Not settled / follow-ups.** * Whether to *offer* a hybrid ML-KEM‖HQC parameter set in the ERC as the assumption-diversity hedge. Cost is additive (§4); it is a one-line spec change plus vectors. Recommend "optional, not default". * ERC text: a sentence in Rationale stating the non-interactive requirement and citing Barak–Mahmoody, so reviewers do not re-open "why not hashes"; a sentence permitting out-of-band `cs` establishment for tag chains. * Lean: the impossibility is not something to formalize (it is a query-complexity result outside VCVio's scope); what *is* in scope and already done is that our reductions bottom out in IND-CCA of the KEM (D-013) — i.e. the proofs themselves say where the structured assumption sits. ## Sources * Impagliazzo, Rudich. *Limits on the provable consequences of one-way permutations.* STOC 1989. * Barak, Mahmoody-Ghidary. *Merkle Puzzles Are Optimal — An O(n²)-Query Attack on Any Key Exchange from a Random Oracle.* CRYPTO 2009. https://www.iacr.org/archive/crypto2009/56770369/56770369.pdf ; https://link.springer.com/chapter/10.1007/978-3-642-03356-8\_22 * Brassard, Høyer, Kalach, Kaplan, Laplante, Salvail. *Merkle Puzzles in a Quantum World.* CRYPTO 2011. https://arxiv.org/abs/1108.2316 ; https://www.iacr.org/archive/crypto2011/68410385/68410385.pdf ; slides https://www.iacr.org/conferences/crypto2011/slides/07-1-Kalach.pdf * Brassard, Salvail. *Quantum Merkle Puzzles.* ICQNM 2008. https://www.researchgate.net/publication/4320907\_Quantum\_Merkle\_Puzzles * NIST IR 8545, *Status Report on the Fourth Round* (HQC selection, 2025-03-11). https://csrc.nist.rip/external/nvlpubs.nist.gov/nistpubs/ir/2025/NIST.IR.8545.pdf ; https://fedscoop.com/nist-backup-algorithm-general-encryption-quantum-cyberattacks-pqc/ ; https://postquantum.com/post-quantum/cryptography-pqc-nist/ * HQC parameter sets. https://pqc-hqc.org/ * EIP-152 (BLAKE2b [`F`](/lean/proofs/demo#f) precompile). https://eips.ethereum.org/EIPS/eip-152 ; BLAKE3 precompile discussion https://ethereum-magicians.org/t/eip-add-precompile-for-blake2s-blake3/12407 * Aztec note discovery (tag-chain prior art). https://docs.aztec.network/developers/docs/foundational-topics/advanced/storage/note\_discovery * Local: `python/benchmarks/hash_kex_bench.py`, `hash_kex_20260825.json`, `discovery_kem_20260801.json`; `docs/DECISIONS.md` D-012, D-015; `docs/research/poseidon2-stark-discovery.md` §2.2. # After Poseidon: BLAKE2/3, Binius-era proving, and the hash policy for PQ stealth discovery :::info[Mirrored page] Source: [`docs/research/hash-migration-blake2-binius.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/hash-migration-blake2-binius.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: **Research question.** The Ethereum Foundation announced (2026-08-13) that it is abandoning Poseidon for L1. Our discovery-layer design (`poseidon2-stark-discovery.md`, 2026-08-05) used Poseidon2 in three places, each explicitly flagged "pending EF cryptanalysis outcome." What happened, what do BLAKE2/BLAKE3 and Binius-style binary-field systems change, and what should our hash policy be now? **Date:** 2026-08-15. Sources: web research (three sweeps: EF decision primary sources; standard- hash proving costs 2026; Binius/AO-hash landscape), citations inline. No local code is affected — Poseidon2 was never normative in this project (spec/vectors/ERC draft use SHAKE256, SHA-256, keccak only). *** ## Verdict in one paragraph The EF's pivot **confirms and strengthens** the conservative track our design already kept open. The reason Poseidon2 appeared in our architecture at all was the ~100× in-circuit penalty of standard hashes; that penalty has collapsed to a single-digit factor in 2026 provers built for Boolean computation (Flock, Binius64, binary GKR), while Poseidon's cryptanalytic margin visibly eroded (full-round CICO-2 on an instance, initiative prizes paused). Consequence for us: **drop Poseidon2 from every site in the discovery design and standardize on SHA-256** (normative, EIP-8304-aligned), with BLAKE3/BLAKE2s permitted as prover- or indexer-internal choices. The dual-root accumulator dies (it existed only to dodge the penalty), the tag-chain PRF becomes SHA-256, and the ZK-spend membership path uses standard-hash Merkle paths in a Flock-class or zkVM prover. "Binius-like hashes" (Vision Mark-32, Grøstl) are the one idea we examined and rejected: the Binius lineage's own conclusion is that hash-friendly SNARKs beat SNARK-friendly hashes, and the arithmetization-oriented (AO) hash family as a whole is taking cryptanalytic hits. Our discovery pipeline is now single-hash-family and PQ-sound from standard hashes alone — the same assumption set lean Ethereum itself is converging on. ## 1. What the EF actually decided (primary sources) * **The announcement.** 2026-08-13, Justin Drake on X ([drakefjustin/2087905684180418733](https://x.com/drakefjustin/status/2087905684180418733)): "Goodbye, Poseidon! An epic 8-year, 8-figure rabbit hole in post-quantum cryptography reaches its dream conclusion. The Ethereum Foundation is abandoning Poseidon for L1, pivoting to SHA or BLAKE." Key rationale line (via [crypto.news, 2026-08-13](https://crypto.news/ethereum-l1-drops-poseidon-in-post-quantum-move/)): **"In hindsight the key was not SNARK-friendly hashes, but hash-friendly SNARKs."** Corroborated same-day/next-day by [Cointelegraph/TradingView](https://www.tradingview.com/news/cointelegraph:94d0d54b3094b:0-ethereum-foundation-pivots-away-from-poseidon-in-post-quantum-plan/), [FXStreet 2026-08-14](https://www.fxstreet.com/cryptocurrencies/news/ethereum-foundation-pivots-away-from-poseidon-in-post-quantum-plan-202608140530), and others. * **What replaces it.** "SHA-2 or BLAKE2s" is the wording carried by the detailed coverage ([crypto.news](https://crypto.news/ethereum-l1-drops-poseidon-in-post-quantum-move/), [bitcoinworld](https://bitcoinworld.co.in/ethereum-foundation-shifts-to-sha2-blake2s-over-poseidon-hash/)); some outlets say "SHA and BLAKE3" ([KuCoin flash](https://www.kucoin.com/news/flash/ethereum-foundation-shifts-focus-from-poseidon-to-sha-and-blake3-hashes)). The correct reading: **the family is decided (standard bit-oriented hashes), the exact member is not yet pinned** — BLAKE3 is what the record-setting prover benchmarks use, BLAKE2s is what the L1 write-ups name, SHA-256 is the ultra-conservative pole. Timeline attached to the announcement: production-grade **leanVM in 2027**, deployments across consensus/data/execution **in 2028**, inside the "Strawmap" roadmap (~7 forks through 2029, [Decrypt](https://decrypt.co/359204/ethereum-foundation-drafts-seven-fork-strawmap-through-2029)). * **Announced direction, not shipped code.** As of 2026-08-14 the [leanEthereum/leanSig](https://github.com/leanEthereum/leanSig) README still hardcodes **Poseidon1** instantiations ("Hardcoded instantiations of this generic framework (using Poseidon1)…"); [leanroadmap.org](https://leanroadmap.org/) still lists the Poseidon Cryptanalysis Initiative at 50%. Poseidon1 was chosen over Poseidon2 in leanSig because the recent attacks touch Poseidon2's more aggressive structure, at a 2× slowdown (coverage of the hash-choice debate, KuCoin/crypto.news above). Expect the repos to follow the announcement. * **Not a full break.** Nothing in production is broken. The pivot is a margin-plus-economics decision: the prover advantage that justified Poseidon's risk disappeared (see §2) exactly as its security margin thinned (see §3). ## 2. Leg 1 — hash-friendly SNARKs closed the gap The 2026 proving-cost picture for **standard** hashes (laptop-class hardware unless noted): | System (paradigm) | Standard-hash throughput | Source | |---|---|---| | **Flock** (R1CS-over-GF(2), zerocheck+lincheck; Bünz–Rothblum–Wang, 2026-06) | **82.1k BLAKE3 compr/s, 42.1k SHA-256/s, 30.7k Keccak-f/s — single M4 Max core; 660k BLAKE3/s on 10 cores**; overhead \<250× native (~170× SHA-256, ~245× Keccak); 8.4× faster than Binius64 on SHA-256, 14× vs Binius64/Plonky3 on BLAKE3 | [eprint 2026/1329](https://eprint.iacr.org/2026/1329), [arXiv 2607.27491](https://arxiv.org/abs/2607.27491), [Succinct blog](https://blog.succinct.xyz/introducing-flock/), [Espresso blog 2026-06](https://medium.com/@espressosys/we-just-broke-the-speed-limit-on-snark-proving-0f47da6340de) | | **SNARK.fast** (AI-assisted optimization over the same class) | **1.8M BLAKE3 compressions/s** (255% over baseline) — UNVERIFIED beyond the announcement coverage | via [crypto.news](https://crypto.news/ethereum-l1-drops-poseidon-in-post-quantum-move/) | | **Binius64** (64-bit-word binary-field SNARK, Irreducible, 2025-09) | ~9.6k Keccak-f/s, ~9.2k BLAKE2s compr/s (C7i.16xlarge, mt: 1,365 Keccak perms in 142 ms; 2,048 BLAKE2s compr in 223 ms); was SOTA for SHA-256 until Flock | [Announcing Binius64](https://www.irreducible.com/posts/announcing-binius64), [binius.xyz/benchmarks](https://www.binius.xyz/benchmarks/) | | **Hashcaster** (GF(2) Frobenius sumcheck) | 36–60k Keccak-f/s; hard to extend to SHA-256/BLAKE3 (u32 additions vs GKR) | [repo](https://github.com/morgana-proofs/hashcaster), [Flock paper comparison](https://eprint.iacr.org/2026/1329) | | **Expander / binary GKR** (Polyhedra) | 14k Keccak-256/s CPU; 110k/s RTX4090; 150k/s H100 | [Polyhedra blog](https://blog.polyhedra.network/binary-gkr/) | | **Plonky3** (prime-field AIR, no lookups for these yet) | 30k BLAKE3/s; 4k Keccak/s | [awesome-plonky3 / Flock comparisons](https://arxiv.org/html/2607.27491v1) | | **Stwo** (Circle STARK) — *the algebraic reference point* | ~620k **Poseidon2-M31**/s (M3 laptop, 2024 demo) | [StarkWare](https://starkware.co/blog/starkware-new-proving-record/) | Reading: our 2026-08-05 doc's "SHA-256 in-circuit ≈ 100× Poseidon2" line is dead. Per-core, Flock's BLAKE3 is within ~7× of the Stwo Poseidon2-M31 demo figure, and multi-core (660k/s) it matches it outright — before SNARK.fast-style tuning. Drake's summary claim: binary-field SNARKs prove ~1M standard-hash calls/s on a laptop at ~100× native overhead ([crypto.news](https://crypto.news/ethereum-l1-drops-poseidon-in-post-quantum-move/)). For the signature-aggregation flavor of this (leanSig/leanVM): leanVM targets **1,000 XMSS signature aggregations/s** with 2-to-1 recursion at ~200 ms, on Plonky3+WHIR at ~0.5–1M hashes/s CPU ([Lean Consensus 2026 plan](https://hackmd.io/@tcoratger/ryS1ElrWbx), [proof-systems report](https://hackmd.io/@tcoratger/rk23bJfpJl), [ZK Podcast #394](https://zeroknowledge.fm/podcast/394/)). Binius64's public benchmark suite includes **XMSS+WOTS-with-Keccak aggregation** — hash-based signatures over standard hashes are a first-class proving target now, which is exactly the workload our lattice-free ZK-spend variant (D-012) would ride. ## 3. Leg 2 — Poseidon's margin eroded Status of the [Poseidon Cryptanalysis Initiative](https://www.poseidon-initiative.info/) as of Aug 2026: * **Poseidon1 collision prize ($992k): PAUSED starting 2026-08-01** ("THE PROGRAM IS PAUSED STARTING 1 AUG 2026 AoE"); one claim verified (q=3, $32k, 2026-04-06). * **Bounty Program 2026 ($150k): FINISHED**, multiple claims verified Apr–Jul 2026 — CICO RF=6/RP=10 solved 2026-07-06 ($15k); zero-test record RF=6/RP=12 claimed 2026-07-27 (two simultaneous submissions). * Fresh papers pushing the frontier: [Slipway (Vitto, eprint 2026/1579, Aug 2026)](https://eprint.iacr.org/2026/1579) — a **full-round CICO-2 solution on the KoalaBear Poseidon instance** via finite subspace trails (effective degree 3^10 instead of the expected 3^28, using matrices that pass the official design checks); [Graeffe-based attacks (2025/1916)](https://eprint.iacr.org/2025/1916) — 2^13 wall-time speedups on interpolation attacks against round-reduced/constrained instances; [Top Gun degree-annihilation (2026/1254)](https://eprint.iacr.org/2026/1254); [Gröbner-basis attacks exploiting subspace trails (2025/954)](https://eprint.iacr.org/2025/954). None of this breaks deployed full-round parameter sets. But CICO-type properties are precisely what a hash used **inside proof systems** must not have, the attack ceiling rose all year, and the safer fallback (Poseidon1) costs 2× — at which point the §2 numbers make standard hashes the better deal. That is the whole EF argument in one line. ## 4. "Binius-like hashes": examined and rejected The user's question was specifically about the binary-field-native hash family. Findings: * **The Binius lineage** ([Diamond–Posen, eprint 2023/1784](https://eprint.iacr.org/2023/1784) → [Binius64, 2025-09](https://www.irreducible.com/posts/announcing-binius64) → Flock/Hashcaster/ binary GKR) was built to make **existing bit-oriented hashes** (Keccak-256, Grøstl, SHA-256, BLAKE) cheap to prove — that is its stated pitch, and in 2026 it demonstrably holds. The ecosystem's own conclusion, now adopted by the EF, is *don't design new circuit-friendly hashes; design provers for the hashes we trust.* The Flock authors state it directly: a SNARK-friendly hash is cheap only inside the one proof system it was designed for, while standard hashes "dissolve that choice" ([Succinct blog](https://blog.succinct.xyz/introducing-flock/)). * **Vision Mark-32** ([eprint 2024/633](https://eprint.iacr.org/2024/633), Irreducible) is the binary-tower AO hash designed *for* Binius (hardware pitch: within 33% of Grøstl per LUT). It is young, thinly cryptanalyzed, and the Binius64 post itself demotes it to a future option. Adopting it would re-import exactly the "novel hash, single-prover lock-in" risk the EF just exited. **Grøstl** (AES-based SHA-3 finalist) is the one AO-adjacent candidate with a real cryptanalytic pedigree, but with Flock-class numbers for SHA-256/BLAKE3 there is no remaining reason to prefer it. * **The AO-hash family is taking hits across the board**, not just Poseidon: [improved resultant attacks (eprint 2025/259)](https://eprint.iacr.org/2025/259) break the claimed security of most Griffin, Arion and Anemoi variants and one 512-bit Rescue parameter set; [round-reduced collision attacks on Tip5/Tip4/Tip4'/Monolith (2024/1900)](https://eprint.iacr.org/2024/1900); high-probability linear approximations in the S-boxes of Skyscraper, Monolith, Tip5 and Reinforced Concrete ([ToSC](https://tosc.iacr.org/index.php/ToSC/article/view/12245)); the [FreeLunch Gröbner framework](https://link.springer.com/chapter/10.1007/978-3-031-68385-5_5) generalizes. Binary-field ports of Poseidon exist ([Poseidon(2)b, eprint 2025/1893](https://eprint.iacr.org/2025/1893)) but inherit the same research risk. **Conclusion: no AO hash anywhere in our design.** ## 5. Re-deciding our four hash sites Site by site against `poseidon2-stark-discovery.md`: 1. **Tag-chain PRF** (§2.2 there: `tag_i = Poseidon2(cs, i)` "(or SHA-256)"). → **SHA-256.** Tags are computed natively by wallets and indexers; Poseidon2 was only ever there for a hypothetical in-circuit tag-membership proof, which standard hashes now cover at 40–80k hashes/s/core (§2). SHA-256 keeps the tag column byte-compatible with the EIP-8304 SSZ/SHA-256 pipeline our PoC already implements, and the unlinkability argument (PRF hop on the chain secret, composing with the Lean KEM-anonymity chain) now rests on SHA-2 — the same assumption family lean Ethereum consensus will rest on. KEM-side KDFs stay SHAKE256 (FIPS 203/204 stack, unchanged and normative in the spec). 2. **Announcement accumulator** (§5 there: dual-root, SHA-256 for light clients + Poseidon2-M31 for circuits). → **Single SHA-256 root; the dual-root idea is dead.** Its only purpose was dodging the ~100× circuit penalty. Recomputed: a 2^16-leaf tree costs ≈1.6 s single-core / ≈0.2 s on 10 cores to prove end-to-end with SHA-256 in a Flock-class prover — comfortably fine for indexer-side completeness proofs, and light clients/8304 verify the same root natively. One tree, one hash, provable and 8304-compatible. 3. **Spend-side ZK membership** (anonymity-set spend; composes with the lattice-free FRI/STARK ownership route of D-012). → **Standard-hash Merkle path (SHA-256 or BLAKE3) inside a Flock-class prover or zkVM.** A depth-20 path is 20 compressions ≈ 0.5 ms of single-core proving at Flock's SHA-256 rate — noise. The end-to-end PQ story ("security from hashes alone") is now uniform: statement, accumulator and prover internals all standard-hash. 4. **STARK-internal FRI/WHIR Merkle commitment.** → prover-internal choice, keep it conservative (BLAKE3 or SHA-256), matching where the field is going; see [SoK: hash-based polynomial commitments (eprint 2026/1367)](https://eprint.iacr.org/2026/1367) and [The Billion Dollar Merkle Tree (Coratger–Khovratovich, eprint 2026/089)](https://eprint.iacr.org/2026/089) for the current design space. Not something our spec needs to fix — but our docs should stop naming Poseidon2-M31 as the assumed instantiation. **BLAKE2s vs BLAKE3 vs SHA-256, explicitly.** SHA-256: NIST-standardized, the 8304/consensus choice, slowest natively of the three, best-studied — our **normative default**. BLAKE3: fastest both natively (~5–13 GB/s class) and in provers (82k/s core in Flock, the SNARK.fast target), tree-hashing built in, but not formally standardized — **allowed for indexer/prover internals**. BLAKE2s: RFC 7693, the name in the EF's own L1 write-ups, sits between — if lean Ethereum pins BLAKE2s normatively we can follow in a spec revision; nothing in our design is sensitive to the choice among the three. What is settled is the *family*: bit-oriented, standard, 128-bit+ security, Grover-only quantum impact. ## 6. What this buys us (and costs us) Gains: * **Simplification**: dual-root machinery gone; one hash family across announcement pipeline, 8304 PoC, accumulator, tag chains, and (future) spend circuit. * **The D-013/D-012 "monitored risk" on Poseidon2 is resolved** — in the direction our conservative default already pointed. The ERC draft needs no change (it never named an AO hash). * **Narrative alignment**: our discovery layer's PQ-soundness argument ("hashes alone") is now literally the L1's own post-quantum argument, with the same primitives; and the tag-chain + completeness-proof deliverables (the publishable pieces of the 2026-08-05 memo) get *cheaper* to defend, since no reviewer can object to a novel hash assumption. * The lattice-free ZK-spend variant rides the exact workload (standard-hash Merkle proofs, XMSS-style aggregation) that leanVM/Binius64/Flock are being optimized and battle-tested for. Costs / open items: * In-circuit hashing is still ~5–10× Poseidon2-per-core in the worst comparison — irrelevant at our proof sizes (§5.2–5.3), but worth re-measuring if we ever prove ML-KEM decaps in-circuit (the unbenchmarked "prove my scan" territory from the 2026-08-05 memo). * The EF's exact hash pick (SHA-2 vs BLAKE2s/3) is not final; our normative SHA-256 default is safe under every outcome, but track leanSpec/leanSig for the pin. * Flock is 2026-06 work, single-team benchmarks (flagged as such); Binius64's own bench page retracted some early numbers. Treat all §2 figures as vendor-measured until independently reproduced — the *direction* (gap closed) is corroborated across ≥4 independent systems. ## 7. Recommended position (supersedes §5/§7 of poseidon2-stark-discovery.md where they touch hashes) 1. **Hash policy (D-015)**: SHA-256 normative for tags, accumulators, completeness proofs; SHAKE256 unchanged for KEM-side KDF/domain-separation; BLAKE3/BLAKE2s permitted prover/indexer-internally; **no arithmetization-oriented hash anywhere** (Poseidon/2, Vision, Skyscraper, Monolith, Grøstl included). 2. Tag-chain ERC extension text: specify `tag_i = SHA-256(cs || i)`-style derivation (exact domain-separated encoding at spec time), drop every Poseidon2 mention. 3. Accumulator design: single SHA-256 root, 8304-superset shape as before; completeness proofs client-verified, proven by indexers in a standard-hash-friendly prover (Flock-class or zkVM). 4. Watch items: leanSpec's final hash pin; independent reproduction of Flock-class benchmarks; Poseidon-initiative postmortem (for citing in the security write-up, not for design). # Key-blinding prior art — bibliography :::info[Mirrored page] Source: [`docs/research/key-blinding-bibliography.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/key-blinding-bibliography.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Reviewed 2026-09-30. This closes the "key-blinding reading" item of the project roadmap ([original-project-plan.md](/research/original-project-plan)): a grounded survey of key-blinding prior art from Tor v3 through the post-quantum line of work this repository's Construction A blinding descends from. It is a literature map, not a security claim; no theorem here changes [the Construction A analysis](/research/construction-a-security), and the current claim boundaries stay in [SECURITY\_ANALYSIS.md](/spec/security-analysis). Scope: key blinding. Blind signatures appear only to keep the vocabularies apart; zk-login / social-recovery key custody and proxy re-encryption are not surveyed here. ## What "key blinding" names Given a master keypair `(sk, pk)` and a public nonce `n`, a key-blinding scheme provides: * a derivation `(sk, n) ↦ sk_n` usable for signing, and `(pk, n) ↦ pk_n` computable without the secret; * unlinkability: someone seeing many blinded public keys and their signatures cannot tell which derive from the same master keypair; * unforgeability: blinding does not weaken signing. This is exactly the shape of Tor v3's appendix *[A. Signature scheme with key blinding \[KEYBLIND\]](https://raw.githubusercontent.com/torproject/torspec/main/rend-spec-v3.txt)* (rend-spec-v3.txt, appendix A.2 for the derivation, §2.1 "Deriving blinded keys and subcredentials \[SUBCRED]" for the mechanism): per-period blinded Ed25519 keys `a' = h·a`, `A' = h·A` with `h = H(BLIND_STRING | A | s | B | N)`, so descriptor lookup indexes and signing keys rotate together without a trusted party. The Tor spec text itself still carries its `[TODO: Insert a more rigorous definition and better references.]` — the rigor arrived in the papers below. ## The classical baseline and why it dies Tor v3's blinding is scalar-multiplication-homomorphic: blinding factor in the exponent, same base point. That is a discrete-log assumption, and a harvest-now-decrypt-later adversary breaks it. Every PQ replacement in the list below keeps the two-derivation shape and swaps the algebra. ## The post-quantum line (the one we build on) ### Eaton, Stebila, Stracovsky — *Post-Quantum Key-Blinding for Authentication in Anonymity Networks*, Latincrypt 2021 (eprint [2021/963](https://eprint.iacr.org/2021/963)) Primary prior art for Construction A's blinded ML-DSA. Four fully PQ key-blinding schemes in the ePrint revision (Dilithium, Picnic, CSI-FiSh, LegRoast); the LATINCRYPT 2021 proceedings version has three, without Picnic, so cite the revision meant. Unlinkability and unforgeability proven in the ROM, plus a **generic framework reducing key-blinded unlinkability to two properties: signing with oracle reprogramming, and independent blinding**. This is the same decomposition this repository instantiates: the blinded key `(s₁+s', s₂+e')` is an "independent blinding" added to the master key, and [`blindPointMassBound_mldsa`](/lean/proofs/blindingentropy#blindpointmassbound_mldsa) / the ROM bad-query bound (`BlindingROM.lean`, `BlindingEntropy.lean`) are our oracle-reprogramming side. Their Dilithium construction is the direct ancestor of our `derive_blinding` → widened-key spend route at `β' = 2·β = τ·2η`. Boundary on that mapping: our ROM work is the **announcement/address** side. `BlindingROM.lean` records the identification of its idealized game with [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) as assumed, not proved, and there is no blinded-signature unlinkability game in the tree at all (issue #34: a direct ML-DSA spend reveals `pk`, whose hash is the published `spend_key`). The framework citation therefore anchors the address argument, not a signing-side result. The related-key unforgeability leg does have an in-repo anchor: [`relatedSpendAdvantage_le_mul_spendForgery`](/lean/proofs/spendsecurity#relatedspendadvantage_le_mul_spendforgery) (`SpendSecurity.lean`). Caveat we inherit: ROM-only (no QROM), and their lattice blinding needs the widened bound — exactly the `2·β` rejection-gate widening our `WidenedSigning.lean` proves. ### Balumuri, Eaton, Lamontagne — *Quantum-Safe Public Key Blinding from MPC-in-the-Head Signature Schemes*, ACNS 2025, LNCS pp. 369–397 (eprint [2024/945](https://eprint.iacr.org/2024/945)) The direct follow-up: first **QROM** key-blinding proof, built from any MPC-in-the-Head signature (instantiated with Helium). Explicitly names the Eaton–Stebila–Stracovsky lattice schemes' shortcomings — large public keys, ROM-only security — and closes the QROM gap on the symmetric side. Relevant to us as the current frontier for QROM blinding: our Construction A analysis remains ROM (classical oracle access), a boundary [construction-a-security.md §7](/research/construction-a-security) already records as an exclusion. ### Basso, Borin, Corte-Real Santos, Dartois, Invernizzi, Maino, Pedersen, Seck — *Isogeny-based Signatures with Randomizable Keys*, 2026 (eprint [2026/1169](https://eprint.iacr.org/2026/1169)) Randomizable (blinded) keys for SQIsign, PRISM and DeuringVUF — deliberately *outside* the class-group-action setting, whose signatures "suffer from a subexponential quantum attack", and so the opposite of the CSI-FiSh route in Eaton et al. Works in the randomizable-key notion of Celi et al. below and proves unlinkability against unbounded adversaries for all three instantiations. Tracked as an alternative PQ blinding family; not used in any route here (no isogeny machinery in this repository). ### Celi, Griffy, Hanzlik, Perez Kempner, Slamanig — *SoK: Signatures with Randomizable Keys*, FC 2025, LNCS pp. 160–187 (eprint [2023/1524](https://eprint.iacr.org/2023/1524)) The systematization this note would otherwise reinvent: unified vocabulary across Derler–Slamanig, Backes et al. and Eaton et al., the security-property taxonomy, and the gaps. Key blinding is one flavour of randomizable keys, and it is the notion Basso et al. build on. Read this before adding entries. ## Adjacent but distinct (do not conflate with key blinding) **Blind signatures** (lattice round-optimal [eprint 2023/077](https://eprint.iacr.org/2023/077), Tanuki [2025/1100](https://eprint.iacr.org/2025/1100), and the older lattice blind-signature line) blind the *message*, not the key: a signer signs without seeing what it signs. Key blinding blinds the *key* — the signer knows exactly what it signs, on a derived pseudonymous identity. Tor's use case and ours are key blinding; blind signatures are a different primitive with a different security game. **Deterministic derivation** (Alkadri et al., *Deterministic Wallets in a Quantum World*, CCS 2020): per-address keys from a master secret with no sender-computable public form. It answers issue #34's question — fresh recipient-controlled keys per address — not the sender-side derivation this scheme needs. Eaton et al. cite it; it is the wrong tool for blinding and the right reference for rotation. **Anonymous credentials / Privacy Pass** ([eprint 2023/414](https://eprint.iacr.org/2023/414)): multi-show unlinkable credential presentations. Overlapping vocabulary, different trust model (issuer-held attributes). ## What this means for the ERC security write-up 1. The key-blinding spine is Tor v3 spec → Eaton et al. 2021 (our direct ancestor, ROM) → Balumuri et al. 2025 (QROM, MPCitH only), with Celi et al.'s SoK as the map and isogeny randomization (Basso et al. 2026, unbounded-adversary unlinkability) as the alternative family. That is the set of families we build on, not a literature-completeness claim. 2. The widened-z write-up in [construction-a-security.md §1.2a/§8](/research/construction-a-security) is the Eaton-framework "independent blinding" side instantiated at ML-DSA-65: cube-uniform mask, widened gates, exact acceptance probability `(1047791/1048576)^1280`, independent of the secret. The two-property decomposition gives the write-up its citation anchor: the address-side blinding analysis is an instance of their generic framework, not an ad-hoc construction (signing side: see the boundary above). 3. The ROM/QROM boundary is the honest frontier statement for the ERC: classical-ROM decomposition proven in-repo; QROM blinding exists for MPCitH (2024/945) and unconditionally for the three isogeny schemes of 2026/1169, but not for lattice blinding — still open as of 2026-10. Date that claim wherever the ERC text repeats it. # Keyed and expiring nonces (EIP-8250 / EIP-8266): the protocol grew a nullifier, so we should stop designing one :::info[Mirrored page] Source: [`docs/research/keyed-nonces-as-nullifiers.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/keyed-nonces-as-nullifiers.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: *2026-09-03. Extends D-020 (spend targets EIP-8141 frame transactions) and revisits the parked D-012 / D-008 ZK-spend variant. No code or vectors change; this is a design memo against two Draft EIPs that were not on the table when D-020 was written.* > **Update 2026-09-09.** Written on 2026-09-03 against the construction-A mainline; its > decision record is now **D-026** (`docs/DECISIONS.md`). Since then D-024 adopted the > 1,217-byte commitment meta-address this memo calls "D-012's prize", and D-025 made the > ZK-authorized spend the demo mainline, with non-replay currently supplied by the > sponsor's nonce (the account is not the tx sender). §5 and §9.4 should be read as > applying to that mainline rather than to a parked variant. The scoping in §6 is > unchanged: the payer of a D-024 payment computes the commitment and the address, so the > nonce key is still not the only per-payment tag. `docs/erc-draft.md` is hand-written > only (D-024); the spec-text items here go to its author and to `docs/TECHNICAL_SPEC.md`. ## 1. Question D-020 locked the spend path onto EIP-8141 frame transactions and inherited that EIP's replay model: **one linear account nonce per sender**. Two follow-on Draft EIPs change that model: * **[EIP-8250](https://eips.ethereum.org/EIPS/eip-8250) — Keyed Nonces for Frame Transactions** (Thiery, Wahrstätter, lightclient, Buterin; Draft, created 2026-04-16; requires 7623, 8037, 8141). Replaces the single `nonce` with `(nonce_keys, nonce_seq)`. * **[EIP-8266](https://eips.ethereum.org/EIPS/eip-8266) — Expiring Nonces for Frame Transactions** (Wahrstätter, lightclient; Draft, created 2026-05-15; requires 8141). Replay protection bounded by a 60-second deadline instead of a nonce. The question: **if a keyed nonce is already a protocol-enforced single-use token — a nullifier — what does that let this scheme stop building, and does it improve stealth address *generation*?** Short answer: it removes one of the two obligations that keep the compact, commitment-based derivation of D-012 parked, and it dissolves the sponsor bottleneck in the shipped frames spend route. But the two EIPs are not interchangeable for us, the recommendation is mostly **8266, not 8250**, and there is a derivation trap that would hand every payer a permanent spend-detection oracle if we get it wrong. *** ## 2. What the two EIPs actually give ### 2.1 EIP-8250: keyed nonces `nonce` becomes two payload fields: `nonce_keys` (1–16 `uint256`, strictly increasing, `MAX_NONCE_KEYS = 16`) and `nonce_seq` (`uint64`). `nonce_keys == [0]` means the legacy account nonce. Any non-zero key gets its own slot in the `NONCE_MANAGER` predeploy at `0x…8250`: ``` slot(sender, nonce_key) = keccak256( left_pad_32(sender) ‖ uint256_to_bytes32(nonce_key) ) ``` A transaction is stateful-valid only if `nonce_seq == current_nonce_seq(sender, k)` for **every** `k` it names, and consumption — writing `nonce_seq + 1` to each named slot — happens exactly once, during EIP-8141's payment-approval transition. Transactions whose non-zero key sets are disjoint are therefore replay-independent: same sender, no serialization between them. The EIP says the nullifier part in its own words: > Tying nonce consumption to EIP-8141's payment-approval step also gives single-use-key > applications, such as nullifiers, an atomic spent-once guarantee: if validation requires > every selected key to be unused, successful inclusion makes all selected keys used. Costs and exposure that matter to us: | Fact | Value | | --- | --- | | First use of a non-zero key | `KEYED_NONCE_FIRST_USE_STATE_GAS = 97,920` state gas, per fresh key | | Slot lifetime | **permanent** — slots are never deleted | | Key exhaustion | at `nonce_seq == 2^64 − 1` the key cannot advance | | Introspection | `TXPARAM 0x01` `nonce_seq`, `0x0E` `len(nonce_keys)`, `0x0F` hash of the key array, `0x10` first key | | App guidance | authenticate at least `(sender, nonce_keys_hash, nonce_seq == 0)` in `VERIFY`; domain-separate derived keys; reject `k == 0` | ### 2.2 EIP-8266: expiring nonces `tx.nonce == 2^64 − 1` selects the mode. The transaction carries exactly one `VERIFY` frame targeting `EXPIRY_VERIFIER` with an 8-byte deadline `d`, and the protocol enforces `now <= d <= now + MAX_EXPIRY_SECS` with `MAX_EXPIRY_SECS = 60`. Seen sig-hashes go into a fixed ring buffer at `NONCE_RING` (`RING_CAPACITY = 2^18`), each fresh write paired with a clear as the pointer wraps, so state growth is bounded at `2 × RING_CAPACITY` slots and is **zero in steady state**. Flat `EXPIRING_NONCE_GAS = 13,000`, deliberately excluding the `SSTORE_SET` premium. And the line that matters most here: nodes **MAY admit multiple pending expiring-nonce transactions per sender**, unlike EIP-8141. ### 2.3 The distinction we have to keep straight These solve two different problems that look alike: * **8266 gives freshness.** "This exact transaction has not been included in the last 60 seconds." Cheap, no permanent state, no derived secret. * **8250 gives a nullifier.** "This *token*, chosen by the application, is now spent, forever, and the fact is public state." Expensive, permanent, and the token is a payload field anyone can read. Our scheme needs freshness on every spend and a nullifier only in one hypothetical variant. Reaching for 8250 by default would be paying 97,920 state gas and a permanent slot per payment for a property we already get from the account balance. *** ## 3. The user's premise, restated precisely > A keyed nonce for the transaction *is* its nullifier, and the process of nullifying a > transaction is not needed any more because double-spending is solved at the protocol > level. Both halves are right, and they cut in opposite directions, which is the useful part. **A nullifier exists because a note is a commitment.** In a UTXO/note system nothing on-chain stops you presenting the same note twice, so the application publishes a single-use tag and maintains a spent set. That machinery is: state for the set, a membership check at spend, and a ZK statement binding the tag to the note secret. **pq-sap does not have notes.** Value at a stealth address is native ETH at an account. Spending it debits a balance; the balance is the double-spend defence. So for the mainline construction A spend path there is nothing to nullify — the user's second half is exactly right, and the correct conclusion is *don't buy a nullifier*, i.e. **8266, not 8250**. **Except in the variants where the authorization is detachable from the balance.** That is D-008/D-012 territory: a ZK ownership proof, or D-018's commit-signer opening, is a piece of evidence that could in principle be replayed independently of the account it authorizes. There the first half of the premise pays: the nullifier we would have had to design is now a choice of `nonce_key`. We don't build it, store it, or prove it — we pick it. *** ## 4. The immediate win: the sponsor stops being a bottleneck and a link This is the concrete, non-speculative one, and it touches shipped code. `js-client/contracts/src/frames/Stealth8141Account.sol` documents the shape a public frames network admits today: **the stealth account is not the transaction's `sender`.** A sponsor EOA is sender and payer, the stealth account is called as a `DEFAULT` frame, and the ML-DSA signature authorizes the whole `(sponsor, nonce, frames)` tuple via `TXPARAM 0x08` (`sig_hash`). In the demo that sponsor is the in-page throwaway key (`ui/src/lib/throwaway.ts`). Two costs follow, both structural: 1. **Linkability.** The sponsor is a unique public sender bound to a recipient's spends, and it must be funded from somewhere. This is precisely the leak D-020 expected native paymaster frames to close — but paymaster frames fix *who pays*, not *whose nonce orders the transaction*. The sender field stays. 2. **Serialization.** One legacy nonce per sender. `ui/src/lib/frames.ts` already carries the scar: `pendingNonce()` exists because "rapid back-to-back frame txs collide on `latest`." A sponsor shared by many recipients would be a global lock. EIP-8250's Rationale names this situation directly — privacy applications hit a throughput bottleneck when "a shared sender becomes necessary to avoid binding onchain activity to a unique public sender." Worth naming what actually happened here: **ERC-4337 had this for free and frame transactions gave it up.** A bundler packs many UserOps from many accounts into one transaction, so the on-chain sender is the bundler and mixing is a side effect of the architecture. EIP-8141 removes the bundler and makes the account originate its own transaction — which is the point — but the mixing goes with it. Keyed and expiring nonces are what give it back. **What we should do with that.** Both EIPs unblock a *shared, public sponsor*: one well-funded account carrying every stealth spend on the network, each spend in its own replay domain, no coordination, nothing for the recipient to fund. The anonymity set of the spend's `sender` field becomes every user of that sponsor instead of one throwaway EOA. 8266 is the better fit — 13,000 gas, no permanent slot, multiple pending transactions per sender explicitly allowed — with 8250 as the fallback if the 60-second deadline is too tight for a pre-signed or hardware-signed PQ spend (ML-DSA blinded signing averages ~30 rejection rounds, D-011, so signing latency is not negligible). **No contract change is needed for the binding.** EIP-8141's `sig_hash` is keccak over the RLP body, and EIP-8250 replaces the `nonce` field *inside that body*. So `Stealth8141Account.executeFrame`, which already validates the ARBITRARY signature over `FRAME_CTX.sigHash()`, binds `nonce_keys` and `nonce_seq` for free; the NatSpec claim "authorizes exactly one `(sponsor, nonce, frames)` tuple" simply becomes `(sponsor, nonce_keys, nonce_seq, frames)`. That already satisfies EIP-8250's guidance to authenticate `(sender, nonce_keys_hash, nonce_seq == 0)` in `VERIFY`. What *would* change is `js-client/src/frame-tx/serialize.ts`, whose body layout is transcribed from deployed ethrex, not from the spec draft — so that is a when-the-chain-ships-it change, not now. **Sponsor incentive.** A public sponsor needs paying. EIP-8141's `[deploy, only_verify, pay]` prefix already allows reimbursement from the stealth account inside the same transaction. That re-creates a value edge from the stealth address to the sponsor, but the sponsor is shared, so it carries no identity — unlike today's per-recipient throwaway. *** ## 5. The generation win: one of the two obstacles in front of the compact meta-address This is the answer to "does it improve stealth address *generation*", and it is indirect but real. Today's derivation is pinned by construction A (D-003): the sender must be able to compute the recipient's one-time **public key**, because the account verifies a signature under a key bound to its address. That single requirement is why the meta-address must carry the full-precision `t` — `version(1) ‖ rho(32) ‖ pack23(t)(4,416) ‖ ek(1,184) ≈ 5,633 B` — and why D-011 measures meta-address registration at ≈3.79M gas naive (~61× the EC baseline). plan.md states the same constraint from the other side: the sender-names-the-address model "constrains the derivation constructions this project evaluates." D-012 measured the prize for escaping it: with a ZK ownership proof instead of a blinded signature, the meta-address sheds `t` and becomes `version ‖ 32-B commitment ‖ ek` — **5,633 B → 1,217 B, 4.6×** — and the KEM decouples from spend entirely, so discovery can be chosen on scan speed alone. D-012 is parked, with the stated reason being that the ZK-spend **address-binding** is unproven. A ZK spend owes two things, not one: * **(a) Binding** — the proof must be tied to *this* address/announcement, so you can only spend what you own. Still open. EIP-8250 does not help. * **(b) Non-replay** — the proof must not be presentable twice. Under construction A this was free and invisible, because the one-time account's own legacy nonce serialized it; in any design where the authorization is detachable it becomes a nullifier obligation. **EIP-8250 discharges (b) at the protocol layer, for the price of picking a key.** That does not unpark D-012 — (a) is the harder half and is untouched — but it removes the obligation that would otherwise have had to be designed, specified, proved, and paid for alongside it. Anyone costing the ZK-spend variant after Hegotá should cost it as "binding, plus a `nonce_key`", not "binding, plus a nullifier subsystem". Scope honesty: the fully note-shaped variant, where value lands in a shared pool and the nullifier does all the work, is **not** an ERC-5564 scheme — value there does not land at an address the sender named. That is the Native-UTXO direction plan.md already points at, and keyed nonces make it substantially cheaper to specify. It is adjacent work, not a change to this ERC. *** ## 6. The derivation trap: never derive the nonce key from the shared secret The obvious derivation is `k = H(ss)`. **It is wrong, and wrong in a way specific to this scheme.** `ss` is the ML-KEM shared secret, and in a stealth scheme the *sender* computes it — they encapsulated it. Meanwhile `nonce_keys` are public payload fields, exposed in-protocol via `TXPARAM 0x0F` / `0x10`, and the consumed slot at `keccak(pad32(sender) ‖ bytes32(k))` in `NONCE_MANAGER` is permanent, publicly readable state. So `k = H(ss)` would hand every payer a permanent, publicly checkable oracle: *has my payment been spent yet, and in which transaction*. **How much this costs depends on the variant, and it is worth being exact.** In today's construction-A shape the payer already has a spend oracle: they know the stealth address, value visibly leaves it, and that stays true under the §4 shared sponsor — mixing the `sender` field does not hide the value edge. So an `ss`-derived key gives the payer no *information* they could not already get; it only makes the lookup cheaper (a storage read keyed to the sponsor, rather than watching an address) and permanent. It becomes decisive the moment the nonce key is the **only** public per-payment tag — which is exactly the direction §5 points at, and exactly what a gas-bank-style draw would be. In a pooled or ZK-spend variant the value edge is gone by construction, and an `ss`-derived key would put the payer's link straight back, keyed to the one party we most want excluded. The rule below costs nothing to follow, so it should be adopted unconditionally rather than when the variant that needs it arrives. **Rule.** The nonce key MUST be derived from a value the sender cannot compute, bound to public per-payment data so keys never collide across payments: ``` k = SHAKE256( "PQSAP-nonce-key-v1" ‖ K_null ‖ ct ) truncated to 256 bits, k != 0 ``` * `K_null` — a **nullifying key** derived from the recipient's master seed under its own domain separator. The sender cannot compute it; the recipient regenerates it deterministically. It may be derived from the ML-DSA spend secret instead, since whoever spends holds it — but keeping it separate lets a watch-only scanner hold the detection key `dk` without gaining the ability to compute spend tags. * `ct` — the announcement's ML-KEM ciphertext. Public, distinct per payment, already in the announcement, so no extra bytes anywhere. * `k != 0` and the domain separator are EIP-8250's own MUSTs. The **sender-uncomputable** requirement is ours: the EIP does not state it, because it does not have a party that knows the payment secret and is not the spender. This is worth carrying upstream as feedback on 8250's Security Considerations. **Key-hierarchy consequence.** This makes a third key alongside detection (`dk`) and spend — small, but a real spec change: the recipient's seed must derive `(dk, spend, K_null)` under distinct domain separators, and a watch-only export must be able to omit `K_null`. **Free side effect, both ways.** Because the key is used exactly once, `nonce_seq` is 0 before the spend and 1 forever after, so `slot(sender, k)` is a public "already spent" flag. A recipient's own wallet can answer "which of my payments are still unspent" with a storage read per payment instead of a transfer scan — genuinely useful. Anyone who learns `K_null` gets the same view over every payment the recipient ever received, retroactively and permanently. `K_null` is therefore a *linking* key with the same disclosure profile as a viewing key, and the spec must say so. *** ## 7. Costs, against D-020's hard constraint D-020's binding constraint is the mempool validation prefix: `MAX_VERIFY_GAS = 100,000` **execution** gas, 500,000 **state** gas, and no measured PQ verifier fits the execution half (C13 at 188,092 gas tx-level; ML-DSA at ~15M — out by two orders of magnitude). Neither EIP moves that number; both add a state-gas line item: | Mechanism | Per-spend cost | Permanent state | Fits the 500k state budget | | --- | --- | --- | --- | | Legacy nonce (`[0]`) | `STATE_BYTES_PER_NEW_ACCOUNT × CPSB` if the sender is fresh | account | yes | | Keyed nonce, first use | 97,920 state gas per fresh key | one slot, forever | yes — but ≤5 fresh keys | | Expiring nonce | 13,000 flat | none (ring, net zero) | yes, comfortably | Two readings: * A stealth scheme generates one payment per announcement, so an 8250-per-payment design buys **one permanent state slot per payment, network-wide, forever.** For a project whose headline cost finding is "on-chain cost is data, not compute" (D-011: the announcement is already pinned to the EIP-7623 calldata floor at 67,580 gas), quietly adding permanent state per payment is the wrong direction and we should say so out loud rather than discover it in review. * It strengthens, rather than weakens, D-020's working-group ask. The ask was a protocol-validated PQ signature identifier or a raised verify budget. Add: **if 8250 lands, the state-gas budget also has to accommodate at least one first-use keyed nonce alongside a PQ verification** — 97,920 of 500,000 is a fifth of the state budget spent before validation begins. *** ## 8. What this does not fix Stated plainly, because the temptation to oversell a protocol-level gift is real: * **Not verification cost.** PQ verification stays in the millions of gas (D-007, D-020). Nothing here helps the public-mempool admission problem. * **Not the announcement.** 1,088-B ciphertext, 67,580-gas calldata floor, unchanged (D-011). * **Not the value graph.** A shared sponsor mixes the *sender* and *gas-funding* edges. The value still leaves a unique stealth address the payer named. The gain is bounded to the sponsor side, and the ERC text must not imply otherwise. * **Not deployment cost.** Each stealth address still deploys its own account and key blob (D-014/D-020: keys never in initcode). Keyed nonces are about replay domains, not code. * **Not available.** Both EIPs are **Draft**, neither is scheduled, and EIP-8141 itself is only SFI for Hegotá. `serialize.ts` tracks *deployed* ethrex on chain 81410 for exactly this reason. *** ## 9. Recommendation 1. **Default to EIP-8266, not EIP-8250, for the mainline spend.** We need freshness, not a nullifier; the balance is our double-spend defence. 13,000 gas and no permanent state beats 97,920 gas and a slot per payment. Accept the 60-second deadline; note it as a constraint for pre-signed spends. 2. **Adopt the shared-sponsor pattern as the target frames spend shape** once either EIP ships, and retire the per-recipient throwaway sponsor. Record it as the completion of D-020's gas-funding-privacy item — paymaster frames fix the payer, nonce independence fixes the sender. 3. **Fix the derivation rule now, in the spec, before anyone implements it.** Nonce keys MUST be derived from a recipient-only `K_null`, domain-separated, bound to `ct`, never from `ss`. Add `K_null` to the key hierarchy with viewing-key disclosure semantics. 4. **Re-cost the D-012 ZK-spend variant** with the non-replay obligation removed. It stays parked on address-binding, but the cost line is now "binding + one `nonce_key`", and the prize is unchanged: 5,633 B → 1,217 B meta-address, KEM decoupled from spend. *(2026-09-09: landed as D-024/D-025 — the cost line now describes the mainline spend.)* 5. **Carry two items to the EIP authors.** (a) The sender-uncomputable requirement belongs in 8250's Security Considerations — schemes where a third party knows the payment secret need it, and 8250's own privacy framing invites exactly those schemes. (b) The D-020 ask should now cover state gas as well as execution gas in the validation prefix. Nothing here changes vectors, the Python reference, or any locked decision. Items 1–3 are spec-text changes to make before the ERC freeze; items 4–5 are follow-ups. ## Sources * [EIP-8250: Keyed Nonces for Frame Transactions](https://eips.ethereum.org/EIPS/eip-8250) ([discussion](https://ethereum-magicians.org/t/eip-8250-keyed-nonces-for-frame-transactions/28437)) * [EIP-8266: Expiring Nonces for Frame Transactions](https://eips.ethereum.org/EIPS/eip-8266) ([discussion](https://ethereum-magicians.org/t/eip-8266-expiring-nonces-for-frame-transactions/28575)) * [EIP-8141: Frame Transaction](https://eips.ethereum.org/EIPS/eip-8141) * In-repo: `docs/DECISIONS.md` D-003, D-007, D-008, D-011, D-012, D-014, D-018, D-020; `js-client/contracts/src/frames/Stealth8141Account.sol`; `js-client/src/frame-tx/serialize.ts`; `ui/src/lib/frames.ts`; `ui/src/lib/throwaway.ts`. # Post-Quantum Stealth Addresses as an ERC-5564 Scheme Extension :::info[Mirrored page] Source: [`docs/research/original-project-plan.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/original-project-plan.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Historical cohort plan, preserved before the D-024/D-025 documentation refactor. Its future-tense tasks, costs, security claims, and protocol expectations are not a current status report. See the [current plan](/overview/plan) and [ERC evidence map](/spec/erc-evidence). The project will specify, implement, benchmark, and test a post-quantum stealth address scheme as a new [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) scheme ID, with client-agnostic test vectors, a reference library, and a written security analysis. ## Motivation Ethereum is preparing for the post-quantum transition well in advance: the EF roadmap expects post-quantum signature schemes to become available on Ethereum following the Hegotá hard fork and EIP-8141 (ethereum.org/roadmap/security/quantum-resistance, April 2026). Stealth address cryptography is already widely used ([Fluidkey](https://www.fluidkey.com/), [Cloaked](https://www.clkd.xyz/), [Umbra](https://umbraprivacy.com/), and other ERC-5564-based protocols) to preserve privacy while keeping human-readable handles - it is much cheaper than heavier privacy systems and offers better UX for compartmentalizing funds. To display a balance, a wallet downloads the announcement logs and in linear time checks a one-byte view tag to skip non-matching entries without decoding everything. On a candidate match, it computes the shared secret `S = v·R`, confirms the payment, and with the spending key converts it into the private key controlling the address. All of this data is public and permanent: a future quantum computer running Shor's algorithm can recover the viewing and spending keys from it, deanonymizing every stealth payment ever made ("harvest now, decrypt later", NIST IR 8547). This project addresses that at the ERC level: a new scheme ID working against the existing ERC-5564/ERC-6538 contracts. Onchain spending from a post-quantum key awaits protocol-level signature support (e.g., EIP-8141) and is scoped out of the core deliverable. Recent discussions like Native UTXOs presuppose exactly such an ERC-5564 scheme for their post-quantum, key-only recipients. And the sender-names-the-address model constrains the derivation constructions this project evaluates. **Stretch goal:** measure the speed, gas costs and end-to-end implementation of a stealth-address protocol using ERC-4337 with post-quantum key exchange and a zero-knowledge ownership proof, for cheaper spending during the transition (per pq.ethereum.org). This is not part of the core deliverable. ## Project description **Proposed solution.** Using lattice-based key encapsulation: Learning With Errors (LWE) and its Module variant (MLWE), the basis of NIST's ML-KEM - for the key-exchange step, we will produce a new ERC-5564 extension: a post-quantum-ready scheme ID. Per [ePrint 2025/112,](https://eprint.iacr.org/2025/112) the LWE approach *In some cases* brings an increase in parsing speed together with future-proofing the Native UTXO design and its ERC-5564 use case. The parsing trick: the errors mixed into the polynomial equations are easy for the recipient to discover. After plugging in their real secret values, the error falls out as a subtraction from the publicly broadcast value. Without the key, separating the noise from the structure is the hard problem, for quantum computers too. We can also generate a proof of such statement for spending instead of using full-pq signature. Log verifiability provided by EIP-8304 (a stretch goal, below) would be the cherry on top allowing to verify completeness proofs that a scan returned all announcements, none omitted. The work divides into one research question and one engineering deliverable. **Research question: sender-side address derivation.** In the SECP256K1 scheme the sender builds the recipient's one-time address from the meta-address and a shared secret (`P = K + hash(S)·G`) without ever seeing the private key. That doesn't move over cleanly to FIPS 204/205. An ML-DSA key *can* be shifted additively, but unlike an EC point the shift grows the secret's norm (so the signer needs a wider bound, `β' = τ·2η`) and it has to land on the rounded public key. Hash-based keys can't be rerandomized at all. We looked at two constructions. * **(A)** Borrow key-blinding techniques from the signature literature (Tor onion services use them) to shift an ML-DSA key by a value derived from the shared secret; the stealth address is the hash of the blinded verification key. This keeps the ERC-5564 property that the sender computes the address. Signing works, and the signatures pass stock FIPS 204 verifiers (dilithium-py, liboqs) and the ZKNOX verifier deployed on Sepolia (its level-2 Dilithium2 profile). A fresh error term `e'` per stealth key kills the linkability, and it costs the sender nothing. There's a test suite, conformance vectors, a live receive-and-spend on Sepolia through an ERC-4337 account, and a Lean 4 proof (no sorries) of the blinding correctness identity, the widened bound, and the fact that ownership and signing-key possession are the same thing. What's left for A isn't engineering, it's the security write-up: mainly the widened-`z` distribution at `β' = τ·2η`, and unlinkability across payments. * **(B)** Use ML-KEM only for the shared secret and have the recipient derive a fresh standardized keypair from it. Only standardized operations, but the sender can't compute the address on its own, so the announcement flow changes and unlinkability needs its own analysis. Fine for account-transfer flows, not for sender-names-the-address ones. We keep it as the fallback if A's security argument doesn't hold up. **Threat model.** One thing shapes both choices. Ethereum's own [PQ roadmap](http://pq.ethereum.org) points out that harvest-now-decrypt-later hits confidentiality, not ownership: recording announcements today doesn't let anyone steal funds later. So the urgent piece is the ML-KEM detection layer, which protects who-paid-whom permanently. Spending is a live threat, and it migrates through account abstraction whenever a quantum computer actually shows up, which is the path the EF roadmap takes anyway. That's why A is the forward-looking spend path, the ZK proof is its cheaper on-chain version, and B is documented as a separate account-transfer protocol, not a fallback under the same scheme ID, all on top of the same confidentiality core that's the real priority. The reason for picking A and the reason for dropping B are both deliverables. **Deliverables:** * A draft spec for the new ERC-5564 scheme ID: meta-address encoding, announcement format (ML-KEM ciphertext as the ephemeral key, plus view tag), and the chosen key derivation. There's a working draft already, along with a running log of the design decisions; it freezes after the security analysis and a round of community review. * Test vectors as versioned JSON in a public repo, including ones that must fail: wrong view tag, malformed ciphertext, wrong recipient. Any implementation can check itself against them without touching our code. This one's done. The v0 vectors are in the repo, deterministic, with the negative cases, and an independent TypeScript port already matches them byte for byte. * A reference library: scanning, viewing keys (can see payments, can't spend), and proof of possession, meaning you sign a challenge with the derived key to show you control the address, no transaction needed. Done in Python, with a TypeScript scanning client alongside it. The crypto primitives come from spec-faithful pure-Python implementations (kyber-py, dilithium-py) for the executable spec, cross-checked against the audited liboqs; we only write the protocol layer on top. There's also a separate Noir prototype for the zero-knowledge ownership version of the proof. * Security analysis in pq is quite complicated topic and proving that something is safe now does not mean that no new attack would be discovered in NIST approved list. We will try to make the best effort by relying on Lean4 proofs and existing academic research. * Benchmarks as a runnable harness. We reproduced the 2025/112 scan experiment from 0x3327/pq-sap (about 23.8 µs per announcement at 80k, the number behind the ~66.8%-vs-Curvy figure) and ran our scheme against a DKSAP baseline on the same machine. We dropped the Curvy run on purpose: DKSAP is the scheme ERC-5564 actually deploys, so it's the honest bar. The cost numbers are measured: an ML-KEM-768 ciphertext is 1,088 bytes where today's ephemeral key is 33; on mainnet the announcement pays the EIP-7623 calldata floor (67,580 gas measured, about 2.5× DKSAP) and EIP-4844 blobs put the L2 data cost below a cent; on the spend side, account deployment around 6.2M gas, roughly 8.2M per ML-DSA verification, ~22 kB public key. The meta-address is ~5.6 kB (full-precision `t` plus the ML-KEM key), a one-time per-recipient registry/ENS cost. What's left is writing the report up. **Stretch goals** - optional, none part of the core scope: An end-to-end spend demonstration. The near-term route is done. We spent from a stealth address on Sepolia through an ERC-4337 account (Kohaku pq-account, ZKNOX verifiers). Their factory derives the account address via CREATE2 from the public keys, so the stealth address is just the counterfactual account address of the derived key, spendable today with no protocol change. It works with construction A, since the sender computes the derived pubkey. The whole receive-and-spend went through on Sepolia, and the per-address costs (deployment, ~22 kB ML-DSA pubkey storage, 1.7–8.4M gas per verification depending on the scheme) go into the cost report. Two things we don't paper over: ZKNOX's on-chain verifier is a level-2 Dilithium2 profile, not our default ML-DSA-65, and its account wants an ECDSA signature next to the PQ one, so what we showed is the plumbing working end to end, not a pure post-quantum spend. The long-term route, native spending via EIP-8141 (still a Draft, not scheduled for a fork), stays out of scope unless it reaches a devnet during the cohort. Verifiable announcement discovery for light clients against a proving-capable log index (EIP-7745 / EIP-8304, under the Pureth umbrella), if such an index shows up on a devnet. This is also where the sub-linear scanning question sits. Beating the linear scan is an old open problem (see the [ethresear.ch](http://ethresear.ch) "improving stealth addresses" thread), and a post-quantum version of it would reuse the same ownership-proof machinery from the spend side. ## Specification As a base reference we'll use the paper published by the [folks from Serbia](https://eprint.iacr.org/2025/112) and take their reference implementation (`0x3327/pq-sap`, Rust) as the starting point. What it doesn't have and we'll complement is a real ERC-5564 scheme ID, a key derivation that lands on a standardized signature scheme, conformance vectors, and execution benchmarks. The full flow in Python pseudocode. ```python # --- Setup (recipient) ------------------------------------------------- sk_spend, K = MLDSA.keygen() # spending keypair (ML-DSA master; K keeps t at full precision) v, V = MLKEM768.keygen() # viewing keypair (ML-KEM) meta_address = encode(SCHEME_ID, K, V) # published via ENS or ERC-6538 # --- Send (sender) ----------------------------------------------------- K, V = resolve(meta_address) R, S = MLKEM768.encaps(V) # R: ciphertext = ephemeral pubkey, S: shared secret stealth_address = derive_stealth_address(K, S) # construction A: blind K with a fresh (s', e') from S view_tag = H(S)[0:VIEW_TAG_BYTES] announcer.announce(SCHEME_ID, stealth_address, R, view_tag) send_assets(stealth_address) # --- Scan (recipient, or any viewing-key holder) ----------------------- for ann in announcements_since(last_scan): S_i = MLKEM768.decaps(v, ann.R) if H(S_i)[0:VIEW_TAG_BYTES] != ann.view_tag: continue # cheap rejection path if derive_stealth_address(K, S_i) == ann.stealth_address: record_payment(ann, S_i) # --- Spend (recipient only; requires spending key sk_spend) ------------ p = derive_stealth_key(sk_spend, S) # blinded one-time key (widened bound beta' = tau*2*eta) assert MLDSA.verify(pubkey(p), sign(p, msg), msg) # stock FIPS 204 verifier # or prove the ownership relation A*s + e = t in zero knowledge instead of signing # On-chain spend semantics: out of core scope (see Project description). # --- Prove possession (no transaction, no protocol support needed) ----- proof = prove_possession(p, challenge) # sign over ctx = "pop" || stealth_address assert verify_possession(stealth_address, challenge, proof) ``` **Notes:** The viewing key [`v`](/lean/proofs/demo#v) alone detects and views payments; spending needs `sk_spend` too. Standard viewing/spending separation. Because the shared secret S itself, not its hash, derives the address here, view tags longer than 1 byte stay safe. The paper's own numbers say it barely matters though: full-hash tags bought about 0.7% over 1 byte at 1M announcements. So 1 byte is the default. ML-KEM-768 by default, and the spec stays parameter-agile across the 512/768/1024 sets. `VIEW_TAG_BYTES = 1`. One caveat that came out of building the spend path: on-chain you're stuck with whatever verifier is actually deployed, which today means a level-2 Dilithium2 profile, not our level-3 default. (1) Construction A vs. B — CLOSED by D-021: Construction A is locked for the ERC-5564 sender-address flow; Construction B is documented as a separate account-transfer protocol. (2) target signature scheme for derived keys: ML-DSA family by default, since it matches the deployed verifiers, with SLH-DSA documented as the alternative if we end up on B. (3) which parameter level, given the deployed-verifier reality above. (4) announcement encoding. ## Roadmap Worth saying up front: it's 2026, code is cheap. A lot of the engineering already exists as a prototype, which is what most of this write-up is reporting. So the estimates below are really about finishing and hardening each piece. * Benchmark reproduction + key-blinding bibliography - mostly done. The paper's numbers from 0x3327/pq-sap are reproduced. What's left is finishing the key-blinding reading (Tor v3 blinding, Eaton et al. Latincrypt 2021 and the work citing it) and locking the A/B call with mentors. Call it a week of the original three. * v0 spec + conformance vectors - done, not frozen yet. The executable Python spec and the versioned JSON vectors with negatives are in the repo. What remains is a review round with mentors and the community, then the freeze. Everything after builds against the frozen spec, same as before. * Security analysis - ~4–5 weeks, Unlinkability across payments, sender can't derive the spending key, viewing/spending separation, the widened-z distribution at β' = τ·2η, parameter analysis. Formal verification with lean4 using vcvio for algebraic and game-based testing. * Reference library - done in v0, Python plus a TypeScript scanning client, built against the vectors. Remaining work is polish and keeping it in sync as the spec freezes. * Benchmarks + cost report - ~2 weeks. The scan benchmarks and the on-chain gas numbers are measured already, and the cost model reproduces the measured 67,580-gas announcement to the gas, mainnet and L2 both priced. What's left is writing the report up. * ERC draft + wrap-up - ~2 weeks. PR to ethereum/ERCs, conformance run, final report and Devcon presentation. A few things landed ahead of the plan too: the Lean proofs, the Noir zero-knowledge ownership prototype, and a live receive-and-spend on Sepolia (a stretch goal). So the shape shifted. The engineering came in early, which frees cohort time for the security analysis and the ERC draft and leaves more room for the stretch goals. Weekly dev updates per EPF process, everything published as I go. ## Possible challenges * Construction A may not survive its own security analysis. The engineering risk is basically gone now: it's built, it signs, it spends on-chain, and the correctness identity is proved in Lean. What's left is the actual security argument, and key blinding for lattice signatures is still active research. The specific worry is the widened-z distribution at β' = τ·2η, which leaks differently than a plain Dilithium signature. We keep B (standardized operations only) as a real fallback, and a well-documented negative result on A still counts as a deliverable. * The protocol surface moves. Which post-quantum signature scheme Ethereum picks, and when accounts get signature agility, are still open. We [hit a live version of this](https://sepolia.etherscan.io/address/0xB9443280E49d728301384Fdbd07Eea8628d1b135): the only PQ verifier deployed on Sepolia is a level-2 Dilithium2 profile, not our level-3 default, and EIP-8141 is still on the way. So the spend path is a moving target. The core scheme doesn't lean on any of it, which is the whole reason the protocol-dependent parts sit in the stretch goals. * Announcements are ~1 KB instead of 33 bytes. An ML-KEM-768 ciphertext is 1,088 bytes, and the cost report gives the numbers at current gas schedules with L2 profiles included. * Scanning is linear in registry size. View tags cut the constant, not the asymptotics, and the benchmarks show the curve across registry sizes. Beating linear is an old open problem (the [ethresear.ch](http://ethresear.ch) "improving stealth addresses" thread), and a post-quantum version of it is future work, not something we're promising here. * We don't roll our own crypto. The executable spec binds to spec-faithful pure-Python ML-KEM and ML-DSA (kyber-py, dilithium-py) and cross-checks against the audited liboqs; our code there is the protocol layer. Two honest exceptions: the TypeScript scanning client re-implements the ML-DSA polynomial math because the audited JS library doesn't expose it, and the Noir circuit implements the ownership relation directly. Both are validated against the conformance vectors, which are the security core of the suite. The negative vectors do the heavy lifting. ## Goal of the project Success means these things exist publicly: 1. An ERC draft with a declared ERC-5564 scheme ID for the post-quantum scheme. 2. Conformance vectors, negative vectors included, that any independent implementation can validate against. 3. A reference library that passes them: sender flow, scanning, viewing keys, proof of possession. 4. The security analysis of the chosen derivation, including lean4 proofs with vcvio with the rejection rationale for the alternative. 5. Benchmarks and the cost report against the elliptic-curve and lattice baselines. ## Collaborators ### Fellows * Skas (project lead, ERC draft, implementation, vectors, Lean coordination) ### Mentors * [Tamaghna](https://github.com/RazorClient) — mentor assignment pending confirmation (currently expressed interest in contributing) ### Acknowledgments * [Iván Mañús](https://github.com/ivanmmurciaua) contributed to the classical-spend hybrid design and cross-client validation work. ## Resources **Papers and articles** * Mikić, Srbakoski, Praška - *Post-Quantum Stealth Address Protocols*, ePrint 2025/112: [https://eprint.iacr.org/2025/112](https://eprint.iacr.org/2025/112) * Mikić, Srbakoski, Praška - *More Efficient Stealth Address Protocol* (hybrid Curvy/MLWE, deliberately not quantum-secure to stay Ethereum-compatible - the gap this project closes): [https://arxiv.org/abs/2504.06744](https://arxiv.org/abs/2504.06744) * asanso - *Towards practical post quantum stealth addresses* (CSIDH-based, ethresear.ch, April 2023 - earliest PQ stealth design for Ethereum; isogeny-based, pre-standardization): [https://ethresear.ch/t/towards-practical-post-quantum-stealth-addresses/15437](https://ethresear.ch/t/towards-practical-post-quantum-stealth-addresses/15437) * *Post-Quantum Threats to Ethereum Privacy* (ethresear.ch, March 2026 - harvest-now-decrypt-later and why privacy migration is more time-sensitive than authentication migration): [https://ethresear.ch/t/post-quantum-threats-to-ethereum-privacy/24450](https://ethresear.ch/t/post-quantum-threats-to-ethereum-privacy/24450) * Eaton et al. - *Post-Quantum Key-Blinding for Authentication in Anonymity Networks*, Latincrypt 2021 (primary prior art for construction A) * Grubbs, Maram, Paterson - *Anonymous, Robust Post-Quantum Public Key Encryption*, Eurocrypt 2022 (KEM anonymity, the property detection unlinkability reduces to) * Maram, Xagawa - *Post-Quantum Anonymity of Kyber*, PKC 2023 (settles ANO-CCA for Kyber; the route the unlinkability analysis follows) * Lyubashevsky - *Basic Lattice Cryptography: The concepts behind Kyber (ML-KEM) and Dilithium (ML-DSA)* (tutorial, ePrint) * Wahrstätter et al. - *BaseSAP: Modular Stealth Address Protocol for Programmable Blockchains*, IEEE TIFS 2024 * Mikić, Srbakoski - *Elliptic Curve Pairing Stealth Address Protocols* (Curvy): [https://arxiv.org/abs/2312.12131](https://arxiv.org/abs/2312.12131) * Buterin - *An incomplete guide to stealth addresses*: [https://vitalik.eth.limo/general/2023/01/20/stealth.html](https://vitalik.eth.limo/general/2023/01/20/stealth.html) * *Native UTXOs on Ethereum* (ethresear.ch, July 2026): [https://ethresear.ch/t/native-utxos-on-ethereum/25368](https://ethresear.ch/t/native-utxos-on-ethereum/25368) * *VCVio: Verified Cryptography in Lean via Oracle Effects and Handlers* ePrint 2026/899: [https://eprint.iacr.org/2026/899.pdf](https://eprint.iacr.org/2026/899.pdf) **Standards and specifications** * ERC-5564 - Stealth Addresses: [https://eips.ethereum.org/EIPS/eip-5564](https://eips.ethereum.org/EIPS/eip-5564) * ERC-6538 - Stealth Meta-Address Registry: [https://eips.ethereum.org/EIPS/eip-6538](https://eips.ethereum.org/EIPS/eip-6538) * EIP-8141 - Frame Transactions (Draft): [https://eips.ethereum.org/EIPS/eip-8141](https://eips.ethereum.org/EIPS/eip-8141) * NIST FIPS 203 (ML-KEM), FIPS 204 (ML-DSA), FIPS 205 (SLH-DSA); NIST IR 8547 (PQC transition) * Tor rend-spec-v3, key-blinding appendix (the deployed Ed25519 blinding design) * Ethereum post-quantum cryptography roadmap: [https://ethereum.org/roadmap/security/quantum-resistance/](https://ethereum.org/roadmap/security/quantum-resistance/) (April 2026) **Implementations and benchmarks** * `0x3327/pq-sap` - ePrint 2025/112 reference implementation and scan benchmarks (Rust): [https://github.com/0x3327/pq-sap](https://github.com/0x3327/pq-sap) * `ethereum/kohaku` `packages/pq-account` - ERC-4337 post-quantum accounts (ZKNOX ML-DSA/Falcon verifiers, deployed on Sepolia and Arbitrum Sepolia); the near-term spend route: [https://github.com/ethereum/kohaku/tree/master/packages/pq-account](https://github.com/ethereum/kohaku/tree/master/packages/pq-account) * PQClean / liboqs and the pq-crystals reference code - audited ML-KEM/ML-DSA implementations the library binds to * `GiacomoPope/kyber-py`, `dilithium-py` - spec-faithful Python implementations, used for vector generation and cross-checking * ScopeLift stealth-address-sdk / Umbra and Fluidkey deployments - DKSAP baseline behavior and announcer usage in production # Pairwise mode: why fresh encapsulation per announcement stays :::info[Mirrored page] Source: [`docs/research/pairwise-mode.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/pairwise-mode.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: *2026-09-30. Analysis note for issue #39 (ethresear.ch scheme-4/5 "pairwise" optimization: after one KEM exchange, later payments derive `ss_i = H(r ‖ nonce_i)` and announce only the nonce). Extends the D-011 announcement-cost finding and the D-028 shape-check contract. No protocol change; the ERC keeps fresh ML-KEM encapsulation per payment.* ## 1. Question A first payment performs a full ML-KEM-768 encapsulation; the announcement carries the 1,088 B ciphertext. The proposal asks: may *subsequent* payments to the same recipient reuse the first shared secret — derive a channel secret [`r`](/lean/proofs/demo#r) from the first `ss`, then announce only a 32 B nonce with `ss_i = H(r ‖ nonce_i)`, saving ~1,056 B of calldata per repeat payment? ## 2. What the proposal's own numbers say * **L1:** a nonce announcement shrinks calldata from 1,316 B to roughly 130 B (mostly zeros), so the EIP-7623 floor drops from 67.58 k to roughly 22–25 k gas — about **3× cheaper per repeat payment** (~$1.7 → ~$0.6 at D-011's anchors: 8 gwei, ETH $3,200). * **L2:** D-011's own finding is that EIP-4844 blobs dissolve the announcement footprint (~$0.004 per announcement). The optimization targets the regime the deployment story de-emphasizes. ## 3. What reusing [`r`](/lean/proofs/demo#r) breaks (each item verified against main, 7d5e15e) **Deployed-announcer compatibility (D-014 / D-028).** The announcement must remain a KEM ciphertext: `StealthKeyExchange.announce` shape-checks exactly four ciphertext lengths (768 / 1,088 / 1,568 / 1,120 B, pairwise distinct); a 32 B nonce reverts `UnsupportedCiphertextLength(32)`. Pairwise mode needs a new scheme ID or an announcer bypass — a protocol fork, not a parameter. **The Lean unlinkability reduction.** [`StealthScheme.ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) (`lean/PqStealth/KEMAnonymity.lean:145`) types each announcement as a *ciphertext* from `kem.encaps`; the decomposition [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) rests on shared-secret hiding, aux independence, and ciphertext anonymity — objects that cease to exist when later announcements are bare PRF outputs. A pairwise scheme needs a new scheme family whose repeat-announcement unlinkability term is PRF security of `H(r ‖ ·)` under **public, attacker-influenced nonce inputs** (anyone may post arbitrary nonces; the recipient derives garbage — harmless, but the proof object is a chosen-input PRF game, not ML-KEM IND-CPA). None of the existing [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) / `MultiRecipient` / `MultiUnlink` theorems transfer. **Recipient scan cost and state.** The nonce does not say which channel it belongs to. The recipient must try every stored [`r`](/lean/proofs/demo#r): `ss_j = H(r_j ‖ nonce)` per correspondent, compare the view tag, then derive. Scan cost per announcement becomes **O(S)** SHA-256s (S = correspondent count) instead of one decapsulation — unbounded, never garbage-collected, and dropping an [`r`](/lean/proofs/demo#r) silently makes later payments invisible (no error, just no detection). Alternatively a per-pair hint (e.g. `H(r)` prefix) restores O(1) scan but is a stable **channel pseudonym**: an observer can count repeat payments per pair and build frequency/timing graphs — an observable fresh encapsulation does not have. There is no cheap option: O(S) scan or a channel pseudonym. **Forward secrecy.** Fresh mode: leaking one `ss` reveals one payment. Pairwise: leaking [`r`](/lean/proofs/demo#r) plus the *public* `spend_key` reproduces **every past and future payment on the channel** without touching ML-KEM keys. Rotation of [`r`](/lean/proofs/demo#r) is exactly the silent-failure state problem above. The 0x02 opener's sender-known-values argument survives unchanged ([`r`](/lean/proofs/demo#r) plays the role of `ss` in derivation), but the compromise scope regresses from per-payment to per-channel. ## 4. Conclusion Do not adopt for this ERC. Fresh encapsulation per announcement is what makes the deployed-announcer compatibility (D-014/D-028), the [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) reduction, the O(1)-storage scan, and the per-payment compromise bound hold at once; the pairwise trade buys ~3.2× L1 announce gas (≈nothing on L2) against regressions on all four. If the community wants pairwise repeat payments, the right container is a **separate scheme ID with its own security statement**; the list above is its pre-admission checklist. # Poseidon2 + Merkle trees + zk-STARKs for a customized registry/discovery layer :::info[Mirrored page] Source: [`docs/research/poseidon2-stark-discovery.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/poseidon2-stark-discovery.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: **Research question.** If we are free to customize the ERC-5564 announcer and ERC-6538 registry (instead of only registering a new scheme ID against the canonical contracts), can Poseidon2 hashing, Merkle accumulators, and zk-STARK proofs improve (a) scanning time and (b) the key exchange for the PQ stealth scheme? **Date:** 2026-08-05. Sources: literature survey, cost fact sheet, and registry-precedent survey (citations inline); local measured numbers from `python/benchmarks/` (dated JSONs). *** ## Verdict in one paragraph Poseidon2/Merkle/STARK machinery **cannot make private detection sub-linear** — that is now a theorem, not folklore (unlinkable detection is PIR-hard; someone must do Ω(N) work per query, eprint 2026/910). What a customized registry *can* deliver, compatibly with the bound, is substantial and complementary: (1) **amortized O(1) discovery for repeat senders** via Aztec-style Poseidon2 tag chains seeded by one ML-KEM handshake — collapsing both scan work and announcement size (1,109 B → ~56 B) for everything after first contact; (2) **completeness-by-construction**: an append-only announcement accumulator with on-chain roots makes "these are ALL your candidate announcements" client-verifiable — an unoccupied research niche for stealth addresses and the trust win our EIP-8304 PoC already measures; (3) a **cheaper, quantum-safe registry**: hash-committed meta-addresses + blob-carried batched announcements fix the 3.79M-gas naive registration and the ERC-6538 ECDSA-auth quantum hole. The linear first-contact scan remains — and at ML-KEM-768's 17.2 µs/decaps (1.38 s per 80k announcements, measured) it is already ~50× cheaper per message than Zcash's deployed trial-decryption baseline (~1 ms/output). *** ## 1. Why sub-linear private scanning is off the table * **Lower bound.** UnifOMR (Fisch–Liu–Tromer–Wang, [eprint 2026/910](https://eprint.iacr.org/2026/910)) reduces single-server PIR to any OMR with strong detection-key unlinkability: the scanning party must touch every message, else its access pattern leaks which messages are irrelevant. Vitalik's information-theoretic remark in the original [ethresear.ch open-problem thread](https://ethresear.ch/t/open-problem-improving-stealth-addresses/7438) (recipients collectively must receive Ω(N) bits) was the informal version. Every proposal in that thread died on exactly this. * **Predictable tags are linkage.** Any tag the recipient can precompute (so an index could serve it sub-linearly) is Fuzzy Message Detection with false-positive rate → 0; Seres et al. ([eprint 2021/1180](https://eprint.iacr.org/2021/1180)) show FMD privacy collapses on real communication graphs as p shrinks, and deterministic tags are the p=0 endpoint. This formally confirms the view-tag analysis in our spec: our 1-byte view tag *cannot* become an index key without giving up unlinkability — with one exception, §3, where the tag is predictable **only to the two parties who already share a secret**. * **The escapes all change the model**, and only one is PQ-sound: | Route | Who pays linear | PQ-sound? | State of the art | |---|---|---|---| | Lattice OMR (delegate under FHE) | server, per recipient | **yes** (LWE) | SophOMR ~1.7 ms/msg ([2024/1814](https://eprint.iacr.org/2024/1814)); PerfOMR ~$0.10/M msgs; never deployed (Zcash evaluated, passed) | | FMD (fuzzy tags) | server (cheap EC ops) | no (DDH) | Penumbra designed s-FMD, has not deployed it | | Two-server / TEE private signaling | servers | partially / hardware trust | HomeRun ([2024/188](https://eprint.iacr.org/2024/188)) ~3,830× cheaper than OMR | | Shared-state tag chains (sender↔recipient) | nobody after first contact | **yes** (hash-based) | **Aztec, deployed** — the route we can copy | | Out-of-band delivery (no on-chain detection) | nobody | yes | Zcash Tachyon direction (NU7) | * **Everyone who stayed on-chain stayed linear.** Zcash DAGSync/warp sync = smarter/parallel linear. Penumbra's Poseidon **Tiered Commitment Tree** accelerates *witness maintenance* (claimed up to 4M× on state sync), not detection — the clean precedent that Merkle trees buy state-sync speed, never detection speed. Railgun scans every commitment ("initial sync can take a few minutes"). **Consequence.** Poseidon2/STARKs should not be aimed at the detection loop. They should be aimed at (i) the repeat-sender path, (ii) trust/completeness, (iii) footprint — where they do win. ## 2. Where scanning time genuinely improves ### 2.1 The measured baseline (what we must beat) * ML-KEM-768 decaps 17.2 µs native → 1.38 s / 80k announcements; our Python client steady-state 44.3 µs/ann, linear across 2.5k–160k (R²=0.999, `registry_curve_20260802.json`). * Mainnet canonical announcer has **202 announcements ever** (measured 2026-08-02): today the linear scan is a non-problem on L1; the design question is L2 registries (Fluidkey/Base) and future volume. ### 2.2 Tag chains: O(counterparties) instead of O(registry) for repeat senders Copy Aztec's settled design ([note discovery docs](https://docs.aztec.network/developers/docs/foundational-topics/advanced/storage/note_discovery)), instantiated post-quantum: * **First contact (handshake):** sender announces the ML-KEM ciphertext exactly as in our current scheme. Both sides derive the chain secret `cs = KDF(ss, "tagchain/v0")` from the shared secret. This is the step Aztec's DH-based variant cannot do post-quantum (ML-KEM is not a NIKE); our KEM announcement **is** the PQ handshake channel. * **Subsequent payments:** announcement carries `tag_i = Poseidon2(cs, i)` (or SHA-256 — see §5; *addendum 2026-08-15: SHA-256 is now the settled choice, see `hash-migration-blake2-binius.md`*) plus the stealth address; per-payment secrets ratchet from `cs`. No new KEM ciphertext. * **Recipient scan:** exact-match lookups of expected tags `\{Poseidon2(cs_j, i) : j ∈ counterparties, i ∈ window\}` against a tag-indexed registry — cost independent of global announcement count. The linear decaps loop runs only over *handshake* announcements. * **Unlinkability:** tags are pseudorandom under `cs` (PRF security of the hash), predictable only to the two parties — this does not contradict §1 because the index serves tags, not identities. New leak to manage: exact-tag queries reveal recipient interest to the indexer (Aztec documents the same IP↔tag correlation; mitigations: range downloads of the tag column, later PIR/OMR). Sender-side state loss ⇒ fall back to a fresh handshake. **Effect on announcement size/gas (the "key exchange" win):** a chain announcement is \~32 B tag + 20 B address + 4 B index ≈ 56 B vs 1,109 B — the tx drops from the 67,580-gas EIP-7623 floor to roughly the 21k-gas class. The 1,088-B ciphertext is paid **once per relationship**, not once per payment. For payment patterns dominated by repeat senders (payroll, subscriptions, exchanges — Fluidkey's actual traffic), both scan time and calldata approach the EC scheme's numbers while staying PQ. ### 2.3 What stays linear, quantified First-contact announcements still require trial decaps by every scanner. If x is the fraction of handshake announcements, scan cost ≈ x·N·17.2 µs + |counterparties|·window lookups. Even at x=100% (today's scheme) we are at 1.38 s/80k native; every repeat-sender relationship moves its payments out of the linear term entirely. ## 3. Completeness proofs: the trust improvement (and the publishable piece) An untrusted indexer serving stealth announcements can currently **omit** — an omitted announcement is a payment the recipient never learns about, the one uncovered threat in our model. Two composable answers: 1. **EIP-8304 path (SHA-256, protocol-level).** Our PoC (`eip8304_scan_poc.py`, measured 2026-08-02): sorted index tables + SSZ `List[Hash32]` merkleization give range multiproofs with boundary-completeness — 1,486 proof bytes, 0.04 ms verify for 12 announcements among 35.8k entries. If Pureth ships, generic announcements become provably-complete-queryable with zero custom infrastructure. Status: EIP-7745 deferred from Glamsterdam, PFI for Amsterdam; the WG only ever debated SHA-2 vs SHA-3 — no ZK-friendly hash was on the table. 2. **Custom accumulator path (pre-8304, or as an 8304 superset).** The announcer maintains an append-only accumulator: only the **root** on-chain (never per-announcement hashing — §4), tree maintained off-chain by permissionless indexers, updates proven correct in a STARK verified **client-side**. Completeness is then by construction: "leaves 0..N under root R" is checkable, unlike Ethereum logs today. The literature survey found **no published work** on STARK-proven completeness/non-omission for stealth-address indexers (nearest: SNARKBlock, authenticated PIR) — this is white space our 8304 PoC already half-occupies, and it recovers the same guarantee 8304 gives, years earlier, at app level. These also compose: the custom accumulator can carry the **view-tag/tag index row** that 8304 currently lacks (our WG feedback item: ~256× light-client receipt-bandwidth cut; measured 13.3 kB → 1.1 kB receipts in the PoC), and the tag-chain lookups of §2.2 need exactly such an indexed column with completeness ("no announcement with tag t in range" must be provable, or omission returns through the side door). ## 4. Cost realities that fix the architecture Numbers from the fact sheet (sources inline there; key ones re-cited): | Quantity | Value | Design consequence | |---|---|---| | Poseidon2 in EVM | ~20.3k gas/hash (Yul, BN254; Huff 14.8k); EIP-5988 **Stagnant** | never hash on-chain | | LeanIMT insert (Semaphore v4) | ~119k gas avg; classic IMT ~560k; depth-20 binary ~768k | an on-chain tree insert costs 2–11× our whole 67.6k announcement | | Native STARK verify on L1 | StarkWare split verification, ~2.3M gas main tx; 100 KB proof ≈ 4.1M gas calldata floor (EIP-7623) alone | no per-announcement / per-batch on-chain STARKs on L1 | | SNARK-wrapped verify | ~270–300k gas | **kills PQ soundness** — not an option for the PQ story | | Client-side STARK verify | milliseconds, zero gas | make the **wallet** the verifier | | STARK proving (Stwo) | ~620k Poseidon2-M31 hashes/s on a laptop; 2^16-hash statement ≈ 0.1 s native, ~1–3 s browser (est.) | indexer proving is cheap; even wallets can prove small statements | | SHA-256 in-circuit | ~100× Poseidon2 per hash (110–200× at Merkle-proof level); zkVM accelerators narrow but don't close it | proving over 8304's SHA-256 tables is feasible; a Poseidon2-M31 side-tree is 1–2 orders cheaper | | Blobs | 1 blob = 128 KB ≈ 117 ML-KEM-768 ciphertexts; DA cost orders below calldata; pruned after ~18 days (archives: Blobscan/Graph/Hemera) | batch handshake announcements into blobs; on-chain = (versioned\_hash, batch\_root) | | ML-KEM decaps in a zkVM | no public benchmark (gap); est. ~1–5M cycles | "prove my scan" designs are unbenchmarked territory | Architecture that falls out (the Railgun-v3 / keystore-rollup shape, hash-based instead of KZG): * **On-chain:** announcer stores batch roots + blob versioned hashes; registry stores meta-address *commitments*. No field hashing, no verifier contracts on the hot path. * **Off-chain:** permissionless indexers maintain the accumulator + tag index, serve range/tag queries with STARK (or bare-Merkle) completeness proofs; wallets verify locally. * **If on-chain proof-gating is ever needed** (e.g. enforcing root correctness rather than client-side checking): settle on an L2/L3 with a native STARK verifier (Starknet/Integrity pattern) — not L1. ## 5. Poseidon2 vs SHA-256: the honest trade > **Addendum 2026-08-15 — this trade has since been decided, against Poseidon2.** On 2026-08-13 > the EF announced it is abandoning Poseidon for L1 ("pivoting to SHA or BLAKE", J. Drake), after > Flock-class binary-field provers collapsed the ~100× standard-hash penalty assumed below to a > single-digit factor (82k BLAKE3 / 42k SHA-256 compressions/s per M4 Max core) and the > cryptanalysis initiative's results (full-round CICO-2 on an instance; prizes paused) eroded the > margin. Everything in this section that reserves Poseidon2 for circuit-heavy paths, and the > dual-root accumulator idea, is superseded: **single SHA-256 root, SHA-256 tag chains, no AO > hash anywhere** (policy D-015). Full analysis and citations: > `hash-migration-blake2-binius.md`. * **For our PQ narrative, SHA-256 is the conservative default.** FRI/STARKs are PQ-sound from hashes alone; that argument is cleanest with SHA-256 (also what 8304 mandates). Poseidon2 is *plausibly* PQ with no full-round break, but the EF is literally paying for its cryptanalysis through Dec 2026 (initiative + unclaimed $992k collision prize), and protocol Ethereum will not adopt it near-term. * **Poseidon2 buys prover cost, nothing else.** On-EVM it's no cheaper than Poseidon (~20k gas); natively SHA-256 is *faster*; the ~100× win exists only inside circuits. So: use Poseidon2 where proofs are generated constantly (tag-chain membership, spend-side Merkle membership for anonymity-set ZK spending — where it composes with our existing STARK ownership route at \~5M gas L1 or client-side); keep SHA-256 for the completeness/8304 layer where proofs are Merkle-path-only and verification is native. A dual-root accumulator (same leaves, SHA-256 root for light clients + Poseidon2-M31 root for circuits) costs the indexer ~1 µs/leaf extra and lets each consumer pick its hash. * Pin Poseidon2 parameters conservatively and record the EF-initiative outcome (2026) as a monitored risk in DECISIONS.md if we adopt it anywhere normative. ## 6. Key-exchange / registry improvements (independent of scanning) 1. **Meta-address registration.** Replace ERC-6538's full-bytes storage (3.79M gas naive for 5,633 B; ~1.2 kB after the ZK-spend reframe drops full-precision t) with an on-chain 32-B commitment + off-chain/blob data, verified against the commitment on fetch. One SSTORE class instead of kilobytes. 2. **Quantum-safe registry auth.** ERC-6538's `registerKeysOnBehalf` (EIP-712/ERC-1271 = ECDSA) allows a future quantum adversary to *overwrite* victims' meta-addresses and divert payments — our ERC draft already flags this; a custom registry closes it: msg.sender-only from AA accounts, PQ signatures — for which ERC-7913 signers (`verifier || key`, D-014) are the standardized hook — or hash-based commit-reveal rotation, plus explicit rotation semantics (keystore-rollup precedents: Vitalik's minimal design, Scroll, Axiom live; Base's retreat = warning against full rollup machinery — keep it to root + events in a normal contract). 3. **Handshake amortization** (§2.2) is itself the big key-exchange win: one 1,088-B KEM ciphertext per relationship instead of per payment. 4. **Blob batching** for handshake announcements via a permissionless `announceBatch` relayer (Tornado-relayer/4337-bundler pattern); per-announcement DA cost collapses ~two orders vs the 67.6k floor. Recipients syncing less often than ~18 days depend on blob archives — a liveness dependency only, not fund-safety, since roots on-chain remain the source of truth. No existing ERC covers blob-carried announcements; second unclaimed draft. ## 7. Recommended position 1. **Do not** pursue sub-linear private detection; cite the PIR-hardness result when the question recurs. Keep first-contact scanning linear and fast (ML-KEM decaps is already the fastest PQ option measured across 15 KEMs). 2. **Adopt the tag-chain extension** as the scanning/key-exchange improvement: PQ handshake via the existing KEM announcement, Poseidon2 (or SHA-256) tag chains after (*addendum 2026-08-15: SHA-256*). Spec it as an optional announcement type in the ERC draft (new `metadata` layout or sibling scheme ID) so plain per-payment KEM announcements remain valid. 3. **Build discovery trust on 8304**, not against it: our PoC + the view-tag/tag index row as WG input; the custom accumulator only as a pre-8304 bridge or L2 deployment, designed as an 8304 superset (same entry encodings, same SSZ merkleization, extra index rows) so it dissolves into the protocol feature when Pureth ships. 4. **Registry v2** (commitment-based, PQ-auth, blob-batched) is worth a section in the ERC draft regardless of everything else — it fixes measured costs and a real quantum attack surface. 5. **Publishable pieces** surfaced by this research: (a) STARK-proven completeness for stealth-address indexers (no prior work); (b) PQ tag chains — Aztec's design has no published PQ analysis, and our KEM-handshake instantiation + unlinkability argument (PRF hop on the chain secret, composing with our Lean KEM-anonymity chain) is novel; (c) the first blob-carried announcement ERC. ## Appendix: what the numbers say end-to-end Scenario: 80k-announcement registry, recipient with 20 counterparties, 90% repeat-sender traffic. * **Today's scheme:** scan = 80k × 17.2 µs ≈ 1.38 s; every payment costs the sender 67,580 gas. * **With tag chains + indexed registry:** scan = 8k handshakes × 17.2 µs ≈ 0.14 s + ~20×window tag lookups (ms, with completeness proofs at ~1.5 kB / 0.04 ms verify each, per our PoC) — \~10× less client work, bounded by handshake rate, not registry size; repeat payments cost \~21k-class gas and 56 B instead of 67.6k and 1,109 B. * **Trust:** omission attacks closed by completeness proofs (8304 or custom accumulator); registry overwrite closed by PQ auth; SHA-256 pipeline keeps the whole discovery story PQ-sound; Poseidon2 reserved for circuit-heavy paths (spend membership), pending EF cryptanalysis outcome (*addendum 2026-08-15: outcome arrived — Poseidon dropped by the EF; circuit-heavy paths also use standard hashes now, see `hash-migration-blake2-binius.md`*). # Stretch goal closed — ERC-4337 spending with post-quantum key exchange and a zero-knowledge ownership proof :::info[Mirrored page] Source: [`docs/research/stretch-goal-report.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/stretch-goal-report.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: *2026-10-29. Closes the cohort proposal's second stretch goal: "measure the speed, gas costs and end-to-end implementation of a stealth-address protocol using ERC-4337 with post-quantum key exchange and a zero-knowledge ownership proof, for cheaper spending during the transition". Every figure below is measured on a committed harness or replayed from a committed fixture; where a number is modelled or is a protocol proposal that is said in the row.* Companion material: [cost report](/research/cost-report) (all L1/L2 and scan costs, provenance per row), [C13-in-circuit experiment](/research/zk-sphincs-frames) (gas breakdown, live frames run), [deployment/native-key experiments](/research/prefix-deploy-native-keys) (where the ML-DSA verify gas goes), [ML-DSA-44 committed-key profile](/research/ml-dsa-commit-profile) (the profile route), [spending research map](/research/spending) (route table), [D-020](/spec/decisions) (frame transactions as target), [D-023](/spec/decisions) (C13 in-circuit), [D-025](/spec/decisions) (browser preimage proof), [D-027](/spec/decisions) (ML-DSA-44 profile), [D-031](/spec/decisions) (gas-exhaustion semantics). ## 0. Why this was a stretch goal, and why it is "done" now The proposal split the work deliberately: the ERC's subject is the ML-KEM key exchange and sender-computable address derivation (D-024), and every protocol-dependent part — how a post-quantum key authorizes a transfer — was listed as optional. That was the right call at proposal time and it is still the right call: the spend side is gated on which PQ signature scheme Ethereum adopts and on EIP-8141 shipping, neither of which this repository controls. What changed is not the scope, it is the evidence. All four things the stretch goal asked to *measure* now exist: | Asked for | Status | | --- | --- | | Speed | scan throughput, blinded-signing latency, proving time, all measured (§2) | | Gas costs | announce, deploy, verify, whole-spend, per route (§3) | | End-to-end implementation | receive → scan → spend, on a real chain, twice: ERC-4337 on Sepolia and a frame transaction on the frames testnet (§4) | | Zero-knowledge ownership proof | two statements implemented and proven client-side, one in a browser (§4) | | "Cheaper spending during the transition" | measured, and the answer is nuanced — §5 | So the item is closed as **delivered**, not as **required**. Nothing in the ERC conformance package depends on it: a wallet that only implements receive/scan never touches any code in this report. ## 1. What "the stretch goal" decomposed into Three separable claims hide in one sentence. Keeping them apart is what makes the measurements interpretable. 1. **ERC-4337 can carry a post-quantum stealth spend at all.** The stealth address must be an account whose validator is a PQ verifier, and the address the sender computes must equal the address that actually gets deployed and spent from. 2. **A zero-knowledge proof can replace the signature**, so a spend does not reveal the recipient's key. 3. **Either route is cheaper than the alternatives**, during the transition period before protocol-native PQ signatures exist. (1) is answered yes, twice, with two different account models. (2) is answered yes, with a caveat that dominates the result: the proof backend that is cheap enough to ship today is not post-quantum sound. (3) is answered "cheaper than ML-DSA on the EVM, not cheaper than a hash-based signature" — the ZK route buys unlinkability at a gas premium, and the genuinely cheap spend is a different route entirely. ## 2. Speed All client-side figures on Apple M1 Max (10 cores, 32 GB), Python 3.12.1, liboqs 0.16.0, best-of-3 (`python/benchmarks/*.json`); the native row is the paper's Rust harness rebuilt locally. | Operation | Measurement | Source | | --- | --- | --- | | Scan, Python + liboqs ML-KEM-768 | **44.31 µs**/announcement fitted slope, 2.5k→160k announcements, R² = 0.99923 | `registry_curve_20260802.json` | | Scan, DKSAP baseline (libsecp256k1) | 27.50 µs/announcement fitted slope, R² = 0.99949 | same file | | Scan, decaps + view-tag loop only | ~19 µs/announcement (the `1/256` full derivations excluded) | `python/benchmarks/README.md` | | Scan, native Rust (`0x3327/pq-sap`, Kyber768) | **22.6 µs**/announcement at 80k (1,809 ms / 80,000) | `python/benchmarks/README.md` table | | Scan, 1M announcements, projected from the Python slope | ~44 s single-core | arithmetic | | Sender: `send` (encaps + derive) | 9.8 ms median / 9.5 ms best | `op_results.json` | | Recipient: scan hit (decaps + tag + derive) | 10.7 ms median | same | | Blinded ML-DSA-65 signing, pure Python | 116 ms median / 21.6 ms best | same | | Blinded signing rejection rounds, L3 | 24.8 mean (stock 5.5), max seen 176 | `param_sweep_20260728.json` | | UltraHonk prove, preimage statement, bb.js WASM | **0.65–1.3 s**, witness 42–61 ms, in a web worker | D-025 | | UltraHonk prove, C13 statement, native | 50–56 s, peak 8.4–10.9 GB, proof 11,456 B | D-023 | Two corrections to earlier prose, both from the committed data: * The native scan figure is **22.6 µs** as arithmetic on the committed 80k row (1,809 ms / 80,000). The 23.8 µs quoted in the cost report and in the proposal text is from an earlier native run and does not reproduce from the table we keep. Either way the conclusion is unchanged and modest: native lattice scanning is *moderately faster* than DKSAP (22.6 vs 27.5–27.9 µs), not the paper's 3× headline, which was measured against Curvy. The Python reference client is 1.61× *slower* than DKSAP, because it pays ~N/256 full derivations in interpreted code; that is a binding choice for wallets, not a property of the scheme. * Pure Python is the executable spec, not a scanner: 4.2 ms/decaps, ~150× liboqs. Vector generation and tests are unaffected. The one real client-side slowdown introduced by the scheme is **blinded signing**: widening both secret vectors to `2η` shrinks the acceptance region and costs ~4.5× ML-DSA's rejection rounds at L3. The penalty is not monotone in the security level — η is 2/4/2 across ML-DSA-44/65/87, so the default L3 set pays the most (24.8 rounds vs L5's 15.3 and L1's 14.4). A tighter probabilistic norm bound than worst-case `2η` would recover most of it. ## 3. Gas ### 3.1 Announcement (the recurring path) | Item | Gas | Regime / provenance | | --- | --- | --- | | PQ announcement on the canonical announcer | **67,580 measured** | Sepolia fork, `e2e-sepolia-fork.test.ts`; EIP-7623 calldata floor binds (standard cost 52,446) | | Same, data-cost model | 67,550 | `onchain_cost.py` with scheme ID `2`; 30 gas below the measurement — see §6 | | DKSAP announcement (33-byte point) | 27,342 **modelled**, not measured | `onchain_cost.py`, standard regime | | Ratio, L1 | **2.5×** | arithmetic on the two rows above | | `StealthKeyExchange.announce` wrapper overhead | **2,065** (~3% of the floor) | `test_announce_overhead_gas`, forge | | Marginal L1 data cost, calldata regime | ~$1.35 | dated anchors ETH $3,200, L1 8 gwei (2026-07-28) | | Marginal L1 data cost, EIP-4844 blob regime | **~$0.0042** | same anchors, blob 1 gwei; ~320× cut vs calldata | The announcement is the only cost a recipient's counterparties impose on everyone, and it is priced as bytes, not compute, since Pectra. That is why the L2 blob row, not any EVM optimization, is the answer to the data footprint. ### 3.2 Spend (the one-shot path, per route) Measured on forge/anvil 1.4.1, solc 0.8.30, `evm_version prague`, unless noted. Full table with per-route caveats: [cost report §5](/research/cost-report). | Route | Verification gas | Setup gas | Notes | | --- | --- | --- | --- | | ECDSA (classical hybrid) | 21,000 | — | tx-level; quantum-vulnerable ownership | | Direct SPHINCS-C13 (ERC-7913) | **188,092** | verifier deploy 310,750 | hash-based soundness; **key revealed at spend** | | Same, commit form | 196,973 | account deploy 620,894 | commitment opens on spend | | ML-DSA-44 committed key (D-027) | 14,966,788 internal / 15,038,028 tx | key setup 10.8–11.8 M + account 704,195 | trusted registrar on key setup | | ML-DSA ERC-7913, blinded sig | 14,911,249 → **13,678,091** with key-setup NTT | PKContract 5.32–7.31 M, account 620,750 | verifier is level-2 Dilithium2, not ML-DSA-65 | | Preimage ZK, forge | **2,942,272** execFrame | account deploy 37,001 in-frame | UltraHonk, not PQ-sound | | Preimage ZK, live frames testnet | **3,544,107** proof verify + transfer | account deploy 37,001 in-frame | total billed 8,576,431 incl. state | | C13-in-ZK, live frames testnet | 4,174,008 proof verify + transfer | account deploy 37,001 | total billed 9,249,124 | | EIP-8164 native key | 50,000 **proposed** | +37.5 k authorization | Draft, no client implementation | ### 3.3 Where the ML-DSA gas actually goes Profiled step by step at `df999ed` (`DilithiumProfile.t.sol`) — this is the number that decides whether an EVM verifier is worth optimizing: | Step | Gas | Share | | --- | --- | --- | | SHAKE256 in Solidity (SampleInBall 0.94 M + final `mu`/`c̃` hashes 4.05 M) | ≈5.0 M | 34% | | `dilithiumCore2` (`expandMat`, NTT(z)×4, `A·z − c·t1`, invNTT, hints) | 5.69 M | 40% | | key-dependent `<< d` + `nttFw(t1)` (moved to setup: −8%) | 1.41 M | 10% | | unpack/norm checks, NTT(c), signature slicing | 2.24 M | 14% | The 8.18 M figure that appears in older text was measured on an earlier upstream revision and does not reproduce at `df999ed`; the 1.8× "regression" attributed to a per-verify `NTT(t1·2^d)` was an inference and is wrong (that step is 10%, not 45%). The largest single lever is replacing SHAKE with `KECCAK256` — which is exactly what the 4.9 M `ZKNOX_ethdilithium` variant does, and exactly why its keys are no longer ML-DSA keys. ## 4. End-to-end Two independent end-to-end runs, two account models. Both start from the same receive/scan layer, which is the point: the key exchange is genuinely independent of who authorizes the spend. ### 4.1 ERC-4337 on Sepolia (real deployed contracts) Stealth address = counterfactual address of a ZKNOX `mldsa_k1` account (ECDSA secp256k1 **AND** ML-DSA-44/level-2), factory `0xF451…8C2e`, verifier `0x092c…21ef`, EntryPoint v0.7 `0x0000…da032` — all already deployed, reused as-is from `ethereum/kohaku`. Nothing PQ-specific was deployed for this. * Receive: announce on the canonical announcer, fund the counterfactual address. Scan: decapsulate, view tag, re-derive — match. * Spend: `createAccount` (~710 k gas), then a self-bundled `handleOps` userOp whose validation runs the on-chain lattice verifier (~8.35 M gas whole-transaction). * **Gasless variant, live:** Pimlico `pm_sponsorUserOperation` accepted the estimation dummy signature and sponsored ~9.3 M gas of on-chain verification with no sponsorship policy — tx [`0x4cde53ee…d948f4`](https://sepolia.etherscan.io/tx/0x4cde53eef9027514cbf181f2f0baf0980dc49cd4af28cdc53412f986bad948f4). This also removes the funding-account privacy leak. * Offline determinism: the whole path replays against recorded Sepolia state (`npm run e2e:fork`), including the on-chain `getUserOpHash` cross-check for both the self-bundled and the paymaster forms. **Honest limit.** That account is *hybrid*: the userOp must also carry a valid ECDSA signature. A PQ-only variant exists and passes (`e2e-fork-pq-only`: deploy our own factory instance with the ML-DSA verifier in **both** validator slots, fork Sepolia, spend with the blinded key signing both — no ECDSA in the authorization), but it is not the deployed factory. So on the real Sepolia infrastructure, ownership is not yet post-quantum only. ### 4.2 Frame transaction on the frames testnet (EIP-8141, PQ-only) Stealth address = CREATE2 `Stealth8141ZkAccount` of a 32-byte commitment; one `0x06` transaction does everything. | Step | Result | | --- | --- | | receive: pay + announce in one frame tx | 295,489 gas | | spend: `[VERIFY(sponsor), DEFAULT(createAccount), DEFAULT(executeFrame)]` | status 1, proof verify + transfer 3,544,107 exec, total billed 8,576,431 | | proof | UltraHonk, 2 keccak calls, 40,626 gates, 8,768 B, proved **in the browser** (bb.js WASM, 0.65–1.3 s, web worker) | | authorization | none — no signature, no signer service, no key on chain | | deployed | `PreimageHonkVerifier` + `Stealth8141ZkFactory` (`ui/public/frames-zk-deployment.json`) | The C13 variant of the same account (proving a *signature* in-circuit instead of the *secret*) is also live on that testnet: 6,382,753 gates, 50.4 s proving, 11,456 B proof, 4,174,008 gas. It is the stronger statement — it keeps the signature-scheme choice — and it is the one that cannot be proven in a browser. **Honest limits, all three material.** The verifier is 26,317 B runtime, above EIP-170's 24,576 B; the frames testnet does not enforce 170, so the run is valid there and **would not deploy on mainnet** without EIP-7907 (removed from Fusaka, a Glamsterdam candidate, not final). The proof backend is UltraHonk/BN254-KZG, i.e. classically sound only — the account stores the verifier as a rotatable pointer precisely so the statement survives a backend swap, but the swap must happen before a CRQC, because after one the attacker can authorize the swap too. And the statement is *ownership by hash preimage*: post-quantum by assumption (a 256-bit preimage), with the proof system, not the statement, setting the security level (D-007). ## 5. "Cheaper spending during the transition" — the honest verdict The stretch goal's motivation was cheaper spending. Measured, per-spend on-chain cost at the same boundary: ``` ECDSA 21,000 classical ownership, deployable today EIP-8164 native key 50,000 proposed charge, no client implementation SPHINCS-C13 direct 188,092 PQ-sound, reveals the key at spend preimage ZK 3,544,107 PQ assumption, hides the key, backend not PQ-sound C13 in ZK 4,174,008 PQ assumption, hides the key, backend not PQ-sound ML-DSA ERC-7913 13,678,091 PQ-sound, reveals the key at spend ``` So: * **Against ML-DSA on the EVM, the ZK route is a real win:** 3.54 M vs 13.68 M, \~3.9× cheaper per spend. That is the number that says "a PQ account does not have to cost 14 M gas". It comes from proving the *relation* a spend needs rather than the *signature*, which is what makes the statement small enough to prove on a laptop or in a browser at all. * **It is not the cheapest PQ spend.** A direct hash-based signature verifies for 188 k — 19× less than the preimage proof. The ZK route is a **privacy** purchase: it is the only route here where a spend reveals nothing, so two spends by one recipient are unlinkable. Direct C13 and direct ML-DSA both reveal the key at spend time and link the recipient's addresses from the first spend. * **The cheapest spend of all is not an EVM verification.** EIP-8164 native keys (50 k) or EIP-8288 aggregation (3 k/30 k per dependency, proposed charges) remove the verifier from the EVM entirely. That is a protocol change, not something a scheme ID can deliver, which is exactly why the proposal put spending outside the core deliverable. * **L2 changes the ranking's relevance, not its order.** Every row above is execution gas, which an L2 prices at a fraction of L1; the announcement row (bytes) is what blobs make ~320× cheaper. Spend is a one-shot operation per payment; detection is the every-payment path. Framing that survives scrutiny: *post-quantum spending during the transition is possible today at 0.19–3.5 M gas depending on what you are willing to reveal, versus 13.7 M for a straightforward on-chain ML-DSA verify; the fully-private option costs a non-PQ-sound proof system until a hash-based verifier exists.* ## 6. Corrections this exercise produced Grounding every number against committed data surfaced three defects, all now fixed in the living documents (the dated decision entries keep their text and gain a correction note): 1. **The announcement model is 30 gas off, not exact.** `onchain_cost.py` reproduces 67,550 on today's committed vectors against the 67,580 measured on the Sepolia fork; the residual is the zero/nonzero byte split of the encoded arguments (1,113/203 modelled vs 1,114/202 measured), so the model is anchored to 0.04%, not to the gas. Claims of "reproduces to the gas, 0.00% error" are now stated as ≤0.04%. The model and its conclusion (the EIP-7623 floor binds, PQ is 2.5× DKSAP on L1) are unchanged. 2. **The DKSAP row carried a fake measurement.** `announce_dksap` reported the PQ measurement (67,580) in its `measured_gas` field with `error_pct` −59.54, because the field was set unconditionally in `model_announce`. DKSAP was never measured on chain; the field is now `null`. 3. **Two native scan figures were in circulation** (22.6 µs from the committed 80k row, 23.8 µs from an earlier run). The report and the proposal now quote the one that reproduces from the committed table and say so. ## 7. Reproduce ```sh # client-side: scan slopes, EC baseline, per-op latency, rejection rounds cd python && python benchmarks/registry_curve.py --reps 3 python benchmarks/scan_bench.py --sizes 5000,20000,80000 --reps 3 python benchmarks/op_bench.py --reps 20 python benchmarks/param_sweep.py --n 20000 --reps 5 --sign-reps 50 # on-chain model (announcement, L2, registration) python benchmarks/onchain_cost.py --json benchmarks/onchain_results.json # Sepolia ERC-4337: receive -> scan -> deploy -> spend, offline replay cd ../js-client && npm run e2e:fork # frames testnet: proof-of-secret spend (needs a funded sponsor on chain 81410) cd ../ui && npm run e2e:frames-preimage # contract-level gas, including the ML-DSA profile breakdown cd ../js-client && npm run test-contracts ``` The live Sepolia gasless run is `LIVE=1 npm run e2e:sepolia-live` and requires `PIMLICO_API_KEY`, `SEPOLIA_PRIVATE_KEY`, `SEPOLIA_RPC_URL`; it broadcasts and spends testnet ETH. ## 8. What is not claimed * No ML-DSA-65 on-chain verifier exists here. Every ML-DSA row is the **level-2 Dilithium2** profile of the deployed ZKNOX verifier; nothing measured transfers to ML-DSA-65, and the keccak-PRNG variant (4.9 M) is not a FIPS measurement. * The ZK accounts are **not post-quantum sound** today. The statement is, the backend is not. * The browser preimage route is a **demo**; the secret is derived from a seed in the page and never transmitted, which is appropriate for a demo and not a wallet design. * The Sepolia 4337 account is **hybrid** (ECDSA required); the PQ-only variant runs against a locally deployed factory instance on a fork. * Blinded signing still runs in a Python service, not in the browser (`ui/scripts/signer-service.mjs`). That is a real operational weakness of the 4337 route, stated in `ui/README.md`. * Registration, announcement, account creation, verification, transfer, and total billed gas are separate rows throughout; they are not summed into one "cost of a stealth payment". # Widened-z distribution and signature-route distinguishability :::info[Mirrored page] Source: [`docs/research/widened-z-distribution.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/widened-z-distribution.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Status: research note feeding the security-analysis write-up (the widened-z distribution item of the EPF definition). It quantifies what the widened signing gates do to the distribution of the response `z`, derives a concrete distinguishing channel between widened (blinded-key) and stock ML-DSA signatures, and maps the spend-side linkability channels — absorbing the analyses behind issues #29 and #33 — against the proven announcement unlinkability bound. Analysis only; no protocol change proposed. Model scope (review F4, 2026-10-01): every probability in this note is exact (rational) **for the `z` gate alone** — the fixed-shift, cube-uniform rejection-sampling model of §2, derived from the cited theorems; decimals are computed from the rationals, never hand-rounded. The full published-signature distribution — after Fiat–Shamir challenge generation, the `r₀` gate, and the hint check — is **not** analyzed here: the stock-side distinguishability figures of §3 are z-gate-model numbers, not exact full-signature distinguishability probabilities, and no full-scheme HVZK theorem is claimed. ## 1. Setup: the widened gates Construction A spending blinds the recipient's ML-DSA key as `(s₁+s′, s₂+e′)` with `(s′, e′)` derived from the shared secret. Both sums are `2η`-short, so the challenge-product bound doubles: `‖c·(s₁+s′)‖∞ ≤ β' = τ·2η = 2β` (`WidenedSigning.lean:43` [`widenedBeta_eq_tau_two_eta`](/lean/proofs/widenedsigning#widenedbeta_eq_tau_two_eta); the bound itself is [`sampleInBall_smul_widened_bound`](/lean/proofs/widenedsigning#sampleinball_smul_widened_bound), `WidenedSigning.lean:102`). The widened identification scheme therefore tightens both response gates to `‖z‖∞ < γ₁ − β'` and `‖r₀‖∞ < γ₂ − β'` ([`widenedIdentificationScheme`](/lean/proofs/widenedsigning#widenedidentificationscheme), `WidenedSigning.lean` §6), and the blinded key satisfies the widened key relation ([`widenedValidKeyPair_blinded`](/lean/proofs/widenedsigning#widenedvalidkeypair_blinded), `WidenedSigning.lean:156`). The mask `y` stays uniform over the FIPS 204 cube `[−(γ₁−1), γ₁]^(l·256)` ([`sampleMaskCube`](/lean/proofs/widenedsigning#samplemaskcube)). Widening shrinks the acceptance region; it does not change the mask distribution. ## 2. The accepted-z distribution is exact and key-independent Two theorems pin the distribution completely: * **Acceptance probability.** For any fixed shift `δ = c·s₁` with `‖δ‖∞ ≤ β'`, a cube-uniform mask passes the `z` gate with probability exactly `((2(γ₁−β')−1)/(2γ₁))^(l·256)`, *independent of `δ`* ([`cube_shift_accept_prob`](/lean/proofs/widenedsigning#cube_shift_accept_prob), `WidenedSigning.lean:351`; instantiated as [`widened_z_accepted_independent`](/lean/proofs/widenedsigning#widened_z_accepted_independent), `WidenedSigning.lean:402`). For ML-DSA-65 (`γ₁ = 2^19`, `β' = 392`, `l·256 = 1280`) that is `(1047791/1048576)^1280 ≈ 0.383425`, kept as an exact rational in [`mldsa65_widened_z_accept_prob`](/lean/proofs/widenedsigning#mldsa65_widened_z_accept_prob) (`WidenedSigning.lean:590`). * **Conditional uniformity.** Each accepted `z` has exactly one mask preimage `y = z − δ` ([`coefficient_preimage`](/lean/proofs/widenedsigning#coefficient_preimage), `WidenedSigning.lean:335`): for `|z| < γ₁ − β'` and `|δ| ≤ β'`, `|y| ≤ γ₁ − 1` lies in the cube, and the accepted set size is `2(γ₁−β')−1` per coefficient for *every* shift ([`card_filter_cube`](/lean/proofs/widenedsigning#card_filter_cube), `WidenedSigning.lean:293`). So, conditioned on acceptance, `z` is uniform on the full `(γ₁−β')`-ball — the key-dependent shift cancels. **The accepted-`z` marginal leaks nothing about the key.** Concrete numbers (exact rationals; `E` = expected rejection rounds for the `z` gate alone, `1/p`): | Parameter set | β | β′ | per-coeff accept | sign-accept `p` | `E[z gate]` | | --- | --- | --- | --- | --- | --- | | ML-DSA-65 (L3) | 196 | 392 | 1047791/1048576 | 0.383425 | 2.61 | | Level-2 deployed profile (Dilithium2 shape, `γ₁=2^17`, `l=4`) | 78 | 156 | 261831/262144 | 0.294232 | 3.40 | Stock (unwidened) acceptance for comparison: ML-DSA-65 `(1048183/1048576)^1280 ≈ 0.618891` (`E` 1.62); level-2 `(261987/262144)^1024 ≈ 0.541471` (`E` 1.85). **Reconciliation with the measured round counts.** The measured end-to-end rejection rounds (`benchmarks/README.md`, `param_sweep.py`, mean of 50) are 24.8 vs stock 5.5 for L3 (4.5×) and 14.4 vs 4.6 for the level-2 shape (3.1×). The `z` gate alone predicts a ratio of only 1.61 (L3) and 1.84 (level-2). The residual factor — 2.8× at L3, 1.7× at level-2 — is the widened `r₀` gate and hint check, which the exact `z`-gate theorem does not cover. The benchmark's own note ("a tighter probabilistic norm bound would help L3 the most") is thus *understated* for the `z` gate alone but correct in direction: most of the widening cost lives in the `r₀`/hint gates, and any probabilistic shortness bound should target those first. ## 3. The `z`-gate model distinguishes widened and stock responses The tight `z` gate is a potential disclosure channel in the stated model. Stock ML-DSA enforces `‖z‖∞ < γ₁ − β`; a widened signer always produces `‖z‖∞ < γ₁ − β' = γ₁ − 2β`. Both signatures verify against the same stock verifier (the widened bound is strictly tighter, so widened signatures pass), but their *supports differ*: * Every coefficient of a widened signature avoids the band `[γ₁−β', γ₁−β)` by construction. * A stock signature lands entirely below `γ₁−2β` with probability `((2(γ₁−2β)−1)/(2(γ₁−β)−1))^(l·256)` under the same uniformity argument as §2 — for ML-DSA-65: `(1047791/1048183)^1280 ≈ 0.619536`. Within the `z`-gate model, the boolean test "all coefficients `< γ₁−2β`" has a stock-side false-positive probability `0.619536`, hence a model classification advantage `1 − 0.619536 ≈ 0.3805` per response at L3, and `1 − 0.543393 ≈ 0.4566` at the level-2 shape. For independent model draws, the probability that at least one stock response exposes a band coefficient is `1 − 0.619536^k`: | k signatures | 1 | 2 | 4 | 8 | | --- | --- | --- | --- | --- | | model advantage (L3) | 0.381 | 0.616 | 0.853 | 0.978 | | model advantage (level-2) | 0.457 | 0.705 | 0.913 | 0.992 | Caveats, stated precisely: * The exactness above is for the `z` gate alone (review F4 scope note). The published signature is additionally conditioned on the `r₀` and hint gates (a fresh `y` per retry), which couples `z` with `w = A·y` and perturbs the *stock* marginal. The widened side's band-avoidance is forced with probability exactly 1 by its own gate, so the *widened signer's band-avoidance event* is exact — but the per-signature **distinguishing advantage** numbers are z-gate-model numbers for the stock side, and the direction of the full-gate perturbation of the stock probability is not analyzed here. No exact full-signature distinguishability probability is claimed. * A coefficient in the band rules out a widened `z`-gate response. Its absence does **not** identify a widened or blinded key: under this model a stock response avoids the band with probability about `0.619536` at L3 and `0.543393` at level 2. Thus it provides only probabilistic model classification, not a route or recipient identifier. The accepted-`z` key-independence result in §2 is for the fixed-shift `z` gate; it does not establish key-independence of a full published signature. This channel should therefore not be used to narrow candidates to `0x01` recipients. * **Widened-vs-stock distinguishability is a different quantity from HVZK** (review F4). The §3 advantage concerns differently-distributed `z`-gate responses in the stated model. The HVZK property of §4 is *not* the claim "widened transcripts look like stock ML-DSA transcripts" — a simulator for the widened scheme must reproduce the **widened protocol's own** transcript distribution (challenge from the Fiat–Shamir hash, `z` uniform on the accepted ball, the same retry/abort behavior), not stock ML-DSA's. The two statements must not be conflated: §3 is a modelled `z`-gate discriminator; §4 is about whether the widened identification scheme itself is zero-knowledge. ## 4. Why [`widenedHvzkDistance`](/lean/proofs/widenedsigning#widenedhvzkdistance) is pinned trivial — and a `z`-gate diagnostic The pinned HVZK simulator [`widenedHvzkSimulator`](/lean/proofs/widenedsigning#widenedhvzksimulator) (`WidenedSigning.lean:548`) draws `z` uniform over the full ring `Rq` (modulus `q = 8 380 417` per coefficient) and only then applies the widened gate. Its acceptance probability is `(1047791/8380417)^1280 ≈ 10^−1156`: the simulator aborts with probability essentially 1. The honest prover, by contrast, **accepts** the `z` gate with probability `0.383425` and aborts with probability `1 − 0.383425 ≈ 0.616575`. Both distributions must be compared on the same experiment (review F4): the abort-probability *difference* is `|0.616575 − (1 − 10^−1156)| ≈ 0.383425` — **not** `0.616575`, the error in the earlier draft of this section, which subtracted the simulator's success probability from the honest abort mass instead of comparing the two abort masses. In the specified `z`-gate-only experiment, the abort-marginal difference is therefore `≈ 0.3834`. The honest prover's other rejection gates (`r₀`, hint — not modeled here) change its abort mass, so this calculation is neither a full-scheme bound nor a proved distance floor. `widenedHvzkDistance = 1` (`WidenedSigning.lean:568`) is only the universal upper bound supplied by `tvDist_le_one`; it does not show that these transcripts are far apart. The calculation above is a diagnostic for the pending simulator and exact transcript experiment. The sharpening path is subtle, not mechanical (review F4): draw the simulator's `z` from the cube ([`sampleMaskCube`](/lean/proofs/widenedsigning#samplemaskcube)) instead of the ring, **resampling until the gate passes**. That makes the *accepted-`z`* marginal match the honest prover's exactly (both uniform on the accepted ball, §2) — but resampling-until-acceptance conditions the simulator on success and changes its abort mass: the resampling simulator never aborts, while the honest prover aborts a round with `z`-gate probability `0.616575` (and more once the `r₀`/hint gates are counted). The simulator must therefore match the exact transcript experiment being defined — if the experiment conditions the honest side on acceptance too, the abort-marginal gap of the first paragraph does not apply, and the remaining distance is the `(w₁, h)`-vs-`z` coupling plus the `r₀`/hint gate conditioning; if the experiment keeps aborts, the simulator must reproduce the abort behavior as well. Which experiment [`CmaToNmaLossNN`](/lean/proofs/spendsecurity#cmatonmalossnn) needs is part of the open work, and until that simulator and experiment land, `zetaWide = widenedHvzkDistance = 1` (`SpendSecurity.lean:255`) and the signature-layer composition stays open, exactly as `construction-a-security.md` §3 records. No nontrivial full-HVZK theorem is claimed here. ## 5. Unlinkability across payments — the channel map The proven statement is passive announcement unlinkability: for `q` announcements to two fixed recipients, `Adv_unlink_q ≤ q · ε_single` ([`unlinkAdvantageMulti_le_mul`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_mul), `MultiUnlink.lean:241`), with the single-announcement decomposition into KEM IND-CPA, blinding, and SPR-to-MLWE terms at [`unlinkAdvantage_metaKem_le`](/lean/proofs/constructiona#unlinkadvantage_metakem_le) (`ConstructionA.lean`; announcement model [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull), `KEMAnonymity.lean:145`; ROM query bound [`blindBadProb_le_queryBound`](/lean/proofs/blindingentropy#blindbadprob_le_querybound), `BlindingEntropy.lean:94`). Everything below is a *spend-time* disclosure and does not touch that bound — but the security write-up must state each channel as a property, because none of them is covered by a theorem: | Channel | Trigger | What it reveals | Evidence / status | | --- | --- | --- | --- | | Announcement transcript | passive observation of announcements | nothing beyond `ε_single` per announcement | Lean decomposition, sorry-free (`construction-a-security.md` §1–2) | | `rho` carried verbatim in `stealth_pk` (#29) | any full-key disclosure: on-chain ML-DSA verify, `PKContract` deployment, key tables | the recipient's matrix seed = the meta-address's `rho`; all revealed keys of one recipient link to it and to each other | `test_construction_a_rho.py`; D-027 finding; recommendation: keep `0x01`, state as property | | `spend_key` hash linkage (#33) | any direct ML-DSA-44 commit spend | revealed `pk` hashes to the published `spend_key`; all direct spends link from the first one | `test_profiles.py:262`, vectors' `linkage` block; recommendation: informative, not normative | | Widened-`z` band (this note) | any published signature whose `z` is available | a `z`-gate-model discriminator only; band avoidance has stock false positives 0.620 (L3) / 0.543 (level-2) | §3 above; full published-signature and HVZK effects open | Composed reading (review F4 correction): the `rho` and `spend_key`-hash channels are **profile-specific and not automatically simultaneous**. Construction A's retained `rho` (#29) is a channel of the `0x01` direct route; the commitment profile's revealed master-key hash is a channel of the `ml-dsa-44-commit/v0` direct route. A recipient on one profile does not automatically expose the other's channel — the two co-occur only for a recipient observed across both routes, and "the first direct spend is linkable twice over" applies to that cross-profile recipient, not to every spend. Stated per profile: a `0x01` recipient's first key-revealing spend exposes `rho` (linking all its revealed keys and its meta-address); an `ml-dsa-44-commit/v0` recipient's first direct spend exposes the `spend_key`-hash linkage (#33). A visible `z`-band coefficient rules out a widened `z`-gate response, while band avoidance is not enough to flag a blinded route because of the stock false-positive probability in §3. Unlinkable spending requires routes that never reveal the key (ZK/preimage). The ERC text (human-written, D-024) should carry this as one security-considerations paragraph spanning #29/#33 and this note, with the per-profile condition stated; the accompanying decision record is drafted as D-030 on the #28/#33 branch. ## 6. What would change the numbers * **Probabilistic `β'`.** A high-probability bound on `‖c·(s₁+s′)‖∞` tighter than the worst case `τ·2η` would shrink both the rejection-round penalty (§2) and the distinguishing band (§3) together, since both scale with `β'`. It would have to be re-proven — [`sampleInBall_smul_widened_bound`](/lean/proofs/widenedsigning#sampleinball_smul_widened_bound) is the worst-case statement the widened gates are defined against. The benchmark note in `python/benchmarks/README.md` calls for exactly this; §2's reconciliation says the payoff concentrates in the `r₀`/hint gates. * **Simulator fix.** The cube-source simulator of §4 makes the `z` marginal exact and isolates the honest quantification of the remaining HVZK gap — prerequisite for any nontrivial `zetaWide` and hence for composing [`CmaToNmaLossNN`](/lean/proofs/spendsecurity#cmatonmalossnn). * **QROM blinding.** Even with all of the above, key blinding for lattice schemes has no QROM proof — the open problem recorded in the [key-blinding bibliography](/research/key-blinding-bibliography) (Eaton et al.'s generic framework is ROM-only at every family it covers, Dilithium included; Balumuri et al. 2024/945 is the MPCitH QROM proof, Basso et al. 2026 an unconditional isogeny result). The `z` analysis here is classical-ROM throughout. ## Reproduce Every number above from exact rationals (no dependencies): ```python from fractions import Fraction as F # ML-DSA-65: gamma1=2^19, beta=196, beta'=392, l*256=1280 g1, b, wb, n = 2**19, 196, 392, 1280 p_wide = F(2*(g1-wb)-1, 2*g1)**n # widened accept = 0.383425… p_stock = F(2*(g1-b)-1, 2*g1)**n # stock accept = 0.618891… avoid_band = F(2*(g1-wb)-1, 2*(g1-b)-1)**n # stock avoids band = 0.619536… adv = 1 - avoid_band # per-response advantage (z-gate model only) = 0.380464… abort = 1 - p_wide # honest z-gate abort marginal = 0.616575… # HVZK abort-marginal gap (review F4): simulator aborts w.p. ~1, honest w.p. abort: # distance contribution = |abort_sim - abort_honest| = 1 - 0.616575 = 0.383425 # (the earlier draft's 0.6166 subtracted the simulator's SUCCESS probability) # Level-2 shape (gamma1=2^17, beta=78, beta'=156, l*256=1024): 0.294232 / 0.543393 / adv 0.456607 # Simulator accept (z uniform over Rq, q=8380417): (F(2*(g1-wb)-1, 8380417))**n ~ 10^-1156 ``` Cross-checks: the per-coefficient widened rational `1047791/1048576` is the one stated in [`mldsa65_widened_z_accept_prob`](/lean/proofs/widenedsigning#mldsa65_widened_z_accept_prob) (`WidenedSigning.lean:590`); the deployed-profile `β' = 156` is `python/scripts/spendable_helper.py:49` (`tau * 2 * eta`); the measured round ratios are the `param_sweep.py` table in `python/benchmarks/README.md` (L3 24.8/5.5, level-2-shape 14.4/4.6). # X-Wing as the discovery KEM: what a PQ/T hybrid buys us, and what it doesn't :::info[Mirrored page] Source: [`docs/research/xwing-hybrid-kem.md`](https://github.com/Skanislav/pq-sap/blob/main/docs/research/xwing-hybrid-kem.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: *2026-08-27. Companion artifacts: `python/benchmarks/xwing_bench.py` (+ `xwing_20260827.json`), validated against the official draft test vectors. Extends the D-012 discovery-KEM design space and the D-016 "lattice is load-bearing" finding.* ## 1. Question D-016 settled that discovery needs a structured PQ KEM — hash-only key exchange is impossible beyond a polynomial (and quantumly, no) gap. That leaves the residual risk D-012 priced with NTRU/HQC backups: **what if Module-LWE falls to classical cryptanalysis?** The standard industry answer is a PQ/T hybrid, and the standalone hybrid KEM is X-Wing (X25519 + ML-KEM-768). This memo evaluates X-Wing as an alternative (or additional parameter set) for the discovery KEM, on the three usual axes plus the one that actually decides it for us: **which of our security terms the hybrid hedges**. ## 2. What X-Wing is (spec status, August 2026) * Construction (draft-connolly-cfrg-xwing-kem-10, 2026-03-02; eprint 2024/039, IACR CiC 1(1)): one ML-KEM-768 encapsulation plus one X25519 ECDH, combined as ``` ss = SHA3-256( ss_M ‖ ss_X ‖ ct_X ‖ pk_X ‖ "\.//^\" ) ``` pk = ek\_M ‖ pk\_X = **1216 B**; ct = ct\_M ‖ ct\_X = **1120 B**; decapsulation key = **32-B seed** (SHAKE256-expanded to d, z, and the X25519 scalar). The ML-KEM ciphertext is deliberately *not* hashed: ML-KEM's FO re-encryption check makes it C2PRI (ciphertext second-preimage resistant even given the secret key), which already binds ct\_M through ss\_M. * Standards status: **not an RFC** and never adopted as a CFRG WG item, but the construction is being standardized bit-identically as **MLKEM768-X25519** in draft-irtf-cfrg-concrete-hybrid-kems and in the HPKE PQ draft (IANA HPKE KEM codepoint 0x647a). Don't confuse it with TLS's X25519MLKEM768 (RFC 10024) — that is a handshake-level concatenation into HKDF, not a standalone IND-CCA KEM. * Deployment: Apple CryptoKit ships it (iOS 26 / macOS 26 PQ HPKE); BoringSSL, Cloudflare CIRCL, filippo.io/mlkem768/xwing, RustCrypto `x-wing`, libcrux. **Not in liboqs or upstream OpenSSL.** NIST SP 800-227 (final, Sept 2025) blesses exactly this shape of hybrid (approved component + approved combiner), so it is FIPS-track-compatible. * Formal verification: EasyCrypt proofs in `formosa-crypto/formosa-x-wing` (README still says WIP); the libjade Jasmin implementation has composed functional-correctness proofs (Jazzline, CCS 2025). ## 3. Measured (this machine, `xwing_bench.py`) Implementation: kyber-py derandomized internals + `cryptography` X25519, **all three official test vectors pass byte-identically** (pk, ct, encaps ss, decaps ss). Timing composes the liboqs ML-KEM-768 figure with a native X25519 (`openssl speed ecdhx25519`: 33,169 op/s = 30.2 µs) so the row is apples-to-apples with `discovery_kem_20260801.json`. | candidate | pk B | ct B | decaps µs | scan80k s | meta B | announce gas | |---|---|---|---|---|---|---| | ML-KEM-768 (reference) | 1184 | 1088 | 17.2 | 1.38 | 1217 | 67,670 | | **X-Wing** (native comp.) | 1216 | 1120 | 48.0 | 3.84 | 1249 | 68,890 | | X-Wing (py-wrapped x25519) | 1216 | 1120 | 177 | 14.2 | 1249 | 68,890 | (Gas via the EIP-7623 floor model with random ciphertext bytes — ±~100 gas run-to-run; the measured on-chain ML-KEM announce is 67,580.) Components: ML-KEM-768 decaps 17.2 µs + X25519 30.2 µs + SHA3-256 combine 0.6 µs. Two observations: 1. **The classical leg dominates the hybrid's scan cost** (X25519 is ~1.8× the ML-KEM decaps — ML-KEM really is faster than curve arithmetic). Hybrid tax: **2.8× scan, +~1,220 gas (+1.8%), +32 B meta-address**. 2. Even so, X-Wing at 3.84 s/80k **beats the best NTRU hedge** (NTRU-HPS-2048-677: 4.19 s) and every non-lattice family by 1–3 orders of magnitude. As a "second assumption" play, the hybrid is cheaper than switching lattice families, and vastly cheaper than leaving lattices. The 32-B seed decapsulation key is an operational win: the whole viewing key backs up as one 32-byte secret (vs 64 B of (d,z) plus formats today). Caveat that comes with it: X-Wing's MAL-BIND-K-\{PK,CT} claims **hold only if the key is stored/transmitted as the seed** — never the expanded ML-KEM decapsulation key (Schmieg, eprint 2024/523; draft §Security). ## 4. The part that decides it: which terms does the hybrid hedge? Our Lean chain decomposes unlinkability as ``` unlink ≤ ssHiding(true) + auxKeyIndependence + anon + ssHiding(false) ``` (shared-secret hiding = real-or-random KEM IND-CPA; anon = ciphertext key-privacy; aux = view tag + stealth address derived from ss and pk). X-Wing treats these terms *differently*, and the recent literature states the split exactly: **Hedged (OR of assumptions): the ssHiding terms.** X-Wing's headline theorems (eprint 2024/039): IND-CCA classically from strong-DH on Curve25519 (+ C2PRI of ML-KEM, ROM), and post-quantum from ML-KEM IND-CCA + SHA3 as PRF (standard model). Either leg keeps ss pseudorandom. For us that means **view tags and derived stealth addresses stay hiding even if Module-LWE falls classically** — the scan-side detection privacy genuinely becomes "MLWE OR strong-DH". **NOT hedged (AND of assumptions): the anon term.** Bao–Pan, "Anonymity of X-Wing and its Variants" (PKC 2026, eprint 2026/396 — the first anonymity analysis; the original paper proves none) states it outright (Remark 1, crediting Günther et al.): a parallel hybrid concatenates both ciphertexts, so **if either component ciphertext leaks which public key it belongs to, hybrid anonymity is broken** — anonymity is an AND, unlike IND-CCA's OR. For X-Wing specifically the X25519 leg is unconditionally weakly anonymous (ct\_X is an ephemeral public key, independent of the recipient — their Lemma 4), so the AND collapses to: **X-Wing's anonymity = ML-KEM's anonymity, full stop**. The classical leg is an anonymity free-rider; it cannot mask ct\_M, which sits on-chain in every announcement. A classical MLWE distinguisher that links ct\_M to a candidate ek\_M delinks X-Wing announcements exactly as it would plain ML-KEM ones. So the honest claim for the ERC is: > A hybrid parameter set hedges *detection privacy* (view-tag and address > pseudorandomness) and shared-secret confidentiality against a classical > lattice break, but announcement–meta-address unlinkability still rests on > ML-KEM's ciphertext anonymity alone. It is not "DKSAP-grade privacy if > lattices fall." Against the actual HNDL adversary (quantum, retroactive) the hybrid is exactly neutral: Shor kills the X25519 leg and X-Wing degrades to precisely our current ML-KEM-only scheme — no regression, no gain. The hybrid's value is purely the *classical-cryptanalysis* hedge, which is also the scenario (lattice estimates eroding) most analysts consider more live than CRQCs this decade. **What an OR-anonymity hybrid would take:** Günther–Rosenberg–Stebila– Veitch (CRYPTO 2025, eprint 2025/408) get it with a *nested* combiner (outer KEM encrypts/obfuscates the inner ciphertext), but that requires uniform-looking outer ciphertexts (Elligator-encoded X25519 or Kemeleon-encoded ML-KEM) plus extra primitives. Nonstandard, heavier, and no deployed spec — noted as the research-grade option, not proposed. ### 4.1 Lean/model notes * Instantiating our [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) chain at X-Wing is structurally easy (it is a KEM with the same ProbComp shape), and the AnonymityFromSPR route gets *simpler* in one respect: the anon term reduces to ML-KEM SPR alone, since a simulator can sample ct\_X honestly (fresh ephemeral) — it carries no key information. * One trap for SPR-style simulators: **honest ct\_X is not uniform bytes** (a random 32-B string lands on the curve only ~half the time), so the [`mlkem768UniformCiphertext`](/lean/proofs/mlkem#mlkem768uniformciphertext)-style uniform-bytes simulator is wrong for the X25519 component — simulate with a random *curve point* (or Elligator if uniform bytes are wanted). Same subtlety Günther et al. handle with obfuscated encodings. * Robustness/detection-soundness modeling carries over unchanged: X-Wing is implicit-rejecting (never ⊥), so scan-side detection must be tag-comparing, as our Soundness work already models for ML-KEM. Bao–Pan's WCPR-CCA assumption (wrong-key decapsulation is pseudorandom) is the false-positive-rate ingredient in hybrid form. ## 5. Fit with the rest of the design * **Complementary to PR #1, not competing.** ivanmmurciaua's classical-spend hybrid hybridizes the *spend* side (secp256k1 key + ML-KEM discovery); X-Wing hybridizes the *discovery* side. A maximal PQ/T-conservative profile would be X-Wing discovery + 7913-signer spend. * **Tag chains compose.** The §2.2 (poseidon2 memo) Aztec-style tag-chain design amortizes whatever KEM seeds the handshake; an X-Wing handshake seeds it identically, and repeat-sender announcements then avoid the +1.8% gas entirely. * **Client story is easy.** js-client can compose X-Wing from @noble/post-quantum (ML-KEM-768) + @noble/curves (x25519) + SHA3 — ~30 lines against the official vectors; no new vendored crypto. Python side as in `xwing_bench.py`. liboqs absence doesn't block us. * **ERC integration**, if adopted: a sibling parameter set / scheme-ID row (meta-address 1 + 32 + 1216 = 1,249 B under ZK-spend; ct 1,120 B; announce ~68,890 gas at the EIP-7623 floor), with two normative MUSTs imported from the draft: store/exchange the decapsulation key only as the 32-B seed (binding), and run the ML-KEM encapsulation-key check. ## 6. Verdict — **adopted as optional (D-017, locked 2026-08-27)** *User decision 2026-08-27: X-Wing is offered as an optional hybrid parameter set; the default stays ML-KEM-768. Folded into the ERC draft (Parameter sets `0x02` variant, Rationale, Security Considerations) and `docs/DECISIONS.md` D-017. Original assessment below.* X-Wing is the **best-value second-assumption play in the whole measured design space**: cheaper on scan than any non-MLWE family including NTRU, +32 B / +1.8% gas footprint, 32-B viewing-key backup, real deployments and a near-RFC spec, and formal analysis (IND-CCA proofs, PKC 2026 anonymity proofs, EasyCrypt WIP) that plugs directly into our Lean decomposition. Its limit must be stated honestly: **it hedges detection privacy and confidentiality, not ciphertext anonymity** — unlinkability keeps a single point of failure in ML-KEM's SPR either way. Recommendation: keep ML-KEM-768 as the default (scheme ID 2) and consider X-Wing/MLKEM768-X25519 as an *optional hybrid parameter set* in the ERC, replacing NTRU as the named hedge (D-012 shortlist update: NTRU's only remaining edge is footprint, and it has no combiner spec, no anonymity analysis, and slower scans). Adopted as D-017. ## Sources Spec/impl: draft-connolly-cfrg-xwing-kem-10; draft-irtf-cfrg-concrete- hybrid-kems; draft-ietf-hpke-pq (IANA 0x647a); RFC 10024 (TLS, distinct); NIST SP 800-227; Apple WWDC25 session 314; github.com/dconnolly/ draft-connolly-cfrg-xwing-kem (test vectors). Proofs: eprint 2024/039 (+ CiC 1(1) journal), 2026/396 (Bao–Pan anonymity, PKC 2026), 2025/408 (Günther et al., OR-anonymity nested combiner), 2025/1397 (Starfighters: QSF generality/C2PRI of FO KEMs — HQC fails C2PRI), 2025/1416 (binding of real-world combiners), 2024/523 (Schmieg, ML-KEM ¬MAL-BIND), 2022/1696 (Maram–Xagawa, Kyber ANO-CCA ROM+QROM), 2021/708, 2021/1323. Verification: formosa-crypto/formosa-x-wing (EasyCrypt, WIP); Jazzline (CCS 2025). Caveat: no published ANO-CCA proof for final FIPS-203 ML-KEM specifically (round-3 Kyber only) — Bao–Pan take component anonymity as assumption. # Lean proof browser :::info[Mirrored page] Source: [`lean/docs-proofs/index.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/index.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: 24 modules, 460 public declarations from `lean/PqStealth/*.lean`, one page per module. Every page shows each declaration's docstring, statement, axiom badge, a collapsible tactic proof and a GitHub permalink. Inline theorem names anywhere on this site link here. | Module | Declarations | Axioms | Summary | |---|---:|---|---| | [AnonymityFromSPR](/lean/proofs/anonymityfromspr) | 4 | sorry-free ✓ | Proved: anonymity is bounded by the two per-branch SPR advantages -- hop through a game whose challenge ciphertext comes from a key-independent simulator … | | [Blinding](/lean/proofs/blinding) | 4 | sorry-free ✓ | Proved: `A·s' + e' + (A·s₁ + s₂) = A·(s₁ + s') + (s₂ + e')` over any commutative ring -- the sender's published value is the honest ML-DSA public key of the … | | [BlindingEntropy](/lean/proofs/blindingentropy) | 15 | sorry-free ✓ | `BlindingROM` reduced the blinding term to the two bad-query probabilities. | | [BlindingROM](/lean/proofs/blindingrom) | 20 | sorry-free ✓ | Proved: `hashAddr ∘ pack` as VCVio's lazily sampled `randomOracle` over `unifSpec + (Bytes →ₒ Addr)`; the fresh-query lemma (from the empty cache the announced … | | [ConstructionA](/lean/proofs/constructiona) | 20 | sorry-free ✓ | Proved: the [`auxGen`](/lean/proofs/constructiona#auxgen) the real scheme uses -- view tag plus `hashAddr (pack rho (power2Round (A *ᵥ s' + e' + t)))`, symmetric primitives bundled uninterpreted … | | [ConstructionASecurity](/lean/proofs/constructionasecurity) | 2 | sorry-free ✓ | | | [Controls](/lean/proofs/controls) | 17 | sorry-free ✓ | Proved, all as ordinary theorems so the build keeps them honest: a scheme whose announcement IS the recipient's meta-address attains the maximal unlinkability … | | [DKSAP](/lean/proofs/dksap) | 21 | sorry-free ✓ | Proved: DKSAP (ERC-5564 scheme 1, the only stealth scheme deployed today) as a [`StealthScheme`](/lean/proofs/games#stealthscheme), and its detection completeness; given the discrete logs of the … | | [DKSAPOracle](/lean/proofs/dksaporacle) | 38 | sorry-free ✓ | Proved: the key-recovery attack as an `OracleComp (unifSpec + (G →ₒ F))` making EXACTLY two oracle queries whatever the number of announcements it is handed … | | [Demo](/lean/proofs/demo) | 19 | — | Everything else here is abstract, which is what makes the theorems apply to any curve and also what makes nothing executable. | | [Games](/lean/proofs/games) | 14 | sorry-free ✓ | Proved: [`StealthScheme`](/lean/proofs/games#stealthscheme) as an abstract randomized algorithm over VCVio's `ProbComp` (mirroring its own `AsymmEncAlg` idiom); detection completeness and the … | | [Invariants](/lean/proofs/invariants) | 25 | sorry-free ✓ | Proved: centered-norm sub-additivity, hence the blinded secret is `2·eta`-short and the signer bound doubles (`beta' = tau·(2·eta) = 2·beta`); the ownership … | | [KEMAnonymity](/lean/proofs/kemanonymity) | 20 | sorry-free ✓ | Proved: [`KEM`](/lean/proofs/kemanonymity#kem) (VCVio's `KEMScheme` over `ProbComp`) and its anonymity game -- key privacy, which VCVio does not ship; [`ofKEM`](/lean/proofs/kemanonymity#stealthschemeofkem), whose unlinkability EQUALS the … | | [MLKEM](/lean/proofs/mlkem) | 21 | sorry-free ✓ | Proved: the three `DecidableEq` instances VCVio's concrete encoding lacks (it sets all encoded types to `ByteArray` but as a plain `def`); that the concrete … | | [MultiRecipient](/lean/proofs/multirecipient) | 19 | sorry-free ✓ | Proved: the `n`-recipient left-or-right game — `n` independently generated meta-addresses, the adversary NAMES the challenge pair after seeing all of them and … | | [MultiUnlink](/lean/proofs/multiunlink) | 20 | sorry-free ✓ | Proved: the `q`-challenge left-or-right unlinkability game against two recipients ([`UnlinkExpMulti`](/lean/proofs/multiunlink#unlinkexpmulti)), and the hybrid bound `unlinkAdvantageMulti ≤ ∑ k ∈ range … | | [Ownership](/lean/proofs/ownership) | 26 | sorry-free ✓ | Proved: spend forgery -- producing a short `(s, e)`with`A \*ᵥ s + e = t`for a target you were not given -- cast as a VCVio`SIS.Problem`, with the honest … | | [ROMUpToBad](/lean/proofs/romuptobad) | 24 | sorry-free ✓ | VCVio's `tvDist\_simulateQ\_randomOracle\_withProgramming\_le\_probEvent\_bad`bounds the distance between a lazily sampled random oracle and a *programmed* one (the … | | [Reorder](/lean/proofs/reorder) | 5 | sorry-free ✓ |`OracleComp`'s `bind`is syntactic, so two games that draw the same independent samples in different orders are not equal on the nose; they are equal under … | | [SPRTwoHop](/lean/proofs/sprtwohop) | 44 | sorry-free ✓ | Proved: the two decision-MLWE problems the SPR argument needs, both as VCVio`LearningWithErrors.Problem`s over the seed `rho`; the two reduction adversaries … | | [SharedSecretHiding](/lean/proofs/sharedsecrethiding) | 10 | sorry-free ✓ | Proved: each shared-secret-hiding term of `unlinkAdvantage\_ofKEMFull\_le`is the bias of a real-or-random guessing game, and that bias EQUALS … | | [Soundness](/lean/proofs/soundness) | 21 | sorry-free ✓ | Proved: DKSAP's false-positive rate is EXACTLY`1 / |F|`, for every hash `h`-- the leak is the uniform spending scalar, not a hash collision; and for the … | | [SpendSecurity](/lean/proofs/spendsecurity) | 23 | sorry-free ✓ | A recipient's stealth keys are all derived from one master ML-DSA key`(A, t = A·s₁ + s₂)`: announcement `i`contributes a blinding offset`(s'ᵢ, e'ᵢ)`and the … | | [WidenedSigning](/lean/proofs/widenedsigning) | 28 | — | The standard ML-DSA identification scheme rejects when`‖z‖∞ \< γ₁ - β`and`‖r₀‖∞ \< γ₂ - β`, where `β = τ·η`bounds`‖c·s‖∞`for an`η`-short `s\`. | # AnonymityFromSPR :::info[Mirrored page] Source: [`lean/docs-proofs/anonymityfromspr.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/anonymityfromspr.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: anonymity is bounded by the two per-branch SPR advantages -- hop through a game whose challenge ciphertext comes from a key-independent simulator, hence carries no information about the hidden bit -- and, chaining into [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le), the full decomposition of stealth unlinkability into five named advantages with no unnamed slack. Assumed: the SPR terms are decomposed in `SPRTwoHop` into two MLWE hops (both identities proved) plus two named residual terms (primitive idealization, simulator gap); lifting to ANO-CCA must track ML-KEM's implicit-rejection FO transform. Neither VCVio nor FIPS 203 supplies `anonymity → MLWE`. See `docs/spr-two-hop.md`. ## [`simBranch`](/lean/proofs/anonymityfromspr#simbranch) The anonymity experiment's branch with a simulated (key-independent) challenge ciphertext. ```lean def simBranch (sim : ProbComp C) (adv : StealthScheme.UnlinkAdv PK C) (a : PK × PK) : ProbComp Bool := do let c ← sim adv a.1 a.2 c ``` [`AnonymityFromSPR.lean:33-37 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/AnonymityFromSPR.lean#L33-L37) ## [`sprAdv`](/lean/proofs/anonymityfromspr#spradv) ```lean noncomputable def sprAdv (kem : KEM PK SK C K) (sim : ProbComp C) (adv : StealthScheme.UnlinkAdv PK C) (b : Bool) : ℝ := (kem.anonSetup >>= kem.anonBranch adv b).boolDistAdvantage (kem.anonSetup >>= simBranch sim adv) ``` [`AnonymityFromSPR.lean:41-45 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/AnonymityFromSPR.lean#L41-L45) ## [`anonAdvantage_le_sprAdv`](/lean/proofs/anonymityfromspr#anonadvantage_le_spradv) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem anonAdvantage_le_sprAdv (kem : KEM PK SK C K) (sim : ProbComp C) (adv : StealthScheme.UnlinkAdv PK C) : kem.anonAdvantage adv ≤ kem.sprAdv sim adv true + kem.sprAdv sim adv false := by ``` :::details[proof] ```lean rw [KEM.anonAdvantage_eq_branchDistAdvantage, KEM.sprAdv, KEM.sprAdv, ProbComp.boolDistAdvantage_comm (kem.anonSetup >>= kem.anonBranch adv false)] exact ProbComp.boolDistAdvantage_triangle _ _ _ ``` ::: [`AnonymityFromSPR.lean:49-55 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/AnonymityFromSPR.lean#L49-L55) ### The simulated branch and per-branch SPR advantages ## [`unlinkAdvantage_ofKEMFull_le_full_decomposition`](/lean/proofs/anonymityfromspr#unlinkadvantage_ofkemfull_le_full_decomposition) **Full-chain unlinkability bound.** The complete announcement decomposes into five named advantages with no unnamed slack: the whole reduction skeleton of the scheme's privacy. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantage_ofKEMFull_le_full_decomposition (kem : KEM PK SK C K) (auxGen : K → PK → Aux) (sim : ProbComp C) (adv : StealthScheme.UnlinkAdv PK (C × Aux)) : (StealthScheme.ofKEMFull kem auxGen).unlinkAdvantage adv ≤ sharedSecretHiding kem auxGen adv true + auxKeyIndependence kem auxGen adv + (kem.sprAdv sim (adv.cipherOf auxGen) true + kem.sprAdv sim (adv.cipherOf auxGen) false) + sharedSecretHiding kem auxGen adv false := by ``` :::details[proof] ```lean calc (StealthScheme.ofKEMFull kem auxGen).unlinkAdvantage adv ≤ sharedSecretHiding kem auxGen adv true + auxKeyIndependence kem auxGen adv + kem.anonAdvantage (adv.cipherOf auxGen) + sharedSecretHiding kem auxGen adv false := unlinkAdvantage_ofKEMFull_le kem auxGen adv _ ≤ sharedSecretHiding kem auxGen adv true + auxKeyIndependence kem auxGen adv + (kem.sprAdv sim (adv.cipherOf auxGen) true + kem.sprAdv sim (adv.cipherOf auxGen) false) + sharedSecretHiding kem auxGen adv false := by gcongr exact kem.anonAdvantage_le_sprAdv sim (adv.cipherOf auxGen) ``` ::: [`AnonymityFromSPR.lean:65-87 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/AnonymityFromSPR.lean#L65-L87) # Blinding :::info[Mirrored page] Source: [`lean/docs-proofs/blinding.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/blinding.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: `A·s' + e' + (A·s₁ + s₂) = A·(s₁ + s') + (s₂ + e')` over any commutative ring -- the sender's published value is the honest ML-DSA public key of the widened secret, which is why the recipient can sign -- and the `Power2Round` rounding-error bound for the announced key, generic and at the concrete ML-DSA parameters (`docs/TECHNICAL_SPEC.md` §4). Assumed: nothing. Pure algebra over VCVio's LatticeCrypto layer. ## [`blinded_key_correctness`](/lean/proofs/blinding#blinded_key_correctness) Blinding is key-preserving: the sender's published value `A *ᵥ s' + e'` added to the recipient's meta key `A *ᵥ s₁ + s₂` is the honest public key of the widened secret `(s₁ + s', s₂ + e')`, over any commutative ring. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blinded_key_correctness {R : Type*} [CommRing R] {k l : ℕ} (A : Matrix (Fin k) (Fin l) R) (s₁ s' : Fin l → R) (s₂ e' : Fin k → R) : A *ᵥ s' + e' + (A *ᵥ s₁ + s₂) = A *ᵥ (s₁ + s') + (s₂ + e') := by ``` :::details[proof] ```lean rw [Matrix.mulVec_add] abel ``` ::: [`Blinding.lean:26-31 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Blinding.lean#L26-L31) ## [`stealth_pk_eq_blinded_keypair`](/lean/proofs/blinding#stealth_pk_eq_blinded_keypair) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem stealth_pk_eq_blinded_keypair {R : Type*} [CommRing R] {k l : ℕ} (A : Matrix (Fin k) (Fin l) R) (s₁ s' : Fin l → R) (s₂ e' t : Fin k → R) (ht : t = A *ᵥ s₁ + s₂) : A *ᵥ s' + e' + t = A *ᵥ (s₁ + s') + (s₂ + e') := by ``` :::details[proof] ```lean subst ht exact blinded_key_correctness A s₁ s' s₂ e' ``` ::: [`Blinding.lean:35-41 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Blinding.lean#L35-L41) ### 1. The blinded-key correctness identity ## [`stealth_pk_rounding_error`](/lean/proofs/blinding#stealth_pk_rounding_error) The rounding error dropped by `Power2Round` on an announced stealth key is at most `2 ^ (d - 1)`, for any parameters satisfying the `Power2RoundOps` laws. `axioms: propext` · sorry-free ✓ ```lean theorem stealth_pk_rounding_error {Coeff : Type*} [CommRing Coeff] {ring : NegacyclicRing Coeff} [AddCommGroup ring.Poly] {d : ℕ} (ops : Power2RoundOps ring d) (cnorm : ring.Poly → ℕ) (laws : Power2RoundOps.Laws ops cnorm) (t' : ring.Poly) : cnorm (t' - ops.shift2 (ops.power2Round t')) ≤ 2 ^ (d - 1) := laws.power2Round_bound t' ``` [`Blinding.lean:49-55 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Blinding.lean#L49-L55) ## [`stealth_pk_rounding_error_concrete`](/lean/proofs/blinding#stealth_pk_rounding_error_concrete) Concrete instantiation at the ML-DSA parameters (`d = 13`): the rounding error of every announced stealth public key is at most `2^12`. Uses the proven (sorry-free) `MLDSA.Concrete.concretePower2RoundLaws`. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem stealth_pk_rounding_error_concrete (t' : Rq) : cInfNorm (t' - concretePower2RoundOps.shift2 (concretePower2RoundOps.power2Round t')) ≤ 2 ^ 12 := concretePower2RoundLaws.power2Round_bound t' ``` [`Blinding.lean:60-64 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Blinding.lean#L60-L64) # BlindingEntropy :::info[Mirrored page] Source: [`lean/docs-proofs/blindingentropy.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/blindingentropy.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: `BlindingROM` reduced the blinding term to the two bad-query probabilities. Here each is bounded by `qH · β`: `qH` bounds the adversary's address-oracle queries and `β` the point mass of the address point `pack rho (power2Round (u + t))` over the uniform mask `u` ([`BlindPointMassBound`](/lean/proofs/blindingentropy#blindpointmassbound)). The generic ROM pieces are in `ROMUpToBad` (§ query budget). ## [`blindPointSample`](/lean/proofs/blindingentropy#blindpointsample) The address point of a uniform mask: `pack rho (power2Round (u + t))` for `u` uniform. ```lean def blindPointSample (P : Prims R Rho WireBytes T1 Tag Addr K k l) [SampleableType R] (rho : Rho) (t : Fin k → R) : ProbComp WireBytes := do let u ← $ᵗ (Fin k → R) pure (P.pack rho (P.power2Round (u + t))) ``` [`BlindingEntropy.lean:21-25 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L21-L25) ## [`BlindPointMassBound`](/lean/proofs/blindingentropy#blindpointmassbound) ```lean def BlindPointMassBound (P : Prims R Rho WireBytes T1 Tag Addr K k l) [SampleableType R] (beta : ℝ≥0∞) : Prop := ∀ (rho : Rho) (t : Fin k → R) (x : WireBytes), Pr[= x | blindPointSample P rho t] ≤ beta ``` [`BlindingEntropy.lean:28-32 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L28-L32) ## [`probOutput_blindBadQuery_le`](/lean/proofs/blindingentropy#proboutput_blindbadquery_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem probOutput_blindBadQuery_le (P : Prims R Rho WireBytes T1 Tag Addr K k l) [SampleableType R] [DecidableEq WireBytes] (tg : Tag) (rho : Bool → Rho) (t : Bool → Fin k → R) (adv : BlindAdvRO Tag Addr WireBytes) (qH : ℕ) (hq : ∀ x, IsQueryBoundP (adv x) (fun i => i.isRight = true) qH) (beta : ℝ≥0∞) (hB : BlindPointMassBound P beta) (b : Bool) : Pr[= true | blindBadQuery P tg rho t adv b] ≤ (qH : ℝ≥0∞) * beta := by ``` :::details[proof] ```lean -- the point-mass hypothesis in event form over the mask alone have hpt : ∀ (a : Addr) (x : WireBytes), Pr[fun u : Fin k → R => blindPoint P rho t b (u, a) = x | $ᵗ (Fin k → R)] ≤ beta := by intro a x have := hB (rho b) (t b) x rw [show blindPointSample P (rho b) (t b) = (fun u => blindPoint P rho t b (u, a)) <$> ($ᵗ (Fin k → R)) from by simp only [blindPointSample, blindPoint, map_eq_bind_pure_comp, Function.comp_def], probOutput_map] at this exact this -- 1. the flag of the programmed run, as an event over the prefix and the run unfold blindBadQuery badQueryGame rw [← map_bind, probOutput_map] -- 2. programmed flag ≤ plain-run "point cached", pointwise in the prefix have hstep : Pr[fun z => z.2.2 = true | blindPrefix (R := R) (k := k) (Addr := Addr) >>= fun x => (simulateQ (programmedROImpl (pointPolicy (blindPoint P rho t b x) x.2)) (adv (tg, x.2))).run (∅, false)] ≤ Pr[fun w : ((Fin k → R) × Addr) × (Bool × (WireBytes →ₒ Addr).QueryCache) => QueryCache.isCached w.2.2 (blindPoint P rho t b w.1) = true | blindPrefix (R := R) (k := k) (Addr := Addr) >>= fun x => (fun z => (x, z)) <$> (simulateQ (roImpl (WireBytes →ₒ Addr)) (adv (tg, x.2))).run ∅] := by rw [probEvent_bind_eq_tsum, probEvent_bind_eq_tsum] refine ENNReal.tsum_le_tsum fun x => mul_le_mul' le_rfl ?_ rw [probEvent_flag_programmed_eq_tracking, probEvent_map] exact probEvent_flag_trackingROImpl_pointPolicy_le (adv (tg, x.2)) _ _ ∅ (QueryCache.empty_apply _) refine hstep.trans ?_ -- 3. sample the mask last: it is independent of the adversary's run simp only [blindPrefix, bind_assoc, pure_bind] rw [probEvent_bind_bind_swap] refine probEvent_bind_le_const _ _ _ _ fun a _ => ?_ simp only [map_eq_bind_pure_comp, Function.comp_def] rw [probEvent_bind_bind_swap] refine probEvent_bind_le_const _ _ _ _ fun z hz => ?_ obtain ⟨S, hS, hkeys⟩ := roImpl_run_keys_subset (adv (tg, a)) qH (hq _) ∅ z hz rw [show (($ᵗ (Fin k → R)) >>= fun u => pure (((u, a), z))) = (fun u => ((u, a), z)) <$> ($ᵗ (Fin k → R)) from by simp only [map_eq_bind_pure_comp, Function.comp_def], probEvent_map] calc Pr[fun u : Fin k → R => QueryCache.isCached z.2 (blindPoint P rho t b (u, a)) = true | $ᵗ (Fin k → R)] ≤ S.card * beta := probEvent_isSome_apply_le ($ᵗ (Fin k → R)) (fun u => blindPoint P rho t b (u, a)) beta (hpt a) z.2 S (fun t' h => by rcases hkeys t' h with h' | h' · simp at h' · exact h') _ ≤ qH * beta := mul_le_mul' (by exact_mod_cast hS) le_rfl ``` ::: [`BlindingEntropy.lean:35-91 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L35-L91) ## [`blindBadProb_le_queryBound`](/lean/proofs/blindingentropy#blindbadprob_le_querybound) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blindBadProb_le_queryBound (P : Prims R Rho WireBytes T1 Tag Addr K k l) [SampleableType R] [DecidableEq WireBytes] (tg : Tag) (rho : Bool → Rho) (t : Bool → Fin k → R) (adv : BlindAdvRO Tag Addr WireBytes) (qH : ℕ) (hq : ∀ x, IsQueryBoundP (adv x) (fun i => i.isRight = true) qH) (beta : ℝ≥0∞) (hB : BlindPointMassBound P beta) (hβ : beta ≠ ⊤) (b : Bool) : blindBadProb P tg rho t adv b ≤ ((qH : ℝ≥0∞) * beta).toReal := ENNReal.toReal_mono (ENNReal.mul_ne_top (ENNReal.natCast_ne_top qH) hβ) (probOutput_blindBadQuery_le P tg rho t adv qH hq beta hB b) ``` [`BlindingEntropy.lean:94-104 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L94-L104) ## [`blindingAdvantageRO_le_queryBound`](/lean/proofs/blindingentropy#blindingadvantagero_le_querybound) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blindingAdvantageRO_le_queryBound (P : Prims R Rho WireBytes T1 Tag Addr K k l) [SampleableType R] [DecidableEq WireBytes] (tg : Tag) (rho : Bool → Rho) (t : Bool → Fin k → R) (adv : BlindAdvRO Tag Addr WireBytes) (qH : ℕ) (hq : ∀ x, IsQueryBoundP (adv x) (fun i => i.isRight = true) qH) (beta : ℝ≥0∞) (hB : BlindPointMassBound P beta) (hβ : beta ≠ ⊤) : blindingAdvantageRO P tg rho t adv ≤ 2 * ((qH : ℝ≥0∞) * beta).toReal := by ``` :::details[proof] ```lean calc blindingAdvantageRO P tg rho t adv ≤ blindBadProb P tg rho t adv true + blindBadProb P tg rho t adv false := blindingAdvantageRO_le_blindBadProb P tg rho t adv _ ≤ ((qH : ℝ≥0∞) * beta).toReal + ((qH : ℝ≥0∞) * beta).toReal := add_le_add (blindBadProb_le_queryBound P tg rho t adv qH hq beta hB hβ true) (blindBadProb_le_queryBound P tg rho t adv qH hq beta hB hβ false) _ = 2 * ((qH : ℝ≥0∞) * beta).toReal := by ring ``` ::: [`BlindingEntropy.lean:107-122 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L107-L122) ## [`mlDsaAddressPointBeta`](/lean/proofs/blindingentropy#mldsaaddresspointbeta) The ML-DSA point-mass value: each of the `k·256` coefficients of the `Power2Round` high part has at most `2^d` preimages among the `q` residues. ```lean noncomputable def mlDsaAddressPointBeta (k : ℕ) : ℝ≥0∞ := ((2 ^ droppedBits : ℝ≥0∞) / modulus) ^ (k * ringDegree) ``` [`BlindingEntropy.lean:132-134 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L132-L134) ## [`power2RoundCoeff_decomp`](/lean/proofs/blindingentropy#power2roundcoeff_decomp) `power2RoundCoeff` decomposes its input: `2^d · r₁ + r₀ = r` in `Coeff`. `axioms: propext, Quot.sound` · sorry-free ✓ ```lean theorem power2RoundCoeff_decomp (r : Coeff) : (power2Scale : Coeff) * ((power2RoundCoeff r).1 : Coeff) + ((power2RoundCoeff r).2 : Coeff) = r := by ``` :::details[proof] ```lean have hdam : (power2Scale : Coeff) * ((r.val / power2Scale : ℕ) : Coeff) + ((r.val % power2Scale : ℕ) : Coeff) = r := by rw [← Nat.cast_mul, ← Nat.cast_add] nth_rewrite 3 [← ZMod.natCast_zmod_val r] congr 1 exact Nat.div_add_mod r.val power2Scale simp only [power2RoundCoeff] split_ifs with h · push_cast exact hdam · simp only [Nat.cast_add, Nat.cast_one, Int.cast_sub, Int.cast_natCast] linear_combination hdam ``` ::: [`BlindingEntropy.lean:138-152 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L138-L152) ## [`power2RoundCoeff_low_range`](/lean/proofs/blindingentropy#power2roundcoeff_low_range) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem power2RoundCoeff_low_range (r : Coeff) : -4095 ≤ (power2RoundCoeff r).2 ∧ (power2RoundCoeff r).2 ≤ 4096 := by ``` :::details[proof] ```lean have hS : power2Scale = 8192 := by decide simp only [power2RoundCoeff, hS] have := Nat.mod_lt r.val (show 0 < 8192 by norm_num) split_ifs with h <;> simp only <;> omega ``` ::: [`BlindingEntropy.lean:154-160 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L154-L160) ## [`card_power2Round_fiber_le`](/lean/proofs/blindingentropy#card_power2round_fiber_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem card_power2Round_fiber_le (c : Coeff) : (Finset.univ.filter fun r : Coeff => ((power2RoundCoeff r).1 : Coeff) = c).card ≤ 2 ^ droppedBits := by ``` :::details[proof] ```lean have hd : 2 ^ droppedBits = 8192 := by decide rw [hd] refine (Finset.card_le_card_of_injOn (fun r => ((power2RoundCoeff r).2 + 4095).toNat) (t := Finset.range 8192) ?_ ?_).trans (by rw [Finset.card_range]) · intro r _ simp only [Finset.coe_range, Set.mem_Iio] have := power2RoundCoeff_low_range r omega · intro r hr r' hr' heq simp only [Finset.mem_coe, Finset.mem_filter, Finset.mem_univ, true_and] at hr hr' have hlow : (power2RoundCoeff r).2 = (power2RoundCoeff r').2 := by have := power2RoundCoeff_low_range r have := power2RoundCoeff_low_range r' simp only at heq omega rw [← power2RoundCoeff_decomp r, ← power2RoundCoeff_decomp r', hr, hr', hlow] ``` ::: [`BlindingEntropy.lean:162-181 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L162-L181) ## [`power2RoundHigh_eq_iff`](/lean/proofs/blindingentropy#power2roundhigh_eq_iff) ```lean theorem power2RoundHigh_eq_iff (r h : Rq) : power2RoundHigh r = h ↔ ∀ i, ((power2RoundCoeff (r.get i)).1 : Coeff) = h.get i := by ``` :::details[proof] ```lean constructor · rintro rfl i simp only [power2RoundHigh, Vector.get_eq_getElem, Vector.getElem_ofFn] · intro hi refine Poly.ext_get_eq fun i => ?_ rw [← hi i] simp only [power2RoundHigh, Vector.get_eq_getElem, Vector.getElem_ofFn] ``` ::: [`BlindingEntropy.lean:183-192 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L183-L192) ## [`vector_ofFn_eq_iff`](/lean/proofs/blindingentropy#vector_offn_eq_iff) ```lean theorem vector_ofFn_eq_iff {α : Type} {n : ℕ} (f : Fin n → α) (h : Vector α n) : Vector.ofFn f = h ↔ ∀ j, f j = h.get j := by ``` :::details[proof] ```lean constructor · rintro rfl j simp only [Vector.get_eq_getElem, Vector.getElem_ofFn] · intro hj refine Vector.ext fun j hj' => ?_ rw [Vector.getElem_ofFn] exact (hj ⟨j, hj'⟩).trans (Vector.get_eq_getElem _ _) ``` ::: [`BlindingEntropy.lean:194-203 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L194-L203) ## [`instFintypeRq`](/lean/proofs/blindingentropy#instfintyperq) ```lean instance instFintypeRq : Fintype Rq := by ``` :::details[proof] ```lean change Fintype (Vector Coeff ringDegree) infer_instance ``` ::: [`BlindingEntropy.lean:205-208 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L205-L208) ## [`coords`](/lean/proofs/blindingentropy#coords) ```lean def coords (k : ℕ) : (Fin k → Rq) ≃ (Fin k → Fin ringDegree → Coeff) where toFun v j i := (v j).get i invFun w j := Vector.ofFn (w j) left_inv v := funext fun j => Poly.ext_get_eq fun i => by simp only [Vector.get_eq_getElem, Vector.getElem_ofFn] right_inv w := funext fun j => funext fun i => by simp only [Vector.get_eq_getElem, Vector.getElem_ofFn] omit [SampleableType Addr] in ``` [`BlindingEntropy.lean:210-218 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L210-L218) ## [`blindPointMassBound_mldsa`](/lean/proofs/blindingentropy#blindpointmassbound_mldsa) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blindPointMassBound_mldsa [SampleableType Rq] (P : Prims Rq Rho WireBytes (Vector Rq k) Tag Addr K k l) (hP2 : ∀ v : Fin k → Rq, P.power2Round v = Vector.ofFn fun j => MLDSA.Concrete.power2RoundHigh (v j)) (hpack : ∀ rho, Function.Injective (P.pack rho)) : BlindPointMassBound P (mlDsaAddressPointBeta k) := by ``` :::details[proof] ```lean intro rho t x rw [show blindPointSample P rho t = (fun u => P.pack rho (P.power2Round (u + t))) <$> ($ᵗ (Fin k → Rq)) from by simp only [blindPointSample, map_eq_bind_pure_comp, Function.comp_def] rfl, probOutput_map] rcases Classical.em (∃ h, P.pack rho h = x) with hx | hx · obtain ⟨h, rfl⟩ := hx -- the fiber, in coordinates have hcard : (Finset.univ.filter fun u : Fin k → Rq => P.power2Round (u + t) = h).card ≤ (2 ^ droppedBits) ^ (k * ringDegree) := by rw [Finset.card_equiv (Equiv.addRight t) (t := Finset.univ.filter fun v : Fin k → Rq => P.power2Round v = h) (fun u => by simp only [Finset.mem_filter, Finset.mem_univ, true_and, Equiv.coe_addRight] rfl)] rw [Finset.card_equiv (coords k) (t := Fintype.piFinset fun j => Fintype.piFinset fun i => Finset.univ.filter fun r : Coeff => ((power2RoundCoeff r).1 : Coeff) = (h.get j).get i) (fun v => ?_)] · rw [Fintype.card_piFinset] simp only [Fintype.card_piFinset] calc ∏ j : Fin k, ∏ i : Fin ringDegree, (Finset.univ.filter fun r : Coeff => ((power2RoundCoeff r).1 : Coeff) = (h.get j).get i).card ≤ ∏ _j : Fin k, ∏ _i : Fin ringDegree, 2 ^ droppedBits := Finset.prod_le_prod' fun j _ => Finset.prod_le_prod' fun i _ => card_power2Round_fiber_le _ _ = (2 ^ droppedBits) ^ (k * ringDegree) := by simp only [Finset.prod_const, Finset.card_univ, Fintype.card_fin] rw [← pow_mul, mul_comm] · simp only [Finset.mem_filter, Finset.mem_univ, true_and, Fintype.mem_piFinset, hP2, vector_ofFn_eq_iff, power2RoundHigh_eq_iff] rfl have htot : Fintype.card (Fin k → Rq) = modulus ^ (k * ringDegree) := by rw [Fintype.card_congr (coords k), Fintype.card_pi] simp only [Fintype.card_pi, Finset.prod_const, Finset.card_univ, Fintype.card_fin, ZMod.card] rw [← pow_mul, mul_comm] calc Pr[fun u => P.pack rho (P.power2Round (u + t)) = P.pack rho h | $ᵗ (Fin k → Rq)] ≤ Pr[fun u => P.power2Round (u + t) = h | $ᵗ (Fin k → Rq)] := probEvent_mono'' fun u hu => hpack rho hu _ = ((Finset.univ.filter fun u : Fin k → Rq => P.power2Round (u + t) = h).card : ℝ≥0∞) / Fintype.card (Fin k → Rq) := probEvent_uniformSample _ _ _ ≤ (((2 ^ droppedBits) ^ (k * ringDegree) : ℕ) : ℝ≥0∞) / ((modulus ^ (k * ringDegree) : ℕ) : ℝ≥0∞) := by rw [htot] exact ENNReal.div_le_div_right (by exact_mod_cast hcard) _ _ = mlDsaAddressPointBeta k := by rw [mlDsaAddressPointBeta, Nat.cast_pow, Nat.cast_pow, Nat.cast_pow, Nat.cast_ofNat, div_eq_mul_inv, ENNReal.inv_pow, ← mul_pow, ← div_eq_mul_inv] · rw [probEvent_eq_zero fun u _ hu => hx ⟨_, hu⟩] exact zero_le omit [SampleableType Addr] in ``` ::: [`BlindingEntropy.lean:222-279 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L222-L279) ## [`blindPointMassBound_mldsa65`](/lean/proofs/blindingentropy#blindpointmassbound_mldsa65) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blindPointMassBound_mldsa65 [SampleableType Rq] (P : Prims Rq (MLDSA.Bytes 32) WireBytes (Vector Rq mldsa65.k) Tag Addr K mldsa65.k mldsa65.l) (hP2 : ∀ v : Fin mldsa65.k → Rq, P.power2Round v = Vector.ofFn fun j => MLDSA.Concrete.power2RoundHigh (v j)) (hpack : ∀ rho, Function.Injective (P.pack rho)) : BlindPointMassBound P (mlDsaAddressPointBeta mldsa65.k) := blindPointMassBound_mldsa P hP2 hpack ``` [`BlindingEntropy.lean:281-288 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingEntropy.lean#L281-L288) # BlindingROM :::info[Mirrored page] Source: [`lean/docs-proofs/blindingrom.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/blindingrom.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: `hashAddr ∘ pack` as VCVio's lazily sampled `randomOracle` over `unifSpec + (Bytes →ₒ Addr)`; the fresh-query lemma (from the empty cache the announced address is a UNIFORM `Addr` whatever was hashed — what kills the `rho` dependence [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t) could not remove); [`blindGameRO_eq`](/lean/proofs/blindingrom#blindgamero_eq); and the degenerate identical-until-bad case, no address query ⇒ advantage `0`. Proved in addition: identical-until-bad ([`blindingAdvantageRO_le_blindBadProb`](/lean/proofs/blindingrom#blindingadvantagero_le_blindbadprob)), against VCVio's programming-oracle engine via `ROMUpToBad`. Closed downstream: the bound on `Pr[bad]` (`blindBadProb ≤ qH · β`) is `BlindingEntropy.blindBadProb_le_queryBound`, under a query bound on the adversary and the point-mass hypothesis [`BlindPointMassBound`](/lean/proofs/blindingentropy#blindpointmassbound). Assumed / NOT closed: that [`blindGameRO`](/lean/proofs/blindingrom#blindgamero) is [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) after the mask hop (a standalone abstraction, NOT identified with it here). See `docs/announcement-model.md`. ## [`addrSpec`](/lean/proofs/blindingrom#addrspec) Oracle interface of the ROM model: uniform sampling plus the address hash. ```lean @[reducible] def addrSpec (Bytes Addr : Type) := unifSpec + (Bytes →ₒ Addr) ``` [`BlindingROM.lean:38-39 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L38-L39) ## [`ROMComp`](/lean/proofs/blindingrom#romcomp) ```lean abbrev ROMComp (Bytes Addr : Type) := OracleComp (addrSpec Bytes Addr) ``` [`BlindingROM.lean:41-42 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L41-L42) ## [`hashAddrRO`](/lean/proofs/blindingrom#hashaddrro) One address-oracle query: the ROM stand-in for `hashAddr (pack …)`. ```lean def hashAddrRO (x : Bytes) : ROMComp Bytes Addr Addr := (addrSpec Bytes Addr).query (Sum.inr x) ``` [`BlindingROM.lean:46-48 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L46-L48) ## [`romImpl`](/lean/proofs/blindingrom#romimpl) The lazily sampled address oracle, over transparent uniform sampling: the generic [`roImpl`](/lean/proofs/romuptobad#roimpl) of `ROMUpToBad` at the hash spec `Bytes →ₒ Addr`. ```lean noncomputable def romImpl : QueryImpl (addrSpec Bytes Addr) (StateT ((Bytes →ₒ Addr).QueryCache) ProbComp) := roImpl (Bytes →ₒ Addr) ``` [`BlindingROM.lean:53-56 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L53-L56) ## [`simulateQ_romImpl_liftComp`](/lean/proofs/blindingrom#simulateq_romimpl_liftcomp) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem simulateQ_romImpl_liftComp {β : Type} (ob : ProbComp β) : simulateQ (romImpl (Bytes := Bytes) (Addr := Addr)) (liftComp ob (addrSpec Bytes Addr)) = (liftM ob : StateT ((Bytes →ₒ Addr).QueryCache) ProbComp β) := by ``` :::details[proof] ```lean simp only [romImpl, roImpl, unifFwdImpl, liftComp_eq_liftM, QueryImpl.simulateQ_add_liftM_left, simulateQ_liftTarget, QueryImpl.simulateQ_toQueryImpl] ``` ::: [`BlindingROM.lean:58-63 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L58-L63) ## [`run_hashAddrRO_empty`](/lean/proofs/blindingrom#run_hashaddrro_empty) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem run_hashAddrRO_empty {α : Type} (x : Bytes) (f : Addr → ROMComp Bytes Addr α) : (simulateQ romImpl (hashAddrRO x >>= f)).run ∅ = (do let a ← ($ᵗ Addr) (simulateQ romImpl (f a)).run ((∅ : (Bytes →ₒ Addr).QueryCache).cacheQuery x a)) := by ``` :::details[proof] ```lean simp only [romImpl, roImpl, hashAddrRO, add_apply_inr, simulateQ_bind, simulateQ_query, OracleQuery.input_query, OracleQuery.cont_query, QueryImpl.add_apply_inr, QueryImpl.withCaching_apply, uniformSampleImpl, QueryCache.cacheQuery, map_bind, id_map, bind_assoc, StateT.run_bind, StateT.run_get, pure_bind, QueryCache.empty_apply, StateT.run_monadLift, monadLift_self, bind_pure_comp, StateT.run_modifyGet, Functor.map_map, bind_map_left] ``` ::: [`BlindingROM.lean:67-77 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L67-L77) ### 1. The oracle interface ## [`BlindAdvRO`](/lean/proofs/blindingrom#blindadvro) A ROM adversary against the blinding term: it sees the auxiliary data and may query the address oracle itself. ```lean abbrev BlindAdvRO (Tag Addr Bytes : Type) := Tag × Addr → ROMComp Bytes Addr Bool ``` [`BlindingROM.lean:90-91 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L90-L91) ## [`blindTraceRO`](/lean/proofs/blindingrom#blindtracero) ```lean noncomputable def blindTraceRO (P : Prims R Rho Bytes T1 Tag Addr K k l) (tg : Tag) (rho : Bool → Rho) (t : Bool → (Fin k → R)) (adv : BlindAdvRO Tag Addr Bytes) (b : Bool) : ProbComp ((Fin k → R) × Bool × (Bytes →ₒ Addr).QueryCache) := do let u ← ($ᵗ (Fin k → R)) let z ← (simulateQ romImpl (hashAddrRO (P.pack (rho b) (P.power2Round (u + t b))) >>= fun a => adv (tg, a))).run ∅ pure (u, z) ``` [`BlindingROM.lean:94-101 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L94-L101) ## [`blindGameRO`](/lean/proofs/blindingrom#blindgamero) ```lean noncomputable def blindGameRO (P : Prims R Rho Bytes T1 Tag Addr K k l) (tg : Tag) (rho : Bool → Rho) (t : Bool → (Fin k → R)) (adv : BlindAdvRO Tag Addr Bytes) (b : Bool) : ProbComp Bool := do let w ← blindTraceRO P tg rho t adv b pure w.2.1 ``` [`BlindingROM.lean:103-108 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L103-L108) ## [`blindingAdvantageRO`](/lean/proofs/blindingrom#blindingadvantagero) ```lean noncomputable def blindingAdvantageRO (P : Prims R Rho Bytes T1 Tag Addr K k l) (tg : Tag) (rho : Bool → Rho) (t : Bool → (Fin k → R)) (adv : BlindAdvRO Tag Addr Bytes) : ℝ := (blindGameRO P tg rho t adv true).boolDistAdvantage (blindGameRO P tg rho t adv false) ``` [`BlindingROM.lean:111-114 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L111-L114) ## [`blindGameRO_eq`](/lean/proofs/blindingrom#blindgamero_eq) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blindGameRO_eq (P : Prims R Rho Bytes T1 Tag Addr K k l) (tg : Tag) (rho : Bool → Rho) (t : Bool → (Fin k → R)) (adv : BlindAdvRO Tag Addr Bytes) (b : Bool) : blindGameRO P tg rho t adv b = (do let u ← ($ᵗ (Fin k → R)) let a ← ($ᵗ Addr) (simulateQ romImpl (adv (tg, a))).run' ((∅ : (Bytes →ₒ Addr).QueryCache).cacheQuery (P.pack (rho b) (P.power2Round (u + t b))) a)) := by ``` :::details[proof] ```lean simp only [blindGameRO, blindTraceRO, bind_assoc, pure_bind, run_hashAddrRO_empty] refine bind_congr fun u => bind_congr fun a => ?_ simp only [StateT.run', map_eq_bind_pure_comp, Function.comp_def, StateT.run] ``` ::: [`BlindingROM.lean:119-130 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L119-L130) ## [`blindingAdvantageRO_eq_zero_of_no_query`](/lean/proofs/blindingrom#blindingadvantagero_eq_zero_of_no_query) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blindingAdvantageRO_eq_zero_of_no_query (P : Prims R Rho Bytes T1 Tag Addr K k l) (tg : Tag) (rho : Bool → Rho) (t : Bool → (Fin k → R)) (adv : Tag × Addr → ProbComp Bool) : blindingAdvantageRO P tg rho t (fun x => liftComp (adv x) (addrSpec Bytes Addr)) = 0 := by ``` :::details[proof] ```lean have key : ∀ b : Bool, blindGameRO P tg rho t (fun x => liftComp (adv x) (addrSpec Bytes Addr)) b = (do let _u ← ($ᵗ (Fin k → R)); let a ← ($ᵗ Addr); adv (tg, a)) := by intro b simp only [blindGameRO, blindTraceRO, bind_assoc, pure_bind, run_hashAddrRO_empty, simulateQ_romImpl_liftComp] refine bind_congr fun _ => bind_congr fun a => ?_ simp only [StateT.run, liftM, monadLift, MonadLift.monadLift, StateT.lift, bind_assoc, pure_bind, bind_pure] rw [blindingAdvantageRO, key true, key false, ProbComp.boolDistAdvantage, sub_self, abs_zero] ``` ::: [`BlindingROM.lean:134-148 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L134-L148) ### 2. The blinding game in the ROM ## [`blindPrefix`](/lean/proofs/blindingrom#blindprefix) The shared prefix of both branches: the mask and the (uniform) announced address. ```lean noncomputable def blindPrefix : ProbComp ((Fin k → R) × Addr) := do let u ← ($ᵗ (Fin k → R)) let a ← ($ᵗ Addr) pure (u, a) ``` [`BlindingROM.lean:158-162 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L158-L162) ## [`blindPoint`](/lean/proofs/blindingrom#blindpoint) ```lean def blindPoint (P : Prims R Rho Bytes T1 Tag Addr K k l) (rho : Bool → Rho) (t : Bool → (Fin k → R)) (b : Bool) (x : (Fin k → R) × Addr) : Bytes := P.pack (rho b) (P.power2Round (x.1 + t b)) ``` [`BlindingROM.lean:164-167 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L164-L167) ## [`blindBadQuery`](/lean/proofs/blindingrom#blindbadquery) ```lean noncomputable def blindBadQuery (P : Prims R Rho Bytes T1 Tag Addr K k l) (tg : Tag) (rho : Bool → Rho) (t : Bool → (Fin k → R)) (adv : BlindAdvRO Tag Addr Bytes) (b : Bool) : ProbComp Bool := badQueryGame (blindPrefix (R := R) (k := k) (Addr := Addr)) (fun x => adv (tg, x.2)) (blindPoint P rho t b) (fun x => x.2) ``` [`BlindingROM.lean:170-175 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L170-L175) ## [`blindBadProb`](/lean/proofs/blindingrom#blindbadprob) ```lean noncomputable def blindBadProb (P : Prims R Rho Bytes T1 Tag Addr K k l) (tg : Tag) (rho : Bool → Rho) (t : Bool → (Fin k → R)) (adv : BlindAdvRO Tag Addr Bytes) (b : Bool) : ℝ := (Pr[= true | blindBadQuery P tg rho t adv b]).toReal ``` [`BlindingROM.lean:177-181 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L177-L181) ## [`blindGameROFree`](/lean/proofs/blindingrom#blindgamerofree) ```lean noncomputable def blindGameROFree (tg : Tag) (adv : BlindAdvRO Tag Addr Bytes) : ProbComp Bool := blindPrefix (R := R) (k := k) >>= fun x => (simulateQ romImpl (adv (tg, x.2))).run' ∅ ``` [`BlindingROM.lean:183-186 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L183-L186) ## [`boolDistAdvantage_blindGameRO_blindGameROFree_le`](/lean/proofs/blindingrom#booldistadvantage_blindgamero_blindgamerofree_le) ```lean theorem boolDistAdvantage_blindGameRO_blindGameROFree_le (P : Prims R Rho Bytes T1 Tag Addr K k l) (tg : Tag) (rho : Bool → Rho) (t : Bool → (Fin k → R)) (adv : BlindAdvRO Tag Addr Bytes) (b : Bool) : ProbComp.boolDistAdvantage (blindGameRO P tg rho t adv b) (blindGameROFree (R := R) (k := k) tg adv) ≤ blindBadProb P tg rho t adv b := by ``` :::details[proof] ```lean have h := boolDistAdvantage_run'_cacheQuery_run'_empty_le (blindPrefix (R := R) (k := k) (Addr := Addr)) (fun x => adv (tg, x.2)) (blindPoint P rho t b) (fun x => x.2) have hgame : blindGameRO P tg rho t adv b = blindPrefix >>= fun x => (simulateQ (roImpl (Bytes →ₒ Addr)) (adv (tg, x.2))).run' ((∅ : (Bytes →ₒ Addr).QueryCache).cacheQuery (blindPoint P rho t b x) x.2) := by rw [blindGameRO_eq] simp only [blindPrefix, blindPoint, romImpl, bind_assoc, pure_bind] unfold blindBadProb blindBadQuery blindGameROFree rw [hgame] exact h ``` ::: [`BlindingROM.lean:188-204 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L188-L204) ## [`blindingAdvantageRO_le_blindBadProb`](/lean/proofs/blindingrom#blindingadvantagero_le_blindbadprob) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blindingAdvantageRO_le_blindBadProb (P : Prims R Rho Bytes T1 Tag Addr K k l) (tg : Tag) (rho : Bool → Rho) (t : Bool → (Fin k → R)) (adv : BlindAdvRO Tag Addr Bytes) : blindingAdvantageRO P tg rho t adv ≤ blindBadProb P tg rho t adv true + blindBadProb P tg rho t adv false := by ``` :::details[proof] ```lean unfold blindingAdvantageRO calc ProbComp.boolDistAdvantage (blindGameRO P tg rho t adv true) (blindGameRO P tg rho t adv false) ≤ ProbComp.boolDistAdvantage (blindGameRO P tg rho t adv true) (blindGameROFree (R := R) (k := k) tg adv) + ProbComp.boolDistAdvantage (blindGameROFree (R := R) (k := k) tg adv) (blindGameRO P tg rho t adv false) := ProbComp.boolDistAdvantage_triangle _ _ _ _ ≤ blindBadProb P tg rho t adv true + blindBadProb P tg rho t adv false := by gcongr · exact boolDistAdvantage_blindGameRO_blindGameROFree_le P tg rho t adv true · rw [ProbComp.boolDistAdvantage_comm] exact boolDistAdvantage_blindGameRO_blindGameROFree_le P tg rho t adv false ``` ::: [`BlindingROM.lean:208-225 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L208-L225) ## [`mlweAdvOfBlindAdvRO`](/lean/proofs/blindingrom#mlweadvofblindadvro) **The ROM reduction, type-checked.** Simulating the address oracle inside, a ROM blinding adversary is an ordinary seeded-MLWE distinguisher: the challenge seed becomes recipient 1's `rho` and the challenge vector the mask. ```lean noncomputable def mlweAdvOfBlindAdvRO (P : Prims R Rho Bytes T1 Tag Addr K k l) (Smp : Samplers R Rho k l) (tg : Tag) (t : Fin k → R) (adv : BlindAdvRO Tag Addr Bytes) : LearningWithErrors.Adversary (blindingProblem P Smp) := fun chal => (simulateQ romImpl (hashAddrRO (P.pack chal.1 (P.power2Round (chal.2 + t))) >>= fun a => adv (tg, a))).run' ∅ ``` [`BlindingROM.lean:239-248 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/BlindingROM.lean#L239-L248) # ConstructionA :::info[Mirrored page] Source: [`lean/docs-proofs/constructiona.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/constructiona.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: the [`auxGen`](/lean/proofs/constructiona#auxgen) the real scheme uses -- view tag plus `hashAddr (pack rho (power2Round (A *ᵥ s' + e' + t)))`, symmetric primitives bundled uninterpreted as [`Prims`](/lean/proofs/constructiona#prims) -- so the announcement in the model is the announcement in the spec; the announced key is `power2Round` of the honest ML-DSA key of the widened secret and an ownership witness for it; [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) vanishes for any [`auxGen`](/lean/proofs/constructiona#auxgen) ignoring the public key; and the seeded-MLWE [`blindingProblem`](/lean/proofs/constructiona#blindingproblem) with its reduction adversary. Assumed / NOT closed: `hashAddr ∘ pack` as a random oracle (`rho` sits outside the mask), [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal), and a bind commutation -- so no advantage inequality is claimed for the blinding hop. See `docs/announcement-model.md`. ## [`auxKeyIndependence_eq_zero_of_pk_independent`](/lean/proofs/constructiona#auxkeyindependence_eq_zero_of_pk_independent) **The term's meaning, pinned.** When [`auxGen`](/lean/proofs/constructiona#auxgen) ignores its public-key argument the two computations compared by [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) are equal, so the advantage is `0`. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem auxKeyIndependence_eq_zero_of_pk_independent (kem : KEM PK SK C K) (auxGen : K → PK → Aux) (adv : StealthScheme.UnlinkAdv PK (C × Aux)) (h : ∀ (kk : K) (pk pk' : PK), auxGen kk pk = auxGen kk pk') : auxKeyIndependence kem auxGen adv = 0 := by ``` :::details[proof] ```lean have key : ((StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= randAuxBranch kem auxGen adv true) = (kem.anonSetup >>= kem.anonBranch (adv.cipherOf auxGen) true) := by simp only [StealthScheme.unlinkSetup, StealthScheme.ofKEMFull, KEM.anonSetup, StealthScheme.UnlinkAdv.cipherOf, randAuxBranch, KEM.anonBranch, bind_assoc, pure_bind, if_true] refine bind_congr fun x => bind_congr fun y => bind_congr fun c => bind_congr fun kk => ?_ rw [h kk _ _] rw [auxKeyIndependence, key, ProbComp.boolDistAdvantage, sub_self, abs_zero] ``` ::: [`ConstructionA.lean:34-49 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L34-L49) ## [`auxKeyIndependence_tagOnly_eq_zero`](/lean/proofs/constructiona#auxkeyindependence_tagonly_eq_zero) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem auxKeyIndependence_tagOnly_eq_zero (kem : KEM PK SK C K) (tag : K → Aux) (adv : StealthScheme.UnlinkAdv PK (C × Aux)) : auxKeyIndependence kem (fun kk _ => tag kk) adv = 0 := auxKeyIndependence_eq_zero_of_pk_independent kem _ adv fun _ _ _ => rfl ``` [`ConstructionA.lean:53-58 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L53-L58) ### 1. Positive control for [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) ## [`Prims`](/lean/proofs/constructiona#prims) The symmetric primitives of construction A, bundled and uninterpreted, so everything proved holds for every instantiation. ```lean structure Prims (R Rho Bytes T1 Tag Addr K : Type) (k l : ℕ) where /-- SHAKE128 expansion of the matrix seed. -/ expandA : Rho → Matrix (Fin k) (Fin l) R /-- The blinding pair `(s', e')` derived from the shared secret. -/ expandBlind : K → (Fin l → R) × (Fin k → R) /-- The high-order part of a stealth key. -/ power2Round : (Fin k → R) → T1 /-- `pack_pk`: the seed and the rounded key as bytes. -/ pack : Rho → T1 → Bytes /-- The address hash. -/ hashAddr : Bytes → Addr /-- The view tag derived from the shared secret. -/ viewTag : K → Tag ``` [`ConstructionA.lean:70-83 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L70-L83) ## [`Samplers`](/lean/proofs/constructiona#samplers) ```lean structure Samplers (R Rho : Type) (k l : ℕ) where /-- The matrix seed. -/ sampleRho : ProbComp Rho /-- The short secret `s₁`. -/ sampleS : ProbComp (Fin l → R) /-- The short error `s₂`. -/ sampleE : ProbComp (Fin k → R) ``` [`ConstructionA.lean:87-94 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L87-L94) ## [`MetaPub`](/lean/proofs/constructiona#metapub) The recipient's meta-address: ML-KEM encapsulation key, matrix seed `rho`, and the FULL-precision spending key `t` (rounding is only well defined on the unrounded value). ```lean abbrev MetaPub (KEMpk Rho R : Type) (k : ℕ) : Type := KEMpk × Rho × (Fin k → R) ``` [`ConstructionA.lean:101-102 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L101-L102) ## [`MetaPriv`](/lean/proofs/constructiona#metapriv) ```lean abbrev MetaPriv (KEMsk R : Type) (k l : ℕ) : Type := KEMsk × (Fin l → R) × (Fin k → R) ``` [`ConstructionA.lean:105-106 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L105-L106) ## [`auxGen`](/lean/proofs/constructiona#auxgen) The auxiliary data of a construction-A announcement: view tag and stealth address. The address folds in the recipient's own `rho` and `t`, which is why [`auxGen`](/lean/proofs/constructiona#auxgen) takes the public key. ```lean def auxGen (kk : K) (pk : MetaPub KEMpk Rho R k) : Tag × Addr := (P.viewTag kk, P.hashAddr (P.pack pk.2.1 (P.power2Round (P.expandA pk.2.1 *ᵥ (P.expandBlind kk).1 + (P.expandBlind kk).2 + pk.2.2)))) ``` [`ConstructionA.lean:112-116 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L112-L116) ## [`stealthAddr_eq_blinded_pk`](/lean/proofs/constructiona#stealthaddr_eq_blinded_pk) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem stealthAddr_eq_blinded_pk (kk : K) (ek : KEMpk) (rho : Rho) (s₁ : Fin l → R) (s₂ : Fin k → R) : (auxGen (KEMpk := KEMpk) P kk (ek, rho, P.expandA rho *ᵥ s₁ + s₂)).2 = P.hashAddr (P.pack rho (P.power2Round (P.expandA rho *ᵥ (s₁ + (P.expandBlind kk).1) + (s₂ + (P.expandBlind kk).2)))) := by ``` :::details[proof] ```lean rw [auxGen, stealth_pk_eq_blinded_keypair (P.expandA rho) s₁ (P.expandBlind kk).1 s₂ (P.expandBlind kk).2 _ rfl] ``` ::: [`ConstructionA.lean:120-127 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L120-L127) ## [`announced_key_isOwnershipWitness`](/lean/proofs/constructiona#announced_key_isownershipwitness) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem announced_key_isOwnershipWitness (kk : K) (rho : Rho) (s₁ : Fin l → R) (s₂ : Fin k → R) : IsOwnershipWitness (P.expandA rho) (P.expandA rho *ᵥ (P.expandBlind kk).1 + (P.expandBlind kk).2 + (P.expandA rho *ᵥ s₁ + s₂)) (s₁ + (P.expandBlind kk).1) (s₂ + (P.expandBlind kk).2) := blinded_is_ownership_witness _ _ _ _ _ ``` [`ConstructionA.lean:131-138 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L131-L138) ### 2. The announcement ## [`metaKem`](/lean/proofs/constructiona#metakem) The composite KEM: an ML-KEM keypair plus the ML-DSA spending keypair, with the meta-address as the public key. ```lean def metaKem (kem : KEM KEMpk KEMsk C K) : KEM (MetaPub KEMpk Rho R k) (MetaPriv KEMsk R k l) C K where keygen := do let (ek, dk) ← kem.keygen let rho ← Smp.sampleRho let s₁ ← Smp.sampleS let s₂ ← Smp.sampleE pure ((ek, rho, P.expandA rho *ᵥ s₁ + s₂), (dk, s₁, s₂)) encaps pk := kem.encaps pk.1 decaps sk c := kem.decaps sk.1 c ``` [`ConstructionA.lean:148-158 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L148-L158) ## [`scheme`](/lean/proofs/demo#scheme) Construction A as a [`StealthScheme`](/lean/proofs/games#stealthscheme): ML-KEM ciphertext plus view tag plus stealth address, addressed to a full meta-address. ```lean def scheme (kem : KEM KEMpk KEMsk C K) : StealthScheme (MetaPub KEMpk Rho R k) (MetaPriv KEMsk R k l × MetaPub KEMpk Rho R k) (C × (Tag × Addr)) := StealthScheme.ofKEMFull (metaKem P Smp kem) (auxGen P) ``` [`ConstructionA.lean:165-169 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L165-L169) ## [`unlinkAdvantage_metaKem_le`](/lean/proofs/constructiona#unlinkadvantage_metakem_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantage_metaKem_le (kem : KEM KEMpk KEMsk C K) (adv : StealthScheme.UnlinkAdv (MetaPub KEMpk Rho R k) (C × (Tag × Addr))) : (scheme P Smp kem).unlinkAdvantage adv ≤ sharedSecretHiding (metaKem P Smp kem) (auxGen P) adv true + auxKeyIndependence (metaKem P Smp kem) (auxGen P) adv + (metaKem P Smp kem).anonAdvantage (adv.cipherOf (auxGen P)) + sharedSecretHiding (metaKem P Smp kem) (auxGen P) adv false := unlinkAdvantage_ofKEMFull_le _ _ adv ``` [`ConstructionA.lean:179-187 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L179-L187) ### 3. The KEM whose public key is a meta-address ## [`blindingProblem`](/lean/proofs/constructiona#blindingproblem) Seeded decision-MLWE: given `(rho, y)`, decide whether `y = expandA rho *ᵥ s + e` for short `(s, e)` or `y` is uniform. An instance of VCVio's `LearningWithErrors.Problem`. ```lean def blindingProblem : LearningWithErrors.Problem Rho (Fin l → R) (Fin k → R) where sampleChallenge := Smp.sampleRho sampleSecret := Smp.sampleS sampleError := Smp.sampleE noiseless := fun s rho => P.expandA rho *ᵥ s sampleUniform := $ᵗ (Fin k → R) ``` [`ConstructionA.lean:204-210 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L204-L210) ## [`realJoint`](/lean/proofs/constructiona#realjoint) The real joint draw the blinding argument is about: one shared secret, its view tag and its blinding pair. [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal) says this is the ideal draw below; naming both computations keeps the idealization statements free of nested `do`-blocks and lets downstream lemmas cite the object instead of re-spelling it. ```lean def realJoint (P : Prims R Rho Bytes T1 Tag Addr K k l) : ProbComp (Tag × (Fin l → R) × (Fin k → R)) := do let kk ← ($ᵗ K) let tg := P.viewTag kk let s' := (P.expandBlind kk).1 let e' := (P.expandBlind kk).2 pure (tg, s', e') ``` [`ConstructionA.lean:219-226 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L219-L226) ## [`idealJoint`](/lean/proofs/constructiona#idealjoint) ```lean def idealJoint (Smp : Samplers R Rho k l) (sampleTag : ProbComp Tag) : ProbComp (Tag × (Fin l → R) × (Fin k → R)) := do let tg ← sampleTag let s' ← Smp.sampleS let e' ← Smp.sampleE pure (tg, s', e') ``` [`ConstructionA.lean:229-235 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L229-L235) ## [`BlindExpandIdealizationBound`](/lean/proofs/constructiona#blindexpandidealizationbound) ```lean def BlindExpandIdealizationBound (epsilonBlindExpand : ℝ≥0∞) : Prop := ∀ (D : Tag × (Fin l → R) × (Fin k → R) → ProbComp Bool), ProbComp.boolDistAdvantage (realJoint P >>= D) (idealJoint Smp sampleTag >>= D) ≤ epsilonBlindExpand.toReal ``` [`ConstructionA.lean:240-244 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L240-L244) ## [`ExpandIsIdeal`](/lean/proofs/constructiona#expandisideal) ```lean def ExpandIsIdeal : Prop := ∀ x : Tag × (Fin l → R) × (Fin k → R), Pr[= x | realJoint P] = Pr[= x | idealJoint Smp sampleTag] omit [CommRing R] [SampleableType R] in ``` [`ConstructionA.lean:248-252 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L248-L252) ## [`blindExpandIdealizationBound_zero_of_expandIsIdeal`](/lean/proofs/constructiona#blindexpandidealizationbound_zero_of_expandisideal) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blindExpandIdealizationBound_zero_of_expandIsIdeal (h : ExpandIsIdeal P Smp sampleTag) : BlindExpandIdealizationBound P Smp sampleTag 0 := by ``` :::details[proof] ```lean intro D have hpr : Pr[= true | (realJoint P >>= D)] = Pr[= true | (idealJoint Smp sampleTag >>= D)] := probOutput_congr rfl (by rw [evalDist_bind, evalDist_bind, evalDist_ext h]) simp only [ProbComp.boolDistAdvantage, hpr, sub_self, abs_zero, ENNReal.toReal_zero] exact le_rfl ``` ::: [`ConstructionA.lean:258-268 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L258-L268) ## [`mlweAdvOfUnlinkAdv`](/lean/proofs/constructiona#mlweadvofunlinkadv) ```lean def mlweAdvOfUnlinkAdv (kem : KEM KEMpk KEMsk C K) (adv : StealthScheme.UnlinkAdv (MetaPub KEMpk Rho R k) (C × (Tag × Addr))) : LearningWithErrors.Adversary (blindingProblem P Smp) := fun chal => do let (ek0, _) ← kem.keygen let rho0 ← Smp.sampleRho let s₁ ← Smp.sampleS let s₂ ← Smp.sampleE let (ek1, _) ← kem.keygen let s₁' ← Smp.sampleS let s₂' ← Smp.sampleE let t₁ := P.expandA chal.1 *ᵥ s₁' + s₂' let (c, _) ← kem.encaps ek1 let tg ← sampleTag adv (ek0, rho0, P.expandA rho0 *ᵥ s₁ + s₂) (ek1, chal.1, t₁) (c, (tg, P.hashAddr (P.pack chal.1 (P.power2Round (chal.2 + t₁))))) ``` [`ConstructionA.lean:272-288 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L272-L288) ## [`idealAux_indep_of_t`](/lean/proofs/constructiona#idealaux_indep_of_t) **The middle-game independence lemma.** A uniform mask absorbs the offset, so the blinding does erase `t`. Note what is NOT quantified over: `rho`, which enters outside the mask and therefore has to be shared between the two sides. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem idealAux_indep_of_t (cont : Tag × Addr → ProbComp Bool) (tg : Tag) (rho : Rho) (t t' : Fin k → R) (z : Bool) : Pr[= z | (do let u ← ($ᵗ (Fin k → R)) cont (tg, P.hashAddr (P.pack rho (P.power2Round (u + t)))))] = Pr[= z | (do let u ← ($ᵗ (Fin k → R)) cont (tg, P.hashAddr (P.pack rho (P.power2Round (u + t')))))] := by ``` :::details[proof] ```lean rw [probOutput_bind_add_right_uniform (Fin k → R) t (fun v => cont (tg, P.hashAddr (P.pack rho (P.power2Round v)))) z, probOutput_bind_add_right_uniform (Fin k → R) t' (fun v => cont (tg, P.hashAddr (P.pack rho (P.power2Round v)))) z] ``` ::: [`ConstructionA.lean:299-309 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionA.lean#L299-L309) # ConstructionASecurity :::info[Mirrored page] Source: [`lean/docs-proofs/constructionasecurity.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/constructionasecurity.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: ## [`unlinkAdvantage_full_bound`](/lean/proofs/constructionasecurity#unlinkadvantage_full_bound) **Single-announcement unlinkability of Construction A, with every term bounded.** Starts from [`unlinkAdvantage_metaKem_le`](/lean/proofs/constructiona#unlinkadvantage_metakem_le) (the generic four-term [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) decomposition at the spec's objects) and replaces each term by its named bound: shared-secret hiding on both branches (IND-CPA), the blinding term (`epsilonBlindExpand + Adv_blindMLWE + 2·qH·β`), and KEM anonymity (the SPR chain, counted once per branch). The hypotheses are the whole content — no term's bound is produced here. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantage_full_bound [SampleableType K] [DecidableEq Tag] [DecidableEq Addr] [SampleableType Addr] (kem : KEM KEMpk KEMsk C K) (adv : StealthScheme.UnlinkAdv (MetaPub KEMpk Rho R k) (C × (Tag × Addr))) (qH : ℕ) (beta : ENNReal) (_hB : BlindPointMassBound P beta) (epsilonBlindExpand Adv_blindMLWE epsilonPrim epsilonEnc Adv_keyHop Adv_ctHop Adv_keyRestore : ENNReal) (indCpaTrue indCpaFalse : ℝ) (hBlind : auxKeyIndependence (metaKem P Smp kem) (auxGen P) adv ≤ (epsilonBlindExpand + Adv_blindMLWE + (2 * qH : ENNReal) * beta).toReal) (hSPR : (metaKem P Smp kem).anonAdvantage (adv.cipherOf (auxGen P)) ≤ (epsilonPrim + Adv_keyHop + Adv_ctHop + epsilonEnc + Adv_keyRestore).toReal) (hInd_true : sharedSecretHiding (metaKem P Smp kem) (auxGen P) adv true ≤ indCpaTrue) (hInd_false : sharedSecretHiding (metaKem P Smp kem) (auxGen P) adv false ≤ indCpaFalse) : (scheme P Smp kem).unlinkAdvantage adv ≤ indCpaTrue + indCpaFalse + (epsilonBlindExpand + Adv_blindMLWE + (2 * qH : ENNReal) * beta).toReal + 2 * (epsilonPrim + Adv_keyHop + Adv_ctHop + epsilonEnc + Adv_keyRestore).toReal := by ``` :::details[proof] ```lean have h := unlinkAdvantage_metaKem_le P Smp kem adv have h0 : 0 ≤ (epsilonPrim + Adv_keyHop + Adv_ctHop + epsilonEnc + Adv_keyRestore).toReal := ENNReal.toReal_nonneg linarith omit [SampleableType R] in ``` ::: [`ConstructionASecurity.lean:27-49 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionASecurity.lean#L27-L49) ## [`relatedSpendAdvantage_le_mul_capstone`](/lean/proofs/constructionasecurity#relatedspendadvantage_le_mul_capstone) **Related-key spend capstone at ML-DSA.** A malicious sender who knows all `q` blinding offsets forges a spend witness for some derived stealth key with probability at most `q` times the master key's ownership-forgery advantage at the widened bound `b + η` (`SpendSecurity.lean`). `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem relatedSpendAdvantage_le_mul_capstone (keyGen : ProbComp (OwnershipKey Rq k l)) (b eta q : ℕ) (offGen : ProbComp (ShortOffset Rq k l (mldsaShort eta))) (adv : RelatedSpendAdv Rq k l (mldsaShort eta) q) {ε : ℝ≥0∞} (hε : ∀ i, spendForgeryAdvantage keyGen (mldsaShort (b + eta)) (relatedSpendReduction keyGen offGen q adv (mldsaShort (b + eta)) i) ≤ ε) : relatedSpendAdvantage keyGen offGen (mldsaShort b) q adv ≤ q * ε := mldsa_relatedSpendAdvantage_le keyGen b eta q offGen adv hε ``` [`ConstructionASecurity.lean:65-73 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ConstructionASecurity.lean#L65-L73) # Controls :::info[Mirrored page] Source: [`lean/docs-proofs/controls.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/controls.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved, all as ordinary theorems so the build keeps them honest: a scheme whose announcement IS the recipient's meta-address attains the maximal unlinkability advantage `1 − keyCollisionProb` -- the true ceiling, since on a key collision the two branches are the same computation -- and likewise a KEM whose ciphertext is the public key, for [`anonAdvantage`](/lean/proofs/kemanonymity#anonadvantage); a KEM that always rejects gives a scheme that is NOT complete; the tag-ignoring scan IS complete yet false-positives with probability `1` on an implicit-rejection KEM; and a DKSAP variant that drops the recipient's spending key is not complete. Assumed: nothing. See `docs/announcement-model.md` (controls rationale) and `docs/dksap-asymmetry.md` (the broken variant). ## [`leakyAdv`](/lean/proofs/controls#leakyadv) The trivial recipient-identifying adversary: remember the second public meta-address, then report whether the announcement equals it. ```lean def leakyAdv : StealthScheme.UnlinkAdv PK PK := fun _ pk1 c => pure (decide (c = pk1)) ``` [`Controls.lean:32-34 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L32-L34) ## [`keyCollisionProb`](/lean/proofs/controls#keycollisionprob) ```lean noncomputable def keyCollisionProb (keygen : ProbComp (PK × SK)) : ℝ := (Pr[= true | do let (pk0, _) ← keygen let (pk1, _) ← keygen pure (decide (pk0 = pk1))]).toReal ``` [`Controls.lean:38-43 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L38-L43) ## [`leakyScheme`](/lean/proofs/controls#leakyscheme) ```lean def leakyScheme (keygen : ProbComp (PK × SK)) : StealthScheme PK SK PK where keygen := keygen announce pk := pure pk scan _ _ := pure true ``` [`Controls.lean:47-51 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L47-L51) ## [`unlinkAdvantage_leakyAdv_eq_one_sub_keyCollisionProb`](/lean/proofs/controls#unlinkadvantage_leakyadv_eq_one_sub_keycollisionprob) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantage_leakyAdv_eq_one_sub_keyCollisionProb (S : StealthScheme PK SK PK) (hann : ∀ pk, S.announce pk = pure pk) : S.unlinkAdvantage leakyAdv = 1 - keyCollisionProb S.keygen := by ``` :::details[proof] ```lean have hT : S.unlinkSetup >>= S.unlinkBranch leakyAdv true = (do let (_, _) ← S.keygen; let (_, _) ← S.keygen; pure true) := by simp only [leakyAdv, StealthScheme.unlinkSetup, bind_pure_comp, map_pure, bind_assoc, bind_map_left, StealthScheme.unlinkBranch, hann, if_true, decide_true] have hF : S.unlinkSetup >>= S.unlinkBranch leakyAdv false = (do let (pk0, _) ← S.keygen; let (pk1, _) ← S.keygen; pure (decide (pk0 = pk1))) := by simp only [leakyAdv, StealthScheme.unlinkSetup, bind_pure_comp, map_pure, bind_assoc, bind_map_left, StealthScheme.unlinkBranch, hann, Bool.false_eq_true, if_false] have hTrue : Pr[= true | S.unlinkSetup >>= S.unlinkBranch leakyAdv true] = 1 := by rw [hT, probOutput_eq_one_iff_forall] refine ⟨?_, ?_⟩ · exact probFailure_of_liftM_PMF _ · intro y hy simp only [support_bind, support_pure, Set.mem_iUnion, Set.mem_singleton_iff] at hy obtain ⟨-, -, -, -, hy⟩ := hy exact hy have hle : keyCollisionProb S.keygen ≤ 1 := by simpa only [keyCollisionProb, bind_pure_comp, ENNReal.toReal_one] using ENNReal.toReal_mono ENNReal.one_ne_top (probOutput_le_one (x := true)) rw [StealthScheme.unlinkAdvantage_eq_branchDistAdvantage, ProbComp.boolDistAdvantage, hTrue, hF, ENNReal.toReal_one] unfold keyCollisionProb at hle ⊢ exact abs_of_nonneg (by linarith) ``` ::: [`Controls.lean:55-81 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L55-L81) ## [`unlinkAdvantage_leakyAdv_pos`](/lean/proofs/controls#unlinkadvantage_leakyadv_pos) ```lean theorem unlinkAdvantage_leakyAdv_pos (S : StealthScheme PK SK PK) (hann : ∀ pk, S.announce pk = pure pk) (hcoll : keyCollisionProb S.keygen < 1) : 0 < S.unlinkAdvantage leakyAdv := by ``` :::details[proof] ```lean rw [unlinkAdvantage_leakyAdv_eq_one_sub_keyCollisionProb S hann] linarith ``` ::: [`Controls.lean:84-90 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L84-L90) ## [`leakyScheme_unlinkAdvantage_eq`](/lean/proofs/controls#leakyscheme_unlinkadvantage_eq) ```lean theorem leakyScheme_unlinkAdvantage_eq (keygen : ProbComp (PK × SK)) : (leakyScheme keygen).unlinkAdvantage leakyAdv = 1 - keyCollisionProb keygen := unlinkAdvantage_leakyAdv_eq_one_sub_keyCollisionProb _ (fun _ => rfl) ``` [`Controls.lean:92-95 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L92-L95) ### Positive control: a scheme that publishes the recipient ## [`leakyKEM`](/lean/proofs/controls#leakykem) A KEM that encapsulates by echoing the public key. Whenever `keygen` never fails it is a perfectly correct KEM -- decapsulation returns exactly the shared secret that was encapsulated -- and anonymous it is not. ```lean def leakyKEM (keygen : ProbComp (PK × SK)) (k₀ : K) : KEM PK SK PK K where keygen := keygen encaps pk := pure (pk, k₀) decaps _ _ := pure (some k₀) ``` [`Controls.lean:103-107 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L103-L107) ## [`leakyKEM_anonAdvantage_eq`](/lean/proofs/controls#leakykem_anonadvantage_eq) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem leakyKEM_anonAdvantage_eq (keygen : ProbComp (PK × SK)) (k₀ : K) : (leakyKEM keygen k₀).anonAdvantage leakyAdv = 1 - keyCollisionProb keygen := by ``` :::details[proof] ```lean rw [← unlinkAdvantage_ofKEM_eq_anonAdvantage] exact unlinkAdvantage_leakyAdv_eq_one_sub_keyCollisionProb _ (fun _ => by simp only [StealthScheme.ofKEM, leakyKEM, bind_pure_comp, map_pure]) ``` ::: [`Controls.lean:111-116 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L111-L116) ### Positive control: a KEM whose ciphertext is the public key ## [`deadKEM`](/lean/proofs/controls#deadkem) A KEM whose decapsulation always rejects. Everything else is trivial, so the only thing the control can be measuring is the rejection. ```lean def deadKEM : KEM Unit Unit Unit Unit where keygen := pure ((), ()) encaps _ := pure ((), ()) decaps _ _ := pure none ``` [`Controls.lean:123-127 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L123-L127) ## [`deadKEM_ofKEMFull_not_perfectlyComplete`](/lean/proofs/controls#deadkem_ofkemfull_not_perfectlycomplete) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem deadKEM_ofKEMFull_not_perfectlyComplete : ¬ (StealthScheme.ofKEMFull deadKEM (fun _ _ => ())).PerfectlyComplete := by ``` :::details[proof] ```lean intro hComplete rw [StealthScheme.PerfectlyComplete, probOutput_eq_one_iff_forall] at hComplete obtain ⟨-, hall⟩ := hComplete have hmem : false ∈ support ((StealthScheme.ofKEMFull deadKEM (fun _ _ => ())).CorrectExp) := by simp only [StealthScheme.CorrectExp, StealthScheme.ofKEMFull, deadKEM, bind_pure_comp, map_pure, decide_true, Option.elim_none, support_pure, Set.mem_singleton_iff] simpa only [Bool.false_eq_true] using hall false hmem ``` ::: [`Controls.lean:131-140 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L131-L140) ### Negative control: a KEM that never decapsulates ## [`StealthScheme.ofKEMFullNoTag`](/lean/proofs/controls#stealthschemeofkemfullnotag) [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) with the tag comparison removed: detection is "decapsulation returned `some`". Announcement and private state are unchanged, so the pair [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) / [`ofKEMFullNoTag`](/lean/proofs/controls#stealthschemeofkemfullnotag) isolates exactly the comparison. ```lean def StealthScheme.ofKEMFullNoTag : StealthScheme PK (SK × PK) (C × Aux) where keygen := do let ks ← kem.keygen pure (ks.1, (ks.2, ks.1)) announce pk := do let ck ← kem.encaps pk pure (ck.1, auxGen ck.2 pk) scan sk ca := do let k? ← kem.decaps sk.1 ca.1 pure k?.isSome ``` [`Controls.lean:150-160 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L150-L160) ## [`perfectlyComplete_ofKEMFullNoTag`](/lean/proofs/controls#perfectlycomplete_ofkemfullnotag) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem perfectlyComplete_ofKEMFullNoTag [DecidableEq K] (hkem : kem.PerfectlyCorrect ProbCompRuntime.probComp) : (StealthScheme.ofKEMFullNoTag kem auxGen).PerfectlyComplete := by ``` :::details[proof] ```lean have hCE : (StealthScheme.ofKEMFullNoTag kem auxGen).CorrectExp = (do let a ← kem.run; pure a.2.2.isSome) := by simp only [StealthScheme.CorrectExp, StealthScheme.ofKEMFullNoTag, KEM.run, bind_assoc, pure_bind] rw [StealthScheme.PerfectlyComplete, hCE] refine probOutput_true_eq_one_of_imp (p := fun a => decide (a.2.2 = some a.2.1.2)) kem.run ?_ ?_ · intro a _ ha simp only [decide_eq_true_eq] at ha simp only [ha, Option.isSome_some] · rw [← kem.correctExp_eq] exact hkem ``` ::: [`Controls.lean:164-179 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L164-L179) ## [`probOutput_falsePositiveExp_ofKEMFullNoTag_eq_one`](/lean/proofs/controls#proboutput_falsepositiveexp_ofkemfullnotag_eq_one) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem probOutput_falsePositiveExp_ofKEMFullNoTag_eq_one (k₀ : K) (hdec : ∀ sk c, kem.decaps sk c = pure (some k₀)) : Pr[= true | (StealthScheme.ofKEMFullNoTag kem auxGen).FalsePositiveExp] = 1 := by ``` :::details[proof] ```lean have hFP : (StealthScheme.ofKEMFullNoTag kem auxGen).FalsePositiveExp = (do let _ ← kem.keygen let ks1 ← kem.keygen let _ ← kem.encaps ks1.1 pure true) := by simp only [StealthScheme.FalsePositiveExp, StealthScheme.ofKEMFullNoTag, hdec, bind_assoc, pure_bind, Option.isSome_some] rw [hFP, probOutput_eq_one_iff_forall] refine ⟨?_, ?_⟩ · exact probFailure_of_liftM_PMF _ · intro y hy simp only [support_bind, support_pure, Set.mem_iUnion, Set.mem_singleton_iff] at hy obtain ⟨-, -, -, -, -, -, hy⟩ := hy exact hy ``` ::: [`Controls.lean:183-200 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L183-L200) ## [`falsePositiveRate_ofKEMFullNoTag_eq_one`](/lean/proofs/controls#falsepositiverate_ofkemfullnotag_eq_one) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem falsePositiveRate_ofKEMFullNoTag_eq_one (k₀ : K) (hdec : ∀ sk c, kem.decaps sk c = pure (some k₀)) : (StealthScheme.ofKEMFullNoTag kem auxGen).falsePositiveRate = 1 := by ``` :::details[proof] ```lean rw [StealthScheme.falsePositiveRate, probOutput_falsePositiveExp_ofKEMFullNoTag_eq_one kem auxGen k₀ hdec, ENNReal.toReal_one] ``` ::: [`Controls.lean:203-209 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L203-L209) ### Negative control: the scan that ignores the tag ## [`dksapBroken_key_mismatch`](/lean/proofs/controls#dksapbroken_key_mismatch) The algebraic reason the broken variant fails: dropping a nonzero spending key changes the derived public key. Stated at spending secret `1`, enough to refute a claim quantified over all recipients. ```lean theorem dksapBroken_key_mismatch (g : G) (hg : g ≠ 0) (s : F) : ((1 : F) + s) • g ≠ s • g := by ``` :::details[proof] ```lean intro hEq rw [add_smul, one_smul] at hEq exact hg (by simpa only [add_sub_cancel_right, sub_self] using congrArg (· - s • g) hEq) ``` ::: [`Controls.lean:221-226 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L221-L226) ## [`dksapBroken`](/lean/proofs/controls#dksapbroken) A deliberately incorrect DKSAP: the recipient recomputes the shared scalar correctly but forgets to add their own spending key, so the key they check against is `s • g` instead of `(m + s) • g`. Announcing is unchanged. ```lean def dksapBroken : StealthScheme (G × G) (F × F) (G × G) where keygen := do let m ← ($ᵗ F) let v ← ($ᵗ F) pure ((m • g, v • g), (m, v)) announce MV := do let r ← ($ᵗ F) pure (r • g, MV.1 + (h (r • MV.2)) • g) scan mv Rp := pure (decide (Rp.2 = (h (mv.2 • Rp.1)) • g)) ``` [`Controls.lean:232-241 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L232-L241) ## [`dksapBroken_not_perfectlyComplete`](/lean/proofs/controls#dksapbroken_not_perfectlycomplete) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksapBroken_not_perfectlyComplete (hg : g ≠ 0) : ¬ (dksapBroken g h).PerfectlyComplete := by ``` :::details[proof] ```lean intro hComplete rw [StealthScheme.PerfectlyComplete, probOutput_eq_one_iff_forall] at hComplete obtain ⟨-, hall⟩ := hComplete have hmem : false ∈ support ((dksapBroken g h).CorrectExp) := by simp only [StealthScheme.CorrectExp, dksapBroken, support_bind, support_pure, Set.mem_iUnion, Set.mem_singleton_iff] refine ⟨(((1 : F) • g, (1 : F) • g), (1 : F), (1 : F)), ⟨1, mem_support_uniformSample _, 1, mem_support_uniformSample _, rfl⟩, ((1 : F) • g, (1 : F) • g + h ((1 : F) • ((1 : F) • g)) • g), ⟨1, mem_support_uniformSample _, rfl⟩, ?_⟩ symm simp only [decide_eq_false_iff_not, ← add_smul] exact dksapBroken_key_mismatch g hg _ simpa only [Bool.false_eq_true] using hall false hmem ``` ::: [`Controls.lean:245-261 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Controls.lean#L245-L261) # Demo :::info[Mirrored page] Source: [`lean/docs-proofs/demo.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/demo.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Everything else here is abstract, which is what makes the theorems apply to any curve and also what makes nothing executable. This module fixes a tiny instance \-- the additive group of `ZMod 23`, where discrete logs are division -- so the scheme and the attack can be RUN: exactly why the attack is executable here, and why the instance offers no security. Each `#eval` is wrapped in `#guard_msgs`, so the numbers in the docstrings are build-checked assertions. See `docs/dksap-asymmetry.md`. ## [`instFactPrime23`](/lean/proofs/demo#instfactprime23) `23` is prime, so `ZMod 23` is a field. ```lean instance instFactPrime23 : Fact (Nat.Prime 23) := ⟨by norm_num⟩ ``` [`Demo.lean:26-27 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L26-L27) ## [`F`](/lean/proofs/demo#f) ```lean abbrev F := ZMod 23 ``` [`Demo.lean:32-33 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L32-L33) ## [`gen`](/lean/proofs/demo#gen) ```lean def gen : F := 5 ``` [`Demo.lean:35-36 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L35-L36) ## [`hash`](/lean/proofs/demo#hash) ```lean def hash (x : F) : F := 7 * x + 3 ``` [`Demo.lean:39-40 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L39-L40) ## [`gen_injective`](/lean/proofs/demo#gen_injective) ```lean theorem gen_injective : Function.Injective (fun x : F => x • gen) := by ``` :::details[proof] ```lean simpa only [smul_eq_mul] using mul_left_injective₀ (b := gen) (by decide) ``` ::: [`Demo.lean:42-44 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L42-L44) ### A tiny field and a generator ## [`m`](/lean/proofs/demo#m) Recipient's spending secret. ```lean def m : F := 9 ``` [`Demo.lean:48-48 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L48-L48) ## [`v`](/lean/proofs/demo#v) ```lean def v : F := 4 ``` [`Demo.lean:50-50 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L50-L50) ## [`r`](/lean/proofs/demo#r) ```lean def r : F := 3 ``` [`Demo.lean:52-52 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L52-L52) ## [`M`](/lean/proofs/demo#m) ```lean def M : F := m • gen ``` [`Demo.lean:54-54 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L54-L54) ## [`V`](/lean/proofs/demo#v) ```lean def V : F := v • gen ``` [`Demo.lean:56-56 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L56-L56) ## [`R`](/lean/proofs/demo#r) ```lean def R : F := r • gen ``` [`Demo.lean:58-58 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L58-L58) ## [`P`](/lean/proofs/demo#p) ```lean def P : F := M + (hash (r • V)) • gen ``` [`Demo.lean:60-60 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L60-L60) ## [`honestKey`](/lean/proofs/demo#honestkey) ```lean def honestKey : F := m + hash (v • R) ``` [`Demo.lean:62-63 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L62-L63) ### One recipient, one payment ## [`genInv`](/lean/proofs/demo#geninv) The generator's multiplicative inverse, `5 * 14 = 70 = 1 mod 23`. ```lean def genInv : F := 14 ``` [`Demo.lean:69-70 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L69-L70) ## [`genInv_spec`](/lean/proofs/demo#geninv_spec) ```lean theorem genInv_spec : genInv * gen = 1 := by decide ``` [`Demo.lean:72-73 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L72-L73) ## [`dlog`](/lean/proofs/demo#dlog) ```lean def dlog (x : F) : F := genInv * x ``` [`Demo.lean:76-77 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L76-L77) ## [`recovered`](/lean/proofs/demo#recovered) ```lean def recovered : F := recover hash (dlog M) (dlog V) R ``` [`Demo.lean:79-80 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L79-L80) ### The attack ### Run it ## [`scheme`](/lean/proofs/demo#scheme) DKSAP at the toy instance. ```lean def scheme : StealthScheme (F × F) (F × F) (F × F) := dksap gen hash ``` [`Demo.lean:127-128 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L127-L128) ## [`gen_bijective`](/lean/proofs/demo#gen_bijective) ```lean theorem gen_bijective : Function.Bijective (fun x : F => x • gen) := Finite.injective_iff_bijective.mp gen_injective ``` [`Demo.lean:131-133 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Demo.lean#L131-L133) # DKSAP :::info[Mirrored page] Source: [`lean/docs-proofs/dksap.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/dksap.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: DKSAP (ERC-5564 scheme 1, the only stealth scheme deployed today) as a [`StealthScheme`](/lean/proofs/games#stealthscheme), and its detection completeness; given the discrete logs of the PUBLISHED meta-address, an adversary recomputes the recipient's honest stealth spending key EXACTLY -- key recovery, hence universal forgery, with no probability slack; and, on the other side, DKSAP's unlinkability is bounded by two hashed-Diffie-Hellman terms and nothing else, the idealized middle game contributing exactly zero. Scope of the break, precisely: [`dksap_key_recovery`](/lean/proofs/dksap#dksap_key_recovery) recovers the scalar that [`recover`](/lean/proofs/dksap#recover) computes, and the statement is `(recover …) • g = (m • g) + (h (r • (v • g))) • g` -- the recovered scalar controls the announced ADDRESS, i.e. the public key. It does NOT say the adversary can produce a signature or otherwise SPEND from it; that needs the discrete-log-to-signing step of the concrete signature scheme, which this module does not model. Call it "key/address recovery modulo the address-hashing step", and do not read it as a signing-key extraction theorem. Assumed: the discrete-log oracle's answers are hypotheses (no quantum computation is formalized), and hashed-DH is stated, not proved -- exactly as MLWE is on the lattice side. See `docs/dksap-asymmetry.md`. ## [`dksap_derivation_agrees`](/lean/proofs/dksap#dksap_derivation_agrees) The Diffie-Hellman step: sender and recipient reach the same scalar because `r • (v • g) = v • (r • g)`. This is the identity the whole scheme rests on, and also the reason the attack works once [`v`](/lean/proofs/demo#v) is known. `axioms: propext, Quot.sound` · sorry-free ✓ ```lean theorem dksap_derivation_agrees (g : G) (h : G → F) (m v r : F) : (m • g) + (h (r • (v • g))) • g = (m + h (v • (r • g))) • g := by ``` :::details[proof] ```lean rw [add_smul, smul_smul, smul_smul, mul_comm] ``` ::: [`DKSAP.lean:45-48 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L45-L48) ### The group model ## [`dksap`](/lean/proofs/dksap#dksap) DKSAP as a stealth scheme: the announcement is `(R, P) = (r • g, M + h(r • V) • g)`, and the recipient recomputes the scalar as `h(v • R)`. [`P`](/lean/proofs/demo#p) stands in for the on-chain address, whose hashing is irrelevant here. ```lean def dksap : StealthScheme (G × G) (F × F) (G × G) where keygen := do let m ← ($ᵗ F) let v ← ($ᵗ F) pure ((m • g, v • g), (m, v)) announce MV := do let r ← ($ᵗ F) pure (r • g, MV.1 + (h (r • MV.2)) • g) scan mv Rp := pure (decide (Rp.2 = (mv.1 + h (mv.2 • Rp.1)) • g)) ``` [`DKSAP.lean:61-70 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L61-L70) ## [`dksap_perfectlyComplete`](/lean/proofs/dksap#dksap_perfectlycomplete) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksap_perfectlyComplete : (dksap g h).PerfectlyComplete := by ``` :::details[proof] ```lean simp only [StealthScheme.PerfectlyComplete, StealthScheme.CorrectExp, dksap, bind_pure_comp, Functor.map_map, bind_assoc, bind_map_left, dksap_derivation_agrees, decide_true, probOutput_bind_const, probFailure_of_liftM_PMF, tsub_zero, probOutput_map_const, probOutput_pure, ↓reduceIte, mul_one] ``` ::: [`DKSAP.lean:74-79 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L74-L79) ### The scheme ## [`recover`](/lean/proofs/dksap#recover) What the adversary computes: the spending scalar of the announcement [`R`](/lean/proofs/demo#r), from the two recovered discrete logs. No secret of the recipient is used. ```lean def recover (xM xV : F) (R : G) : F := xM + h (xV • R) ``` [`DKSAP.lean:93-94 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L93-L94) ## [`dksap_recover_eq_honest`](/lean/proofs/dksap#dksap_recover_eq_honest) `axioms: propext, Quot.sound` · sorry-free ✓ ```lean theorem dksap_recover_eq_honest (hinj : Function.Injective (fun x : F => x • g)) (m v xM xV : F) (hM : xM • g = m • g) (hV : xV • g = v • g) (R : G) : recover h xM xV R = m + h (v • R) := by ``` :::details[proof] ```lean rw [recover, hinj hM, hinj hV] ``` ::: [`DKSAP.lean:98-103 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L98-L103) ## [`dksap_key_recovery`](/lean/proofs/dksap#dksap_key_recovery) `axioms: propext, Quot.sound` · sorry-free ✓ ```lean theorem dksap_key_recovery (hinj : Function.Injective (fun x : F => x • g)) (m v r xM xV : F) (hM : xM • g = m • g) (hV : xV • g = v • g) : (recover h xM xV (r • g)) • g = (m • g) + (h (r • (v • g))) • g := by ``` :::details[proof] ```lean rw [dksap_recover_eq_honest h g hinj m v xM xV hM hV, dksap_derivation_agrees] ``` ::: [`DKSAP.lean:107-112 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L107-L112) ### Key recovery from the published meta-address ## [`dksapIdeal`](/lean/proofs/dksap#dksapideal) DKSAP with an idealized sender: the shared scalar is uniform instead of `h(r * V)`. A proof device, not a runnable scheme -- detection cannot work -- used only as the middle game of a hop. ```lean def dksapIdeal : StealthScheme (G × G) (F × F) (G × G) where keygen := do let m ← ($ᵗ F) let v ← ($ᵗ F) pure ((m • g, v • g), (m, v)) announce MV := do let r ← ($ᵗ F) let s ← ($ᵗ F) pure (r • g, MV.1 + s • g) scan _ _ := pure false ``` [`DKSAP.lean:125-135 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L125-L135) ### DKSAP under a classical adversary ## [`dksapIdeal_announce_indep`](/lean/proofs/dksap#dksapideal_announce_indep) With a uniform scalar, announcing to `M1` and to `M0` are the same computation: substitute `s ↦ d + s` for `d` the discrete log of `M1 - M0`. ```lean theorem dksapIdeal_announce_indep (hbij : Function.Bijective (fun x : F => x • g)) (M0 M1 : G) (cont : G → ProbComp Bool) (z : Bool) : Pr[= z | ($ᵗ F) >>= fun s => cont (M1 + s • g)] = Pr[= z | ($ᵗ F) >>= fun s => cont (M0 + s • g)] := by ``` :::details[proof] ```lean obtain ⟨d, hd⟩ := hbij.surjective (M1 - M0) simp only at hd have key : ∀ s : F, M0 + (d + s) • g = M1 + s • g := by intro s rw [add_smul, ← add_assoc, hd, add_sub_cancel] have hbij' : Function.Bijective (fun s : F => d + s) := (Equiv.addLeft d).bijective calc Pr[= z | ($ᵗ F) >>= fun s => cont (M1 + s • g)] = Pr[= z | ($ᵗ F) >>= fun s => (fun s' => cont (M0 + s' • g)) (d + s)] := by simp only [key] _ = Pr[= z | ($ᵗ F) >>= fun s' => cont (M0 + s' • g)] := probOutput_bind_bijective_uniform_cross F (fun s : F => d + s) hbij' (fun s' => cont (M0 + s' • g)) z ``` ::: [`DKSAP.lean:142-159 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L142-L159) ## [`dksapIdeal_branch_indep`](/lean/proofs/dksap#dksapideal_branch_indep) ```lean theorem dksapIdeal_branch_indep (hbij : Function.Bijective (fun x : F => x • g)) (M0 M1 : G) (cont : G × G → ProbComp Bool) (z : Bool) : Pr[= z | ($ᵗ F) >>= fun r => ($ᵗ F) >>= fun s => cont (r • g, M1 + s • g)] = Pr[= z | ($ᵗ F) >>= fun r => ($ᵗ F) >>= fun s => cont (r • g, M0 + s • g)] := by ``` :::details[proof] ```lean have inner := fun (r : F) => dksapIdeal_announce_indep g hbij M0 M1 (fun P => cont (r • g, P)) z simp only [probOutput_bind_eq_tsum] at inner ⊢ exact tsum_congr fun r => by rw [inner r] ``` ::: [`DKSAP.lean:161-170 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L161-L170) ## [`dksapIdeal_unlinkAdvantage_eq_zero`](/lean/proofs/dksap#dksapideal_unlinkadvantage_eq_zero) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksapIdeal_unlinkAdvantage_eq_zero (hbij : Function.Bijective (fun x : F => x • g)) : (dksapIdeal F g).unlinkAdvantage adv = 0 := by ``` :::details[proof] ```lean rw [StealthScheme.unlinkAdvantage_eq_branchDistAdvantage, ProbComp.boolDistAdvantage] refine abs_eq_zero.mpr (sub_eq_zero.mpr ?_) congr 1 simp only [probOutput_bind_eq_tsum] refine tsum_congr fun a => ?_ congr 1 simp only [StealthScheme.unlinkBranch, dksapIdeal, bind_assoc, pure_bind, if_true, Bool.false_eq_true, if_false] exact dksapIdeal_branch_indep g hbij _ _ _ _ ``` ::: [`DKSAP.lean:174-186 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L174-L186) ### The idealized scheme is perfectly unlinkable ## [`hashedDH`](/lean/proofs/dksap#hasheddh) Hashed-DH advantage on branch `b`: real `h(r * V)` versus uniform. The classical assumption the scheme rests on, and exactly what a discrete-log oracle destroys. ```lean noncomputable def hashedDH (b : Bool) : ℝ := ((dksap g h).unlinkSetup >>= (dksap g h).unlinkBranch adv b).boolDistAdvantage ((dksapIdeal F g).unlinkSetup >>= (dksapIdeal F g).unlinkBranch adv b) ``` [`DKSAP.lean:202-205 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L202-L205) ### The two hops ## [`dksap_unlinkAdvantage_le_hashedDH`](/lean/proofs/dksap#dksap_unlinkadvantage_le_hasheddh) **DKSAP's unlinkability is two hashed-DH gaps**, the idealized middle game contributing exactly zero. [`hashedDH`](/lean/proofs/dksap#hasheddh) is itself a game distance; it is bounded by VCVio's named DDH and entropy-smoothing advantages below ([`hashedDH_le_ddh_add_es`](/lean/proofs/dksap#hasheddh_le_ddh_add_es)). Read alongside [`dksap_key_recovery`](/lean/proofs/dksap#dksap_key_recovery): the same scheme is sound classically and totally broken given a discrete-log oracle. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksap_unlinkAdvantage_le_hashedDH (hbij : Function.Bijective (fun x : F => x • g)) : (dksap g h).unlinkAdvantage adv ≤ hashedDH g h adv true + hashedDH g h adv false := by ``` :::details[proof] ```lean rw [StealthScheme.unlinkAdvantage_eq_branchDistAdvantage] unfold hashedDH rw [ProbComp.boolDistAdvantage_comm ((dksap g h).unlinkSetup >>= (dksap g h).unlinkBranch adv false)] set Pt := (dksap g h).unlinkSetup >>= (dksap g h).unlinkBranch adv true set Pf := (dksap g h).unlinkSetup >>= (dksap g h).unlinkBranch adv false set It := (dksapIdeal F g).unlinkSetup >>= (dksapIdeal F g).unlinkBranch adv true set If := (dksapIdeal F g).unlinkSetup >>= (dksapIdeal F g).unlinkBranch adv false have hzero : It.boolDistAdvantage If = 0 := by rw [← StealthScheme.unlinkAdvantage_eq_branchDistAdvantage] exact dksapIdeal_unlinkAdvantage_eq_zero g adv hbij calc Pt.boolDistAdvantage Pf ≤ Pt.boolDistAdvantage It + It.boolDistAdvantage Pf := ProbComp.boolDistAdvantage_triangle Pt It Pf _ ≤ Pt.boolDistAdvantage It + (It.boolDistAdvantage If + If.boolDistAdvantage Pf) := by gcongr exact ProbComp.boolDistAdvantage_triangle It If Pf _ = Pt.boolDistAdvantage It + If.boolDistAdvantage Pf := by rw [hzero, zero_add] ``` ::: [`DKSAP.lean:213-237 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L213-L237) ### The classical security statement ## [`ddhReductionDKSAP`](/lean/proofs/dksap#ddhreductiondksap) The DDH reduction on branch `b`: the challenge `A` is the target's viewing key, `B` the ephemeral key, `T` the point to hash; the remaining keys are drawn in the order `mT, mO, vO`. ```lean def ddhReductionDKSAP (b : Bool) : DiffieHellman.DDHAdversary F G := fun g A B T => do let mT ← ($ᵗ F); let mO ← ($ᵗ F); let vO ← ($ᵗ F) let pkT : G × G := (mT • g, A) let pkO : G × G := (mO • g, vO • g) adv (if b then pkO else pkT) (if b then pkT else pkO) (B, mT • g + h T • g) ``` [`DKSAP.lean:257-262 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L257-L262) ## [`dksapDHGame`](/lean/proofs/dksap#dksapdhgame) ```lean def dksapDHGame (b : Bool) : ProbComp Bool := do let vT ← ($ᵗ F); let r ← ($ᵗ F); let c ← ($ᵗ F) let mT ← ($ᵗ F); let mO ← ($ᵗ F); let vO ← ($ᵗ F) let pkT : G × G := (mT • g, vT • g) let pkO : G × G := (mO • g, vO • g) adv (if b then pkO else pkT) (if b then pkT else pkO) (r • g, mT • g + h (c • g) • g) ``` [`DKSAP.lean:266-272 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L266-L272) ## [`esReductionDKSAP`](/lean/proofs/dksap#esreductiondksap) ```lean def esReductionDKSAP (b : Bool) : Fin 1 × F → ProbComp Bool := fun x => do let vT ← ($ᵗ F); let r ← ($ᵗ F) let mT ← ($ᵗ F); let mO ← ($ᵗ F); let vO ← ($ᵗ F) let pkT : G × G := (mT • g, vT • g) let pkO : G × G := (mO • g, vO • g) adv (if b then pkO else pkT) (if b then pkT else pkO) (r • g, mT • g + x.2 • g) ``` [`DKSAP.lean:276-282 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L276-L282) ## [`evalDist_unlinkBranch_dksap_eq_ddhExpReal`](/lean/proofs/dksap#evaldist_unlinkbranch_dksap_eq_ddhexpreal) ```lean theorem evalDist_unlinkBranch_dksap_eq_ddhExpReal [DecidableEq G] (b : Bool) : 𝒟[(dksap g h).unlinkSetup >>= (dksap g h).unlinkBranch adv b] = 𝒟[DiffieHellman.ddhExpReal g (ddhReductionDKSAP h adv b)] := by ``` :::details[proof] ```lean cases b · simp only [StealthScheme.unlinkSetup, StealthScheme.unlinkBranch, dksap, ddhReductionDKSAP, DiffieHellman.ddhExpReal, bind_assoc, pure_bind, Bool.false_eq_true, if_false, smul_smul] rw [evalDist_bind_bind_swap] refine evalDist_bind_congr' _ fun v0 => ?_ rw [evalDist_pull₄] refine evalDist_bind_congr' _ fun r => evalDist_bind_congr' _ fun m0 => evalDist_bind_congr' _ fun m1 => evalDist_bind_congr' _ fun v1 => ?_ rw [mul_comm r v0] · simp only [StealthScheme.unlinkSetup, StealthScheme.unlinkBranch, dksap, ddhReductionDKSAP, DiffieHellman.ddhExpReal, bind_assoc, pure_bind, if_true, smul_smul] rw [evalDist_pull₄] refine evalDist_bind_congr' _ fun v1 => ?_ rw [evalDist_pull₄] refine evalDist_bind_congr' _ fun r => ?_ rw [evalDist_pull₃] refine evalDist_bind_congr' _ fun m1 => evalDist_bind_congr' _ fun m0 => evalDist_bind_congr' _ fun v0 => ?_ rw [mul_comm r v1] ``` ::: [`DKSAP.lean:284-306 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L284-L306) ## [`evalDist_ddhExpRand_eq_dksapDHGame`](/lean/proofs/dksap#evaldist_ddhexprand_eq_dksapdhgame) ```lean theorem evalDist_ddhExpRand_eq_dksapDHGame (b : Bool) : 𝒟[DiffieHellman.ddhExpRand g (ddhReductionDKSAP h adv b)] = 𝒟[dksapDHGame g h adv b] := by ``` :::details[proof] ```lean simp only [DiffieHellman.ddhExpRand, ddhReductionDKSAP, dksapDHGame] ``` ::: [`DKSAP.lean:308-311 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L308-L311) ## [`evalDist_dksapDHGame_eq_esReal`](/lean/proofs/dksap#evaldist_dksapdhgame_eq_esreal) ```lean theorem evalDist_dksapDHGame_eq_esReal (b : Bool) : 𝒟[dksapDHGame g h adv b] = 𝒟[EntropySmoothing.realExp F g (fun (_ : Fin 1) => h) (esReductionDKSAP g adv b)] := by ``` :::details[proof] ```lean simp only [EntropySmoothing.realExp, esReductionDKSAP, dksapDHGame] rw [evalDist_uniformSample_bind_const, evalDist_pull₃] ``` ::: [`DKSAP.lean:313-318 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L313-L318) ## [`evalDist_esIdeal_eq_unlinkBranch_dksapIdeal`](/lean/proofs/dksap#evaldist_esideal_eq_unlinkbranch_dksapideal) ```lean theorem evalDist_esIdeal_eq_unlinkBranch_dksapIdeal (b : Bool) : 𝒟[EntropySmoothing.idealExp (HK := Fin 1) (M := F) (esReductionDKSAP g adv b)] = 𝒟[(dksapIdeal F g).unlinkSetup >>= (dksapIdeal F g).unlinkBranch adv b] := by ``` :::details[proof] ```lean simp only [EntropySmoothing.idealExp, esReductionDKSAP, StealthScheme.unlinkSetup, StealthScheme.unlinkBranch, dksapIdeal, bind_assoc, pure_bind] rw [evalDist_uniformSample_bind_const] symm cases b · simp only [Bool.false_eq_true, if_false] rw [evalDist_pull₆] refine evalDist_bind_congr' _ fun s => ?_ rw [evalDist_bind_bind_swap] refine evalDist_bind_congr' _ fun v0 => ?_ rw [evalDist_pull₄] · simp only [if_true] rw [evalDist_pull₆] refine evalDist_bind_congr' _ fun s => ?_ rw [evalDist_pull₄] refine evalDist_bind_congr' _ fun v1 => ?_ rw [evalDist_pull₄] refine evalDist_bind_congr' _ fun r => ?_ rw [evalDist_pull₃] ``` ::: [`DKSAP.lean:320-342 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L320-L342) ## [`hashedDH_le_ddh_add_es`](/lean/proofs/dksap#hasheddh_le_ddh_add_es) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem hashedDH_le_ddh_add_es [DecidableEq G] (b : Bool) : hashedDH g h adv b ≤ DiffieHellman.ddhDistAdvantage g (ddhReductionDKSAP h adv b) + EntropySmoothing.advantage F g (fun (_ : Fin 1) => h) (esReductionDKSAP g adv b) := by ``` :::details[proof] ```lean unfold hashedDH DiffieHellman.ddhDistAdvantage EntropySmoothing.advantage ProbComp.boolDistAdvantage rw [probOutput_congr rfl (evalDist_unlinkBranch_dksap_eq_ddhExpReal g h adv b), ← probOutput_congr rfl (evalDist_esIdeal_eq_unlinkBranch_dksapIdeal g adv b), probOutput_congr rfl (evalDist_ddhExpRand_eq_dksapDHGame g h adv b), probOutput_congr rfl (evalDist_dksapDHGame_eq_esReal g h adv b)] exact abs_sub_le _ _ _ ``` ::: [`DKSAP.lean:346-357 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L346-L357) ## [`dksap_unlinkAdvantage_le_ddh_add_es`](/lean/proofs/dksap#dksap_unlinkadvantage_le_ddh_add_es) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksap_unlinkAdvantage_le_ddh_add_es [DecidableEq G] (hbij : Function.Bijective (fun x : F => x • g)) : (dksap g h).unlinkAdvantage adv ≤ (DiffieHellman.ddhDistAdvantage g (ddhReductionDKSAP h adv true) + EntropySmoothing.advantage F g (fun (_ : Fin 1) => h) (esReductionDKSAP g adv true)) + (DiffieHellman.ddhDistAdvantage g (ddhReductionDKSAP h adv false) + EntropySmoothing.advantage F g (fun (_ : Fin 1) => h) (esReductionDKSAP g adv false)) := (dksap_unlinkAdvantage_le_hashedDH g h adv hbij).trans (add_le_add (hashedDH_le_ddh_add_es g h adv true) (hashedDH_le_ddh_add_es g h adv false)) ``` [`DKSAP.lean:360-369 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAP.lean#L360-L369) # DKSAPOracle :::info[Mirrored page] Source: [`lean/docs-proofs/dksaporacle.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/dksaporacle.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: the key-recovery attack as an `OracleComp (unifSpec + (G →ₒ F))` making EXACTLY two oracle queries whatever the number of announcements it is handed (`≤ 2` and not `≤ 1`), and its correctness when the oracle is simulated by a genuine discrete-log function; and, on the positive side, DKSAP restated over the same interface with the hash as a LAZY RANDOM ORACLE, the idealized RO game proved perfectly unlinkable, and the RO unlinkability advantage bounded by the two bad-query probabilities — identical-until-bad closed against VCVio's programming-oracle engine (`ROMUpToBad`). Assumed: the CDH reduction from the bad event is defined ([`cdhOfUnlinkAdvRO`](/lean/proofs/dksaporacle#cdhofunlinkadvro), logging through VCVio's `appendInputLog`) but its advantage inequality `dksapROBadProb ≤ q_H · Adv_CDH` is open: it needs a query bound on the adversary and the fact that the flag fires with the same probability in the programmed and in the tracking run. See the "Oracle attack" and "DDH/ROM bound" sections of `docs/dksap-asymmetry.md`. ## [`scalarSpec`](/lean/proofs/dksaporacle#scalarspec) Uniform sampling plus one oracle taking a group element to a scalar. VCVio's `+` on specs, so `unifSpec ⊂ₒ scalarSpec F G` and `ProbComp` lifts in. ```lean abbrev scalarSpec (F G : Type) : OracleSpec (ℕ ⊕ G) := unifSpec + (G →ₒ F) ``` [`DKSAPOracle.lean:44-45 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L44-L45) ## [`scalarQuery`](/lean/proofs/dksaporacle#scalarquery) ```lean def scalarQuery (y : G) : OracleComp (scalarSpec F G) F := (scalarSpec F G).query (Sum.inr y) ``` [`DKSAPOracle.lean:47-49 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L47-L49) ### The oracle interface ## [`dlogAttack`](/lean/proofs/dksaporacle#dlogattack) The attack on a published meta-address `(M, V)`: two oracle queries, then [`recover`](/lean/proofs/dksap#recover) on every announcement in `l`. No query depends on `l`. ```lean def dlogAttack (h : G → F) (MV : G × G) (l : List (G × G)) : OracleComp (scalarSpec F G) (List F) := do let xM ← scalarQuery (F := F) MV.1 let xV ← scalarQuery (F := F) MV.2 pure (l.map fun RP => recover h xM xV RP.1) ``` [`DKSAPOracle.lean:64-69 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L64-L69) ## [`dlogAttack_isTotalQueryBound`](/lean/proofs/dksaporacle#dlogattack_istotalquerybound) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dlogAttack_isTotalQueryBound (h : G → F) (MV : G × G) (l : List (G × G)) : IsTotalQueryBound (dlogAttack h MV l) 2 := ⟨by norm_num, fun _ => ⟨by norm_num, fun _ => trivial⟩⟩ ``` [`DKSAPOracle.lean:73-76 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L73-L76) ## [`dlogAttack_not_isTotalQueryBound_one`](/lean/proofs/dksaporacle#dlogattack_not_istotalquerybound_one) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dlogAttack_not_isTotalQueryBound_one (h : G → F) (MV : G × G) (l : List (G × G)) : ¬ IsTotalQueryBound (dlogAttack h MV l) 1 := fun hb => absurd (hb.2 (0 : F)).1 (by norm_num) ``` [`DKSAPOracle.lean:79-82 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L79-L82) ## [`dlogImpl`](/lean/proofs/dksaporacle#dlogimpl) ```lean noncomputable def dlogImpl (dlog : G → F) : QueryImpl (scalarSpec F G) ProbComp | Sum.inl n => $[0..n] | Sum.inr y => pure (dlog y) ``` [`DKSAPOracle.lean:85-88 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L85-L88) ## [`simulateQ_dlogAttack`](/lean/proofs/dksaporacle#simulateq_dlogattack) ```lean theorem simulateQ_dlogAttack (dlog : G → F) (h : G → F) (MV : G × G) (l : List (G × G)) : simulateQ (dlogImpl dlog) (dlogAttack h MV l) = pure (l.map fun RP => recover h (dlog MV.1) (dlog MV.2) RP.1) := by ``` :::details[proof] ```lean simp only [dlogAttack, scalarQuery, add_apply_inr, bind_pure_comp, simulateQ_bind, simulateQ_query, OracleQuery.input_query, OracleQuery.cont_query, dlogImpl, map_pure, id_eq, simulateQ_map] ``` ::: [`DKSAPOracle.lean:90-96 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L90-L96) ## [`dksapAnnounce`](/lean/proofs/dksaporacle#dksapannounce) ```lean def dksapAnnounce (g : G) (h : G → F) (m v r : F) : G × G := (r • g, (m • g) + (h (r • (v • g))) • g) ``` [`DKSAPOracle.lean:99-101 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L99-L101) ## [`dksapAnnounce_mem_announce_support`](/lean/proofs/dksaporacle#dksapannounce_mem_announce_support) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksapAnnounce_mem_announce_support [SampleableType F] [DecidableEq G] (g : G) (h : G → F) (m v r : F) : dksapAnnounce g h m v r ∈ support ((dksap g h).announce (m • g, v • g)) := by ``` :::details[proof] ```lean simp only [dksap, dksapAnnounce, support_bind, support_pure, Set.mem_iUnion, Set.mem_singleton_iff, exists_prop] exact ⟨r, mem_support_uniformSample F, rfl⟩ ``` ::: [`DKSAPOracle.lean:104-110 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L104-L110) ## [`dlogAttack_key_recovery`](/lean/proofs/dksaporacle#dlogattack_key_recovery) `axioms: propext, Quot.sound` · sorry-free ✓ ```lean theorem dlogAttack_key_recovery (g : G) (hinj : Function.Injective (fun x : F => x • g)) (dlog : G → F) (hdlog : ∀ y : G, dlog y • g = y) (h : G → F) (m v : F) (l : List (G × G)) : simulateQ (dlogImpl dlog) (dlogAttack h (m • g, v • g) l) = pure (l.map fun RP => m + h (v • RP.1)) := by ``` :::details[proof] ```lean rw [simulateQ_dlogAttack] congr 1 exact List.map_congr_left fun RP _ => dksap_recover_eq_honest h g hinj m v _ _ (hdlog (m • g)) (hdlog (v • g)) RP.1 ``` ::: [`DKSAPOracle.lean:114-124 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L114-L124) ## [`dlogAttack_forall_key_recovery`](/lean/proofs/dksaporacle#dlogattack_forall_key_recovery) `axioms: propext, Quot.sound` · sorry-free ✓ ```lean theorem dlogAttack_forall_key_recovery (g : G) (hinj : Function.Injective (fun x : F => x • g)) (dlog : G → F) (hdlog : ∀ y : G, dlog y • g = y) (h : G → F) (m v : F) (rs : List F) : List.Forall (fun r : F => (recover h (dlog (m • g)) (dlog (v • g)) (r • g)) • g = (dksapAnnounce g h m v r).2) rs := by ``` :::details[proof] ```lean rw [List.forall_iff_forall_mem] exact fun r _ => dksap_key_recovery h g hinj m v r _ _ (hdlog (m • g)) (hdlog (v • g)) ``` ::: [`DKSAPOracle.lean:128-138 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L128-L138) ### The discrete-log attack, with a real oracle and a query bound ## [`dksapRomImpl`](/lean/proofs/dksaporacle#dksapromimpl) The ROM implementation: uniform queries pass through, hash queries are lazily sampled and cached. ```lean noncomputable def dksapRomImpl (F G : Type) [SampleableType F] [DecidableEq G] : QueryImpl (scalarSpec F G) (StateT ((G →ₒ F).QueryCache) ProbComp) := unifFwdImpl (G →ₒ F) + OracleSpec.randomOracle (spec := (G →ₒ F)) ``` [`DKSAPOracle.lean:154-157 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L154-L157) ## [`dksapRomImpl_eq_roImpl`](/lean/proofs/dksaporacle#dksapromimpl_eq_roimpl) ```lean theorem dksapRomImpl_eq_roImpl : dksapRomImpl F G = roImpl (G →ₒ F) := rfl ``` [`DKSAPOracle.lean:159-160 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L159-L160) ## [`dksapRomImpl_run'_liftM_bind`](/lean/proofs/dksaporacle#dksapromimpl_run_liftm_bind) ```lean theorem dksapRomImpl_run'_liftM_bind {α β : Type} (oa : ProbComp α) (rest : α → StateT ((G →ₒ F).QueryCache) ProbComp β) (s : (G →ₒ F).QueryCache) : (simulateQ (dksapRomImpl F G) (liftM oa) >>= rest).run' s = oa >>= fun x => (rest x).run' s := roSim.run'_liftM_bind _ oa rest s ``` [`DKSAPOracle.lean:163-169 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L163-L169) ## [`dksapRomImpl_run'_scalarQuery_bind`](/lean/proofs/dksaporacle#dksapromimpl_run_scalarquery_bind) ```lean theorem dksapRomImpl_run'_scalarQuery_bind (Z : G) (rest : F → StateT ((G →ₒ F).QueryCache) ProbComp Bool) (cache : (G →ₒ F).QueryCache) (hc : cache Z = none) : (simulateQ (dksapRomImpl F G) (scalarQuery (F := F) Z) >>= rest).run' cache = ($ᵗ F) >>= fun s => (rest s).run' (QueryCache.cacheQuery cache Z s) := by ``` :::details[proof] ```lean have hstep : (simulateQ (dksapRomImpl F G) (scalarQuery (F := F) Z) : StateT ((G →ₒ F).QueryCache) ProbComp F) = OracleSpec.randomOracle (spec := (G →ₒ F)) Z := by simp only [dksapRomImpl, scalarQuery, add_apply_inr, simulateQ_query, OracleQuery.input_query, OracleQuery.cont_query, QueryImpl.add_apply_inr, QueryImpl.withCaching_apply, map_bind, id_map] rw [hstep] simp only [StateT.run'_eq] rw [StateT.run_bind, QueryImpl.withCaching_run_none (m := ProbComp) uniformSampleImpl hc] simp only [uniformSampleImpl, map_eq_bind_pure_comp, bind_assoc, Function.comp_apply, pure_bind] ``` ::: [`DKSAPOracle.lean:172-186 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L172-L186) ## [`UnlinkAdvRO`](/lean/proofs/dksaporacle#unlinkadvro) RO-model unlinkability adversary: both meta-addresses, the challenge announcement, and oracle access to the hash. ```lean abbrev UnlinkAdvRO (F G : Type) := (G × G) → (G × G) → (G × G) → OracleComp (scalarSpec F G) Bool ``` [`DKSAPOracle.lean:198-200 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L198-L200) ## [`dksapROKeys`](/lean/proofs/dksaporacle#dksaprokeys) Two independent DKSAP recipients, public halves only. No oracle query. ```lean def dksapROKeys (g : G) : ProbComp ((G × G) × (G × G)) := do let m0 ← ($ᵗ F); let v0 ← ($ᵗ F); let m1 ← ($ᵗ F); let v1 ← ($ᵗ F) pure (((m0 • g, v0 • g) : G × G), ((m1 • g, v1 • g) : G × G)) ``` [`DKSAPOracle.lean:203-206 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L203-L206) ## [`dksapROSetup`](/lean/proofs/dksaporacle#dksaprosetup) ```lean def dksapROSetup (g : G) : OracleComp (scalarSpec F G) ((G × G) × (G × G)) := liftM (dksapROKeys F g) ``` [`DKSAPOracle.lean:208-210 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L208-L210) ## [`advRunAt`](/lean/proofs/dksaporacle#advrunat) ```lean noncomputable def advRunAt (adv : UnlinkAdvRO F G) (a : (G × G) × (G × G)) (c : G × G) (cache : (G →ₒ F).QueryCache) : ProbComp Bool := (simulateQ (dksapRomImpl F G) (adv a.1 a.2 c)).run' cache ``` [`DKSAPOracle.lean:212-215 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L212-L215) ## [`dksapROBranch`](/lean/proofs/dksaporacle#dksaprobranch) ```lean def dksapROBranch (g : G) (adv : UnlinkAdvRO F G) (b : Bool) (a : (G × G) × (G × G)) : OracleComp (scalarSpec F G) Bool := do let r ← liftM ($ᵗ F) let s ← scalarQuery (F := F) (r • (if b then a.2 else a.1).2) adv a.1 a.2 (r • g, (if b then a.2 else a.1).1 + s • g) ``` [`DKSAPOracle.lean:218-223 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L218-L223) ## [`dksapROIdealBranch`](/lean/proofs/dksaporacle#dksaproidealbranch) ```lean def dksapROIdealBranch (g : G) (adv : UnlinkAdvRO F G) (b : Bool) (a : (G × G) × (G × G)) : OracleComp (scalarSpec F G) Bool := do let r ← liftM ($ᵗ F) let s ← liftM ($ᵗ F) adv a.1 a.2 (r • g, (if b then a.2 else a.1).1 + s • g) ``` [`DKSAPOracle.lean:225-230 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L225-L230) ## [`dksapRORun`](/lean/proofs/dksaporacle#dksaprorun) ```lean noncomputable def dksapRORun (g : G) (adv : UnlinkAdvRO F G) (b : Bool) : ProbComp Bool := (simulateQ (dksapRomImpl F G) (dksapROSetup g >>= dksapROBranch g adv b)).run' ∅ ``` [`DKSAPOracle.lean:232-234 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L232-L234) ## [`dksapROIdealRun`](/lean/proofs/dksaporacle#dksaproidealrun) ```lean noncomputable def dksapROIdealRun (g : G) (adv : UnlinkAdvRO F G) (b : Bool) : ProbComp Bool := (simulateQ (dksapRomImpl F G) (dksapROSetup g >>= dksapROIdealBranch g adv b)).run' ∅ ``` [`DKSAPOracle.lean:236-238 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L236-L238) ## [`dksapROIdealRun_eq`](/lean/proofs/dksaporacle#dksaproidealrun_eq) Peeling: keygen and the two scalars are ordinary sampling, so the ideal game is the adversary run on an announcement built from a uniform scalar. ```lean theorem dksapROIdealRun_eq (g : G) (adv : UnlinkAdvRO F G) (b : Bool) : dksapROIdealRun g adv b = dksapROKeys F g >>= fun a => ($ᵗ F) >>= fun r => ($ᵗ F) >>= fun s => advRunAt adv a (r • g, (if b then a.2 else a.1).1 + s • g) ∅ := by ``` :::details[proof] ```lean unfold dksapROIdealRun dksapROSetup dksapROIdealBranch advRunAt simp only [simulateQ_bind] rw [dksapRomImpl_run'_liftM_bind] refine bind_congr fun a => ?_ rw [dksapRomImpl_run'_liftM_bind] refine bind_congr fun r => ?_ rw [dksapRomImpl_run'_liftM_bind] ``` ::: [`DKSAPOracle.lean:243-254 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L243-L254) ## [`dksapROIdeal_boolDistAdvantage_eq_zero`](/lean/proofs/dksaporacle#dksaproideal_booldistadvantage_eq_zero) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksapROIdeal_boolDistAdvantage_eq_zero (g : G) (adv : UnlinkAdvRO F G) (hbij : Function.Bijective (fun x : F => x • g)) : (dksapROIdealRun g adv true).boolDistAdvantage (dksapROIdealRun g adv false) = 0 := by ``` :::details[proof] ```lean rw [ProbComp.boolDistAdvantage] refine abs_eq_zero.mpr (sub_eq_zero.mpr ?_) congr 1 rw [dksapROIdealRun_eq, dksapROIdealRun_eq, probOutput_bind_eq_tsum, probOutput_bind_eq_tsum] refine tsum_congr fun a => ?_ congr 1 simp only [if_true, Bool.false_eq_true, if_false] exact dksapIdeal_branch_indep g hbij a.1.1 a.2.1 (fun c => advRunAt adv a c ∅) true ``` ::: [`DKSAPOracle.lean:258-270 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L258-L270) ## [`unlinkAdvantageRO`](/lean/proofs/dksaporacle#unlinkadvantagero) Unlinkability advantage of DKSAP in the random-oracle model. ```lean noncomputable def unlinkAdvantageRO (g : G) (adv : UnlinkAdvRO F G) : ℝ := (dksapRORun g adv true).boolDistAdvantage (dksapRORun g adv false) ``` [`DKSAPOracle.lean:274-276 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L274-L276) ## [`hashedDHRO`](/lean/proofs/dksaporacle#hasheddhro) ```lean noncomputable def hashedDHRO (g : G) (adv : UnlinkAdvRO F G) (b : Bool) : ℝ := (dksapRORun g adv b).boolDistAdvantage (dksapROIdealRun g adv b) ``` [`DKSAPOracle.lean:279-281 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L279-L281) ## [`dksap_unlinkAdvantageRO_le_hashedDHRO`](/lean/proofs/dksaporacle#dksap_unlinkadvantagero_le_hasheddhro) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksap_unlinkAdvantageRO_le_hashedDHRO (g : G) (adv : UnlinkAdvRO F G) (hbij : Function.Bijective (fun x : F => x • g)) : unlinkAdvantageRO g adv ≤ hashedDHRO g adv true + hashedDHRO g adv false := by ``` :::details[proof] ```lean unfold unlinkAdvantageRO hashedDHRO rw [ProbComp.boolDistAdvantage_comm (dksapRORun g adv false)] calc (dksapRORun g adv true).boolDistAdvantage (dksapRORun g adv false) ≤ (dksapRORun g adv true).boolDistAdvantage (dksapROIdealRun g adv true) + (dksapROIdealRun g adv true).boolDistAdvantage (dksapRORun g adv false) := ProbComp.boolDistAdvantage_triangle _ _ _ _ ≤ (dksapRORun g adv true).boolDistAdvantage (dksapROIdealRun g adv true) + ((dksapROIdealRun g adv true).boolDistAdvantage (dksapROIdealRun g adv false) + (dksapROIdealRun g adv false).boolDistAdvantage (dksapRORun g adv false)) := by gcongr exact ProbComp.boolDistAdvantage_triangle _ _ _ _ = _ := by rw [dksapROIdeal_boolDistAdvantage_eq_zero g adv hbij, zero_add] ``` ::: [`DKSAPOracle.lean:285-300 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L285-L300) ## [`dksapRORun_eq`](/lean/proofs/dksaporacle#dksaprorun_eq) The real game is the ideal game run against a cache already programmed at the DH point: the announce query is the first one, so it hits an empty cache. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksapRORun_eq (g : G) (adv : UnlinkAdvRO F G) (b : Bool) : dksapRORun g adv b = dksapROKeys F g >>= fun a => ($ᵗ F) >>= fun r => ($ᵗ F) >>= fun s => advRunAt adv a (r • g, (if b then a.2 else a.1).1 + s • g) (QueryCache.cacheQuery ∅ (r • (if b then a.2 else a.1).2) s) := by ``` :::details[proof] ```lean unfold dksapRORun dksapROSetup dksapROBranch advRunAt simp only [simulateQ_bind] rw [dksapRomImpl_run'_liftM_bind] refine bind_congr fun a => ?_ rw [dksapRomImpl_run'_liftM_bind] refine bind_congr fun r => ?_ exact dksapRomImpl_run'_scalarQuery_bind _ _ ∅ rfl ``` ::: [`DKSAPOracle.lean:311-323 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L311-L323) ## [`dksapROPrefix`](/lean/proofs/dksaporacle#dksaproprefix) ```lean def dksapROPrefix (g : G) : ProbComp (((G × G) × (G × G)) × F × F) := dksapROKeys F g >>= fun a => ($ᵗ F) >>= fun r => ($ᵗ F) >>= fun s => pure (a, r, s) ``` [`DKSAPOracle.lean:325-327 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L325-L327) ## [`dksapROAdvRun`](/lean/proofs/dksaporacle#dksaproadvrun) ```lean def dksapROAdvRun (g : G) (adv : UnlinkAdvRO F G) (b : Bool) (x : ((G × G) × (G × G)) × F × F) : OracleComp (scalarSpec F G) Bool := adv x.1.1 x.1.2 (x.2.1 • g, (if b then x.1.2 else x.1.1).1 + x.2.2 • g) ``` [`DKSAPOracle.lean:329-332 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L329-L332) ## [`dksapRODHPoint`](/lean/proofs/dksaporacle#dksaprodhpoint) ```lean def dksapRODHPoint (b : Bool) (x : ((G × G) × (G × G)) × F × F) : G := x.2.1 • (if b then x.1.2 else x.1.1).2 ``` [`DKSAPOracle.lean:334-336 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L334-L336) ## [`dksapROBad`](/lean/proofs/dksaporacle#dksaprobad) ```lean noncomputable def dksapROBad (g : G) (adv : UnlinkAdvRO F G) (b : Bool) : ProbComp Bool := badQueryGame (dksapROPrefix g) (dksapROAdvRun g adv b) (dksapRODHPoint b) (fun x => x.2.2) ``` [`DKSAPOracle.lean:339-341 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L339-L341) ## [`dksapROBadProb`](/lean/proofs/dksaporacle#dksaprobadprob) ```lean noncomputable def dksapROBadProb (g : G) (adv : UnlinkAdvRO F G) (b : Bool) : ℝ := (Pr[= true | dksapROBad g adv b]).toReal ``` [`DKSAPOracle.lean:344-346 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L344-L346) ## [`hashedDHRO_le_dksapROBadProb`](/lean/proofs/dksaporacle#hasheddhro_le_dksaprobadprob) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem hashedDHRO_le_dksapROBadProb (g : G) (adv : UnlinkAdvRO F G) (b : Bool) : hashedDHRO g adv b ≤ dksapROBadProb g adv b := by ``` :::details[proof] ```lean have h := boolDistAdvantage_run'_cacheQuery_run'_empty_le (dksapROPrefix g) (dksapROAdvRun g adv b) (dksapRODHPoint b) (fun x => x.2.2) have hreal : dksapRORun g adv b = dksapROPrefix g >>= fun x => (simulateQ (roImpl (G →ₒ F)) (dksapROAdvRun g adv b x)).run' ((∅ : (G →ₒ F).QueryCache).cacheQuery (dksapRODHPoint b x) x.2.2) := by rw [dksapRORun_eq] simp only [dksapROPrefix, dksapROAdvRun, dksapRODHPoint, advRunAt, dksapRomImpl_eq_roImpl, bind_assoc, pure_bind] have hideal : dksapROIdealRun g adv b = dksapROPrefix g >>= fun x => (simulateQ (roImpl (G →ₒ F)) (dksapROAdvRun g adv b x)).run' ∅ := by rw [dksapROIdealRun_eq] simp only [dksapROPrefix, dksapROAdvRun, advRunAt, dksapRomImpl_eq_roImpl, bind_assoc, pure_bind] unfold hashedDHRO dksapROBadProb dksapROBad rw [hreal, hideal] exact h ``` ::: [`DKSAPOracle.lean:349-367 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L349-L367) ## [`dksap_unlinkAdvantageRO_le_badProb`](/lean/proofs/dksaporacle#dksap_unlinkadvantagero_le_badprob) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksap_unlinkAdvantageRO_le_badProb (g : G) (adv : UnlinkAdvRO F G) (hbij : Function.Bijective (fun x : F => x • g)) : unlinkAdvantageRO g adv ≤ dksapROBadProb g adv true + dksapROBadProb g adv false := (dksap_unlinkAdvantageRO_le_hashedDHRO g adv hbij).trans (add_le_add (hashedDHRO_le_dksapROBadProb g adv true) (hashedDHRO_le_dksapROBadProb g adv false)) ``` [`DKSAPOracle.lean:371-377 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L371-L377) ## [`loggingROImpl`](/lean/proofs/dksaporacle#loggingroimpl) A random oracle that also logs the queried points, so the reduction can return one of them: VCVio's `appendInputLog` (a `preInsert`) over the lazy random oracle, with uniform queries forwarded. ```lean noncomputable def loggingROImpl (F G : Type) [SampleableType F] [DecidableEq G] : QueryImpl (scalarSpec F G) (StateT (List G) (StateT ((G →ₒ F).QueryCache) ProbComp)) := (HasQuery.toQueryImpl (spec := unifSpec) (m := ProbComp)).liftTarget (StateT (List G) (StateT ((G →ₒ F).QueryCache) ProbComp)) + QueryImpl.appendInputLog (OracleSpec.randomOracle (spec := (G →ₒ F))) ``` [`DKSAPOracle.lean:389-395 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L389-L395) ## [`cdhOfUnlinkAdvRO`](/lean/proofs/dksaporacle#cdhofunlinkadvro) ```lean noncomputable def cdhOfUnlinkAdvRO (adv : UnlinkAdvRO F G) (b : Bool) : DiffieHellman.CDHAdversary F G := fun g A B => do let mT ← ($ᵗ F); let mO ← ($ᵗ F); let vO ← ($ᵗ F); let s ← ($ᵗ F) let pkT : G × G := (mT • g, B) let pkO : G × G := (mO • g, vO • g) let z ← ((simulateQ (loggingROImpl F G) (adv (if b then pkO else pkT) (if b then pkT else pkO) (A, mT • g + s • g))).run []).run' ∅ let i ← ($[0..z.2.length - 1]) pure (z.2.getD i.val 0) ``` [`DKSAPOracle.lean:399-408 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/DKSAPOracle.lean#L399-L408) # Games :::info[Mirrored page] Source: [`lean/docs-proofs/games.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/games.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: [`StealthScheme`](/lean/proofs/games#stealthscheme) as an abstract randomized algorithm over VCVio's `ProbComp` (mirroring its own `AsymmEncAlg` idiom); detection completeness and the false-positive experiment; unlinkability -- recipient anonymity -- as a hidden-bit game, and the first hop, [`unlinkAdvantage_eq_branchDistAdvantage`](/lean/proofs/games#unlinkadvantage_eq_branchdistadvantage): the advantage IS the advantage of distinguishing an announcement to recipient 1 from one to recipient 0. Assumed: nothing. The hidden bit selects the RECIPIENT, not the message, so this is key privacy, not IND-CPA. See `docs/announcement-model.md`. ## [`ProbComp.boolBiasAdvantage_bind_uniformBool_branch`](/lean/proofs/games#probcompboolbiasadvantage_bind_uniformbool_branch) Bias of a hidden-bit guessing game whose branch is selected by the bit: the distinguishing advantage between the two branches. (Scoped under `PqStealth` so a future upstream lemma of the same name cannot clash; upstream candidate, see `docs/vcvio-upstream.md`.) ```lean theorem ProbComp.boolBiasAdvantage_bind_uniformBool_branch {α : Type} (pref : ProbComp α) (branch : Bool → α → ProbComp Bool) : (do let a ← pref let b ← ($ᵗ Bool) let z ← branch b a pure (b == z)).boolBiasAdvantage = (pref >>= branch true).boolDistAdvantage (pref >>= branch false) := by ``` :::details[proof] ```lean have h : (do let a ← pref; let b ← ($ᵗ Bool); let z ← branch b a; pure (b == z)) = (do let a ← pref let b ← ($ᵗ Bool) let z ← if b then branch true a else branch false a pure (b == z)) := bind_congr fun a => bind_congr fun b => by cases b <;> rfl rw [h] exact ProbComp.boolBiasAdvantage_bind_uniformBool_eq_boolDistAdvantage pref (branch true) (branch false) ``` ::: [`Games.lean:32-50 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L32-L50) ## [`ProbComp.boolDistAdvantage_comm`](/lean/proofs/games#probcompbooldistadvantage_comm) ```lean theorem ProbComp.boolDistAdvantage_comm (p q : ProbComp Bool) : p.boolDistAdvantage q = q.boolDistAdvantage p := abs_sub_comm _ _ ``` [`Games.lean:54-57 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L54-L57) ### Hidden-bit games with a bit-indexed branch ## [`StealthScheme`](/lean/proofs/games#stealthscheme) A stealth address scheme: key generation, sender-side announcing, and recipient-side detection, all as probabilistic computations. ```lean structure StealthScheme (MetaPub MetaPriv Announcement : Type) where /-- Recipient key generation: a meta-address and its private counterpart. -/ keygen : ProbComp (MetaPub × MetaPriv) /-- Sender side: publish an announcement for a recipient's meta-address. -/ announce : MetaPub → ProbComp Announcement /-- Recipient side: decide whether an announcement is addressed to us. -/ scan : MetaPriv → Announcement → ProbComp Bool ``` [`Games.lean:62-69 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L62-L69) ### The scheme as an abstract randomized algorithm ## [`CorrectExp`](/lean/proofs/games#correctexp) Completeness experiment: announce to a freshly generated recipient, then scan with that recipient's secret; should return `true`. ```lean def CorrectExp : ProbComp Bool := do let (pk, sk) ← S.keygen let c ← S.announce pk S.scan sk c ``` [`Games.lean:79-83 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L79-L83) ## [`PerfectlyComplete`](/lean/proofs/games#perfectlycomplete) ```lean def PerfectlyComplete : Prop := Pr[= true | S.CorrectExp] = 1 ``` [`Games.lean:86-87 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L86-L87) ### Detection completeness ## [`FalsePositiveExp`](/lean/proofs/games#falsepositiveexp) False-positive experiment: two independent recipients, an announcement addressed to the second, scanned with the first one's private state. `true` is a false positive. ```lean def FalsePositiveExp : ProbComp Bool := do let (_, sk0) ← S.keygen let (pk1, _) ← S.keygen let c ← S.announce pk1 S.scan sk0 c ``` [`Games.lean:96-101 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L96-L101) ## [`falsePositiveRate`](/lean/proofs/games#falsepositiverate) ```lean noncomputable def falsePositiveRate : ℝ := (Pr[= true | S.FalsePositiveExp]).toReal ``` [`Games.lean:105-106 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L105-L106) ## [`SoundWithin`](/lean/proofs/games#soundwithin) ```lean def SoundWithin (ε : ℝ) : Prop := S.falsePositiveRate ≤ ε ``` [`Games.lean:119-120 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L119-L120) ### The false-positive experiment ## [`UnlinkAdv`](/lean/proofs/games#unlinkadv) Unlinkability adversary: given both public meta-addresses and the challenge announcement, guess which recipient it is for. A function rather than a two-phase structure — the games are non-adaptive (`docs/announcement-model.md`). ```lean abbrev UnlinkAdv (MetaPub Announcement : Type) := MetaPub → MetaPub → Announcement → ProbComp Bool ``` [`Games.lean:126-128 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L126-L128) ## [`unlinkSetup`](/lean/proofs/games#unlinksetup) ```lean def unlinkSetup : ProbComp (MetaPub × MetaPub) := do let (pk0, _) ← S.keygen let (pk1, _) ← S.keygen pure (pk0, pk1) ``` [`Games.lean:131-135 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L131-L135) ## [`unlinkBranch`](/lean/proofs/games#unlinkbranch) The branch of the unlinkability game selected by the hidden bit: the challenge announcement goes to recipient `b`. ```lean def unlinkBranch (b : Bool) (a : MetaPub × MetaPub) : ProbComp Bool := do let c ← S.announce (if b then a.2 else a.1) adv a.1 a.2 c ``` [`Games.lean:140-143 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L140-L143) ## [`UnlinkExp`](/lean/proofs/games#unlinkexp) ```lean def UnlinkExp : ProbComp Bool := do let a ← S.unlinkSetup let b ← ($ᵗ Bool) let z ← S.unlinkBranch adv b a pure (b == z) ``` [`Games.lean:147-152 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L147-L152) ## [`unlinkAdvantage`](/lean/proofs/games#unlinkadvantage) ```lean noncomputable def unlinkAdvantage : ℝ := (S.UnlinkExp adv).boolBiasAdvantage ``` [`Games.lean:154-155 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L154-L155) ## [`unlinkAdvantage_eq_branchDistAdvantage`](/lean/proofs/games#unlinkadvantage_eq_branchdistadvantage) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantage_eq_branchDistAdvantage : S.unlinkAdvantage adv = (S.unlinkSetup >>= S.unlinkBranch adv true).boolDistAdvantage (S.unlinkSetup >>= S.unlinkBranch adv false) := ProbComp.boolBiasAdvantage_bind_uniformBool_branch S.unlinkSetup (S.unlinkBranch adv) ``` [`Games.lean:159-164 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Games.lean#L159-L164) # Invariants :::info[Mirrored page] Source: [`lean/docs-proofs/invariants.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/invariants.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: centered-norm sub-additivity, hence the blinded secret is `2·eta`-short and the signer bound doubles (`beta' = tau·(2·eta) = 2·beta`); the ownership relation TOGETHER WITH the coefficient bound IS possession of an ML-DSA signing key, and the blinded secret is such a key; the stealth public key roundtrips, and so do both meta-address wire formats as CONCRETE byte strings at their spec lengths — `0x01` at 5,633 B and the commitment form `0x02` at 1,217 B (`docs/DECISIONS.md` D-024, Lean items 1-3). Assumed: the two inner packers (`pack23` of full-precision `t`, the ML-KEM encapsulation key) are parameters with roundtrip hypotheses — VCVio has no fixed-length byte encoder with a proven law (`docs/encodings.md`). ## [`cInfNorm_add_le`](/lean/proofs/invariants#cinfnorm_add_le) Centered infinity norm is sub-additive: `‖f + g‖ ≤ ‖f‖ + ‖g‖`. The centered representative is `ZMod.valMinAbs`, which is sub-additive in absolute value (`natAbs_valMinAbs_add_le`), lifted coefficient-wise. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem cInfNorm_add_le (f g : Rq) : cInfNorm (f + g) ≤ cInfNorm f + cInfNorm g := by ``` :::details[proof] ```lean apply cInfNorm_le_of_coeff_le intro i rw [Rq.get_add, centeredRepr_eq_valMinAbs] calc ((f.get i + g.get i).valMinAbs).natAbs ≤ ((f.get i).valMinAbs + (g.get i).valMinAbs).natAbs := ZMod.natAbs_valMinAbs_add_le _ _ _ ≤ ((f.get i).valMinAbs).natAbs + ((g.get i).valMinAbs).natAbs := Int.natAbs_add_le _ _ _ ≤ cInfNorm f + cInfNorm g := by rw [← centeredRepr_eq_valMinAbs, ← centeredRepr_eq_valMinAbs] exact Nat.add_le_add (coeff_le_cInfNorm f i) (coeff_le_cInfNorm g i) ``` ::: [`Invariants.lean:35-48 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L35-L48) ## [`blinded_norm_bound`](/lean/proofs/invariants#blinded_norm_bound) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blinded_norm_bound {f g : Rq} {eta : ℕ} (hf : cInfNorm f ≤ eta) (hg : cInfNorm g ≤ eta) : cInfNorm (f + g) ≤ 2 * eta := (cInfNorm_add_le f g).trans (by omega) ``` [`Invariants.lean:51-55 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L51-L55) ## [`beta_blinded_eq_two_beta`](/lean/proofs/invariants#beta_blinded_eq_two_beta) `axioms: propext` · sorry-free ✓ ```lean theorem beta_blinded_eq_two_beta (params : Params) : params.tau * (2 * params.eta) = 2 * params.beta := by ``` :::details[proof] ```lean unfold MLDSA.Params.beta; ring ``` ::: [`Invariants.lean:58-61 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L58-L61) ### 1. Widened-bound signing invariant ## [`IsOwnershipWitness`](/lean/proofs/invariants#isownershipwitness) The MLWE ownership relation the ZK circuit proves: `A *ᵥ s + e = t`. This is the linear half only; shortness is [`IsShortPair`](/lean/proofs/invariants#isshortpair). ```lean def IsOwnershipWitness {R : Type*} [CommRing R] {k l : ℕ} (A : Matrix (Fin k) (Fin l) R) (t : Fin k → R) (s : Fin l → R) (e : Fin k → R) : Prop := A *ᵥ s + e = t ``` [`Invariants.lean:69-73 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L69-L73) ## [`IsShortPair`](/lean/proofs/invariants#isshortpair) ```lean def IsShortPair {k l : ℕ} (eta : ℕ) (s : Fin l → Rq) (e : Fin k → Rq) : Prop := (∀ i, cInfNorm (s i) ≤ eta) ∧ (∀ i, cInfNorm (e i) ≤ eta) ``` [`Invariants.lean:76-78 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L76-L78) ## [`IsSigningKey`](/lean/proofs/invariants#issigningkey) ```lean def IsSigningKey {k l : ℕ} (A : Matrix (Fin k) (Fin l) Rq) (t : Fin k → Rq) (s : Fin l → Rq) (e : Fin k → Rq) (eta : ℕ) : Prop := IsOwnershipWitness A t s e ∧ IsShortPair eta s e ``` [`Invariants.lean:82-86 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L82-L86) ## [`ownership_iff_signing`](/lean/proofs/invariants#ownership_iff_signing) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem ownership_iff_signing {k l : ℕ} (A : Matrix (Fin k) (Fin l) Rq) (t : Fin k → Rq) (s : Fin l → Rq) (e : Fin k → Rq) (eta : ℕ) : (IsOwnershipWitness A t s e ∧ (∀ i, cInfNorm (s i) ≤ eta) ∧ (∀ i, cInfNorm (e i) ≤ eta)) ↔ IsSigningKey A t s e eta := Iff.rfl ``` [`Invariants.lean:90-97 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L90-L97) ## [`blinded_is_ownership_witness`](/lean/proofs/invariants#blinded_is_ownership_witness) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blinded_is_ownership_witness {R : Type*} [CommRing R] {k l : ℕ} (A : Matrix (Fin k) (Fin l) R) (s₁ s' : Fin l → R) (s₂ e' : Fin k → R) : IsOwnershipWitness A (A *ᵥ s' + e' + (A *ᵥ s₁ + s₂)) (s₁ + s') (s₂ + e') := (blinded_key_correctness A s₁ s' s₂ e').symm ``` [`Invariants.lean:101-105 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L101-L105) ## [`blinded_is_signing_key`](/lean/proofs/invariants#blinded_is_signing_key) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem blinded_is_signing_key {k l : ℕ} (A : Matrix (Fin k) (Fin l) Rq) (s₁ s' : Fin l → Rq) (s₂ e' : Fin k → Rq) {eta : ℕ} (hs₁ : ∀ i, cInfNorm (s₁ i) ≤ eta) (hs' : ∀ i, cInfNorm (s' i) ≤ eta) (hs₂ : ∀ i, cInfNorm (s₂ i) ≤ eta) (he' : ∀ i, cInfNorm (e' i) ≤ eta) : IsSigningKey A (A *ᵥ s' + e' + (A *ᵥ s₁ + s₂)) (s₁ + s') (s₂ + e') (2 * eta) := ⟨blinded_is_ownership_witness A s₁ s' s₂ e', fun i => blinded_norm_bound (hs₁ i) (hs' i), fun i => blinded_norm_bound (hs₂ i) (he' i)⟩ ``` [`Invariants.lean:109-119 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L109-L119) ### 2. Ownership relation \<-> signing-key possession ## [`stealth_pk_roundtrips`](/lean/proofs/invariants#stealth_pk_roundtrips) The stealth public key roundtrips: decoding the encoded `(rho, t1)` returns it unchanged — a direct consequence of VCVio's public-key encoding law. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem stealth_pk_roundtrips {p : Params} {prims : Primitives p} (enc : Encoding p prims) (laws : enc.Laws) (rho : Bytes 32) (t1 : Vector prims.Power2High p.k) : enc.pkDecode (enc.pkEncode rho t1) = (rho, t1) := laws.pkDecode_pkEncode rho t1 ``` [`Invariants.lean:126-131 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L126-L131) ## [`splitBytes`](/lean/proofs/invariants#splitbytes) Split a byte string of length `m + n` at offset [`m`](/lean/proofs/demo#m). ```lean def splitBytes {m n : ℕ} (v : Bytes (m + n)) : Bytes m × Bytes n := ((v.extract 0 m).cast (by omega), (v.extract m (m + n)).cast (by omega)) ``` [`Invariants.lean:135-137 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L135-L137) ## [`splitBytes_append`](/lean/proofs/invariants#splitbytes_append) ```lean theorem splitBytes_append {m n : ℕ} (a : Bytes m) (b : Bytes n) : splitBytes (a ++ b) = (a, b) := by ``` :::details[proof] ```lean have h₁ : ((a ++ b).extract 0 m).cast (by omega) = a := by refine Vector.ext fun i hi => ?_ simp only [Vector.getElem_cast, Vector.getElem_extract, Nat.zero_add, Vector.getElem_append] rw [dif_pos hi] have h₂ : ((a ++ b).extract m (m + n)).cast (by omega) = b := by refine Vector.ext fun i hi => ?_ simp only [Vector.getElem_cast, Vector.getElem_extract, Vector.getElem_append, Nat.add_sub_cancel_left] rw [dif_neg (by omega)] rw [splitBytes, h₁, h₂] ``` ::: [`Invariants.lean:140-153 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L140-L153) ## [`qBits`](/lean/proofs/invariants#qbits) Bit width of a full-precision ML-DSA coefficient: `q = 8380417 < 2^23` (the spec's `Q_BITS`; a standard public key would instead carry `t1`). ```lean def qBits : ℕ := 23 ``` [`Invariants.lean:158-159 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L158-L159) ## [`packedTBytes`](/lean/proofs/invariants#packedtbytes) ```lean def packedTBytes (p : Params) : ℕ := p.k * ringDegree * qBits / 8 ``` [`Invariants.lean:162-163 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L162-L163) ## [`packedTBytes_mldsa65`](/lean/proofs/invariants#packedtbytes_mldsa65) ```lean theorem packedTBytes_mldsa65 : packedTBytes mldsa65 = 4416 := rfl ``` [`Invariants.lean:165-166 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L165-L166) ## [`metaAddressEncode`](/lean/proofs/invariants#metaaddressencode) ```lean def metaAddressEncode {T Ek : Type*} {nt nek : ℕ} (packT : T → Bytes nt) (packEk : Ek → Bytes nek) (version : UInt8) (rho : Bytes 32) (t : T) (ek : Ek) : Bytes (1 + (32 + (nt + nek))) := #v[version] ++ (rho ++ (packT t ++ packEk ek)) ``` [`Invariants.lean:169-174 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L169-L174) ## [`metaAddressDecode`](/lean/proofs/invariants#metaaddressdecode) ```lean def metaAddressDecode {T Ek : Type*} {nt nek : ℕ} (unpackT : Bytes nt → T) (unpackEk : Bytes nek → Ek) (m : Bytes (1 + (32 + (nt + nek)))) : UInt8 × Bytes 32 × T × Ek := let s := splitBytes m let r := splitBytes s.2 let b := splitBytes r.2 (s.1[0], r.1, unpackT b.1, unpackEk b.2) ``` [`Invariants.lean:177-184 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L177-L184) ## [`meta_address_roundtrips`](/lean/proofs/invariants#meta_address_roundtrips) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem meta_address_roundtrips {T Ek : Type*} {nt nek : ℕ} (packT : T → Bytes nt) (unpackT : Bytes nt → T) (hT : ∀ t, unpackT (packT t) = t) (packEk : Ek → Bytes nek) (unpackEk : Bytes nek → Ek) (hEk : ∀ ek, unpackEk (packEk ek) = ek) (version : UInt8) (rho : Bytes 32) (t : T) (ek : Ek) : metaAddressDecode unpackT unpackEk (metaAddressEncode packT packEk version rho t ek) = (version, rho, t, ek) := by ``` :::details[proof] ```lean simp only [metaAddressEncode, metaAddressDecode, splitBytes_append, hT, hEk] rfl ``` ::: [`Invariants.lean:187-197 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L187-L197) ## [`metaAddress_size_mldsa65_mlkem768`](/lean/proofs/invariants#metaaddress_size_mldsa65_mlkem768) `axioms: propext` · sorry-free ✓ ```lean theorem metaAddress_size_mldsa65_mlkem768 : 1 + (32 + (packedTBytes mldsa65 + MLKEM.Params.publicKeyBytes MLKEM.mlkem768)) = 5633 := rfl ``` [`Invariants.lean:200-203 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L200-L203) ## [`meta_address_roundtrips_5633`](/lean/proofs/invariants#meta_address_roundtrips_5633) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem meta_address_roundtrips_5633 {T Ek : Type*} (packT : T → Bytes 4416) (unpackT : Bytes 4416 → T) (hT : ∀ t, unpackT (packT t) = t) (packEk : Ek → Bytes 1184) (unpackEk : Bytes 1184 → Ek) (hEk : ∀ ek, unpackEk (packEk ek) = ek) (version : UInt8) (rho : Bytes 32) (t : T) (ek : Ek) : metaAddressDecode unpackT unpackEk (metaAddressEncode packT packEk version rho t ek : Bytes 5633) = (version, rho, t, ek) := meta_address_roundtrips packT unpackT hT packEk unpackEk hEk version rho t ek ``` [`Invariants.lean:206-216 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L206-L216) ## [`metaAddressCommitEncode`](/lean/proofs/invariants#metaaddresscommitencode) The commitment meta-address, `version(1) ‖ spend_key(32) ‖ ek`: the full-precision `t` of format `0x01` is replaced by a 32-byte commitment to the spending key. ```lean def metaAddressCommitEncode {Ek : Type*} {nek : ℕ} (packEk : Ek → Bytes nek) (version : UInt8) (commitment : Bytes 32) (ek : Ek) : Bytes (1 + (32 + nek)) := #v[version] ++ (commitment ++ packEk ek) ``` [`Invariants.lean:226-229 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L226-L229) ## [`metaAddressCommitDecode`](/lean/proofs/invariants#metaaddresscommitdecode) ```lean def metaAddressCommitDecode {Ek : Type*} {nek : ℕ} (unpackEk : Bytes nek → Ek) (m : Bytes (1 + (32 + nek))) : UInt8 × Bytes 32 × Ek := let s := splitBytes m let r := splitBytes s.2 (s.1[0], r.1, unpackEk r.2) ``` [`Invariants.lean:231-236 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L231-L236) ## [`meta_address_commit_roundtrips`](/lean/proofs/invariants#meta_address_commit_roundtrips) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem meta_address_commit_roundtrips {Ek : Type*} {nek : ℕ} (packEk : Ek → Bytes nek) (unpackEk : Bytes nek → Ek) (hEk : ∀ ek, unpackEk (packEk ek) = ek) (version : UInt8) (commitment : Bytes 32) (ek : Ek) : metaAddressCommitDecode unpackEk (metaAddressCommitEncode packEk version commitment ek) = (version, commitment, ek) := by ``` :::details[proof] ```lean simp only [metaAddressCommitEncode, metaAddressCommitDecode, splitBytes_append, hEk] rfl ``` ::: [`Invariants.lean:238-246 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L238-L246) ## [`metaAddressCommit_size_mlkem768`](/lean/proofs/invariants#metaaddresscommit_size_mlkem768) `axioms: none` · sorry-free ✓ ```lean theorem metaAddressCommit_size_mlkem768 : 1 + (32 + MLKEM.Params.publicKeyBytes MLKEM.mlkem768) = 1217 := rfl ``` [`Invariants.lean:249-251 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L249-L251) ## [`meta_address_commit_roundtrips_1217`](/lean/proofs/invariants#meta_address_commit_roundtrips_1217) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem meta_address_commit_roundtrips_1217 {Ek : Type*} (packEk : Ek → Bytes 1184) (unpackEk : Bytes 1184 → Ek) (hEk : ∀ ek, unpackEk (packEk ek) = ek) (version : UInt8) (commitment : Bytes 32) (ek : Ek) : metaAddressCommitDecode unpackEk (metaAddressCommitEncode packEk version commitment ek : Bytes 1217) = (version, commitment, ek) := meta_address_commit_roundtrips packEk unpackEk hEk version commitment ek ``` [`Invariants.lean:253-261 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Invariants.lean#L253-L261) # KEMAnonymity :::info[Mirrored page] Source: [`lean/docs-proofs/kemanonymity.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/kemanonymity.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: [`KEM`](/lean/proofs/kemanonymity#kem) (VCVio's `KEMScheme` over `ProbComp`) and its anonymity game -- key privacy, which VCVio does not ship; [`ofKEM`](/lean/proofs/kemanonymity#stealthschemeofkem), whose unlinkability EQUALS the KEM's anonymity advantage; [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull), the faithful scheme whose announcement also carries `auxGen sharedSecret pk` and whose scan recomputes it, with [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) bounding unlinkability by two shared-secret-hiding terms, [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence), and anonymity; and detection completeness of [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) from VCVio's `KEMScheme.PerfectlyCorrect`. Assumed: nothing here. `anonymity → MLWE` for ML-KEM is the open piece (`AnonymityFromSPR`). See `docs/announcement-model.md`. ## [`KEM`](/lean/proofs/kemanonymity#kem) A key encapsulation mechanism over `ProbComp`: key generation, encapsulation (producing a ciphertext and a shared secret), and decapsulation. ```lean abbrev KEM (PK SK C K : Type) := KEMScheme ProbComp K PK SK C ``` [`KEMAnonymity.lean:30-31 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L30-L31) ### An abstract KEM ## [`probOutput_true_eq_one_of_imp`](/lean/proofs/kemanonymity#proboutput_true_eq_one_of_imp) Transfer a probability-one Boolean verdict along a pointwise implication: VCVio's `probEvent_mono` read through `probOutput_map`. Used to derive detection completeness from the strictly stronger verdict of KEM correctness. ```lean theorem probOutput_true_eq_one_of_imp {α : Type} (oa : ProbComp α) {p q : α → Bool} (h : ∀ a ∈ support oa, p a = true → q a = true) (hp : Pr[= true | (do let a ← oa; pure (p a))] = 1) : Pr[= true | (do let a ← oa; pure (q a))] = 1 := by ``` :::details[proof] ```lean rw [bind_pure_comp, probOutput_map] at hp ⊢ exact le_antisymm probEvent_le_one (hp ▸ probEvent_mono h) ``` ::: [`KEMAnonymity.lean:37-43 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L37-L43) ### A `Pr[= true | …] = 1` transfer lemma ## [`run`](/lean/proofs/kemanonymity#run) The draw prefix common to KEM correctness and to detection completeness: a keypair, an encapsulation to its public key, and a decapsulation of the result. ```lean def run : ProbComp ((PK × SK) × (C × K) × Option K) := do let ks ← kem.keygen let ck ← kem.encaps ks.1 let k? ← kem.decaps ks.2 ck.1 pure (ks, ck, k?) ``` [`KEMAnonymity.lean:57-62 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L57-L62) ## [`correctExp_eq`](/lean/proofs/kemanonymity#correctexp_eq) ```lean theorem correctExp_eq [DecidableEq K] : kem.CorrectExp = (do let a ← kem.run; pure (decide (a.2.2 = some a.2.1.2))) := by ``` :::details[proof] ```lean simp only [KEMScheme.CorrectExp, KEM.run, bind_assoc, pure_bind] ``` ::: [`KEMAnonymity.lean:65-68 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L65-L68) ### Correctness ## [`anonSetup`](/lean/proofs/kemanonymity#anonsetup) Shared prefix: two keypairs, both public keys published. ```lean def anonSetup : ProbComp (PK × PK) := do let (pk0, _) ← kem.keygen let (pk1, _) ← kem.keygen pure (pk0, pk1) ``` [`KEMAnonymity.lean:75-79 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L75-L79) ## [`anonBranch`](/lean/proofs/kemanonymity#anonbranch) ```lean def anonBranch (b : Bool) (a : PK × PK) : ProbComp Bool := do let (c, _) ← kem.encaps (if b then a.2 else a.1) adv a.1 a.2 c ``` [`KEMAnonymity.lean:81-84 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L81-L84) ## [`AnonExp`](/lean/proofs/kemanonymity#anonexp) ```lean def AnonExp : ProbComp Bool := do let a ← kem.anonSetup let b ← ($ᵗ Bool) let z ← kem.anonBranch adv b a pure (b == z) ``` [`KEMAnonymity.lean:86-91 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L86-L91) ## [`anonAdvantage`](/lean/proofs/kemanonymity#anonadvantage) ```lean noncomputable def anonAdvantage : ℝ := (kem.AnonExp adv).boolBiasAdvantage ``` [`KEMAnonymity.lean:93-94 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L93-L94) ## [`anonAdvantage_eq_branchDistAdvantage`](/lean/proofs/kemanonymity#anonadvantage_eq_branchdistadvantage) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem anonAdvantage_eq_branchDistAdvantage : kem.anonAdvantage adv = (kem.anonSetup >>= kem.anonBranch adv true).boolDistAdvantage (kem.anonSetup >>= kem.anonBranch adv false) := ProbComp.boolBiasAdvantage_bind_uniformBool_branch kem.anonSetup (kem.anonBranch adv) ``` [`KEMAnonymity.lean:97-102 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L97-L102) ### Anonymity (key privacy) ## [`StealthScheme.ofKEM`](/lean/proofs/kemanonymity#stealthschemeofkem) Stealth scheme from a KEM: announcement = ciphertext, detection = "did decapsulation succeed". A teaching model, not the scheme -- on an implicit-rejection KEM its `scan` is the constant `true`. ```lean def StealthScheme.ofKEM (kem : KEM PK SK C K) : StealthScheme PK SK C where keygen := kem.keygen announce pk := do let (c, _) ← kem.encaps pk pure c scan sk c := do let k ← kem.decaps sk c pure k.isSome ``` [`KEMAnonymity.lean:112-120 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L112-L120) ## [`unlinkAdvantage_ofKEM_eq_anonAdvantage`](/lean/proofs/kemanonymity#unlinkadvantage_ofkem_eq_anonadvantage) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantage_ofKEM_eq_anonAdvantage (kem : KEM PK SK C K) (adv : StealthScheme.UnlinkAdv PK C) : (StealthScheme.ofKEM kem).unlinkAdvantage adv = kem.anonAdvantage adv := by ``` :::details[proof] ```lean unfold StealthScheme.unlinkAdvantage KEM.anonAdvantage congr 1 simp only [StealthScheme.UnlinkExp, KEM.AnonExp, StealthScheme.unlinkSetup, KEM.anonSetup, StealthScheme.unlinkBranch, KEM.anonBranch, StealthScheme.ofKEM, bind_assoc, pure_bind] ``` ::: [`KEMAnonymity.lean:124-132 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L124-L132) ### The KEM-based stealth scheme and the reduction ## [`StealthScheme.ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) Stealth scheme from a KEM with a full announcement: ciphertext plus `auxGen sharedSecret pk`. Detection recomputes the auxiliary data from the decapsulated secret and the recipient's OWN key, hence the `SK × PK` state. ```lean def StealthScheme.ofKEMFull [DecidableEq Aux] (kem : KEM PK SK C K) (auxGen : K → PK → Aux) : StealthScheme PK (SK × PK) (C × Aux) where keygen := do let ks ← kem.keygen pure (ks.1, (ks.2, ks.1)) announce pk := do let ck ← kem.encaps pk pure (ck.1, auxGen ck.2 pk) scan sk ca := do let k? ← kem.decaps sk.1 ca.1 pure (k?.elim false fun k => decide (ca.2 = auxGen k sk.2)) ``` [`KEMAnonymity.lean:145-156 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L145-L156) ## [`perfectlyComplete_ofKEMFull`](/lean/proofs/kemanonymity#perfectlycomplete_ofkemfull) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem perfectlyComplete_ofKEMFull [DecidableEq K] [DecidableEq Aux] (kem : KEM PK SK C K) (auxGen : K → PK → Aux) (hkem : kem.PerfectlyCorrect ProbCompRuntime.probComp) : (StealthScheme.ofKEMFull kem auxGen).PerfectlyComplete := by ``` :::details[proof] ```lean have hCE : (StealthScheme.ofKEMFull kem auxGen).CorrectExp = (do let a ← kem.run pure (a.2.2.elim false fun k => decide (auxGen a.2.1.2 a.1.1 = auxGen k a.1.1))) := by simp only [StealthScheme.CorrectExp, StealthScheme.ofKEMFull, KEM.run, bind_assoc, pure_bind] rw [StealthScheme.PerfectlyComplete, hCE] refine probOutput_true_eq_one_of_imp (p := fun a => decide (a.2.2 = some a.2.1.2)) kem.run ?_ ?_ · intro a _ ha simp only [decide_eq_true_eq] at ha simp only [ha, Option.elim_some, decide_true] · rw [← kem.correctExp_eq] exact hkem ``` ::: [`KEMAnonymity.lean:160-177 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L160-L177) ## [`unlinkSetup_ofKEMFull`](/lean/proofs/kemanonymity#unlinksetup_ofkemfull) ```lean theorem unlinkSetup_ofKEMFull [DecidableEq Aux] (kem : KEM PK SK C K) (auxGen : K → PK → Aux) : (StealthScheme.ofKEMFull kem auxGen).unlinkSetup = kem.anonSetup := by ``` :::details[proof] ```lean simp only [StealthScheme.unlinkSetup, KEM.anonSetup, StealthScheme.ofKEMFull, bind_assoc, pure_bind] ``` ::: [`KEMAnonymity.lean:180-185 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L180-L185) ## [`StealthScheme.UnlinkAdv.cipherOf`](/lean/proofs/kemanonymity#stealthschemeunlinkadvcipherof) The ciphertext-anonymity adversary induced by a full-announcement one: it synthesizes the auxiliary data from a FRESH random shared secret and a FIXED public key, recipient 0's. See `docs/announcement-model.md`. ```lean def StealthScheme.UnlinkAdv.cipherOf (adv : StealthScheme.UnlinkAdv PK (C × Aux)) (auxGen : K → PK → Aux) : StealthScheme.UnlinkAdv PK C := fun pk0 pk1 c => do let k' ← ($ᵗ K) adv pk0 pk1 (c, auxGen k' pk0) ``` [`KEMAnonymity.lean:191-197 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L191-L197) ## [`randAuxBranch`](/lean/proofs/kemanonymity#randauxbranch) The intermediate game on branch `b`: same ciphertext and same public key, but the auxiliary data is built from a fresh random shared secret. ```lean def randAuxBranch (b : Bool) (a : PK × PK) : ProbComp Bool := do let (c, _) ← kem.encaps (if b then a.2 else a.1) let k' ← ($ᵗ K) adv a.1 a.2 (c, auxGen k' (if b then a.2 else a.1)) ``` [`KEMAnonymity.lean:203-207 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L203-L207) ## [`randAuxBranch_false`](/lean/proofs/kemanonymity#randauxbranch_false) ```lean theorem randAuxBranch_false : randAuxBranch kem auxGen adv false = kem.anonBranch (adv.cipherOf auxGen) false := rfl ``` [`KEMAnonymity.lean:211-213 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L211-L213) ## [`sharedSecretHiding`](/lean/proofs/kemanonymity#sharedsecrethiding) Shared-secret-hiding advantage on branch `b`: real shared secret versus a fresh random key, from the SAME public key on both sides. A KEM IND-CPA advantage ([`sharedSecretHiding_eq_indCpaAdvantage`](/lean/proofs/sharedsecrethiding#sharedsecrethiding_eq_indcpaadvantage)). ```lean noncomputable def sharedSecretHiding (b : Bool) : ℝ := ((StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= (StealthScheme.ofKEMFull kem auxGen).unlinkBranch adv b).boolDistAdvantage ((StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= randAuxBranch kem auxGen adv b) ``` [`KEMAnonymity.lean:219-224 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L219-L224) ## [`auxKeyIndependence`](/lean/proofs/kemanonymity#auxkeyindependence) ```lean noncomputable def auxKeyIndependence : ℝ := ((StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= randAuxBranch kem auxGen adv true).boolDistAdvantage (kem.anonSetup >>= kem.anonBranch (adv.cipherOf auxGen) true) ``` [`KEMAnonymity.lean:228-232 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L228-L232) ## [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantage_ofKEMFull_le : (StealthScheme.ofKEMFull kem auxGen).unlinkAdvantage adv ≤ sharedSecretHiding kem auxGen adv true + auxKeyIndependence kem auxGen adv + kem.anonAdvantage (adv.cipherOf auxGen) + sharedSecretHiding kem auxGen adv false := by ``` :::details[proof] ```lean have hQf : kem.anonSetup >>= kem.anonBranch (adv.cipherOf auxGen) false = (StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= randAuxBranch kem auxGen adv false := by rw [randAuxBranch_false, unlinkSetup_ofKEMFull] rw [StealthScheme.unlinkAdvantage_eq_branchDistAdvantage, KEM.anonAdvantage_eq_branchDistAdvantage] unfold sharedSecretHiding auxKeyIndependence rw [hQf, ProbComp.boolDistAdvantage_comm ((StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= (StealthScheme.ofKEMFull kem auxGen).unlinkBranch adv false)] set Pt := (StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= (StealthScheme.ofKEMFull kem auxGen).unlinkBranch adv true set Pf := (StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= (StealthScheme.ofKEMFull kem auxGen).unlinkBranch adv false set Mt := (StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= randAuxBranch kem auxGen adv true set Mf := (StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= randAuxBranch kem auxGen adv false set Qt := kem.anonSetup >>= kem.anonBranch (adv.cipherOf auxGen) true calc Pt.boolDistAdvantage Pf ≤ Pt.boolDistAdvantage Mt + Mt.boolDistAdvantage Pf := ProbComp.boolDistAdvantage_triangle Pt Mt Pf _ ≤ Pt.boolDistAdvantage Mt + (Mt.boolDistAdvantage Qt + Qt.boolDistAdvantage Pf) := by gcongr exact ProbComp.boolDistAdvantage_triangle Mt Qt Pf _ ≤ Pt.boolDistAdvantage Mt + (Mt.boolDistAdvantage Qt + (Qt.boolDistAdvantage Mf + Mf.boolDistAdvantage Pf)) := by gcongr exact ProbComp.boolDistAdvantage_triangle Qt Mf Pf _ = Pt.boolDistAdvantage Mt + Mt.boolDistAdvantage Qt + Qt.boolDistAdvantage Mf + Mf.boolDistAdvantage Pf := by ring ``` ::: [`KEMAnonymity.lean:236-274 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/KEMAnonymity.lean#L236-L274) # MLKEM :::info[Mirrored page] Source: [`lean/docs-proofs/mlkem.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/mlkem.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: the three `DecidableEq` instances VCVio's concrete encoding lacks (it sets all encoded types to `ByteArray` but as a plain `def`); that the concrete ciphertext type is INFINITE, hence `SampleableType` on it is provably uninhabited and the SPR simulator must be an explicit argument; the uniform-1088-byte FIPS 203 simulator and the `u` half of its layout against what honest K-PKE encryption emits; and the three capstones -- `_le`, `_le_full_decomposition`, `_le_indCpa` -- with no instance hypotheses left open. Assumed: the [`v`](/lean/proofs/demo#v)-half byte count, taken from FIPS 203 because VCVio's `byteEncode_size` is `private`; and KEM-IND-CPA → MLWE, the missing upstream lemma recorded below. See `docs/spr-two-hop.md`. ## [`recorded`](/lean/proofs/mlkem#recorded) ```lean lemma recorded below. See `docs/spr-two-hop.md`. -/ ``` [`MLKEM.lean:20-22 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L20-L22) ## [`instDecidableEqMlkem768EncodedTHat`](/lean/proofs/mlkem#instdecidableeqmlkem768encodedthat) Decidable equality on encoded `t̂`, through the `ByteArray` representation. ```lean instance instDecidableEqMlkem768EncodedTHat : DecidableEq mlkem768Encoding.EncodedTHat := inferInstanceAs (DecidableEq ByteArray) ``` [`MLKEM.lean:32-34 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L32-L34) ## [`instDecidableEqMlkem768EncodedU`](/lean/proofs/mlkem#instdecidableeqmlkem768encodedu) ```lean instance instDecidableEqMlkem768EncodedU : DecidableEq mlkem768Encoding.EncodedU := inferInstanceAs (DecidableEq ByteArray) ``` [`MLKEM.lean:36-38 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L36-L38) ## [`instDecidableEqMlkem768EncodedV`](/lean/proofs/mlkem#instdecidableeqmlkem768encodedv) ```lean instance instDecidableEqMlkem768EncodedV : DecidableEq mlkem768Encoding.EncodedV := inferInstanceAs (DecidableEq ByteArray) ``` [`MLKEM.lean:40-42 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L40-L42) ### Decidable equality on the concrete encoded types ## [`infinite_byteArray`](/lean/proofs/mlkem#infinite_bytearray) `ByteArray` is infinite: the all-zero arrays have pairwise distinct lengths. ```lean theorem infinite_byteArray : Infinite ByteArray := Infinite.of_injective (fun n : ℕ => ByteArray.mk (Array.replicate n 0)) (by intro a b hab have h := congrArg ByteArray.size hab simp only [ByteArray.size, Array.size_replicate] at h exact h) ``` [`MLKEM.lean:48-54 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L48-L54) ## [`infinite_mlkem768Ciphertext`](/lean/proofs/mlkem#infinite_mlkem768ciphertext) ```lean theorem infinite_mlkem768Ciphertext : Infinite (Ciphertext mlkem768 mlkem768Encoding) := haveI := infinite_byteArray Infinite.of_injective (fun b : ByteArray => (⟨b, ByteArray.mk #[]⟩ : Ciphertext mlkem768 mlkem768Encoding)) (by intro a b hab; exact congrArg KPKE.Ciphertext.uEncoded hab) ``` [`MLKEM.lean:57-64 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L57-L64) ## [`isEmpty_sampleableType_mlkem768Ciphertext`](/lean/proofs/mlkem#isempty_sampleabletype_mlkem768ciphertext) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem isEmpty_sampleableType_mlkem768Ciphertext : IsEmpty (SampleableType (Ciphertext mlkem768 mlkem768Encoding)) := ⟨fun h => haveI := infinite_mlkem768Ciphertext @not_finite _ inferInstance (@SampleableType.Finite _ h)⟩ ``` [`MLKEM.lean:68-73 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L68-L73) ### The concrete ciphertext type is infinite ## [`uEncodedBytes`](/lean/proofs/mlkem#uencodedbytes) Bytes occupied by the `u` component of an encoded ciphertext: `k` polynomials, `du` bits per coefficient, 256 coefficients each. ```lean def uEncodedBytes (params : Params) : ℕ := 32 * params.du * params.k ``` [`MLKEM.lean:78-79 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L78-L79) ## [`vEncodedBytes`](/lean/proofs/mlkem#vencodedbytes) ```lean def vEncodedBytes (params : Params) : ℕ := 32 * params.dv ``` [`MLKEM.lean:82-83 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L82-L83) ## [`uEncodedBytes_add_vEncodedBytes_eq_ciphertextBytes`](/lean/proofs/mlkem#uencodedbytes_add_vencodedbytes_eq_ciphertextbytes) ```lean theorem uEncodedBytes_add_vEncodedBytes_eq_ciphertextBytes (params : Params) : uEncodedBytes params + vEncodedBytes params = params.ciphertextBytes := by ``` :::details[proof] ```lean simp only [uEncodedBytes, vEncodedBytes, Params.ciphertextBytes] ring ``` ::: [`MLKEM.lean:86-90 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L86-L90) ## [`uEncodedBytes_mlkem768`](/lean/proofs/mlkem#uencodedbytes_mlkem768) ```lean theorem uEncodedBytes_mlkem768 : uEncodedBytes mlkem768 = 960 := by decide ``` [`MLKEM.lean:92-93 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L92-L93) ## [`vEncodedBytes_mlkem768`](/lean/proofs/mlkem#vencodedbytes_mlkem768) ```lean theorem vEncodedBytes_mlkem768 : vEncodedBytes mlkem768 = 128 := by decide ``` [`MLKEM.lean:95-96 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L95-L96) ## [`size_byteEncodeDUVec_mlkem768`](/lean/proofs/mlkem#size_byteencodeduvec_mlkem768) ```lean theorem size_byteEncodeDUVec_mlkem768 (u : RqVec mlkem768.k) : (mlkem768Encoding.byteEncodeDUVec u : ByteArray).size = uEncodedBytes mlkem768 := by ``` :::details[proof] ```lean simp only [ByteArray.size, mlkem768Encoding, concreteEncoding, byteEncodeVec, Array.size_ofFn, uEncodedBytes] ``` ::: [`MLKEM.lean:98-102 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L98-L102) ## [`size_uEncoded_encrypt_mlkem768`](/lean/proofs/mlkem#size_uencoded_encrypt_mlkem768) ```lean theorem size_uEncoded_encrypt_mlkem768 (ek : EncapsulationKey mlkem768 mlkem768Encoding) (msg : Message) (coins : Coins) : ((KPKE.encrypt concreteNTTRingOps mlkem768Encoding mlkem768Primitives ek msg coins).uEncoded : ByteArray).size = uEncodedBytes mlkem768 := size_byteEncodeDUVec_mlkem768 _ ``` [`MLKEM.lean:106-111 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L106-L111) ### The FIPS 203 ciphertext byte layout ## [`mlkem768UniformCiphertext`](/lean/proofs/mlkem#mlkem768uniformciphertext) Uniform over the ML-KEM-768 ciphertext byte space: 960 bytes of `u`, 128 of [`v`](/lean/proofs/demo#v). The key-independent simulator the SPR argument compares real encapsulations against, and sharper than uniform-over-the-encoded-type would be. ```lean def mlkem768UniformCiphertext : ProbComp (Ciphertext mlkem768 mlkem768Encoding) := do let u ← $ᵗ (Bytes (uEncodedBytes mlkem768)) let v ← $ᵗ (Bytes (vEncodedBytes mlkem768)) return ⟨ByteArray.mk u.toArray, ByteArray.mk v.toArray⟩ ``` [`MLKEM.lean:117-121 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L117-L121) ### The uniform-ciphertext-bytes simulator ## [`mlkem768KEM`](/lean/proofs/mlkem#mlkem768kem) Our [`KEM`](/lean/proofs/kemanonymity#kem), at the FIPS 203 ML-KEM-768 parameter set. Since [`KEM`](/lean/proofs/kemanonymity#kem) is `KEMScheme` over `ProbComp` with the type arguments reordered, this IS `MLKEM.asKEMScheme`, not a transport of it. ```lean def mlkem768KEM : KEM (EncapsulationKey mlkem768 mlkem768Encoding) (DecapsulationKey mlkem768 mlkem768Encoding) (Ciphertext mlkem768 mlkem768Encoding) SharedSecret := MLKEM.asKEMScheme concreteNTTRingOps mlkem768Encoding mlkem768Primitives ``` [`MLKEM.lean:127-132 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L127-L132) ## [`mlkem768StealthScheme`](/lean/proofs/mlkem#mlkem768stealthscheme) The post-quantum stealth scheme on ML-KEM-768: discovery via real ML-KEM, announcement `(ciphertext, auxGen sharedSecret encapsulationKey)`. ```lean def mlkem768StealthScheme : StealthScheme (EncapsulationKey mlkem768 mlkem768Encoding) (DecapsulationKey mlkem768 mlkem768Encoding × EncapsulationKey mlkem768 mlkem768Encoding) (Ciphertext mlkem768 mlkem768Encoding × Aux) := StealthScheme.ofKEMFull mlkem768KEM auxGen ``` [`MLKEM.lean:140-146 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L140-L146) ## [`mlkem768_unlinkAdvantage_le`](/lean/proofs/mlkem#mlkem768_unlinkadvantage_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem mlkem768_unlinkAdvantage_le (adv : StealthScheme.UnlinkAdv (EncapsulationKey mlkem768 mlkem768Encoding) (Ciphertext mlkem768 mlkem768Encoding × Aux)) : (mlkem768StealthScheme auxGen).unlinkAdvantage adv ≤ sharedSecretHiding mlkem768KEM auxGen adv true + auxKeyIndependence mlkem768KEM auxGen adv + mlkem768KEM.anonAdvantage (adv.cipherOf auxGen) + sharedSecretHiding mlkem768KEM auxGen adv false := unlinkAdvantage_ofKEMFull_le mlkem768KEM auxGen adv ``` [`MLKEM.lean:150-159 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L150-L159) ## [`mlkem768_unlinkAdvantage_le_full_decomposition`](/lean/proofs/mlkem#mlkem768_unlinkadvantage_le_full_decomposition) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem mlkem768_unlinkAdvantage_le_full_decomposition (adv : StealthScheme.UnlinkAdv (EncapsulationKey mlkem768 mlkem768Encoding) (Ciphertext mlkem768 mlkem768Encoding × Aux)) : (mlkem768StealthScheme auxGen).unlinkAdvantage adv ≤ sharedSecretHiding mlkem768KEM auxGen adv true + auxKeyIndependence mlkem768KEM auxGen adv + (mlkem768KEM.sprAdv mlkem768UniformCiphertext (adv.cipherOf auxGen) true + mlkem768KEM.sprAdv mlkem768UniformCiphertext (adv.cipherOf auxGen) false) + sharedSecretHiding mlkem768KEM auxGen adv false := unlinkAdvantage_ofKEMFull_le_full_decomposition mlkem768KEM auxGen mlkem768UniformCiphertext adv ``` [`MLKEM.lean:163-174 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L163-L174) ## [`mlkem768_unlinkAdvantage_le_indCpa`](/lean/proofs/mlkem#mlkem768_unlinkadvantage_le_indcpa) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem mlkem768_unlinkAdvantage_le_indCpa (adv : StealthScheme.UnlinkAdv (EncapsulationKey mlkem768 mlkem768Encoding) (Ciphertext mlkem768 mlkem768Encoding × Aux)) : (mlkem768StealthScheme auxGen).unlinkAdvantage adv ≤ KEMScheme.IND_CPA_Advantage ProbCompRuntime.probComp (indCpaAdv mlkem768KEM auxGen adv true) + auxKeyIndependence mlkem768KEM auxGen adv + mlkem768KEM.anonAdvantage (adv.cipherOf auxGen) + KEMScheme.IND_CPA_Advantage ProbCompRuntime.probComp (indCpaAdv mlkem768KEM auxGen adv false) := unlinkAdvantage_ofKEMFull_le_indCpa mlkem768KEM auxGen adv ``` [`MLKEM.lean:178-189 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L178-L189) ### The scheme at ML-KEM-768 ## [`MLKEM.kem_ind_cpa_security`](/lean/proofs/mlkem#mlkemkem_ind_cpa_security) ```lean theorem MLKEM.kem_ind_cpa_security {params : Params} (ring : NTTRingOps) (encoding : Encoding params) (prims : Primitives params encoding) [DecidableEq encoding.EncodedTHat] [DecidableEq encoding.EncodedU] [DecidableEq encoding.EncodedV] [SampleableType SharedSecret] : ∃ mlwe : LearningWithErrors.Problem (TqMatrix params.k params.k) (TqVec params.k) (TqVec params.k), ∀ cpaAdv : (MLKEM.asKEMScheme ring encoding prims).IND_CPA_Adversary, ∃ mlweAdv : LearningWithErrors.Adversary mlwe, KEMScheme.IND_CPA_Advantage ProbCompRuntime.probComp cpaAdv ≤ |LearningWithErrors.advantage mlwe mlweAdv| ``` `LatticeCrypto/MLKEM/Security.lean` supplies `kpke_ind_cpa_security` instead: it is about K-PKE rather than the KEM, and is `sorry` upstream. The step between them is the T-transform half of Fujisaki-Okamoto. See `docs/spr-two-hop.md`. -/ ``` [`MLKEM.lean:200-215 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MLKEM.lean#L200-L215) ``` # MultiRecipient :::info[Mirrored page] Source: [`lean/docs-proofs/multirecipient.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/multirecipient.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: the `n`-recipient left-or-right game — `n` independently generated meta-addresses, the adversary NAMES the challenge pair after seeing all of them and keeps a state for the guessing stage — and the pair-guessing bound `unlinkAdvantageN ≤ ∑ over ordered pairs of unlinkAdvantage (pairGuessAdv …) ≤ n·(n−1)·ε`, over the exchangeability lemma [`evalDist_pubKeysN_embedPair`](/lean/proofs/multirecipient#evaldist_pubkeysn_embedpair) (two slots of `n` i.i.d. key generations may be overwritten by two independently generated ones). Assumed: the adversary names two DISTINCT recipients — an explicit hypothesis of the headline theorems. (`keygen`/`announce` are `ProbComp`s, so they never fail: `probFailure_of_liftM_PMF`; no totality hypothesis is carried.) See `docs/announcement-model.md`. ## [`pubKeysN`](/lean/proofs/multirecipient#pubkeysn) The public meta-addresses of `n` independently generated recipients. ```lean def pubKeysN (S : StealthScheme MetaPub MetaPriv Announcement) : (n : ℕ) → ProbComp (Fin n → MetaPub) | 0 => pure Fin.elim0 | n + 1 => do let ks ← S.keygen let f ← S.pubKeysN n pure (Fin.cons ks.1 f) ``` [`MultiRecipient.lean:31-38 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L31-L38) ## [`embedPair`](/lean/proofs/multirecipient#embedpair) ```lean def embedPair {n : ℕ} (j : Fin n × Fin n) (f : Fin n → MetaPub) (a : MetaPub × MetaPub) : Fin n → MetaPub := Function.update (Function.update f j.1 a.1) j.2 a.2 ``` [`MultiRecipient.lean:40-43 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L40-L43) ## [`embedPair_fst`](/lean/proofs/multirecipient#embedpair_fst) ```lean theorem embedPair_fst {n : ℕ} {j : Fin n × Fin n} (hj : j.1 ≠ j.2) (f : Fin n → MetaPub) (a : MetaPub × MetaPub) : embedPair j f a j.1 = a.1 := by ``` :::details[proof] ```lean simp only [embedPair, Function.update_of_ne hj, Function.update_self] ``` ::: [`MultiRecipient.lean:46-49 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L46-L49) ## [`embedPair_snd`](/lean/proofs/multirecipient#embedpair_snd) ```lean theorem embedPair_snd {n : ℕ} (j : Fin n × Fin n) (f : Fin n → MetaPub) (a : MetaPub × MetaPub) : embedPair j f a j.2 = a.2 := by ``` :::details[proof] ```lean simp only [embedPair, Function.update_self] ``` ::: [`MultiRecipient.lean:51-54 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L51-L54) ### `n` published meta-addresses ## [`evalDist_pubKeysN_update`](/lean/proofs/multirecipient#evaldist_pubkeysn_update) **Exchangeability, one slot.** Overwriting one slot of the `n` independent meta-addresses with a freshly generated one leaves the distribution unchanged; the displaced draw disappears because a `ProbComp` never fails. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem evalDist_pubKeysN_update {β : Type} : ∀ (n : ℕ) (j : Fin n) (k : (Fin n → MetaPub) → ProbComp β), 𝒟[do let f ← S.pubKeysN n let x ← S.keygen k (Function.update f j x.1)] = 𝒟[S.pubKeysN n >>= k] := by ``` :::details[proof] ```lean intro n induction n with | zero => exact fun j => j.elim0 | succ n ih => refine Fin.cases ?_ ?_ · intro k have hL : (do let f ← S.pubKeysN (n + 1) let x ← S.keygen k (Function.update f 0 x.1)) = (do let _ ← S.keygen let f ← S.pubKeysN n let x ← S.keygen k (Fin.cons x.1 f)) := by simp only [pubKeysN, bind_assoc, pure_bind, Fin.update_cons_zero] rw [hL, OracleComp.DeferredSampling.evalDist_bind_const_neverFails S.keygen (probFailure_of_liftM_PMF _)] simp only [pubKeysN, bind_assoc, pure_bind] exact evalDist_bind_bind_swap (S.pubKeysN n) S.keygen fun f x => k (Fin.cons x.1 f) · intro i k have hL : (do let f ← S.pubKeysN (n + 1) let x ← S.keygen k (Function.update f i.succ x.1)) = (do let ks ← S.keygen let f ← S.pubKeysN n let x ← S.keygen k (Fin.cons ks.1 (Function.update f i x.1))) := by simp only [pubKeysN, bind_assoc, pure_bind, Fin.cons_update] rw [hL] refine (evalDist_bind_congr' S.keygen fun ks : MetaPub × MetaPriv => ih i fun g => k (Fin.cons ks.1 g)).trans ?_ simp only [pubKeysN, bind_assoc, pure_bind] ``` ::: [`MultiRecipient.lean:62-98 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L62-L98) ## [`evalDist_pubKeysN_embedPair`](/lean/proofs/multirecipient#evaldist_pubkeysn_embedpair) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem evalDist_pubKeysN_embedPair {β : Type} {n : ℕ} (j : Fin n × Fin n) (k : (Fin n → MetaPub) → ProbComp β) : 𝒟[do let a ← S.unlinkSetup let f ← S.pubKeysN n k (embedPair j f a)] = 𝒟[S.pubKeysN n >>= k] := by ``` :::details[proof] ```lean have h0 : (do let a ← S.unlinkSetup let f ← S.pubKeysN n k (embedPair j f a)) = (do let x ← S.keygen let y ← S.keygen let f ← S.pubKeysN n k (Function.update (Function.update f j.1 x.1) j.2 y.1)) := by simp only [unlinkSetup, embedPair, bind_assoc, pure_bind] have h1 : 𝒟[do let x ← S.keygen let y ← S.keygen let f ← S.pubKeysN n k (Function.update (Function.update f j.1 x.1) j.2 y.1)] = 𝒟[do let x ← S.keygen let f ← S.pubKeysN n let y ← S.keygen k (Function.update (Function.update f j.1 x.1) j.2 y.1)] := evalDist_bind_congr' S.keygen fun x => evalDist_bind_bind_swap S.keygen (S.pubKeysN n) fun y f => k (Function.update (Function.update f j.1 x.1) j.2 y.1) have h2 : 𝒟[do let x ← S.keygen let f ← S.pubKeysN n let y ← S.keygen k (Function.update (Function.update f j.1 x.1) j.2 y.1)] = 𝒟[do let f ← S.pubKeysN n let x ← S.keygen let y ← S.keygen k (Function.update (Function.update f j.1 x.1) j.2 y.1)] := evalDist_bind_bind_swap S.keygen (S.pubKeysN n) fun x f => do let y ← S.keygen k (Function.update (Function.update f j.1 x.1) j.2 y.1) have h3 : 𝒟[do let f ← S.pubKeysN n let x ← S.keygen let y ← S.keygen k (Function.update (Function.update f j.1 x.1) j.2 y.1)] = 𝒟[do let f ← S.pubKeysN n let y ← S.keygen k (Function.update f j.2 y.1)] := S.evalDist_pubKeysN_update n j.1 fun g => do let y ← S.keygen; k (Function.update g j.2 y.1) rw [h0, h1, h2, h3] exact S.evalDist_pubKeysN_update n j.2 k ``` ::: [`MultiRecipient.lean:102-148 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L102-L148) ### Exchangeability of the `n` independent key generations ## [`UnlinkChooseN`](/lean/proofs/multirecipient#unlinkchoosen) Challenge selection: after seeing all `n` published meta-addresses the adversary names the ordered pair of recipients it wants to distinguish, and keeps a state `St` for the guessing stage (VCVio's two-phase adversaries carry a `State` the same way; a stateless adversary takes `St := Unit`). ```lean abbrev UnlinkChooseN (MetaPub : Type) (n : ℕ) (St : Type) := (Fin n → MetaPub) → ProbComp ((Fin n × Fin n) × St) ``` [`MultiRecipient.lean:155-157 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L155-L157) ## [`UnlinkGuessN`](/lean/proofs/multirecipient#unlinkguessn) ```lean abbrev UnlinkGuessN (MetaPub Announcement : Type) (n : ℕ) (St : Type) := St → (Fin n → MetaPub) → Fin n × Fin n → Announcement → ProbComp Bool ``` [`MultiRecipient.lean:160-162 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L160-L162) ## [`unlinkBranchN`](/lean/proofs/multirecipient#unlinkbranchn) Branch of the `n`-recipient game selected by the hidden bit: the challenge goes to the first or to the second of the recipients the adversary named. ```lean def unlinkBranchN (b : Bool) (pks : Fin n → MetaPub) : ProbComp Bool := do let i ← pick pks let c ← S.announce (pks (if b then i.1.2 else i.1.1)) guess i.2 pks i.1 c ``` [`MultiRecipient.lean:168-172 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L168-L172) ## [`UnlinkExpN`](/lean/proofs/multirecipient#unlinkexpn) ```lean def UnlinkExpN : ProbComp Bool := do let pks ← S.pubKeysN n let b ← ($ᵗ Bool) let z ← S.unlinkBranchN pick guess b pks pure (b == z) ``` [`MultiRecipient.lean:175-180 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L175-L180) ## [`unlinkAdvantageN`](/lean/proofs/multirecipient#unlinkadvantagen) ```lean noncomputable def unlinkAdvantageN : ℝ := (S.UnlinkExpN pick guess).boolBiasAdvantage ``` [`MultiRecipient.lean:182-183 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L182-L183) ## [`unlinkAdvantageN_eq_branchDistAdvantage`](/lean/proofs/multirecipient#unlinkadvantagen_eq_branchdistadvantage) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantageN_eq_branchDistAdvantage : S.unlinkAdvantageN pick guess = (S.pubKeysN n >>= S.unlinkBranchN pick guess true).boolDistAdvantage (S.pubKeysN n >>= S.unlinkBranchN pick guess false) := ProbComp.boolBiasAdvantage_bind_uniformBool_branch (S.pubKeysN n) (S.unlinkBranchN pick guess) ``` [`MultiRecipient.lean:186-192 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L186-L192) ### The `n`-recipient game ## [`unlinkBranchNAt`](/lean/proofs/multirecipient#unlinkbranchnat) The `n`-recipient branch gated on the ordered pair `j`: the adversary's verdict counts only when it named exactly `j`. ```lean def unlinkBranchNAt (j : Fin n × Fin n) (b : Bool) (pks : Fin n → MetaPub) : ProbComp Bool := do let i ← pick pks let c ← S.announce (pks (if b then i.1.2 else i.1.1)) if i.1 = j then guess i.2 pks i.1 c else pure false ``` [`MultiRecipient.lean:197-201 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L197-L201) ## [`pairGuessAdv`](/lean/proofs/multirecipient#pairguessadv) ```lean def pairGuessAdv (j : Fin n × Fin n) : UnlinkAdv MetaPub Announcement := fun pk0 pk1 c => do let f ← S.pubKeysN n let i ← pick (embedPair j f (pk0, pk1)) if i.1 = j then guess i.2 (embedPair j f (pk0, pk1)) i.1 c else pure false ``` [`MultiRecipient.lean:205-210 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L205-L210) ## [`evalDist_unlinkBranch_pairGuessAdv`](/lean/proofs/multirecipient#evaldist_unlinkbranch_pairguessadv) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem evalDist_unlinkBranch_pairGuessAdv {j : Fin n × Fin n} (hj : j.1 ≠ j.2) (b : Bool) : 𝒟[S.unlinkSetup >>= S.unlinkBranch (S.pairGuessAdv pick guess j) b] = 𝒟[S.pubKeysN n >>= S.unlinkBranchNAt pick guess j b] := by ``` :::details[proof] ```lean have hL : (S.unlinkSetup >>= S.unlinkBranch (S.pairGuessAdv pick guess j) b) = (do let a ← S.unlinkSetup let c ← S.announce (if b then a.2 else a.1) let f ← S.pubKeysN n let i ← pick (embedPair j f a) if i.1 = j then guess i.2 (embedPair j f a) i.1 c else pure false) := rfl have hswap : 𝒟[do let a ← S.unlinkSetup let c ← S.announce (if b then a.2 else a.1) let f ← S.pubKeysN n let i ← pick (embedPair j f a) if i.1 = j then guess i.2 (embedPair j f a) i.1 c else pure false] = 𝒟[do let a ← S.unlinkSetup let f ← S.pubKeysN n let c ← S.announce (if b then a.2 else a.1) let i ← pick (embedPair j f a) if i.1 = j then guess i.2 (embedPair j f a) i.1 c else pure false] := evalDist_bind_congr' S.unlinkSetup fun a => evalDist_bind_bind_swap (S.announce (if b then a.2 else a.1)) (S.pubKeysN n) fun c f => do let i ← pick (embedPair j f a) if i.1 = j then guess i.2 (embedPair j f a) i.1 c else pure false have hstep : 𝒟[do let a ← S.unlinkSetup let f ← S.pubKeysN n let c ← S.announce (if b then a.2 else a.1) let i ← pick (embedPair j f a) if i.1 = j then guess i.2 (embedPair j f a) i.1 c else pure false] = 𝒟[S.pubKeysN n >>= fun pks => do let c ← S.announce (pks (if b then j.2 else j.1)) let i ← pick pks if i.1 = j then guess i.2 pks i.1 c else pure false] := by refine Eq.trans (congrArg _ (bind_congr fun a => bind_congr fun f => ?_)) (S.evalDist_pubKeysN_embedPair j fun pks => do let c ← S.announce (pks (if b then j.2 else j.1)) let i ← pick pks if i.1 = j then guess i.2 pks i.1 c else pure false) cases b with | false => simp only [Bool.false_eq_true, if_false, embedPair_fst hj] | true => simp only [if_true, embedPair_snd] rw [hL, hswap, hstep] refine evalDist_bind_congr' (S.pubKeysN n) fun pks => ?_ rw [evalDist_bind_bind_swap (S.announce (pks (if b then j.2 else j.1))) (pick pks) fun c i => if i.1 = j then guess i.2 pks i.1 c else pure false] simp only [unlinkBranchNAt] refine evalDist_bind_congr' (pick pks) fun i => ?_ by_cases hij : i.1 = j · subst hij rfl · simp only [if_neg hij] rw [OracleComp.DeferredSampling.evalDist_bind_const_neverFails _ (probFailure_of_liftM_PMF _) (pure false), OracleComp.DeferredSampling.evalDist_bind_const_neverFails _ (probFailure_of_liftM_PMF _) (pure false)] ``` ::: [`MultiRecipient.lean:214-270 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L214-L270) ### The pair-guessing reduction ## [`sum_probOutput_unlinkBranchNAt`](/lean/proofs/multirecipient#sum_proboutput_unlinkbranchnat) The gated branches partition the `n`-recipient branch: summed over ordered pairs of DISTINCT indices they recover it exactly. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem sum_probOutput_unlinkBranchNAt (hpick : ∀ (pks : Fin n → MetaPub), ∀ i ∈ support (pick pks), i.1.1 ≠ i.1.2) (b : Bool) : ∑ j ∈ Finset.univ.offDiag, Pr[= true | S.pubKeysN n >>= S.unlinkBranchNAt pick guess j b] = Pr[= true | S.pubKeysN n >>= S.unlinkBranchN pick guess b] := by ``` :::details[proof] ```lean have hinner : ∀ pks : Fin n → MetaPub, ∑ j ∈ Finset.univ.offDiag, Pr[= true | S.unlinkBranchNAt pick guess j b pks] = Pr[= true | S.unlinkBranchN pick guess b pks] := by intro pks simp only [unlinkBranchNAt, unlinkBranchN, probOutput_bind_eq_tsum] rw [← Summable.tsum_finsetSum fun _ _ => ENNReal.summable] refine tsum_congr fun i => ?_ rw [← Finset.mul_sum] by_cases hi : i.1.1 = i.1.2 · have hzero : Pr[= i | pick pks] = 0 := probOutput_eq_zero_of_not_mem_support fun hmem => hpick pks i hmem hi simp only [hzero, zero_mul] · have hmem : i.1 ∈ (Finset.univ.offDiag : Finset (Fin n × Fin n)) := by simp only [Finset.mem_offDiag, Finset.mem_univ, true_and] exact hi congr 1 refine Finset.sum_eq_single_of_mem i.1 hmem ?_ |>.trans ?_ · intro j _ hji simp only [if_neg (Ne.symm hji), probOutput_pure, Bool.true_eq_false, ↓reduceIte, mul_zero, tsum_zero] · exact tsum_congr fun x => by rw [if_pos rfl] simp only [probOutput_bind_eq_tsum] rw [← Summable.tsum_finsetSum fun _ _ => ENNReal.summable] exact tsum_congr fun pks => by rw [← Finset.mul_sum, hinner pks] ``` ::: [`MultiRecipient.lean:275-304 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L275-L304) ### Summing the gated games ## [`unlinkAdvantageN_le_sum`](/lean/proofs/multirecipient#unlinkadvantagen_le_sum) **`n`-recipient unlinkability, honest form.** The advantage is at most the sum, over ordered pairs of distinct recipients, of the two-recipient advantage of the reduction that guesses that pair. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantageN_le_sum (hpick : ∀ (pks : Fin n → MetaPub), ∀ i ∈ support (pick pks), i.1.1 ≠ i.1.2) : S.unlinkAdvantageN pick guess ≤ ∑ j ∈ Finset.univ.offDiag, S.unlinkAdvantage (S.pairGuessAdv pick guess j) := by ``` :::details[proof] ```lean have hterm : ∀ j ∈ (Finset.univ.offDiag : Finset (Fin n × Fin n)), S.unlinkAdvantage (S.pairGuessAdv pick guess j) = |(Pr[= true | S.pubKeysN n >>= S.unlinkBranchNAt pick guess j true]).toReal - (Pr[= true | S.pubKeysN n >>= S.unlinkBranchNAt pick guess j false]).toReal| := by intro j hj rw [Finset.mem_offDiag] at hj rw [S.unlinkAdvantage_eq_branchDistAdvantage, ProbComp.boolDistAdvantage, probOutput_congr rfl (S.evalDist_unlinkBranch_pairGuessAdv pick guess hj.2.2 true), probOutput_congr rfl (S.evalDist_unlinkBranch_pairGuessAdv pick guess hj.2.2 false)] have hsum : ∀ b : Bool, (Pr[= true | S.pubKeysN n >>= S.unlinkBranchN pick guess b]).toReal = ∑ j ∈ Finset.univ.offDiag, (Pr[= true | S.pubKeysN n >>= S.unlinkBranchNAt pick guess j b]).toReal := by intro b rw [← S.sum_probOutput_unlinkBranchNAt pick guess hpick b, ENNReal.toReal_sum fun j _ => probOutput_ne_top] rw [S.unlinkAdvantageN_eq_branchDistAdvantage, ProbComp.boolDistAdvantage, hsum true, hsum false, ← Finset.sum_sub_distrib, Finset.sum_congr rfl hterm] exact Finset.abs_sum_le_sum_abs _ _ ``` ::: [`MultiRecipient.lean:310-333 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L310-L333) ## [`unlinkAdvantageN_le_mul`](/lean/proofs/multirecipient#unlinkadvantagen_le_mul) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantageN_le_mul (hpick : ∀ (pks : Fin n → MetaPub), ∀ i ∈ support (pick pks), i.1.1 ≠ i.1.2) {ε : ℝ} (h : ∀ j : Fin n × Fin n, S.unlinkAdvantage (S.pairGuessAdv pick guess j) ≤ ε) : S.unlinkAdvantageN pick guess ≤ (n * (n - 1) : ℕ) * ε := by ``` :::details[proof] ```lean refine le_trans (S.unlinkAdvantageN_le_sum pick guess hpick) ?_ have hcard : (Finset.univ.offDiag : Finset (Fin n × Fin n)).card = n * (n - 1) := by rw [Finset.offDiag_card, Finset.card_univ, Fintype.card_fin] cases n with | zero => rfl | succ m => simp only [Nat.add_sub_cancel, Nat.mul_succ, Nat.succ_sub_one] calc ∑ j ∈ Finset.univ.offDiag, S.unlinkAdvantage (S.pairGuessAdv pick guess j) ≤ ∑ _j ∈ (Finset.univ.offDiag : Finset (Fin n × Fin n)), ε := Finset.sum_le_sum fun j _ => h j _ = (n * (n - 1) : ℕ) * ε := by rw [Finset.sum_const, hcard, nsmul_eq_mul] ``` ::: [`MultiRecipient.lean:337-352 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L337-L352) ## [`unlinkAdvantageN_ofKEMFull_le`](/lean/proofs/multirecipient#unlinkadvantagen_ofkemfull_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantageN_ofKEMFull_le {PK SK C K Aux : Type} [SampleableType K] [DecidableEq Aux] (kem : KEM PK SK C K) (auxGen : K → PK → Aux) {n : ℕ} {St : Type} (pick : UnlinkChooseN PK n St) (guess : UnlinkGuessN PK (C × Aux) n St) (hpick : ∀ (pks : Fin n → PK), ∀ i ∈ support (pick pks), i.1.1 ≠ i.1.2) {ε : ℝ} (h : ∀ j : Fin n × Fin n, sharedSecretHiding kem auxGen ((StealthScheme.ofKEMFull kem auxGen).pairGuessAdv pick guess j) true + auxKeyIndependence kem auxGen ((StealthScheme.ofKEMFull kem auxGen).pairGuessAdv pick guess j) + kem.anonAdvantage (((StealthScheme.ofKEMFull kem auxGen).pairGuessAdv pick guess j).cipherOf auxGen) + sharedSecretHiding kem auxGen ((StealthScheme.ofKEMFull kem auxGen).pairGuessAdv pick guess j) false ≤ ε) : (StealthScheme.ofKEMFull kem auxGen).unlinkAdvantageN pick guess ≤ (n * (n - 1) : ℕ) * ε := (StealthScheme.ofKEMFull kem auxGen).unlinkAdvantageN_le_mul pick guess hpick fun j => le_trans (unlinkAdvantage_ofKEMFull_le kem auxGen ((StealthScheme.ofKEMFull kem auxGen).pairGuessAdv pick guess j)) (h j) ``` [`MultiRecipient.lean:358-380 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiRecipient.lean#L358-L380) # MultiUnlink :::info[Mirrored page] Source: [`lean/docs-proofs/multiunlink.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/multiunlink.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: the `q`-challenge left-or-right unlinkability game against two recipients ([`UnlinkExpMulti`](/lean/proofs/multiunlink#unlinkexpmulti)), and the hybrid bound `unlinkAdvantageMulti ≤ ∑ k ∈ range q, unlinkAdvantage (hybridAdv …)` with the corollary `≤ q * ε`, over a general telescoping lemma [`boolDistAdvantage_le_sum_hybrids`](/lean/proofs/multiunlink#probcompbooldistadvantage_le_sum_hybrids). By design hybrid `k` IS the single-challenge game of the derived adversary up to ONE swap of independent draws, so each hop is an `evalDist` equality ([`evalDist_announceList_append_cons`](/lean/proofs/multiunlink#evaldist_announcelist_append_cons)) rather than a re-derivation. Assumed: nothing. The `n`-recipient game (the adversary picks the challenge pair among `n` published meta-addresses, loss `n·(n−1)`) is `MultiRecipient`; see `docs/announcement-model.md`. ## [`ProbComp.boolDistAdvantage_congr`](/lean/proofs/multiunlink#probcompbooldistadvantage_congr) Distinguishing advantage only sees the output distributions, so it can be transported along `evalDist` equalities — the form every hybrid hop needs. ```lean theorem ProbComp.boolDistAdvantage_congr {p p' q q' : ProbComp Bool} (hp : 𝒟[p] = 𝒟[p']) (hq : 𝒟[q] = 𝒟[q']) : p.boolDistAdvantage q = p'.boolDistAdvantage q' := by ``` :::details[proof] ```lean rw [ProbComp.boolDistAdvantage, ProbComp.boolDistAdvantage, probOutput_congr rfl hp, probOutput_congr rfl hq] ``` ::: [`MultiUnlink.lean:27-32 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L27-L32) ## [`ProbComp.boolDistAdvantage_le_sum_hybrids`](/lean/proofs/multiunlink#probcompbooldistadvantage_le_sum_hybrids) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem ProbComp.boolDistAdvantage_le_sum_hybrids (H : ℕ → ProbComp Bool) (n : ℕ) : (H n).boolDistAdvantage (H 0) ≤ ∑ k ∈ Finset.range n, (H (k + 1)).boolDistAdvantage (H k) := by ``` :::details[proof] ```lean induction n with | zero => simp only [Finset.range_zero, Finset.sum_empty, ProbComp.boolDistAdvantage, sub_self, abs_zero, le_refl] | succ n ih => rw [Finset.sum_range_succ] calc (H (n + 1)).boolDistAdvantage (H 0) ≤ (H (n + 1)).boolDistAdvantage (H n) + (H n).boolDistAdvantage (H 0) := ProbComp.boolDistAdvantage_triangle _ _ _ _ ≤ (H (n + 1)).boolDistAdvantage (H n) + ∑ k ∈ Finset.range n, (H (k + 1)).boolDistAdvantage (H k) := by linarith [ih] _ = (∑ k ∈ Finset.range n, (H (k + 1)).boolDistAdvantage (H k)) + (H (n + 1)).boolDistAdvantage (H n) := add_comm _ _ ``` ::: [`MultiUnlink.lean:35-52 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L35-L52) ### Telescoping a chain of hybrids ## [`announceList`](/lean/proofs/multiunlink#announcelist) One independent announcement per public meta-address in the list, in order. ```lean def announceList (S : StealthScheme MetaPub MetaPriv Announcement) : List MetaPub → ProbComp (List Announcement) | [] => pure [] | pk :: pks => do let c ← S.announce pk let cs ← S.announceList pks pure (c :: cs) ``` [`MultiUnlink.lean:63-70 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L63-L70) ## [`length_of_mem_support_announceList`](/lean/proofs/multiunlink#length_of_mem_support_announcelist) The adversary really does receive one announcement per recipient: the `List` encoding does not silently drop the count. ```lean theorem length_of_mem_support_announceList (pks : List MetaPub) : ∀ cs ∈ support (S.announceList pks), cs.length = pks.length := by ``` :::details[proof] ```lean induction pks with | nil => intro cs hcs simp only [announceList, support_pure, Set.mem_singleton_iff] at hcs simp only [hcs, List.length_nil] | cons pk pks ih => intro cs hcs simp only [announceList, support_bind, support_pure, Set.mem_iUnion, Set.mem_singleton_iff] at hcs obtain ⟨c, _, cs', hcs', rfl⟩ := hcs simp only [List.length_cons, ih cs' hcs'] ``` ::: [`MultiUnlink.lean:75-88 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L75-L88) ## [`evalDist_announceList_append_cons`](/lean/proofs/multiunlink#evaldist_announcelist_append_cons) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem evalDist_announceList_append_cons {β : Type} (pk : MetaPub) (l₂ : List MetaPub) : ∀ (l₁ : List MetaPub) (f : List Announcement → ProbComp β), 𝒟[S.announceList (l₁ ++ pk :: l₂) >>= f] = 𝒟[do let c ← S.announce pk let cs₁ ← S.announceList l₁ let cs₂ ← S.announceList l₂ f (cs₁ ++ c :: cs₂)] := by ``` :::details[proof] ```lean intro l₁ induction l₁ with | nil => intro f simp only [List.nil_append, announceList, bind_assoc, pure_bind] | cons a l₁ ih => intro f simp only [List.cons_append, announceList, bind_assoc, pure_bind] refine (evalDist_bind_congr' _ fun x => ih (fun cs => f (x :: cs))).trans ?_ exact evalDist_bind_bind_swap (S.announce a) (S.announce pk) _ ``` ::: [`MultiUnlink.lean:92-110 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L92-L110) ### A list of announcements, one per listed recipient ## [`UnlinkAdvMulti`](/lean/proofs/multiunlink#unlinkadvmulti) Multi-challenge unlinkability adversary: both public meta-addresses and all `q` challenge announcements, which all go to the same hidden recipient. ```lean abbrev UnlinkAdvMulti (MetaPub Announcement : Type) := MetaPub → MetaPub → List Announcement → ProbComp Bool ``` [`MultiUnlink.lean:115-117 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L115-L117) ## [`unlinkBranchMulti`](/lean/proofs/multiunlink#unlinkbranchmulti) Branch of the `q`-challenge game selected by the hidden bit: all `q` announcements are addressed to recipient `b`. ```lean def unlinkBranchMulti (q : ℕ) (b : Bool) (a : MetaPub × MetaPub) : ProbComp Bool := do let cs ← S.announceList (List.replicate q (if b then a.2 else a.1)) advM a.1 a.2 cs ``` [`MultiUnlink.lean:122-125 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L122-L125) ## [`UnlinkExpMulti`](/lean/proofs/multiunlink#unlinkexpmulti) ```lean def UnlinkExpMulti (q : ℕ) : ProbComp Bool := do let a ← S.unlinkSetup let b ← ($ᵗ Bool) let z ← S.unlinkBranchMulti advM q b a pure (b == z) ``` [`MultiUnlink.lean:128-133 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L128-L133) ## [`unlinkAdvantageMulti`](/lean/proofs/multiunlink#unlinkadvantagemulti) ```lean noncomputable def unlinkAdvantageMulti (q : ℕ) : ℝ := (S.UnlinkExpMulti advM q).boolBiasAdvantage ``` [`MultiUnlink.lean:135-137 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L135-L137) ## [`unlinkAdvantageMulti_eq_branchDistAdvantage`](/lean/proofs/multiunlink#unlinkadvantagemulti_eq_branchdistadvantage) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantageMulti_eq_branchDistAdvantage (q : ℕ) : S.unlinkAdvantageMulti advM q = (S.unlinkSetup >>= S.unlinkBranchMulti advM q true).boolDistAdvantage (S.unlinkSetup >>= S.unlinkBranchMulti advM q false) := ProbComp.boolBiasAdvantage_bind_uniformBool_branch S.unlinkSetup (S.unlinkBranchMulti advM q) ``` [`MultiUnlink.lean:140-145 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L140-L145) ### The `q`-challenge left-or-right game ## [`unlinkHybrid`](/lean/proofs/multiunlink#unlinkhybrid) Hybrid `(i, j)`: the first `i` announcements go to recipient 1, the last `j` to recipient 0. `(0, q)` and `(q, 0)` are the two branches of the game. ```lean def unlinkHybrid (i j : ℕ) (a : MetaPub × MetaPub) : ProbComp Bool := do let cs ← S.announceList (List.replicate i a.2 ++ List.replicate j a.1) advM a.1 a.2 cs ``` [`MultiUnlink.lean:153-156 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L153-L156) ## [`hybridAdv`](/lean/proofs/multiunlink#hybridadv) ```lean def hybridAdv (i j : ℕ) : UnlinkAdv MetaPub Announcement := fun pk0 pk1 c => do let cs₁ ← S.announceList (List.replicate i pk1) let cs₂ ← S.announceList (List.replicate j pk0) advM pk0 pk1 (cs₁ ++ c :: cs₂) ``` [`MultiUnlink.lean:160-164 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L160-L164) ## [`unlinkHybrid_zero_left`](/lean/proofs/multiunlink#unlinkhybrid_zero_left) ```lean theorem unlinkHybrid_zero_left (q : ℕ) (a : MetaPub × MetaPub) : S.unlinkHybrid advM 0 q a = S.unlinkBranchMulti advM q false a := by ``` :::details[proof] ```lean simp only [unlinkHybrid, unlinkBranchMulti, List.replicate_zero, List.nil_append, Bool.false_eq_true, if_false] ``` ::: [`MultiUnlink.lean:166-170 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L166-L170) ## [`unlinkHybrid_zero_right`](/lean/proofs/multiunlink#unlinkhybrid_zero_right) ```lean theorem unlinkHybrid_zero_right (q : ℕ) (a : MetaPub × MetaPub) : S.unlinkHybrid advM q 0 a = S.unlinkBranchMulti advM q true a := by ``` :::details[proof] ```lean simp only [unlinkHybrid, unlinkBranchMulti, List.replicate_zero, List.append_nil, if_true] ``` ::: [`MultiUnlink.lean:172-175 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L172-L175) ## [`evalDist_unlinkBranch_hybridAdv`](/lean/proofs/multiunlink#evaldist_unlinkbranch_hybridadv) ```lean theorem evalDist_unlinkBranch_hybridAdv (i j : ℕ) (b : Bool) (a : MetaPub × MetaPub) : 𝒟[S.unlinkBranch (S.hybridAdv advM i j) b a] = 𝒟[S.unlinkHybrid advM (if b then i + 1 else i) (if b then j else j + 1) a] := by ``` :::details[proof] ```lean cases b with | false => have hlist : List.replicate i a.2 ++ List.replicate (j + 1) a.1 = List.replicate i a.2 ++ a.1 :: List.replicate j a.1 := by simp only [List.replicate_succ] simp only [unlinkBranch, unlinkHybrid, hybridAdv, hlist, Bool.false_eq_true, if_false] exact (S.evalDist_announceList_append_cons a.1 (List.replicate j a.1) (List.replicate i a.2) (advM a.1 a.2)).symm | true => have hlist : List.replicate (i + 1) a.2 ++ List.replicate j a.1 = List.replicate i a.2 ++ a.2 :: List.replicate j a.1 := by simp only [List.replicate_succ', List.append_assoc, List.singleton_append] simp only [unlinkBranch, unlinkHybrid, hybridAdv, hlist, if_true] exact (S.evalDist_announceList_append_cons a.2 (List.replicate j a.1) (List.replicate i a.2) (advM a.1 a.2)).symm ``` ::: [`MultiUnlink.lean:179-197 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L179-L197) ## [`unlinkAdvantage_hybridAdv_eq`](/lean/proofs/multiunlink#unlinkadvantage_hybridadv_eq) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantage_hybridAdv_eq (i j : ℕ) : S.unlinkAdvantage (S.hybridAdv advM i j) = (S.unlinkSetup >>= S.unlinkHybrid advM (i + 1) j).boolDistAdvantage (S.unlinkSetup >>= S.unlinkHybrid advM i (j + 1)) := by ``` :::details[proof] ```lean rw [S.unlinkAdvantage_eq_branchDistAdvantage] exact ProbComp.boolDistAdvantage_congr (evalDist_bind_congr' _ fun a => S.evalDist_unlinkBranch_hybridAdv advM i j true a) (evalDist_bind_congr' _ fun a => S.evalDist_unlinkBranch_hybridAdv advM i j false a) ``` ::: [`MultiUnlink.lean:200-208 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L200-L208) ### The hybrids and the derived single-challenge adversaries ## [`unlinkAdvantageMulti_le_sum`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_sum) **Multi-challenge unlinkability, honest form.** The `q`-challenge advantage is at most the sum of the `q` single-challenge advantages of the derived adversaries. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantageMulti_le_sum (q : ℕ) : S.unlinkAdvantageMulti advM q ≤ ∑ k ∈ Finset.range q, S.unlinkAdvantage (S.hybridAdv advM k (q - k - 1)) := by ``` :::details[proof] ```lean set H : ℕ → ProbComp Bool := fun k => S.unlinkSetup >>= S.unlinkHybrid advM k (q - k) with hH have hend : S.unlinkAdvantageMulti advM q = (H q).boolDistAdvantage (H 0) := by rw [S.unlinkAdvantageMulti_eq_branchDistAdvantage advM q] simp only [hH, Nat.sub_self, Nat.sub_zero] exact ProbComp.boolDistAdvantage_congr (evalDist_bind_congr' _ fun a => by rw [S.unlinkHybrid_zero_right advM q a]) (evalDist_bind_congr' _ fun a => by rw [S.unlinkHybrid_zero_left advM q a]) have hstep : ∀ k ∈ Finset.range q, (H (k + 1)).boolDistAdvantage (H k) = S.unlinkAdvantage (S.hybridAdv advM k (q - k - 1)) := by intro k hk rw [Finset.mem_range] at hk have key := S.unlinkAdvantage_hybridAdv_eq advM k (q - k - 1) have h1 : q - (k + 1) = q - k - 1 := by omega have h2 : q - k - 1 + 1 = q - k := by omega simp only [hH] rw [key, h1, h2] rw [hend] refine le_trans (ProbComp.boolDistAdvantage_le_sum_hybrids H q) ?_ exact le_of_eq (Finset.sum_congr rfl hstep) ``` ::: [`MultiUnlink.lean:214-237 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L214-L237) ## [`unlinkAdvantageMulti_le_mul`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_mul) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantageMulti_le_mul (q : ℕ) {ε : ℝ} (h : ∀ i j, S.unlinkAdvantage (S.hybridAdv advM i j) ≤ ε) : S.unlinkAdvantageMulti advM q ≤ q * ε := by ``` :::details[proof] ```lean refine le_trans (S.unlinkAdvantageMulti_le_sum advM q) ?_ calc ∑ k ∈ Finset.range q, S.unlinkAdvantage (S.hybridAdv advM k (q - k - 1)) ≤ ∑ _k ∈ Finset.range q, ε := Finset.sum_le_sum fun k _ => h k (q - k - 1) _ = q * ε := by rw [Finset.sum_const, Finset.card_range, nsmul_eq_mul] ``` ::: [`MultiUnlink.lean:241-248 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L241-L248) ### The hybrid bound ## [`unlinkAdvantageMulti_ofKEMFull_le_sum`](/lean/proofs/multiunlink#unlinkadvantagemulti_ofkemfull_le_sum) **Multi-challenge unlinkability of the KEM-based scheme, honest form.** The `q`-challenge advantage of [`ofKEMFull`](/lean/proofs/kemanonymity#stealthschemeofkemfull) is at most the sum over the hybrids of each derived adversary's four-term decomposition: shared-secret hiding on both branches, key independence of the auxiliary data, and KEM anonymity of the induced ciphertext adversary. Instantiation of [`unlinkAdvantageMulti_le_sum`](/lean/proofs/multiunlink#unlinkadvantagemulti_le_sum) with [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) plus `Finset.sum_add_distrib`. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantageMulti_ofKEMFull_le_sum {PK SK C K Aux : Type} [SampleableType K] [DecidableEq Aux] (kem : KEM PK SK C K) (auxGen : K → PK → Aux) (advM : UnlinkAdvMulti PK (C × Aux)) (q : ℕ) : (StealthScheme.ofKEMFull kem auxGen).unlinkAdvantageMulti advM q ≤ ∑ k ∈ Finset.range q, (sharedSecretHiding kem auxGen ((StealthScheme.ofKEMFull kem auxGen).hybridAdv advM k (q - k - 1)) true + auxKeyIndependence kem auxGen ((StealthScheme.ofKEMFull kem auxGen).hybridAdv advM k (q - k - 1)) + kem.anonAdvantage (((StealthScheme.ofKEMFull kem auxGen).hybridAdv advM k (q - k - 1)).cipherOf auxGen) + sharedSecretHiding kem auxGen ((StealthScheme.ofKEMFull kem auxGen).hybridAdv advM k (q - k - 1)) false) := by ``` :::details[proof] ```lean refine le_trans ((StealthScheme.ofKEMFull kem auxGen).unlinkAdvantageMulti_le_sum advM q) ?_ exact Finset.sum_le_sum fun k hk => unlinkAdvantage_ofKEMFull_le kem auxGen ((StealthScheme.ofKEMFull kem auxGen).hybridAdv advM k (q - k - 1)) ``` ::: [`MultiUnlink.lean:257-275 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L257-L275) ## [`unlinkAdvantageMulti_ofKEMFull_le`](/lean/proofs/multiunlink#unlinkadvantagemulti_ofkemfull_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantageMulti_ofKEMFull_le {PK SK C K Aux : Type} [SampleableType K] [DecidableEq Aux] (kem : KEM PK SK C K) (auxGen : K → PK → Aux) (advM : UnlinkAdvMulti PK (C × Aux)) (q : ℕ) {ε : ℝ} (h : ∀ i j, sharedSecretHiding kem auxGen ((StealthScheme.ofKEMFull kem auxGen).hybridAdv advM i j) true + auxKeyIndependence kem auxGen ((StealthScheme.ofKEMFull kem auxGen).hybridAdv advM i j) + kem.anonAdvantage (((StealthScheme.ofKEMFull kem auxGen).hybridAdv advM i j).cipherOf auxGen) + sharedSecretHiding kem auxGen ((StealthScheme.ofKEMFull kem auxGen).hybridAdv advM i j) false ≤ ε) : (StealthScheme.ofKEMFull kem auxGen).unlinkAdvantageMulti advM q ≤ q * ε := (StealthScheme.ofKEMFull kem auxGen).unlinkAdvantageMulti_le_mul advM q fun i j => le_trans (unlinkAdvantage_ofKEMFull_le kem auxGen ((StealthScheme.ofKEMFull kem auxGen).hybridAdv advM i j)) (h i j) ``` [`MultiUnlink.lean:281-299 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/MultiUnlink.lean#L281-L299) # Ownership :::info[Mirrored page] Source: [`lean/docs-proofs/ownership.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/ownership.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: spend forgery -- producing a short `(s, e)` with `A *ᵥ s + e = t` for a target you were not given -- cast as a VCVio `SIS.Problem`, with the honest blinded witness always valid; the `[A | I | -t]` augmentation reshaping that inhomogeneous instance into VCVio's HOMOGENEOUS matrix-SIS, with an EQUALITY of advantages and a validity predicate equal on the nose to `SIS.matrixProblem`'s; and the ML-DSA instance, where winning the game is literally producing a signing key ([`ownershipValid_mldsaShort_iff_isSigningKey`](/lean/proofs/ownership#ownershipvalid_mldsashort_iff_issigningkey)). Assumed: HNF absorption and MLWE pseudorandomness of `t` -- the challenge here is the scheme's distribution, not uniform -- and the whole Fiat-Shamir / signature-of-knowledge layer. See `docs/msis-reshaping.md`. ## [`OwnershipKey`](/lean/proofs/ownership#ownershipkey) A stealth public key viewed as an ownership statement: the public matrix `A` and the target `t`. ```lean structure OwnershipKey (R : Type) [CommRing R] (k l : ℕ) where /-- The public matrix of the ownership relation. -/ A : Matrix (Fin k) (Fin l) R /-- The target vector: the stealth public key proper. -/ t : Fin k → R ``` [`Ownership.lean:30-35 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L30-L35) ## [`ownershipValid`](/lean/proofs/ownership#ownershipvalid) A spend witness `(s, e)` is valid when it is short and satisfies the ownership relation `A * s + e = t`. ```lean def ownershipValid (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (key : OwnershipKey R k l) (w : (Fin l → R) × (Fin k → R)) : Bool := isShort w && decide (key.A *ᵥ w.1 + w.2 = key.t) ``` [`Ownership.lean:42-46 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L42-L46) ## [`ownershipSISProblem`](/lean/proofs/ownership#ownershipsisproblem) ```lean def ownershipSISProblem (keyGen : ProbComp (OwnershipKey R k l)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) : SIS.Problem (OwnershipKey R k l) ((Fin l → R) × (Fin k → R)) where sampleChallenge := keyGen isValid := ownershipValid isShort ``` [`Ownership.lean:50-55 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L50-L55) ## [`spendForgeryAdvantage`](/lean/proofs/ownership#spendforgeryadvantage) ```lean noncomputable def spendForgeryAdvantage (keyGen : ProbComp (OwnershipKey R k l)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (adv : SIS.Adversary (ownershipSISProblem keyGen isShort)) : ℝ≥0∞ := SIS.advantage (ownershipSISProblem keyGen isShort) adv ``` [`Ownership.lean:58-62 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L58-L62) ## [`spendForgeryAdvantage_ne_top`](/lean/proofs/ownership#spendforgeryadvantage_ne_top) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem spendForgeryAdvantage_ne_top (keyGen : ProbComp (OwnershipKey R k l)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (adv : SIS.Adversary (ownershipSISProblem keyGen isShort)) : spendForgeryAdvantage keyGen isShort adv ≠ ⊤ := ne_top_of_le_ne_top ENNReal.one_ne_top probOutput_le_one ``` [`Ownership.lean:64-69 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L64-L69) ## [`spendForgeryAdvantageReal`](/lean/proofs/ownership#spendforgeryadvantagereal) ```lean noncomputable def spendForgeryAdvantageReal (keyGen : ProbComp (OwnershipKey R k l)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (adv : SIS.Adversary (ownershipSISProblem keyGen isShort)) : ℝ := (spendForgeryAdvantage keyGen isShort adv).toReal ``` [`Ownership.lean:73-77 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L73-L77) ## [`honest_witness_valid`](/lean/proofs/ownership#honest_witness_valid) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem honest_witness_valid (A : Matrix (Fin k) (Fin l) R) (s₁ s' : Fin l → R) (s₂ e' : Fin k → R) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (hshort : isShort (s₁ + s', s₂ + e') = true) : ownershipValid isShort ⟨A, A *ᵥ s' + e' + (A *ᵥ s₁ + s₂)⟩ (s₁ + s', s₂ + e') = true := by ``` :::details[proof] ```lean simp only [ownershipValid, hshort, Bool.true_and, decide_eq_true_eq] exact blinded_is_ownership_witness A s₁ s' s₂ e' ``` ::: [`Ownership.lean:81-88 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L81-L88) ### 1. Spend forgery as a search problem ## [`augmentedMatrix`](/lean/proofs/ownership#augmentedmatrix) The `[A | I | -t]` augmentation of an ownership key: the `k × (l+k+1)` matrix whose homogeneous kernel elements with last coordinate `1` are exactly the ownership witnesses for `(A, t)`. ```lean def augmentedMatrix (key : OwnershipKey R k l) : Matrix (Fin k) (Fin (l + k + 1)) R := Matrix.of fun i => Fin.append (Fin.append (key.A i) ((1 : Matrix (Fin k) (Fin k) R) i)) ![-key.t i] ``` [`Ownership.lean:100-104 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L100-L104) ## [`augmentedWitness`](/lean/proofs/ownership#augmentedwitness) ```lean def augmentedWitness (w : (Fin l → R) × (Fin k → R)) : Fin (l + k + 1) → R := Fin.append (Fin.append w.1 w.2) ![1] ``` [`Ownership.lean:107-109 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L107-L109) ## [`readKey`](/lean/proofs/ownership#readkey) ```lean def readKey (M : Matrix (Fin k) (Fin (l + k + 1)) R) : OwnershipKey R k l where A := fun i j => M i ((j.castAdd k).castAdd 1) t := fun i => -M i (Fin.natAdd (l + k) 0) ``` [`Ownership.lean:113-116 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L113-L116) ## [`augmentedShort`](/lean/proofs/ownership#augmentedshort) ```lean def augmentedShort [DecidableEq R] (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (z : Fin (l + k + 1) → R) : Bool := isShort (fun j => z ((j.castAdd k).castAdd 1), fun j => z ((Fin.natAdd l j).castAdd 1)) && decide (z (Fin.natAdd (l + k) 0) = 1) ``` [`Ownership.lean:120-126 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L120-L126) ## [`readKey_augmentedMatrix`](/lean/proofs/ownership#readkey_augmentedmatrix) ```lean @[simp] theorem readKey_augmentedMatrix (key : OwnershipKey R k l) : readKey (augmentedMatrix key) = key := by ``` :::details[proof] ```lean obtain ⟨A, t⟩ := key simp only [readKey, augmentedMatrix, Matrix.of_apply, Fin.append_left, Fin.append_right, Matrix.cons_val_fin_one, neg_neg] ``` ::: [`Ownership.lean:129-134 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L129-L134) ## [`augmentedWitness_ne_zero`](/lean/proofs/ownership#augmentedwitness_ne_zero) The augmented witness is never the zero vector: its marker coordinate is `1`. This is the `x ≠ 0` conjunct of VCVio's matrix-SIS validity. `axioms: propext, Quot.sound` · sorry-free ✓ ```lean theorem augmentedWitness_ne_zero [Nontrivial R] (w : (Fin l → R) × (Fin k → R)) : augmentedWitness w ≠ 0 := by ``` :::details[proof] ```lean intro h have : augmentedWitness w (Fin.natAdd (l + k) 0) = 0 := by rw [h]; rfl simp only [augmentedWitness, Fin.append_right, Matrix.cons_val_fin_one] at this exact one_ne_zero this ``` ::: [`Ownership.lean:146-152 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L146-L152) ## [`augmentedMatrix_mulVec_augmentedWitness`](/lean/proofs/ownership#augmentedmatrix_mulvec_augmentedwitness) ```lean theorem augmentedMatrix_mulVec_augmentedWitness (key : OwnershipKey R k l) (w : (Fin l → R) × (Fin k → R)) : augmentedMatrix key *ᵥ augmentedWitness w = key.A *ᵥ w.1 + w.2 - key.t := by ``` :::details[proof] ```lean funext i simp only [Matrix.mulVec, dotProduct, augmentedMatrix, augmentedWitness, Matrix.of_apply, Pi.add_apply, Pi.sub_apply] rw [Fin.sum_univ_add, Fin.sum_univ_add] simp only [Fin.append_left, Fin.append_right, Matrix.cons_val_fin_one, Fin.sum_univ_one, Matrix.one_apply, ite_mul, one_mul, zero_mul, Finset.sum_ite_eq, Finset.mem_univ, if_true, mul_one] ring ``` ::: [`Ownership.lean:154-165 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L154-L165) ## [`augmented_isValid_eq_ownershipValid`](/lean/proofs/ownership#augmented_isvalid_eq_ownershipvalid) ```lean theorem augmented_isValid_eq_ownershipValid [DecidableEq R] [Nontrivial R] (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (key : OwnershipKey R k l) (w : (Fin l → R) × (Fin k → R)) : (decide (augmentedWitness w ≠ 0) && augmentedShort isShort (augmentedWitness w) && decide (augmentedMatrix key *ᵥ augmentedWitness w = 0)) = ownershipValid isShort key w := by ``` :::details[proof] ```lean rw [augmentedMatrix_mulVec_augmentedWitness] simp only [augmentedShort_augmentedWitness, augmentedWitness_ne_zero, ne_eq, not_false_eq_true, decide_true, Bool.true_and, ownershipValid, sub_eq_zero] ``` ::: [`Ownership.lean:169-180 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L169-L180) ## [`augmentedSISProblem`](/lean/proofs/ownership#augmentedsisproblem) ```lean def augmentedSISProblem [DecidableEq R] (keyGen : ProbComp (OwnershipKey R k l)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) : SIS.Problem (Matrix (Fin k) (Fin (l + k + 1)) R) (Fin (l + k + 1) → R) where sampleChallenge := do let key ← keyGen return augmentedMatrix key isValid M z := decide (z ≠ 0) && augmentedShort isShort z && decide (M *ᵥ z = 0) ``` [`Ownership.lean:184-192 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L184-L192) ## [`augmentedAdversary`](/lean/proofs/ownership#augmentedadversary) ```lean def augmentedAdversary [DecidableEq R] (keyGen : ProbComp (OwnershipKey R k l)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (adv : SIS.Adversary (ownershipSISProblem keyGen isShort)) : SIS.Adversary (augmentedSISProblem keyGen isShort) := fun M => do let w ← adv (readKey M) return augmentedWitness w ``` [`Ownership.lean:195-203 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L195-L203) ## [`spendForgeryAdvantage_eq_sis_advantage`](/lean/proofs/ownership#spendforgeryadvantage_eq_sis_advantage) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem spendForgeryAdvantage_eq_sis_advantage [DecidableEq R] [Nontrivial R] (keyGen : ProbComp (OwnershipKey R k l)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (adv : SIS.Adversary (ownershipSISProblem keyGen isShort)) : spendForgeryAdvantage keyGen isShort adv = SIS.advantage (augmentedSISProblem keyGen isShort) (augmentedAdversary keyGen isShort adv) := by ``` :::details[proof] ```lean simp only [spendForgeryAdvantage, SIS.advantage, SIS.experiment, ownershipSISProblem, augmentedSISProblem, augmentedAdversary, bind_assoc, pure_bind, readKey_augmentedMatrix, augmented_isValid_eq_ownershipValid] ``` ::: [`Ownership.lean:207-218 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L207-L218) ## [`spendForgeryAdvantage_le_augmentedSIS`](/lean/proofs/ownership#spendforgeryadvantage_le_augmentedsis) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem spendForgeryAdvantage_le_augmentedSIS [DecidableEq R] [Nontrivial R] (keyGen : ProbComp (OwnershipKey R k l)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (adv : SIS.Adversary (ownershipSISProblem keyGen isShort)) : spendForgeryAdvantage keyGen isShort adv ≤ SIS.advantage (augmentedSISProblem keyGen isShort) (augmentedAdversary keyGen isShort adv) := le_of_eq (spendForgeryAdvantage_eq_sis_advantage keyGen isShort adv) ``` [`Ownership.lean:223-231 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L223-L231) ## [`honest_augmented_witness_valid`](/lean/proofs/ownership#honest_augmented_witness_valid) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem honest_augmented_witness_valid [DecidableEq R] [Nontrivial R] (keyGen : ProbComp (OwnershipKey R k l)) (A : Matrix (Fin k) (Fin l) R) (s₁ s' : Fin l → R) (s₂ e' : Fin k → R) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (hshort : isShort (s₁ + s', s₂ + e') = true) : (augmentedSISProblem keyGen isShort).isValid (augmentedMatrix ⟨A, A *ᵥ s' + e' + (A *ᵥ s₁ + s₂)⟩) (augmentedWitness (s₁ + s', s₂ + e')) = true := by ``` :::details[proof] ```lean simp only [augmentedSISProblem, augmented_isValid_eq_ownershipValid] exact honest_witness_valid A s₁ s' s₂ e' isShort hshort ``` ::: [`Ownership.lean:234-244 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L234-L244) ## [`augmentedSISProblem_isValid_eq_matrixProblem`](/lean/proofs/ownership#augmentedsisproblem_isvalid_eq_matrixproblem) The reshaped problem's validity predicate IS VCVio's `SIS.matrixProblem` predicate at `n = k`, `m = l + k + 1`: a spend forger is scored by exactly the function `SIS.matrixProblem` scores solutions by. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem augmentedSISProblem_isValid_eq_matrixProblem (keyGen : ProbComp (OwnershipKey R k l)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) : (augmentedSISProblem keyGen isShort).isValid = (SIS.matrixProblem k (l + k + 1) (augmentedShort isShort)).isValid := rfl ``` [`Ownership.lean:252-258 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L252-L258) ### 2. Reshaping into homogeneous matrix-SIS ## [`rq_one_ne_zero`](/lean/proofs/ownership#rq_one_ne_zero) `Rq` is nontrivial: the constant polynomial `1` differs from `0` in its degree-zero coefficient. Needed for the `z ≠ 0` conjunct of matrix-SIS validity. ```lean theorem rq_one_ne_zero : (1 : Rq) ≠ (0 : Rq) := by ``` :::details[proof] ```lean intro h have h1 : (1 : Rq) = Vector.ofFn (fun i : Fin ringDegree => if (i : ℕ) = 0 then (1 : Coeff) else 0) := rfl rw [h1] at h have h0 := congrArg (fun v : Vector Coeff ringDegree => v.get ⟨0, by norm_num [ringDegree]⟩) h rw [Vector.get_ofFn, Rq.get_zero] at h0 simp only [↓reduceIte] at h0 exact absurd h0 (by decide) ``` ::: [`Ownership.lean:278-287 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L278-L287) ## [`instNontrivialRq`](/lean/proofs/ownership#instnontrivialrq) ```lean instance instNontrivialRq : Nontrivial Rq := ⟨1, 0, rq_one_ne_zero⟩ ``` [`Ownership.lean:289-290 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L289-L290) ## [`mldsaShort`](/lean/proofs/ownership#mldsashort) ```lean def mldsaShort (bound : ℕ) (w : (Fin l → Rq) × (Fin k → Rq)) : Bool := decide ((∀ i, cInfNorm (w.1 i) ≤ bound) ∧ (∀ i, cInfNorm (w.2 i) ≤ bound)) ``` [`Ownership.lean:294-296 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L294-L296) ## [`ownershipValid_mldsaShort_iff_isSigningKey`](/lean/proofs/ownership#ownershipvalid_mldsashort_iff_issigningkey) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem ownershipValid_mldsaShort_iff_isSigningKey (bound : ℕ) (key : OwnershipKey Rq k l) (w : (Fin l → Rq) × (Fin k → Rq)) : ownershipValid (mldsaShort bound) key w = true ↔ IsSigningKey key.A key.t w.1 w.2 bound := by ``` :::details[proof] ```lean simp only [ownershipValid, mldsaShort, Bool.and_eq_true, decide_eq_true_eq, IsSigningKey, IsOwnershipWitness, IsShortPair] exact and_comm ``` ::: [`Ownership.lean:299-306 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L299-L306) ## [`mldsa_spendForgeryAdvantage_eq_sis_advantage`](/lean/proofs/ownership#mldsa_spendforgeryadvantage_eq_sis_advantage) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem mldsa_spendForgeryAdvantage_eq_sis_advantage (keyGen : ProbComp (OwnershipKey Rq k l)) (bound : ℕ) (adv : SIS.Adversary (ownershipSISProblem keyGen (mldsaShort bound))) : spendForgeryAdvantage keyGen (mldsaShort bound) adv = SIS.advantage (augmentedSISProblem keyGen (mldsaShort bound)) (augmentedAdversary keyGen (mldsaShort bound) adv) := spendForgeryAdvantage_eq_sis_advantage keyGen (mldsaShort bound) adv ``` [`Ownership.lean:310-317 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Ownership.lean#L310-L317) # Reorder :::info[Mirrored page] Source: [`lean/docs-proofs/reorder.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/reorder.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: `OracleComp`'s `bind` is syntactic, so two games that draw the same independent samples in different orders are not equal on the nose; they are equal under `evalDist`, which is all an advantage sees. The lemmas here pull the `k`-th of `k` independent draws to the front (`k = 3 … 6`), each a composition of VCVio's `evalDist_bind_bind_swap` under `evalDist_bind_congr'`. A game that differs from another by a permutation of independent draws is rewritten with a few of them. ## [`evalDist_pull₃`](/lean/proofs/reorder#evaldist_pull) Pull the third of three independent draws to the front. ```lean theorem evalDist_pull₃ (o₁ : ProbComp α₁) (o₂ : ProbComp α₂) (o₃ : ProbComp α₃) (k : α₁ → α₂ → α₃ → ProbComp β) : 𝒟[do let a ← o₁; let b ← o₂; let c ← o₃; k a b c] = 𝒟[do let c ← o₃; let a ← o₁; let b ← o₂; k a b c] := by ``` :::details[proof] ```lean calc 𝒟[do let a ← o₁; let b ← o₂; let c ← o₃; k a b c] = 𝒟[do let a ← o₁; let c ← o₃; let b ← o₂; k a b c] := evalDist_bind_congr' _ fun a => evalDist_bind_bind_swap o₂ o₃ (k a) _ = 𝒟[do let c ← o₃; let a ← o₁; let b ← o₂; k a b c] := evalDist_bind_bind_swap o₁ o₃ _ ``` ::: [`Reorder.lean:22-31 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Reorder.lean#L22-L31) ## [`evalDist_pull₄`](/lean/proofs/reorder#evaldist_pull) ```lean theorem evalDist_pull₄ (o₁ : ProbComp α₁) (o₂ : ProbComp α₂) (o₃ : ProbComp α₃) (o₄ : ProbComp α₄) (k : α₁ → α₂ → α₃ → α₄ → ProbComp β) : 𝒟[do let a ← o₁; let b ← o₂; let c ← o₃; let d ← o₄; k a b c d] = 𝒟[do let d ← o₄; let a ← o₁; let b ← o₂; let c ← o₃; k a b c d] := by ``` :::details[proof] ```lean calc 𝒟[do let a ← o₁; let b ← o₂; let c ← o₃; let d ← o₄; k a b c d] = 𝒟[do let a ← o₁; let d ← o₄; let b ← o₂; let c ← o₃; k a b c d] := evalDist_bind_congr' _ fun a => evalDist_pull₃ o₂ o₃ o₄ (k a) _ = 𝒟[do let d ← o₄; let a ← o₁; let b ← o₂; let c ← o₃; k a b c d] := evalDist_bind_bind_swap o₁ o₄ _ ``` ::: [`Reorder.lean:33-42 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Reorder.lean#L33-L42) ## [`evalDist_pull₅`](/lean/proofs/reorder#evaldist_pull) ```lean theorem evalDist_pull₅ (o₁ : ProbComp α₁) (o₂ : ProbComp α₂) (o₃ : ProbComp α₃) (o₄ : ProbComp α₄) (o₅ : ProbComp α₅) (k : α₁ → α₂ → α₃ → α₄ → α₅ → ProbComp β) : 𝒟[do let a ← o₁; let b ← o₂; let c ← o₃; let d ← o₄; let e ← o₅; k a b c d e] = 𝒟[do let e ← o₅; let a ← o₁; let b ← o₂; let c ← o₃; let d ← o₄; k a b c d e] := by ``` :::details[proof] ```lean calc 𝒟[do let a ← o₁; let b ← o₂; let c ← o₃; let d ← o₄; let e ← o₅; k a b c d e] = 𝒟[do let a ← o₁; let e ← o₅; let b ← o₂; let c ← o₃; let d ← o₄; k a b c d e] := evalDist_bind_congr' _ fun a => evalDist_pull₄ o₂ o₃ o₄ o₅ (k a) _ = 𝒟[do let e ← o₅; let a ← o₁; let b ← o₂; let c ← o₃; let d ← o₄; k a b c d e] := evalDist_bind_bind_swap o₁ o₅ _ ``` ::: [`Reorder.lean:44-53 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Reorder.lean#L44-L53) ## [`evalDist_pull₆`](/lean/proofs/reorder#evaldist_pull) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem evalDist_pull₆ (o₁ : ProbComp α₁) (o₂ : ProbComp α₂) (o₃ : ProbComp α₃) (o₄ : ProbComp α₄) (o₅ : ProbComp α₅) (o₆ : ProbComp α₆) (k : α₁ → α₂ → α₃ → α₄ → α₅ → α₆ → ProbComp β) : 𝒟[o₁ >>= fun a => o₂ >>= fun b => o₃ >>= fun c => o₄ >>= fun d => o₅ >>= fun e => o₆ >>= fun f => k a b c d e f] = 𝒟[o₆ >>= fun f => o₁ >>= fun a => o₂ >>= fun b => o₃ >>= fun c => o₄ >>= fun d => o₅ >>= fun e => k a b c d e f] := by ``` :::details[proof] ```lean calc 𝒟[o₁ >>= fun a => o₂ >>= fun b => o₃ >>= fun c => o₄ >>= fun d => o₅ >>= fun e => o₆ >>= fun f => k a b c d e f] = 𝒟[o₁ >>= fun a => o₆ >>= fun f => o₂ >>= fun b => o₃ >>= fun c => o₄ >>= fun d => o₅ >>= fun e => k a b c d e f] := evalDist_bind_congr' _ fun a => evalDist_pull₅ o₂ o₃ o₄ o₅ o₆ (k a) _ = 𝒟[o₆ >>= fun f => o₁ >>= fun a => o₂ >>= fun b => o₃ >>= fun c => o₄ >>= fun d => o₅ >>= fun e => k a b c d e f] := evalDist_bind_bind_swap o₁ o₆ _ ``` ::: [`Reorder.lean:55-70 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Reorder.lean#L55-L70) ## [`evalDist_uniformSample_bind_const`](/lean/proofs/reorder#evaldist_uniformsample_bind_const) ```lean theorem evalDist_uniformSample_bind_const {α γ : Type} [SampleableType α] (p : ProbComp γ) : 𝒟[(($ᵗ α) >>= fun _ => p)] = 𝒟[p] := OracleComp.DeferredSampling.evalDist_bind_const_neverFails _ (probFailure_of_liftM_PMF _) p ``` [`Reorder.lean:73-76 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Reorder.lean#L73-L76) # ROMUpToBad :::info[Mirrored page] Source: [`lean/docs-proofs/romuptobad.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/romuptobad.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: VCVio's `tvDist_simulateQ_randomOracle_withProgramming_le_probEvent_bad` bounds the distance between a lazily sampled random oracle and a *programmed* one (the oracle forced to answer a chosen point with a chosen value) by the probability that the programmed point is queried. It is stated for computations whose only oracle is the hash. Our ROM games live over `unifSpec + hashSpec` — the adversary also samples — and are simulated by `unifFwdImpl hashSpec + hashSpec.randomOracle` ([`dksapRomImpl`](/lean/proofs/dksaporacle#dksapromimpl) in `DKSAPOracle`, [`romImpl`](/lean/proofs/blindingrom#romimpl) in `BlindingROM`). Proved, for that shape (everything here is stated over an arbitrary `hashSpec`): * [`tvDist_programmedROImpl_trackingROImpl_le`](/lean/proofs/romuptobad#tvdist_programmedroimpl_trackingroimpl_le): the engine bound (`tvDist_simulateQ_run_le_probEvent_output_bad`) for `unifFwd + withProgramming policy` vs `unifFwd + withCachingTrackingPolicy policy`, with the programming flag as the bad event; * [`relTriple_run'_programmedROImpl_roImpl`](/lean/proofs/romuptobad#reltriple_run_programmedroimpl_roimpl): the programmed run from `(cache₀, false)` is, on the output marginal (`evalDist_eq_of_relTriple_eqRel`), the plain random oracle run from the cache `cache₀` overridden by the policy; * [`relTriple_run'_trackingROImpl_roImpl`](/lean/proofs/romuptobad#reltriple_run_trackingroimpl_roimpl): the tracking run is, on the output marginal, the plain random oracle run from `cache₀`; * [`tvDist_run'_roImpl_cacheOr_le_probEvent_bad`](/lean/proofs/romuptobad#tvdist_run_roimpl_cacheor_le_probevent_bad): the three combined — the distance between the random oracle run from `cache₀ ⊕ policy` and from `cache₀` is at most the probability that the policy fires. This is the lemma `DKSAPOracle` and `BlindingROM` consume. ## [`unifFwdFlagImpl`](/lean/proofs/romuptobad#uniffwdflagimpl) Uniform forwarding lifted to the flagged cache state `QueryCache × Bool`. ```lean noncomputable def unifFwdFlagImpl (hashSpec : OracleSpec ι) : QueryImpl unifSpec (StateT (hashSpec.QueryCache × Bool) ProbComp) := (HasQuery.toQueryImpl (spec := unifSpec) (m := ProbComp)).liftTarget (StateT (hashSpec.QueryCache × Bool) ProbComp) ``` [`ROMUpToBad.lean:43-47 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L43-L47) ## [`roImpl`](/lean/proofs/romuptobad#roimpl) ```lean noncomputable def roImpl (hashSpec : OracleSpec ι) [∀ t : hashSpec.Domain, SampleableType (hashSpec.Range t)] : QueryImpl (unifSpec + hashSpec) (StateT hashSpec.QueryCache ProbComp) := unifFwdImpl hashSpec + hashSpec.randomOracle ``` [`ROMUpToBad.lean:50-54 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L50-L54) ## [`programmedROImpl`](/lean/proofs/romuptobad#programmedroimpl) ```lean noncomputable def programmedROImpl (policy : hashSpec.ProgrammingPolicy) : QueryImpl (unifSpec + hashSpec) (StateT (hashSpec.QueryCache × Bool) ProbComp) := unifFwdFlagImpl hashSpec + QueryImpl.withProgramming uniformSampleImpl policy ``` [`ROMUpToBad.lean:57-60 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L57-L60) ## [`trackingROImpl`](/lean/proofs/romuptobad#trackingroimpl) ```lean noncomputable def trackingROImpl (policy : hashSpec.ProgrammingPolicy) : QueryImpl (unifSpec + hashSpec) (StateT (hashSpec.QueryCache × Bool) ProbComp) := unifFwdFlagImpl hashSpec + QueryImpl.withCachingTrackingPolicy uniformSampleImpl policy ``` [`ROMUpToBad.lean:63-66 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L63-L66) ### The implementations ## [`cacheOr`](/lean/proofs/romuptobad#cacheor) A cache overridden by a policy: the cache where it is defined, the policy otherwise. ```lean def cacheOr (cache : hashSpec.QueryCache) (policy : hashSpec.ProgrammingPolicy) : hashSpec.QueryCache := fun t => (cache t).or (policy t) omit [DecidableEq ι] in ``` [`ROMUpToBad.lean:76-80 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L76-L80) ## [`cacheOr_apply`](/lean/proofs/romuptobad#cacheor_apply) ```lean @[simp] theorem cacheOr_apply (cache : hashSpec.QueryCache) (t : hashSpec.Domain) : cacheOr cache policy t = (cache t).or (policy t) := rfl ``` [`ROMUpToBad.lean:82-84 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L82-L84) ## [`cacheOr_cacheQuery`](/lean/proofs/romuptobad#cacheor_cachequery) ```lean theorem cacheOr_cacheQuery (cache : hashSpec.QueryCache) (t : hashSpec.Domain) (u : hashSpec.Range t) : cacheOr (cache.cacheQuery t u) policy = (cacheOr cache policy).cacheQuery t u := by ``` :::details[proof] ```lean funext t' by_cases h : t' = t · subst h simp only [cacheOr_apply, QueryCache.cacheQuery_self, Option.some_or] · simp only [cacheOr_apply, QueryCache.cacheQuery_of_ne _ _ h] ``` ::: [`ROMUpToBad.lean:86-94 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L86-L94) ## [`cacheOr_cacheQuery_of_policy`](/lean/proofs/romuptobad#cacheor_cachequery_of_policy) ```lean theorem cacheOr_cacheQuery_of_policy (cache : hashSpec.QueryCache) (t : hashSpec.Domain) (u : hashSpec.Range t) (hc : cache t = none) (hp : policy t = some u) : cacheOr (cache.cacheQuery t u) policy = cacheOr cache policy := by ``` :::details[proof] ```lean funext t' by_cases h : t' = t · subst h simp only [cacheOr_apply, QueryCache.cacheQuery_self, Option.some_or, hc, hp, Option.none_or] · simp only [cacheOr_apply, QueryCache.cacheQuery_of_ne _ _ h] ``` ::: [`ROMUpToBad.lean:97-106 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L97-L106) ### A cache overridden by a policy ## [`unifFwdFlagImpl_run`](/lean/proofs/romuptobad#uniffwdflagimpl_run) ```lean theorem unifFwdFlagImpl_run (n : ℕ) (s : hashSpec.QueryCache × Bool) : (unifFwdFlagImpl hashSpec n).run s = (fun x => (x, s)) <$> ($[0..n]) := rfl ``` [`ROMUpToBad.lean:113-116 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L113-L116) ## [`relTriple_unifFwdFlagImpl_unifFwdImpl`](/lean/proofs/romuptobad#reltriple_uniffwdflagimpl_uniffwdimpl) The uniform forwarders are coupled step by step under any state relation. ```lean theorem relTriple_unifFwdFlagImpl_unifFwdImpl (R : hashSpec.QueryCache × Bool → hashSpec.QueryCache → Prop) (n : ℕ) (s₁ : hashSpec.QueryCache × Bool) (s₂ : hashSpec.QueryCache) (hs : R s₁ s₂) : RelTriple ((unifFwdFlagImpl hashSpec n).run s₁) ((unifFwdImpl hashSpec n).run s₂) (fun p₁ p₂ => p₁.1 = p₂.1 ∧ R p₁.2 p₂.2) := by ``` :::details[proof] ```lean rw [unifFwdFlagImpl_run, unifFwdImpl_run] refine relTriple_map (relTriple_post_mono (relTriple_refl _) ?_) intro x y hxy exact ⟨hxy, hs⟩ ``` ::: [`ROMUpToBad.lean:123-132 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L123-L132) ### Running the uniform forwarders ## [`probOutput_withProgramming_eq_withCachingTrackingPolicy_of_not_bad_output`](/lean/proofs/romuptobad#proboutput_withprogramming_eq_withcachingtrackingpolicy_of_not_bad_output) On a non-bad input, the programmed and tracking hash handlers agree on every non-bad output. (VCVio proves this for its own bridge but keeps it `private`.) ```lean theorem probOutput_withProgramming_eq_withCachingTrackingPolicy_of_not_bad_output (t : hashSpec.Domain) (cache : hashSpec.QueryCache) (u : hashSpec.Range t) (cache' : hashSpec.QueryCache) : Pr[= (u, (cache', false)) | (QueryImpl.withProgramming uniformSampleImpl policy t).run (cache, false)] = Pr[= (u, (cache', false)) | (QueryImpl.withCachingTrackingPolicy uniformSampleImpl policy t).run (cache, false)] := by ``` :::details[proof] ```lean classical cases hcache : cache t with | some v => simp only [QueryImpl.withProgramming_apply, StateT.run_mk, hcache, probOutput_pure, Prod.mk.injEq, and_true, QueryImpl.withCachingTrackingPolicy_apply, Bool.if_true_left, Bool.decide_eq_true, Functor.map_map] | none => cases hpol : policy t with | none => simp only [QueryImpl.withProgramming_apply, hpol, Functor.map_map, StateT.run_mk, hcache, QueryImpl.withCachingTrackingPolicy_apply, Option.isSome_none, Bool.false_eq_true, ↓reduceIte] | some v => have hne : ∀ (w : hashSpec.Range t) (c : hashSpec.QueryCache), ((u, (cache', false)) : hashSpec.Range t × hashSpec.QueryCache × Bool) ≠ (w, (c, true)) := by intro w c hcontr injection hcontr with _ h2 injection h2 with _ h3 cases h3 have hL : (QueryImpl.withProgramming uniformSampleImpl policy t).run (cache, false) = (pure (v, (cache.cacheQuery t v, true)) : ProbComp (hashSpec.Range t × hashSpec.QueryCache × Bool)) := by simp only [QueryImpl.withProgramming_apply, hpol, map_pure, StateT.run_mk, hcache] have hR : (QueryImpl.withCachingTrackingPolicy uniformSampleImpl policy t).run (cache, false) = (uniformSampleImpl t >>= fun u' => pure (u', (cache.cacheQuery t u', true)) : ProbComp (hashSpec.Range t × hashSpec.QueryCache × Bool)) := by simp only [QueryImpl.withCachingTrackingPolicy_apply, hpol, Option.isSome_some, ↓reduceIte, Functor.map_map, StateT.run_mk, hcache, bind_pure_comp] rw [hL, hR, probOutput_pure, if_neg (hne v _), probOutput_bind_eq_tsum] symm refine ENNReal.tsum_eq_zero.mpr (fun u' => ?_) rw [probOutput_pure, if_neg (hne u' _), mul_zero] ``` ::: [`ROMUpToBad.lean:144-185 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L144-L185) ## [`tvDist_programmedROImpl_trackingROImpl_le`](/lean/proofs/romuptobad#tvdist_programmedroimpl_trackingroimpl_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem tvDist_programmedROImpl_trackingROImpl_le {α : Type} (oa : OracleComp (unifSpec + hashSpec) α) (cache₀ : hashSpec.QueryCache) : tvDist ((simulateQ (programmedROImpl policy) oa).run (cache₀, false)) ((simulateQ (trackingROImpl policy) oa).run (cache₀, false)) ≤ Pr[fun z : α × hashSpec.QueryCache × Bool => z.2.2 = true | (simulateQ (programmedROImpl policy) oa).run (cache₀, false)].toReal := by ``` :::details[proof] ```lean refine OracleComp.ProgramLogic.Relational.tvDist_simulateQ_run_le_probEvent_output_bad (programmedROImpl policy) (trackingROImpl policy) oa cache₀ ?_ ?_ ?_ · rintro (n | t) s u s' · rfl · exact probOutput_withProgramming_eq_withCachingTrackingPolicy_of_not_bad_output policy t s u s' · rintro (n | t) ⟨c, b⟩ hp z hz · cases (show b = true from hp) simp only [add_apply_inl, programmedROImpl, QueryImpl.add_apply_inl, unifFwdFlagImpl_run, support_map, ProbComp.support_uniformFin, Set.image_univ] at hz obtain ⟨x, rfl⟩ := hz rfl · cases (show b = true from hp) exact QueryImpl.withProgramming_bad_monotone (so := uniformSampleImpl) (policy := policy) t c z hz · rintro (n | t) ⟨c, b⟩ hp z hz · cases (show b = true from hp) simp only [add_apply_inl, trackingROImpl, QueryImpl.add_apply_inl, unifFwdFlagImpl_run, support_map, ProbComp.support_uniformFin, Set.image_univ] at hz obtain ⟨x, rfl⟩ := hz rfl · cases (show b = true from hp) exact QueryImpl.withCachingTrackingPolicy_bad_monotone (so := uniformSampleImpl) (policy := policy) t c z hz ``` ::: [`ROMUpToBad.lean:189-219 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L189-L219) ### The engine: programmed vs tracking, identical until the flag fires ## [`relTriple_run'_programmedROImpl_roImpl`](/lean/proofs/romuptobad#reltriple_run_programmedroimpl_roimpl) **Programmed = cached.** Running the programmed oracle from `(cache₀, false)` has the same output distribution as running the plain random oracle from `cache₀` overridden by the policy. ```lean theorem relTriple_run'_programmedROImpl_roImpl {α : Type} (oa : OracleComp (unifSpec + hashSpec) α) (cache₀ : hashSpec.QueryCache) : RelTriple ((simulateQ (programmedROImpl policy) oa).run' (cache₀, false)) ((simulateQ (roImpl hashSpec) oa).run' (cacheOr cache₀ policy)) (EqRel α) := by ``` :::details[proof] ```lean refine relTriple_simulateQ_run' (programmedROImpl policy) (roImpl hashSpec) (fun p c => c = cacheOr p.1 policy) oa ?_ (cache₀, false) _ rfl rintro (n | t) ⟨c, bad⟩ c' hc' · exact relTriple_unifFwdFlagImpl_unifFwdImpl (R := fun p c => c = cacheOr p.1 policy) n (c, bad) c' hc' · subst hc' simp only [programmedROImpl, roImpl, QueryImpl.add_apply_inr, OracleSpec.randomOracle] cases hcache : c t with | some v => simp only [add_apply_inr, QueryImpl.withProgramming_apply, StateT.run_mk, hcache, QueryImpl.withCaching_apply, StateT.run_bind, StateT.run_get, pure_bind, cacheOr_apply, Option.some_or, StateT.run_pure, relTriple_iff_relWP, MAlgRelOrdered.relWP_pure, and_self] | none => cases hpol : policy t with | some p => simp only [add_apply_inr, QueryImpl.withProgramming_apply, hpol, map_pure, StateT.run_mk, hcache, QueryImpl.withCaching_apply, StateT.run_bind, StateT.run_get, pure_bind, cacheOr_apply, Option.or_some, Option.getD_none, StateT.run_pure, relTriple_iff_relWP, MAlgRelOrdered.relWP_pure, true_and] exact (cacheOr_cacheQuery_of_policy policy c t p hcache hpol).symm | none => simp only [add_apply_inr, QueryImpl.withProgramming_apply, hpol, Functor.map_map, StateT.run_mk, hcache, QueryImpl.withCaching_apply, StateT.run_bind, StateT.run_get, pure_bind, cacheOr_apply, Option.or_self, StateT.run_monadLift, monadLift_self, bind_pure_comp, StateT.run_modifyGet] refine relTriple_map (relTriple_post_mono (relTriple_refl _) ?_) intro x y hxy cases hxy exact ⟨rfl, (cacheOr_cacheQuery policy c t x).symm⟩ ``` ::: [`ROMUpToBad.lean:233-266 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L233-L266) ## [`relTriple_run'_trackingROImpl_roImpl`](/lean/proofs/romuptobad#reltriple_run_trackingroimpl_roimpl) ```lean theorem relTriple_run'_trackingROImpl_roImpl {α : Type} (oa : OracleComp (unifSpec + hashSpec) α) (cache₀ : hashSpec.QueryCache) : RelTriple ((simulateQ (trackingROImpl policy) oa).run' (cache₀, false)) ((simulateQ (roImpl hashSpec) oa).run' cache₀) (EqRel α) := by ``` :::details[proof] ```lean refine relTriple_simulateQ_run' (trackingROImpl policy) (roImpl hashSpec) (fun p c => c = p.1) oa ?_ (cache₀, false) _ rfl rintro (n | t) ⟨c, bad⟩ c' hc' · exact relTriple_unifFwdFlagImpl_unifFwdImpl (R := fun p c => c = p.1) n (c, bad) c' hc' · subst hc' simp only [trackingROImpl, roImpl, QueryImpl.add_apply_inr, OracleSpec.randomOracle] cases hcache : c' t with | some v => simp only [add_apply_inr, QueryImpl.withCachingTrackingPolicy_apply, Bool.if_true_left, Bool.decide_eq_true, Functor.map_map, StateT.run_mk, hcache, QueryImpl.withCaching_apply, StateT.run_bind, StateT.run_get, pure_bind, StateT.run_pure, relTriple_iff_relWP, MAlgRelOrdered.relWP_pure, and_self] | none => simp only [add_apply_inr, QueryImpl.withCachingTrackingPolicy_apply, Bool.if_true_left, Bool.decide_eq_true, Functor.map_map, StateT.run_mk, hcache, QueryImpl.withCaching_apply, StateT.run_bind, StateT.run_get, pure_bind, StateT.run_monadLift, monadLift_self, bind_pure_comp, StateT.run_modifyGet] refine relTriple_map (relTriple_post_mono (relTriple_refl _) ?_) intro x y hxy cases hxy exact ⟨rfl, rfl⟩ ``` ::: [`ROMUpToBad.lean:269-294 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L269-L294) ## [`tvDist_run'_roImpl_cacheOr_le_probEvent_bad`](/lean/proofs/romuptobad#tvdist_run_roimpl_cacheor_le_probevent_bad) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem tvDist_run'_roImpl_cacheOr_le_probEvent_bad {α : Type} (oa : OracleComp (unifSpec + hashSpec) α) (cache₀ : hashSpec.QueryCache) : tvDist ((simulateQ (roImpl hashSpec) oa).run' (cacheOr cache₀ policy)) ((simulateQ (roImpl hashSpec) oa).run' cache₀) ≤ Pr[fun z : α × hashSpec.QueryCache × Bool => z.2.2 = true | (simulateQ (programmedROImpl policy) oa).run (cache₀, false)].toReal := by ``` :::details[proof] ```lean have hB := evalDist_eq_of_relTriple_eqRel (relTriple_run'_programmedROImpl_roImpl policy oa cache₀) have hC := evalDist_eq_of_relTriple_eqRel (relTriple_run'_trackingROImpl_roImpl policy oa cache₀) calc tvDist ((simulateQ (roImpl hashSpec) oa).run' (cacheOr cache₀ policy)) ((simulateQ (roImpl hashSpec) oa).run' cache₀) = tvDist ((simulateQ (programmedROImpl policy) oa).run' (cache₀, false)) ((simulateQ (trackingROImpl policy) oa).run' (cache₀, false)) := by rw [tvDist, tvDist, hB, hC] _ ≤ tvDist ((simulateQ (programmedROImpl policy) oa).run (cache₀, false)) ((simulateQ (trackingROImpl policy) oa).run (cache₀, false)) := by rw [StateT.run', StateT.run'] exact tvDist_map_le Prod.fst _ _ _ ≤ _ := tvDist_programmedROImpl_trackingROImpl_le policy oa cache₀ ``` ::: [`ROMUpToBad.lean:298-316 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L298-L316) ## [`probEvent_flag_programmed_eq_tracking`](/lean/proofs/romuptobad#probevent_flag_programmed_eq_tracking) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem probEvent_flag_programmed_eq_tracking {α : Type} (oa : OracleComp (unifSpec + hashSpec) α) (cache₀ : hashSpec.QueryCache) : Pr[fun z => z.2.2 = true | (simulateQ (programmedROImpl policy) oa).run (cache₀, false)] = Pr[fun z => z.2.2 = true | (simulateQ (trackingROImpl policy) oa).run (cache₀, false)] := by ``` :::details[proof] ```lean refine OracleComp.ProgramLogic.Relational.probEvent_output_bad_eq' (programmedROImpl policy) (trackingROImpl policy) ?_ ?_ ?_ oa cache₀ · rintro (n | t) s u s' · rfl · exact probOutput_withProgramming_eq_withCachingTrackingPolicy_of_not_bad_output policy t s u s' · rintro (n | t) ⟨c, b⟩ hp z hz · cases (show b = true from hp) simp only [add_apply_inl, programmedROImpl, QueryImpl.add_apply_inl, unifFwdFlagImpl_run, support_map, ProbComp.support_uniformFin, Set.image_univ] at hz obtain ⟨x, rfl⟩ := hz rfl · cases (show b = true from hp) exact QueryImpl.withProgramming_bad_monotone (so := uniformSampleImpl) (policy := policy) t c z hz · rintro (n | t) ⟨c, b⟩ hp z hz · cases (show b = true from hp) simp only [add_apply_inl, trackingROImpl, QueryImpl.add_apply_inl, unifFwdFlagImpl_run, support_map, ProbComp.support_uniformFin, Set.image_univ] at hz obtain ⟨x, rfl⟩ := hz rfl · cases (show b = true from hp) exact QueryImpl.withCachingTrackingPolicy_bad_monotone (so := uniformSampleImpl) (policy := policy) t c z hz ``` ::: [`ROMUpToBad.lean:321-351 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L321-L351) ### Projections: programmed and tracking runs against the plain random oracle ## [`pointPolicy`](/lean/proofs/romuptobad#pointpolicy) The policy programming one point `t ↦ u`. ```lean def pointPolicy (t : hashSpec.Domain) (u : hashSpec.Range t) : hashSpec.ProgrammingPolicy := fun t' => if h : t' = t then some (h ▸ u) else none omit [∀ t : hashSpec.Domain, SampleableType (hashSpec.Range t)] in ``` [`ROMUpToBad.lean:361-364 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L361-L364) ## [`cacheOr_empty_pointPolicy`](/lean/proofs/romuptobad#cacheor_empty_pointpolicy) ```lean theorem cacheOr_empty_pointPolicy (t : hashSpec.Domain) (u : hashSpec.Range t) : cacheOr ∅ (pointPolicy t u) = (∅ : hashSpec.QueryCache).cacheQuery t u := by ``` :::details[proof] ```lean funext t' by_cases h : t' = t · subst h simp only [cacheOr_apply, QueryCache.empty_apply, Option.none_or, pointPolicy, dite_true, QueryCache.cacheQuery_self] · simp only [cacheOr_apply, QueryCache.empty_apply, Option.none_or, pointPolicy, h, dite_false, QueryCache.cacheQuery_of_ne _ _ h] ``` ::: [`ROMUpToBad.lean:366-375 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L366-L375) ## [`badQueryGame`](/lean/proofs/romuptobad#badquerygame) ```lean noncomputable def badQueryGame {γ : Type} (pre : ProbComp γ) (oa : γ → OracleComp (unifSpec + hashSpec) Bool) (pt : γ → hashSpec.Domain) (val : (x : γ) → hashSpec.Range (pt x)) : ProbComp Bool := pre >>= fun x => (fun z => z.2.2) <$> (simulateQ (programmedROImpl (pointPolicy (pt x) (val x))) (oa x)).run (∅, false) ``` [`ROMUpToBad.lean:377-383 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L377-L383) ## [`boolDistAdvantage_run'_cacheQuery_run'_empty_le`](/lean/proofs/romuptobad#booldistadvantage_run_cachequery_run_empty_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem boolDistAdvantage_run'_cacheQuery_run'_empty_le {γ : Type} (pre : ProbComp γ) (oa : γ → OracleComp (unifSpec + hashSpec) Bool) (pt : γ → hashSpec.Domain) (val : (x : γ) → hashSpec.Range (pt x)) : ProbComp.boolDistAdvantage (pre >>= fun x => (simulateQ (roImpl hashSpec) (oa x)).run' ((∅ : hashSpec.QueryCache).cacheQuery (pt x) (val x))) (pre >>= fun x => (simulateQ (roImpl hashSpec) (oa x)).run' ∅) ≤ (Pr[= true | badQueryGame pre oa pt val]).toReal := by ``` :::details[proof] ```lean classical set f : γ → ProbComp Bool := fun x => (simulateQ (roImpl hashSpec) (oa x)).run' ((∅ : hashSpec.QueryCache).cacheQuery (pt x) (val x)) set g : γ → ProbComp Bool := fun x => (simulateQ (roImpl hashSpec) (oa x)).run' ∅ set bad : γ → ℝ≥0∞ := fun x => Pr[fun z : Bool × hashSpec.QueryCache × Bool => z.2.2 = true | (simulateQ (programmedROImpl (pointPolicy (pt x) (val x))) (oa x)).run (∅, false)] have hterm : ∀ x, tvDist (f x) (g x) ≤ (bad x).toReal := by intro x have := tvDist_run'_roImpl_cacheOr_le_probEvent_bad (pointPolicy (pt x) (val x)) (oa x) ∅ rwa [cacheOr_empty_pointPolicy] at this have hbadGame : Pr[= true | badQueryGame pre oa pt val] = ∑' x, Pr[= x | pre] * bad x := by rw [badQueryGame, probOutput_bind_eq_tsum] refine tsum_congr fun x => ?_ rw [probOutput_map] have hne_top : ∀ x, Pr[= x | pre] * bad x ≠ ⊤ := fun x => ENNReal.mul_ne_top (ne_top_of_le_ne_top one_ne_top probOutput_le_one) (ne_top_of_le_ne_top one_ne_top probEvent_le_one) have hsum_ne_top : (∑' x, Pr[= x | pre] * bad x) ≠ ⊤ := by rw [← hbadGame] exact ne_top_of_le_ne_top one_ne_top probOutput_le_one have hsummable : Summable fun x => (Pr[= x | pre] * bad x).toReal := ENNReal.summable_toReal hsum_ne_top calc ProbComp.boolDistAdvantage (pre >>= f) (pre >>= g) ≤ tvDist (pre >>= f) (pre >>= g) := abs_probOutput_toReal_sub_le_tvDist _ _ _ ≤ ∑' x, Pr[= x | pre].toReal * tvDist (f x) (g x) := tvDist_bind_left_le pre f g _ ≤ ∑' x, (Pr[= x | pre] * bad x).toReal := by refine Summable.tsum_le_tsum (fun x => ?_) ?_ hsummable · rw [ENNReal.toReal_mul] exact mul_le_mul_of_nonneg_left (hterm x) ENNReal.toReal_nonneg · exact hsummable.of_nonneg_of_le (fun x => mul_nonneg ENNReal.toReal_nonneg (tvDist_nonneg _ _)) (fun x => by rw [ENNReal.toReal_mul] exact mul_le_mul_of_nonneg_left (hterm x) ENNReal.toReal_nonneg) _ = (Pr[= true | badQueryGame pre oa pt val]).toReal := by rw [hbadGame, ENNReal.tsum_toReal_eq hne_top] ``` ::: [`ROMUpToBad.lean:387-432 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L387-L432) ### Programming a single point, and the averaged bound ## [`probEvent_bind_le_const`](/lean/proofs/romuptobad#probevent_bind_le_const) A bound that holds on every support point of the continuation bounds the bind. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem probEvent_bind_le_const {α β : Type} (mx : ProbComp α) (my : α → ProbComp β) (q : β → Prop) (c : ℝ≥0∞) (h : ∀ x ∈ support mx, Pr[q | my x] ≤ c) : Pr[q | mx >>= my] ≤ c := by ``` :::details[proof] ```lean rw [probEvent_bind_eq_tsum] calc ∑' x, Pr[= x | mx] * Pr[q | my x] ≤ ∑' x, Pr[= x | mx] * c := by refine ENNReal.tsum_le_tsum fun x => ?_ by_cases hx : x ∈ support mx · exact mul_le_mul' le_rfl (h x hx) · simp [probOutput_eq_zero_of_not_mem_support hx] _ = (∑' x, Pr[= x | mx]) * c := ENNReal.tsum_mul_right _ ≤ 1 * c := mul_le_mul' tsum_probOutput_le_one le_rfl _ = c := one_mul c ``` ::: [`ROMUpToBad.lean:453-466 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L453-L466) ## [`probEvent_flag_trackingROImpl_pointPolicy_le`](/lean/proofs/romuptobad#probevent_flag_trackingroimpl_pointpolicy_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem probEvent_flag_trackingROImpl_pointPolicy_le {α : Type} (oa : OracleComp (unifSpec + hashSpec) α) (pt : hashSpec.Domain) (v : hashSpec.Range pt) (cache₀ : hashSpec.QueryCache) (h₀ : cache₀ pt = none) : Pr[fun z => z.2.2 = true | (simulateQ (trackingROImpl (pointPolicy pt v)) oa).run (cache₀, false)] ≤ Pr[fun z => QueryCache.isCached z.2 pt = true | (simulateQ (roImpl hashSpec) oa).run cache₀] := by ``` :::details[proof] ```lean have hrel := relTriple_simulateQ_run (trackingROImpl (pointPolicy pt v)) (roImpl hashSpec) (fun p c => c = p.1 ∧ p.2 = (p.1 pt).isSome) oa ?_ (cache₀, false) cache₀ ⟨rfl, by simp [h₀]⟩ · refine probEvent_le_of_relTriple hrel ?_ rintro a b ⟨-, hc, hflag⟩ hbad show (b.2 pt).isSome = true rw [hc, ← hflag] exact hbad · rintro (n | t) ⟨c, bad⟩ c' ⟨hc', hbad⟩ · exact relTriple_unifFwdFlagImpl_unifFwdImpl (R := fun p c => c = p.1 ∧ p.2 = (p.1 pt).isSome) n (c, bad) c' ⟨hc', hbad⟩ · subst hc' simp only [trackingROImpl, roImpl, QueryImpl.add_apply_inr, OracleSpec.randomOracle] cases hcache : c' t with | some w => simp only [add_apply_inr, QueryImpl.withCachingTrackingPolicy_apply, Functor.map_map, StateT.run_mk, hcache, QueryImpl.withCaching_apply, StateT.run_bind, StateT.run_get, pure_bind, StateT.run_pure, relTriple_iff_relWP, MAlgRelOrdered.relWP_pure] refine ⟨trivial, trivial, ?_⟩ simpa using hbad | none => simp only [add_apply_inr, QueryImpl.withCachingTrackingPolicy_apply, Functor.map_map, StateT.run_mk, hcache, QueryImpl.withCaching_apply, StateT.run_bind, StateT.run_get, pure_bind, StateT.run_monadLift, monadLift_self, bind_pure_comp, StateT.run_modifyGet] refine relTriple_map (relTriple_post_mono (relTriple_refl _) ?_) intro x y hxy cases hxy dsimp only at hbad ⊢ refine ⟨rfl, rfl, ?_⟩ by_cases ht : t = pt · subst ht simp [pointPolicy] · simp only [pointPolicy, ht, dite_false, Option.isSome_none, Bool.false_eq_true, if_false, QueryCache.cacheQuery_of_ne _ _ (Ne.symm ht)] exact hbad ``` ::: [`ROMUpToBad.lean:470-510 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L470-L510) ## [`roImpl_run_keys_subset`](/lean/proofs/romuptobad#roimpl_run_keys_subset) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem roImpl_run_keys_subset {α : Type} (oa : OracleComp (unifSpec + hashSpec) α) (n : ℕ) (hq : IsQueryBoundP oa (fun i => i.isRight = true) n) (cache₀ : hashSpec.QueryCache) : ∀ z ∈ support ((simulateQ (roImpl hashSpec) oa).run cache₀), ∃ S : Finset hashSpec.Domain, S.card ≤ n ∧ ∀ t, QueryCache.isCached z.2 t = true → QueryCache.isCached cache₀ t = true ∨ t ∈ S := by ``` :::details[proof] ```lean induction oa using OracleComp.inductionOn generalizing n cache₀ with | pure x => intro z hz simp only [simulateQ_pure, StateT.run_pure, support_pure, Set.mem_singleton_iff] at hz subst hz exact ⟨∅, by simp, fun t h => Or.inl h⟩ | query_bind t mx ih => intro z hz rw [isQueryBoundP_query_bind_iff] at hq obtain ⟨hvalid, hrest⟩ := hq simp only [simulateQ_bind, simulateQ_query, OracleQuery.input_query, OracleQuery.cont_query, id_map, StateT.run_bind, support_bind, Set.mem_iUnion] at hz obtain ⟨⟨u, c'⟩, hstep, hz⟩ := hz rcases t with n' | t · have hrun : (roImpl hashSpec (Sum.inl n')).run cache₀ = ((fun x => (x, cache₀)) <$> ($[0..n']) : ProbComp ((unifSpec + hashSpec).Range (Sum.inl n') × hashSpec.QueryCache)) := rfl have hb := hrest u simp only [Sum.isRight_inl, Bool.false_eq_true, if_false] at hb rw [hrun] at hstep simp only [support_map, Set.mem_image, Prod.mk.injEq] at hstep obtain ⟨x, -, rfl, rfl⟩ := hstep exact ih _ n hb _ z hz · have hb := hrest u simp only [Sum.isRight_inr, if_true] at hb simp only [Sum.isRight_inr, not_true_eq_false, false_or] at hvalid cases hcache : cache₀ t with | some w => have hrun : (roImpl hashSpec (Sum.inr t)).run cache₀ = (pure (w, cache₀) : ProbComp ((unifSpec + hashSpec).Range (Sum.inr t) × hashSpec.QueryCache)) := by show (hashSpec.randomOracle t).run cache₀ = _ simp [StateT.run_bind, StateT.run_get, hcache] rw [hrun] at hstep simp only [support_pure, Set.mem_singleton_iff, Prod.mk.injEq] at hstep obtain ⟨rfl, rfl⟩ := hstep obtain ⟨S, hS, hkeys⟩ := ih _ (n - 1) hb _ z hz exact ⟨S, hS.trans (Nat.sub_le n 1), hkeys⟩ | none => have hrun : (roImpl hashSpec (Sum.inr t)).run cache₀ = (($ᵗ hashSpec.Range t) >>= fun x => pure (x, cache₀.cacheQuery t x) : ProbComp ((unifSpec + hashSpec).Range (Sum.inr t) × hashSpec.QueryCache)) := by show (hashSpec.randomOracle t).run cache₀ = _ simp [StateT.run_bind, StateT.run_get, hcache, StateT.run_monadLift, monadLift_self, StateT.run_modifyGet, uniformSampleImpl] rw [hrun] at hstep simp only [support_bind, support_pure, Set.mem_iUnion, Set.mem_singleton_iff, Prod.mk.injEq] at hstep obtain ⟨x, -, rfl, rfl⟩ := hstep obtain ⟨S, hS, hkeys⟩ := ih _ (n - 1) hb _ z hz refine ⟨insert t S, ?_, ?_⟩ · calc (insert t S).card ≤ S.card + 1 := Finset.card_insert_le _ _ _ ≤ n := by omega · intro t' h rcases hkeys t' h with h' | h' · by_cases htt : t' = t · subst htt exact Or.inr (Finset.mem_insert_self _ _) · rw [QueryCache.isCached_cacheQuery_of_ne _ _ htt] at h' exact Or.inl h' · exact Or.inr (Finset.mem_insert_of_mem h') omit [DecidableEq ι] [∀ t : hashSpec.Domain, SampleableType (hashSpec.Range t)] in ``` ::: [`ROMUpToBad.lean:514-582 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L514-L582) ## [`probEvent_isSome_apply_le`](/lean/proofs/romuptobad#probevent_issome_apply_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem probEvent_isSome_apply_le {γ : Type} (samp : ProbComp γ) (f : γ → hashSpec.Domain) (beta : ℝ≥0∞) (hB : ∀ x, Pr[fun y => f y = x | samp] ≤ beta) (c : hashSpec.QueryCache) (S : Finset hashSpec.Domain) (hS : ∀ t, QueryCache.isCached c t = true → t ∈ S) : Pr[fun y => QueryCache.isCached c (f y) = true | samp] ≤ S.card * beta := by ``` :::details[proof] ```lean calc Pr[fun y => QueryCache.isCached c (f y) = true | samp] ≤ Pr[fun y => ∃ t ∈ S, f y = t | samp] := probEvent_mono'' fun y hy => ⟨f y, hS _ hy, rfl⟩ _ ≤ ∑ t ∈ S, Pr[fun y => f y = t | samp] := probEvent_exists_finset_le_sum S samp (fun t y => f y = t) _ ≤ ∑ _t ∈ S, beta := Finset.sum_le_sum fun t _ => hB t _ = S.card * beta := by rw [Finset.sum_const, nsmul_eq_mul] ``` ::: [`ROMUpToBad.lean:585-597 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/ROMUpToBad.lean#L585-L597) # SharedSecretHiding :::info[Mirrored page] Source: [`lean/docs-proofs/sharedsecrethiding.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/sharedsecrethiding.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: each shared-secret-hiding term of [`unlinkAdvantage_ofKEMFull_le`](/lean/proofs/kemanonymity#unlinkadvantage_ofkemfull_le) is the bias of a real-or-random guessing game, and that bias EQUALS `KEMScheme.IND_CPA_Advantage` of the explicit reduction adversary [`indCpaAdv`](/lean/proofs/sharedsecrethiding#indcpaadv) against the KEM itself. The two experiments draw the same independent samples in different orders, with one draw unused on one branch; `bind` is a syntactic constructor, so this holds after `evalDist` rather than on the nose, which is all an advantage sees. Hence [`unlinkAdvantage_ofKEMFull_le_indCpa`](/lean/proofs/sharedsecrethiding#unlinkadvantage_ofkemfull_le_indcpa). Assumed: nothing. Bounding `KEMScheme.IND_CPA_Advantage` for ML-KEM by MLWE is the missing upstream lemma recorded in `MLKEM.lean`. See `docs/spr-two-hop.md`. ## [`indCpaAdv`](/lean/proofs/sharedsecrethiding#indcpaadv) IND-CPA reduction adversary for branch `b`: the challenge key is embedded as recipient `b`, the other recipient is generated by the reduction, and the state carries both keys so the auxiliary data can be rebuilt from the challenge key. ```lean def indCpaAdv (b : Bool) : kem.IND_CPA_Adversary where State := PK × PK preChallenge pk := do let (pk', _) ← kem.keygen pure (if b then (pk', pk) else (pk, pk')) postChallenge s cStar k := adv s.1 s.2 (cStar, auxGen k (if b then s.2 else s.1)) ``` [`SharedSecretHiding.lean:35-41 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SharedSecretHiding.lean#L35-L41) ### The concrete VCVio IND-CPA reduction adversary ## [`evalDist_bind_pull_front`](/lean/proofs/sharedsecrethiding#evaldist_bind_pull_front) An independent draw may be pulled to the front of a two-step prefix (the dependent-prefix form of `Reorder.evalDist_pull₃`). ```lean theorem evalDist_bind_pull_front {α β δ ζ : Type} (oa : ProbComp α) (ob : α → ProbComp β) (od : ProbComp δ) (k : α → β → δ → ProbComp ζ) : 𝒟[(do let a ← oa; let b ← ob a; let d ← od; k a b d)] = 𝒟[(do let d ← od; let a ← oa; let b ← ob a; k a b d)] := by ``` :::details[proof] ```lean calc 𝒟[(do let a ← oa; let b ← ob a; let d ← od; k a b d)] = 𝒟[(do let a ← oa; let d ← od; let b ← ob a; k a b d)] := by refine evalDist_bind_congr' _ fun a => ?_ exact evalDist_bind_bind_swap (ob a) od fun b d => k a b d _ = 𝒟[(do let d ← od; let a ← oa; let b ← ob a; k a b d)] := evalDist_bind_bind_swap oa od _ ``` ::: [`SharedSecretHiding.lean:51-62 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SharedSecretHiding.lean#L51-L62) ## [`boolBiasAdvantage_congr`](/lean/proofs/sharedsecrethiding#boolbiasadvantage_congr) ```lean theorem boolBiasAdvantage_congr {p q : ProbComp Bool} (h : 𝒟[p] = 𝒟[q]) : p.boolBiasAdvantage = q.boolBiasAdvantage := by ``` :::details[proof] ```lean simp only [ProbComp.boolBiasAdvantage, probOutput_def, h] ``` ::: [`SharedSecretHiding.lean:64-67 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SharedSecretHiding.lean#L64-L67) ### Distribution-level bridges ## [`rorNormalForm`](/lean/proofs/sharedsecrethiding#rornormalform) Normal form of the branch-`b` reduction game: hidden bit, keypair 0, keypair 1, the challenge encapsulation to recipient `b`, then the idealized shared secret -- drawn on both branches of the hidden bit. ```lean noncomputable def rorNormalForm (b : Bool) : ProbComp Bool := do let hb ← ($ᵗ Bool) let pk0 ← kem.keygen let pk1 ← kem.keygen let ck ← kem.encaps (if b then pk1.1 else pk0.1) let kRand ← ($ᵗ K) let z ← adv pk0.1 pk1.1 (ck.1, auxGen (if hb then ck.2 else kRand) (if b then pk1.1 else pk0.1)) pure (hb == z) ``` [`SharedSecretHiding.lean:78-87 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SharedSecretHiding.lean#L78-L87) ## [`indCpaGame_eq_evalDist_normalForm`](/lean/proofs/sharedsecrethiding#indcpagame_eq_evaldist_normalform) ```lean theorem indCpaGame_eq_evalDist_normalForm (b : Bool) : KEMScheme.IND_CPA_Game ProbCompRuntime.probComp (indCpaAdv kem auxGen adv b) = 𝒟[rorNormalForm kem auxGen adv b] := by ``` :::details[proof] ```lean cases b with | true => have hgame : KEMScheme.IND_CPA_Game ProbCompRuntime.probComp (indCpaAdv kem auxGen adv true) = 𝒟[(do let pk1 ← kem.keygen let pk0 ← kem.keygen let hb ← ($ᵗ Bool) let ck ← kem.encaps pk1.1 let kRand ← ($ᵗ K) let z ← adv pk0.1 pk1.1 (ck.1, auxGen (if hb then ck.2 else kRand) pk1.1) pure (hb == z) : ProbComp Bool)] := by simp only [KEMScheme.IND_CPA_Game, indCpaAdv, bind_assoc, pure_bind, ProbCompRuntime.probComp, ProbCompRuntime.liftProbComp, ProbCompLift.id] rfl rw [hgame, evalDist_bind_bind_swap kem.keygen kem.keygen (fun pk1 pk0 => do let hb ← ($ᵗ Bool) let ck ← kem.encaps pk1.1 let kRand ← ($ᵗ K) let z ← adv pk0.1 pk1.1 (ck.1, auxGen (if hb then ck.2 else kRand) pk1.1) pure (hb == z))] exact evalDist_bind_pull_front kem.keygen (fun _ => kem.keygen) ($ᵗ Bool) (fun pk0 pk1 hb => do let ck ← kem.encaps pk1.1 let kRand ← ($ᵗ K) let z ← adv pk0.1 pk1.1 (ck.1, auxGen (if hb then ck.2 else kRand) pk1.1) pure (hb == z)) | false => have hgame : KEMScheme.IND_CPA_Game ProbCompRuntime.probComp (indCpaAdv kem auxGen adv false) = 𝒟[(do let pk0 ← kem.keygen let pk1 ← kem.keygen let hb ← ($ᵗ Bool) let ck ← kem.encaps pk0.1 let kRand ← ($ᵗ K) let z ← adv pk0.1 pk1.1 (ck.1, auxGen (if hb then ck.2 else kRand) pk0.1) pure (hb == z) : ProbComp Bool)] := by simp only [KEMScheme.IND_CPA_Game, indCpaAdv, bind_assoc, pure_bind, ProbCompRuntime.probComp, ProbCompRuntime.liftProbComp, ProbCompLift.id] rfl rw [hgame] exact evalDist_bind_pull_front kem.keygen (fun _ => kem.keygen) ($ᵗ Bool) (fun pk0 pk1 hb => do let ck ← kem.encaps pk0.1 let kRand ← ($ᵗ K) let z ← adv pk0.1 pk1.1 (ck.1, auxGen (if hb then ck.2 else kRand) pk0.1) pure (hb == z)) ``` ::: [`SharedSecretHiding.lean:91-143 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SharedSecretHiding.lean#L91-L143) ### The hiding terms ARE VCVio IND-CPA advantages ## [`rorGame`](/lean/proofs/sharedsecrethiding#rorgame) The shared-secret real-or-random game on branch `b`: real secret or fresh random key, from the SAME public key on both sides. Orientation is VCVio's -- the REAL secret sits on hidden bit `true`, for both values of `b`. ```lean noncomputable def rorGame (b : Bool) : ProbComp Bool := do let hb ← ($ᵗ Bool) let z ← if hb then (StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= (StealthScheme.ofKEMFull kem auxGen).unlinkBranch adv b else (StealthScheme.ofKEMFull kem auxGen).unlinkSetup >>= randAuxBranch kem auxGen adv b pure (hb == z) ``` [`SharedSecretHiding.lean:151-160 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SharedSecretHiding.lean#L151-L160) ## [`sharedSecretHiding_eq_rorBias`](/lean/proofs/sharedsecrethiding#sharedsecrethiding_eq_rorbias) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem sharedSecretHiding_eq_rorBias (b : Bool) : sharedSecretHiding kem auxGen adv b = (rorGame kem auxGen adv b).boolBiasAdvantage := by ``` :::details[proof] ```lean unfold sharedSecretHiding rorGame exact (ProbComp.boolBiasAdvantage_eq_boolDistAdvantage_uniformBool_branch _ _).symm ``` ::: [`SharedSecretHiding.lean:163-167 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SharedSecretHiding.lean#L163-L167) ## [`evalDist_rorGame_eq_normalForm`](/lean/proofs/sharedsecrethiding#evaldist_rorgame_eq_normalform) ```lean theorem evalDist_rorGame_eq_normalForm (b : Bool) : 𝒟[rorGame kem auxGen adv b] = 𝒟[rorNormalForm kem auxGen adv b] := by ``` :::details[proof] ```lean unfold rorGame rorNormalForm refine evalDist_bind_congr' _ fun hb => ?_ cases hb with | true => simp only [StealthScheme.unlinkSetup, StealthScheme.unlinkBranch, StealthScheme.ofKEMFull, bind_assoc, pure_bind, if_true] refine evalDist_bind_congr' _ fun pk0 => evalDist_bind_congr' _ fun pk1 => evalDist_bind_congr' _ fun ck => ?_ exact (evalDist_uniformSample_bind_const _).symm | false => simp only [StealthScheme.unlinkSetup, randAuxBranch, StealthScheme.ofKEMFull, bind_assoc, pure_bind, Bool.false_eq_true, if_false] ``` ::: [`SharedSecretHiding.lean:170-184 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SharedSecretHiding.lean#L170-L184) ## [`sharedSecretHiding_eq_indCpaAdvantage`](/lean/proofs/sharedsecrethiding#sharedsecrethiding_eq_indcpaadvantage) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem sharedSecretHiding_eq_indCpaAdvantage (b : Bool) : sharedSecretHiding kem auxGen adv b = KEMScheme.IND_CPA_Advantage ProbCompRuntime.probComp (indCpaAdv kem auxGen adv b) := by ``` :::details[proof] ```lean rw [sharedSecretHiding_eq_rorBias, KEMScheme.IND_CPA_Advantage, indCpaGame_eq_evalDist_normalForm, boolBiasAdvantage_congr (evalDist_rorGame_eq_normalForm kem auxGen adv b)] rfl ``` ::: [`SharedSecretHiding.lean:187-194 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SharedSecretHiding.lean#L187-L194) ## [`unlinkAdvantage_ofKEMFull_le_indCpa`](/lean/proofs/sharedsecrethiding#unlinkadvantage_ofkemfull_le_indcpa) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem unlinkAdvantage_ofKEMFull_le_indCpa : (StealthScheme.ofKEMFull kem auxGen).unlinkAdvantage adv ≤ KEMScheme.IND_CPA_Advantage ProbCompRuntime.probComp (indCpaAdv kem auxGen adv true) + auxKeyIndependence kem auxGen adv + kem.anonAdvantage (adv.cipherOf auxGen) + KEMScheme.IND_CPA_Advantage ProbCompRuntime.probComp (indCpaAdv kem auxGen adv false) := by ``` :::details[proof] ```lean rw [← sharedSecretHiding_eq_indCpaAdvantage, ← sharedSecretHiding_eq_indCpaAdvantage] exact unlinkAdvantage_ofKEMFull_le kem auxGen adv ``` ::: [`SharedSecretHiding.lean:198-207 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SharedSecretHiding.lean#L198-L207) # Soundness :::info[Mirrored page] Source: [`lean/docs-proofs/soundness.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/soundness.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: DKSAP's false-positive rate is EXACTLY `1 / |F|`, for every hash `h` -- the leak is the uniform spending scalar, not a hash collision; and for the KEM-based scheme, `falsePositiveRate ≤ ε + decapsRoR`, where `ε` bounds tag collisions over a uniform secret and [`decapsRoR`](/lean/proofs/soundness#decapsror) is the real-or-random gap of recipient 0's decapsulated key (a KEM IND-CPA question, as everywhere else), with `decapsRoR = 0` under an ideal decapsulation; and the view-tag instance, `SoundWithin (1/256 + decapsRoR)` for a one-byte tag. Assumed: tag collision-freeness and the uniformity of `viewTag` on a uniform key are hypotheses, as is the ideal decapsulation. See `docs/announcement-model.md`. ## [`probOutput_true_bind_decide_eq`](/lean/proofs/soundness#proboutput_true_bind_decide_eq) Testing a draw against a fixed value returns `true` exactly as often as the draw hits it. ```lean theorem probOutput_true_bind_decide_eq {α : Type} [DecidableEq α] (p : ProbComp α) (t : α) : Pr[= true | (do let x ← p; pure (decide (x = t)))] = Pr[= t | p] := by ``` :::details[proof] ```lean rw [bind_pure_comp, probOutput_map] simp only [decide_eq_true_eq] exact probEvent_eq_eq_probOutput p t ``` ::: [`Soundness.lean:34-39 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L34-L39) ## [`probOutput_true_bind_le`](/lean/proofs/soundness#proboutput_true_bind_le) ```lean theorem probOutput_true_bind_le {α : Type} (p : ProbComp α) (f : α → ProbComp Bool) (c : ENNReal) (h : ∀ a, Pr[= true | f a] ≤ c) : Pr[= true | p >>= f] ≤ c := by ``` :::details[proof] ```lean rw [← probEvent_eq_eq_probOutput] exact probEvent_bind_le_of_forall_le fun a _ => by rw [probEvent_eq_eq_probOutput]; exact h a ``` ::: [`Soundness.lean:42-48 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L42-L48) ## [`probOutput_true_bind_eq`](/lean/proofs/soundness#proboutput_true_bind_eq) ```lean theorem probOutput_true_bind_eq {α : Type} (p : ProbComp α) (f : α → ProbComp Bool) (c : ENNReal) (h : ∀ a, Pr[= true | f a] = c) : Pr[= true | p >>= f] = c := by ``` :::details[proof] ```lean rw [probOutput_bind_of_const p (fun a _ => h a), probFailure_of_liftM_PMF, tsub_zero, one_mul] ``` ::: [`Soundness.lean:52-56 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L52-L56) ### Bookkeeping for `Pr[= true | …]` over a bind ## [`probOutput_falsePositiveExp_dksap_eq`](/lean/proofs/soundness#proboutput_falsepositiveexp_dksap_eq) The false-positive probability of DKSAP is exactly `|F|⁻¹`, for every hash `h`: injectivity of `x ↦ x • g` turns the scan test into a linear equation in recipient 1's uniform spending scalar. ```lean theorem probOutput_falsePositiveExp_dksap_eq (g : G) (h : G → F) (hinj : Function.Injective (fun x : F => x • g)) : Pr[= true | (dksap g h).FalsePositiveExp] = (Fintype.card F : ENNReal)⁻¹ := by ``` :::details[proof] ```lean have key : ∀ m0 v0 m1 v1 r : F, (m1 • g + h (r • v1 • g) • g = (m0 + h (v0 • r • g)) • g) ↔ (m1 = m0 + h (v0 • r • g) - h (r • v1 • g)) := by intro m0 v0 m1 v1 r rw [← add_smul, eq_sub_iff_add_eq] exact ⟨fun hEq => hinj hEq, fun hEq => congrArg (fun x : F => x • g) hEq⟩ have base : ∀ c : F, Pr[= true | (do let m1 ← ($ᵗ F); pure (decide (m1 = c)))] = (Fintype.card F : ENNReal)⁻¹ := fun c => by rw [probOutput_true_bind_decide_eq, probOutput_uniformSample] simp only [StealthScheme.FalsePositiveExp, dksap, bind_assoc, pure_bind, key] refine probOutput_true_bind_eq _ _ _ fun m0 => ?_ refine probOutput_true_bind_eq _ _ _ fun v0 => ?_ rw [probOutput_bind_bind_swap] refine probOutput_true_bind_eq _ _ _ fun v1 => ?_ rw [probOutput_bind_bind_swap] refine probOutput_true_bind_eq _ _ _ fun r => ?_ exact base _ ``` ::: [`Soundness.lean:72-92 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L72-L92) ## [`dksap_falsePositiveRate_eq`](/lean/proofs/soundness#dksap_falsepositiverate_eq) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksap_falsePositiveRate_eq (g : G) (h : G → F) (hinj : Function.Injective (fun x : F => x • g)) : (dksap g h).falsePositiveRate = 1 / (Fintype.card F : ℝ) := by ``` :::details[proof] ```lean rw [StealthScheme.falsePositiveRate, probOutput_falsePositiveExp_dksap_eq g h hinj, ENNReal.toReal_inv, ENNReal.toReal_natCast, one_div] ``` ::: [`Soundness.lean:96-101 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L96-L101) ## [`dksap_falsePositiveRate_le`](/lean/proofs/soundness#dksap_falsepositiverate_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem dksap_falsePositiveRate_le (g : G) (h : G → F) (hinj : Function.Injective (fun x : F => x • g)) : (dksap g h).SoundWithin (1 / (Fintype.card F : ℝ)) := le_of_eq (dksap_falsePositiveRate_eq g h hinj) ``` [`Soundness.lean:103-107 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L103-L107) ### DKSAP: the exact false-positive rate ## [`AuxCollisionFree`](/lean/proofs/soundness#auxcollisionfree) The tag is `ε`-collision-free: over a UNIFORM secret, [`auxGen`](/lean/proofs/constructiona#auxgen) hits any prescribed value with probability at most `ε`. For a view tag this is a counting argument on the tag alphabet. ```lean def AuxCollisionFree (auxGen : K → PK → Aux) (ε : ℝ) : Prop := ∀ (a : Aux) (pk : PK), (Pr[= true | (do let k ← ($ᵗ K); pure (decide (a = auxGen k pk)))]).toReal ≤ ε ``` [`Soundness.lean:125-128 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L125-L128) ## [`falsePositiveIdeal`](/lean/proofs/soundness#falsepositiveideal) ```lean def falsePositiveIdeal : ProbComp Bool := do let ks0 ← kem.keygen let ks1 ← kem.keygen let ck ← kem.encaps ks1.1 let k ← ($ᵗ K) pure (decide (auxGen ck.2 ks1.1 = auxGen k ks0.1)) ``` [`Soundness.lean:132-138 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L132-L138) ## [`decapsRoR`](/lean/proofs/soundness#decapsror) ```lean noncomputable def decapsRoR : ℝ := (StealthScheme.ofKEMFull kem auxGen).FalsePositiveExp.boolDistAdvantage (falsePositiveIdeal kem auxGen) ``` [`Soundness.lean:142-145 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L142-L145) ## [`probOutput_falsePositiveIdeal_le`](/lean/proofs/soundness#proboutput_falsepositiveideal_le) ```lean theorem probOutput_falsePositiveIdeal_le (ε : ℝ) (hε : 0 ≤ ε) (hTag : AuxCollisionFree auxGen ε) : (Pr[= true | falsePositiveIdeal kem auxGen]).toReal ≤ ε := by ``` :::details[proof] ```lean have hbound : Pr[= true | falsePositiveIdeal kem auxGen] ≤ ENNReal.ofReal ε := by refine probOutput_true_bind_le _ _ _ fun ks0 => ?_ refine probOutput_true_bind_le _ _ _ fun ks1 => ?_ refine probOutput_true_bind_le _ _ _ fun ck => ?_ exact (ENNReal.le_ofReal_iff_toReal_le (ne_top_of_le_ne_top ENNReal.one_ne_top probOutput_le_one) hε).2 (hTag (auxGen ck.2 ks1.1) ks0.1) exact (ENNReal.le_ofReal_iff_toReal_le (ne_top_of_le_ne_top ENNReal.one_ne_top probOutput_le_one) hε).1 hbound ``` ::: [`Soundness.lean:148-160 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L148-L160) ## [`falsePositiveRate_ofKEMFull_le`](/lean/proofs/soundness#falsepositiverate_ofkemfull_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem falsePositiveRate_ofKEMFull_le (ε : ℝ) (hε : 0 ≤ ε) (hTag : AuxCollisionFree auxGen ε) : (StealthScheme.ofKEMFull kem auxGen).SoundWithin (ε + decapsRoR kem auxGen) := by ``` :::details[proof] ```lean have hgap : (StealthScheme.ofKEMFull kem auxGen).falsePositiveRate - (Pr[= true | falsePositiveIdeal kem auxGen]).toReal ≤ decapsRoR kem auxGen := le_abs_self _ have hideal := probOutput_falsePositiveIdeal_le kem auxGen ε hε hTag unfold StealthScheme.SoundWithin linarith ``` ::: [`Soundness.lean:164-173 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L164-L173) ## [`decapsRoR_eq_zero_of_decaps_uniform`](/lean/proofs/soundness#decapsror_eq_zero_of_decaps_uniform) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem decapsRoR_eq_zero_of_decaps_uniform (hdec : ∀ sk c, kem.decaps sk c = (do let k ← ($ᵗ K); pure (some k))) : decapsRoR kem auxGen = 0 := by ``` :::details[proof] ```lean have hEq : (StealthScheme.ofKEMFull kem auxGen).FalsePositiveExp = falsePositiveIdeal kem auxGen := by simp only [StealthScheme.FalsePositiveExp, StealthScheme.ofKEMFull, falsePositiveIdeal, hdec, bind_assoc, pure_bind, Option.elim_some] simp only [decapsRoR, hEq, ProbComp.boolDistAdvantage, sub_self, abs_zero] ``` ::: [`Soundness.lean:177-185 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L177-L185) ## [`falsePositiveRate_ofKEMFull_le_of_decaps_uniform`](/lean/proofs/soundness#falsepositiverate_ofkemfull_le_of_decaps_uniform) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem falsePositiveRate_ofKEMFull_le_of_decaps_uniform (ε : ℝ) (hε : 0 ≤ ε) (hTag : AuxCollisionFree auxGen ε) (hdec : ∀ sk c, kem.decaps sk c = (do let k ← ($ᵗ K); pure (some k))) : (StealthScheme.ofKEMFull kem auxGen).SoundWithin ε := by ``` :::details[proof] ```lean have h := falsePositiveRate_ofKEMFull_le kem auxGen ε hε hTag rw [decapsRoR_eq_zero_of_decaps_uniform kem auxGen hdec, add_zero] at h exact h ``` ::: [`Soundness.lean:188-195 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L188-L195) ### The KEM scheme: tag collisions plus a real-or-random term ## [`vectorEquivPi`](/lean/proofs/soundness#vectorequivpi) The equivalence underlying `instFintypeVector`, named so the fiber of a coordinate projection can be transferred from `Vector α n` to `Fin n → α`. ```lean noncomputable def vectorEquivPi (n : ℕ) : Vector α n ≃ (Fin n → α) where toFun := fun v i => v.get i invFun := Vector.ofFn left_inv := fun v => Vector.ext fun i hi => by simp [Vector.ofFn, Vector.get] right_inv := fun f => funext fun i => by simp [Vector.get, Vector.ofFn] ``` [`Soundness.lean:214-219 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L214-L219) ## [`card_fiber_eval`](/lean/proofs/soundness#card_fiber_eval) ```lean theorem card_fiber_eval' {α : Type} [Fintype α] [DecidableEq α] [Nonempty α] {n : ℕ} (i : Fin n) (x : α) : Fintype.card { f : Fin n → α // x = f i } = Fintype.card α ^ (n - 1) := by ``` :::details[proof] ```lean classical let e : { f : Fin n → α // x = f i } ≃ ({ j : Fin n // j ≠ i } → α) := { toFun := fun f j => f.1 j.1 invFun := fun g => ⟨fun j => if h : j = i then x else g ⟨j, h⟩, by simp⟩ left_inv := fun f => by apply Subtype.ext; funext j dsimp only split_ifs with h · subst h; simp only [f.2] · rfl right_inv := fun g => by funext j dsimp only split_ifs with h · exact (j.2 h).elim · exact congrArg g (Subtype.ext rfl) } rw [Fintype.card_congr e, Fintype.card_fun, Fintype.card_subtype_compl (· = i), Fintype.card_fin, Fintype.card_subtype_eq i] ``` ::: [`Soundness.lean:222-244 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L222-L244) ## [`probOutput_map_get_uniformSample_vector`](/lean/proofs/soundness#proboutput_map_get_uniformsample_vector) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem probOutput_map_get_uniformSample_vector {n : ℕ} (i : Fin n) (x : α) : Pr[= x | (fun v : Vector α n => v.get i) <$> ($ᵗ (Vector α n))] = (Fintype.card α : ENNReal)⁻¹ := by ``` :::details[proof] ```lean classical rw [probOutput_map_eq_sum_fintype_ite] simp only [probOutput_uniformSample] rw [← Finset.sum_filter] rw [Finset.sum_const, nsmul_eq_mul] -- The filtered count is the fiber cardinality `|α| ^ (n - 1)`. have hfilt : (Finset.univ.filter (fun v : Vector α n => x = v.get i)).card = Fintype.card α ^ (n - 1) := by have hcard_eq : (Finset.univ.filter (fun v : Vector α n => x = v.get i)).card = (Finset.univ.filter (fun f : Fin n → α => x = f i)).card := by apply Finset.card_equiv (vectorEquivPi n) intro v simp [vectorEquivPi, Finset.mem_filter, Finset.mem_univ, true_and] rw [hcard_eq] rw [← @Fintype.card_of_subtype (Fin n → α) (fun f => x = f i) (Finset.univ.filter (fun f : Fin n → α => x = f i)) (fun f => by simp only [Finset.mem_filter, Finset.mem_univ, true_and])] exact card_fiber_eval' i x rw [hfilt, Nat.cast_pow] -- The total count `|α| ^ n`; cast through and cancel one factor. have hC0 : (Fintype.card α : ENNReal) ≠ 0 := by exact_mod_cast Fintype.card_ne_zero have hCtop : (Fintype.card α : ENNReal) ≠ (⊤ : ENNReal) := ENNReal.natCast_ne_top _ have hcard : (Fintype.card (Vector α n) : ENNReal) = (Fintype.card α : ENNReal) ^ n := by have h1 : Fintype.card (Vector α n) = Fintype.card (Fin n → α) := Fintype.card_congr (vectorEquivPi n) rw [h1, Fintype.card_fun, Fintype.card_fin, Nat.cast_pow] have hCn : (Fintype.card α : ENNReal) ^ n = (Fintype.card α : ENNReal) ^ (n - 1) * (Fintype.card α : ENNReal) := by have hn : NeZero n := Fin.neZero i rw [← pow_succ, Nat.sub_add_cancel hn.pos] have hCpow0 : (Fintype.card α : ENNReal) ^ (n - 1) ≠ 0 := ENNReal.pow_ne_zero hC0 (n - 1) have hCpowtop : (Fintype.card α : ENNReal) ^ (n - 1) ≠ (⊤ : ENNReal) := ENNReal.pow_ne_top hCtop -- Algebra: C^(n-1) * (C^(n-1) * C)⁻¹ = C⁻¹. rw [hcard, hCn] rw [ENNReal.mul_inv (Or.inl hCpow0) (Or.inr hC0)] rw [ENNReal.mul_inv_cancel_left hCpow0 hCpowtop] ``` ::: [`Soundness.lean:249-289 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L249-L289) ### A coordinate of a uniform vector is uniform ## [`taggedAux`](/lean/proofs/soundness#taggedaux) Auxiliary data led by a view tag: the tag depends on the shared secret alone, the remainder (stealth address, ciphertext hash, …) may depend on both. ```lean def taggedAux (viewTag : K → T) (rest : K → PK → Rest) : K → PK → T × Rest := fun k pk => (viewTag k, rest k pk) ``` [`Soundness.lean:305-307 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L305-L307) ## [`auxCollisionFree_taggedAux`](/lean/proofs/soundness#auxcollisionfree_taggedaux) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem auxCollisionFree_taggedAux (viewTag : K → T) (rest : K → PK → Rest) (hUnif : ∀ t : T, Pr[= t | viewTag <$> ($ᵗ K)] = (Fintype.card T : ENNReal)⁻¹) : AuxCollisionFree (taggedAux viewTag rest) (1 / (Fintype.card T : ℝ)) := by ``` :::details[proof] ```lean intro a pk haveI : Nonempty T := ⟨a.1⟩ have hmono : Pr[= true | (do let k ← ($ᵗ K); pure (decide (a = taggedAux viewTag rest k pk)))] ≤ Pr[= true | (do let k ← ($ᵗ K); pure (decide (viewTag k = a.1)))] := by refine probOutput_bind_mono fun k _ => ?_ by_cases hk : a = taggedAux viewTag rest k pk · have htag : viewTag k = a.1 := by simp only [hk, taggedAux] simp only [hk, htag, decide_true, le_refl] · simp only [hk, decide_false, probOutput_pure, Bool.true_eq_false, if_false, zero_le] have hbridge : (do let x ← (viewTag <$> ($ᵗ K)); pure (decide (x = a.1))) = (do let k ← ($ᵗ K); pure (decide (viewTag k = a.1))) := by simp only [map_eq_bind_pure_comp, bind_assoc, pure_bind, Function.comp_apply] have hval : Pr[= true | (do let k ← ($ᵗ K); pure (decide (viewTag k = a.1)))] = (Fintype.card T : ENNReal)⁻¹ := by rw [← hbridge, probOutput_true_bind_decide_eq, hUnif] have hfin : ((Fintype.card T : ENNReal))⁻¹ ≠ ⊤ := by simp only [ne_eq, ENNReal.inv_eq_top, Nat.cast_eq_zero, Fintype.card_ne_zero, not_false_eq_true] have hle := ENNReal.toReal_mono hfin (hmono.trans_eq hval) rwa [ENNReal.toReal_inv, ENNReal.toReal_natCast, ← one_div] at hle ``` ::: [`Soundness.lean:311-334 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L311-L334) ## [`soundWithin_ofKEMFull_taggedAux`](/lean/proofs/soundness#soundwithin_ofkemfull_taggedaux) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem soundWithin_ofKEMFull_taggedAux {SK C : Type} (kem : KEM PK SK C K) (viewTag : K → T) (rest : K → PK → Rest) (hUnif : ∀ t : T, Pr[= t | viewTag <$> ($ᵗ K)] = (Fintype.card T : ENNReal)⁻¹) : (StealthScheme.ofKEMFull kem (taggedAux viewTag rest)).SoundWithin (1 / (Fintype.card T : ℝ) + decapsRoR kem (taggedAux viewTag rest)) := falsePositiveRate_ofKEMFull_le kem (taggedAux viewTag rest) _ (by positivity) (auxCollisionFree_taggedAux viewTag rest hUnif) ``` [`Soundness.lean:338-345 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L338-L345) ### The view tag ## [`soundWithin_ofKEMFull_byteTag`](/lean/proofs/soundness#soundwithin_ofkemfull_bytetag) **The ERC's tag-length rationale, machine-checked.** With an `n`-byte view tag the false-positive rate is at most `2 ^ (-8n)` plus the real-or-random term; `n = 1` gives `1/256`. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem soundWithin_ofKEMFull_byteTag (n : ℕ) (kem : KEM PK SK C K) (viewTag : K → Fin (2 ^ (8 * n))) (rest : K → PK → Rest) (hUnif : ∀ t : Fin (2 ^ (8 * n)), Pr[= t | viewTag <$> ($ᵗ K)] = ((2 ^ (8 * n) : ℕ) : ENNReal)⁻¹) : (StealthScheme.ofKEMFull kem (taggedAux viewTag rest)).SoundWithin (1 / ((2 ^ (8 * n) : ℕ) : ℝ) + decapsRoR kem (taggedAux viewTag rest)) := by ``` :::details[proof] ```lean have hcard : Fintype.card (Fin (2 ^ (8 * n))) = 2 ^ (8 * n) := Fintype.card_fin _ have := soundWithin_ofKEMFull_taggedAux kem viewTag rest (by simpa only [hcard, Nat.cast_pow, Nat.cast_ofNat] using hUnif) rwa [hcard] at this ``` ::: [`Soundness.lean:360-370 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L360-L370) ## [`soundWithin_ofKEMFull_oneByteTag`](/lean/proofs/soundness#soundwithin_ofkemfull_onebytetag) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem soundWithin_ofKEMFull_oneByteTag (kem : KEM PK SK C K) (viewTag : K → Fin 256) (rest : K → PK → Rest) (hUnif : ∀ t : Fin 256, Pr[= t | viewTag <$> ($ᵗ K)] = (256 : ENNReal)⁻¹) : (StealthScheme.ofKEMFull kem (taggedAux viewTag rest)).SoundWithin (1 / 256 + decapsRoR kem (taggedAux viewTag rest)) := by ``` :::details[proof] ```lean have hcard : Fintype.card (Fin 256) = 256 := Fintype.card_fin 256 have h := soundWithin_ofKEMFull_taggedAux kem viewTag rest (by simpa only [hcard, Nat.cast_ofNat] using hUnif) rw [hcard] at h exact_mod_cast h ``` ::: [`Soundness.lean:373-383 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/Soundness.lean#L373-L383) # SpendSecurity :::info[Mirrored page] Source: [`lean/docs-proofs/spendsecurity.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/spendsecurity.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: A recipient's stealth keys are all derived from one master ML-DSA key `(A, t = A·s₁ + s₂)`: announcement `i` contributes a blinding offset `(s'ᵢ, e'ᵢ)` and the derived key is `tᵢ = t + A·s'ᵢ + e'ᵢ`. A malicious sender KNOWS the offsets it created, so the honest adversary model hands the attacker the master public key together with all `q` offsets and asks for a spend witness for any one derived key. This file: 1. defines that game in the search-witness model of `Ownership.lean` ([`relatedSpendRun`](/lean/proofs/spendsecurity#relatedspendrun), [`relatedSpendAdvantage`](/lean/proofs/spendsecurity#relatedspendadvantage)); 2. proves the `q`-fold union bound over the targeted key ([`relatedSpendAdvantage_le_mul`](/lean/proofs/spendsecurity#relatedspendadvantage_le_mul)); 3. proves the offset-subtraction reduction: a witness for derived key `i` minus the known offset is a witness for the master key at a wider bound ([`relatedSpendAdvantageAt_le_spendForgery`](/lean/proofs/spendsecurity#relatedspendadvantageat_le_spendforgery)), so the related-key game is at most `q` times the master's ownership-forgery advantage ([`relatedSpendAdvantage_le_mul_spendForgery`](/lean/proofs/spendsecurity#relatedspendadvantage_le_mul_spendforgery)); 4. instantiates at `Rq` with the ML-DSA range check: witnesses at bound `b` and offsets at bound `η` give master witnesses at bound `b + η` ([`mldsa_relatedSpendAdvantage_le`](/lean/proofs/spendsecurity#mldsa_relatedspendadvantage_le)). What is NOT here: the signature-of-knowledge layer. The deployed spend is a Fiat-Shamir-with-aborts signature over the ownership relation; its EUF-CMA to ownership-forgery reduction (CMA-to-NMA loss, retry truncation) is the follow-up recorded in `Ownership.lean` §4. The two loss quantities it will need are defined at the end as nonnegative reals with their hypotheses made explicit, but nothing composes them yet. ## [`Offset`](/lean/proofs/spendsecurity#offset) A blinding offset `(s', e')`, the per-announcement shift of the master secret. ```lean abbrev Offset (R : Type) (k l : ℕ) := (Fin l → R) × (Fin k → R) ``` [`SpendSecurity.lean:44-45 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L44-L45) ## [`ShortOffset`](/lean/proofs/spendsecurity#shortoffset) ```lean abbrev ShortOffset (R : Type) (k l : ℕ) (offShort : Offset R k l → Bool) := {off : Offset R k l // offShort off = true} ``` [`SpendSecurity.lean:49-51 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L49-L51) ## [`deriveKey`](/lean/proofs/spendsecurity#derivekey) ```lean def deriveKey (key : OwnershipKey R k l) (off : Offset R k l) : OwnershipKey R k l := ⟨key.A, key.t + (key.A *ᵥ off.1 + off.2)⟩ ``` [`SpendSecurity.lean:53-55 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L53-L55) ## [`subtractOffset`](/lean/proofs/spendsecurity#subtractoffset) ```lean def subtractOffset (off : Offset R k l) (w : (Fin l → R) × (Fin k → R)) : (Fin l → R) × (Fin k → R) := (w.1 - off.1, w.2 - off.2) ``` [`SpendSecurity.lean:57-60 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L57-L60) ## [`ownershipValid_subtractOffset`](/lean/proofs/spendsecurity#ownershipvalid_subtractoffset) A witness for the derived key, minus the offset, is a witness for the master key: the relation transports exactly, the shortness through `hshort`. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem ownershipValid_subtractOffset {isShort isShort' : ((Fin l → R) × (Fin k → R)) → Bool} {offShort : Offset R k l → Bool} (hshort : ∀ w off, isShort w = true → offShort off = true → isShort' (subtractOffset off w) = true) (key : OwnershipKey R k l) (off : Offset R k l) (hoff : offShort off = true) (w : (Fin l → R) × (Fin k → R)) (hw : ownershipValid isShort (deriveKey key off) w = true) : ownershipValid isShort' key (subtractOffset off w) = true := by ``` :::details[proof] ```lean simp only [ownershipValid, Bool.and_eq_true, decide_eq_true_eq, deriveKey] at hw ⊢ refine ⟨hshort w off hw.1 hoff, ?_⟩ simp only [subtractOffset, Matrix.mulVec_sub] calc key.A *ᵥ w.1 - key.A *ᵥ off.1 + (w.2 - off.2) = (key.A *ᵥ w.1 + w.2) - (key.A *ᵥ off.1 + off.2) := by abel _ = key.t := by rw [hw.2]; abel ``` ::: [`SpendSecurity.lean:67-82 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L67-L82) ### 1. Offsets and derived keys ## [`RelatedSpendAdv`](/lean/proofs/spendsecurity#relatedspendadv) A related-key spend adversary: given the master public key and all `q` offsets, name a derived key and produce a witness for it. ```lean abbrev RelatedSpendAdv (R : Type) [CommRing R] (k l : ℕ) (offShort : Offset R k l → Bool) (q : ℕ) := OwnershipKey R k l → (Fin q → ShortOffset R k l offShort) → ProbComp (Fin q × ((Fin l → R) × (Fin k → R))) ``` [`SpendSecurity.lean:89-93 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L89-L93) ## [`relatedSpendRun`](/lean/proofs/spendsecurity#relatedspendrun) ```lean def relatedSpendRun (keyGen : ProbComp (OwnershipKey R k l)) (offGen : ProbComp (ShortOffset R k l offShort)) (q : ℕ) (adv : RelatedSpendAdv R k l offShort q) : ProbComp (OwnershipKey R k l × (Fin q → ShortOffset R k l offShort) × (Fin q × ((Fin l → R) × (Fin k → R)))) := do let key ← keyGen let offs ← ProbComp.sampleIID q offGen let out ← adv key offs pure (key, offs, out) ``` [`SpendSecurity.lean:95-104 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L95-L104) ## [`RelatedSpendWins`](/lean/proofs/spendsecurity#relatedspendwins) ```lean def RelatedSpendWins (isShort : ((Fin l → R) × (Fin k → R)) → Bool) {q : ℕ} (x : OwnershipKey R k l × (Fin q → ShortOffset R k l offShort) × (Fin q × ((Fin l → R) × (Fin k → R)))) : Prop := ownershipValid isShort (deriveKey x.1 (x.2.1 x.2.2.1).1) x.2.2.2 = true ``` [`SpendSecurity.lean:106-110 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L106-L110) ## [`relatedSpendAdvantage`](/lean/proofs/spendsecurity#relatedspendadvantage) ```lean noncomputable def relatedSpendAdvantage (keyGen : ProbComp (OwnershipKey R k l)) (offGen : ProbComp (ShortOffset R k l offShort)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (q : ℕ) (adv : RelatedSpendAdv R k l offShort q) : ℝ≥0∞ := Pr[RelatedSpendWins isShort | relatedSpendRun keyGen offGen q adv] ``` [`SpendSecurity.lean:113-118 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L113-L118) ## [`relatedSpendAdvantageAt`](/lean/proofs/spendsecurity#relatedspendadvantageat) ```lean noncomputable def relatedSpendAdvantageAt (keyGen : ProbComp (OwnershipKey R k l)) (offGen : ProbComp (ShortOffset R k l offShort)) (isShort : ((Fin l → R) × (Fin k → R)) → Bool) (q : ℕ) (adv : RelatedSpendAdv R k l offShort q) (i : Fin q) : ℝ≥0∞ := Pr[fun x => x.2.2.1 = i ∧ RelatedSpendWins isShort x | relatedSpendRun keyGen offGen q adv] ``` [`SpendSecurity.lean:120-125 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L120-L125) ### 2. The related-key game ## [`relatedSpendAdvantage_le_sum`](/lean/proofs/spendsecurity#relatedspendadvantage_le_sum) **Union bound.** Winning means winning against the key the adversary names. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem relatedSpendAdvantage_le_sum : relatedSpendAdvantage keyGen offGen isShort q adv ≤ ∑ i, relatedSpendAdvantageAt keyGen offGen isShort q adv i := by ``` :::details[proof] ```lean unfold relatedSpendAdvantage relatedSpendAdvantageAt refine (probEvent_mono'' fun x hx => ?_).trans (probEvent_exists_finset_le_sum Finset.univ _ (fun i x => x.2.2.1 = i ∧ RelatedSpendWins isShort x)) exact ⟨x.2.2.1, Finset.mem_univ _, rfl, hx⟩ ``` ::: [`SpendSecurity.lean:132-140 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L132-L140) ## [`relatedSpendAdvantage_le_mul`](/lean/proofs/spendsecurity#relatedspendadvantage_le_mul) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem relatedSpendAdvantage_le_mul {ε : ℝ≥0∞} (h : ∀ i, relatedSpendAdvantageAt keyGen offGen isShort q adv i ≤ ε) : relatedSpendAdvantage keyGen offGen isShort q adv ≤ q * ε := by ``` :::details[proof] ```lean refine (relatedSpendAdvantage_le_sum keyGen offGen isShort q adv).trans ?_ calc ∑ i, relatedSpendAdvantageAt keyGen offGen isShort q adv i ≤ ∑ _i : Fin q, ε := Finset.sum_le_sum fun i _ => h i _ = q * ε := by rw [Finset.sum_const, Finset.card_univ, Fintype.card_fin, nsmul_eq_mul] ``` ::: [`SpendSecurity.lean:142-149 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L142-L149) ### 3. The union bound over the targeted key ## [`relatedSpendReduction`](/lean/proofs/spendsecurity#relatedspendreduction) **Reduction to the master key's ownership problem.** Sample the offsets yourself, run the related-key adversary, and return its witness minus the offset of key `i`. ```lean def relatedSpendReduction (isShort' : ((Fin l → R) × (Fin k → R)) → Bool) (i : Fin q) : SIS.Adversary (ownershipSISProblem keyGen isShort') := fun key => do let offs ← ProbComp.sampleIID q offGen let out ← adv key offs pure (subtractOffset (offs i).1 out.2) ``` [`SpendSecurity.lean:155-160 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L155-L160) ## [`relatedSpendAdvantageAt_le_spendForgery`](/lean/proofs/spendsecurity#relatedspendadvantageat_le_spendforgery) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem relatedSpendAdvantageAt_le_spendForgery (isShort' : ((Fin l → R) × (Fin k → R)) → Bool) (hshort : ∀ w off, isShort w = true → offShort off = true → isShort' (subtractOffset off w) = true) (i : Fin q) : relatedSpendAdvantageAt keyGen offGen isShort q adv i ≤ spendForgeryAdvantage keyGen isShort' (relatedSpendReduction keyGen offGen q adv isShort' i) := by ``` :::details[proof] ```lean have hexp : SIS.experiment (ownershipSISProblem keyGen isShort') (relatedSpendReduction keyGen offGen q adv isShort' i) = (fun x => ownershipValid isShort' x.1 (subtractOffset (x.2.1 i).1 x.2.2.2)) <$> relatedSpendRun keyGen offGen q adv := by simp only [SIS.experiment, ownershipSISProblem, relatedSpendReduction, relatedSpendRun, map_eq_bind_pure_comp, Function.comp_def, bind_assoc, pure_bind] unfold relatedSpendAdvantageAt spendForgeryAdvantage SIS.advantage rw [hexp, probOutput_map] refine probEvent_mono'' fun x hx => ?_ obtain ⟨hi, hwin⟩ := hx subst hi exact ownershipValid_subtractOffset hshort x.1 (x.2.1 x.2.2.1).1 (x.2.1 x.2.2.1).2 x.2.2.2 hwin ``` ::: [`SpendSecurity.lean:163-182 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L163-L182) ## [`relatedSpendAdvantage_le_mul_spendForgery`](/lean/proofs/spendsecurity#relatedspendadvantage_le_mul_spendforgery) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem relatedSpendAdvantage_le_mul_spendForgery (isShort' : ((Fin l → R) × (Fin k → R)) → Bool) (hshort : ∀ w off, isShort w = true → offShort off = true → isShort' (subtractOffset off w) = true) {ε : ℝ≥0∞} (hε : ∀ i, spendForgeryAdvantage keyGen isShort' (relatedSpendReduction keyGen offGen q adv isShort' i) ≤ ε) : relatedSpendAdvantage keyGen offGen isShort q adv ≤ q * ε := relatedSpendAdvantage_le_mul keyGen offGen isShort q adv fun i => (relatedSpendAdvantageAt_le_spendForgery keyGen offGen isShort q adv isShort' hshort i).trans (hε i) ``` [`SpendSecurity.lean:185-196 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L185-L196) ### 4. The offset-subtraction reduction ## [`cInfNorm_sub_le`](/lean/proofs/spendsecurity#cinfnorm_sub_le) Centered infinity norm is sub-additive under subtraction. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem cInfNorm_sub_le (f g : Rq) : cInfNorm (f - g) ≤ cInfNorm f + cInfNorm g := by ``` :::details[proof] ```lean apply cInfNorm_le_of_coeff_le intro i rw [Rq.get_sub, sub_eq_add_neg, centeredRepr_eq_valMinAbs] calc ((f.get i + -g.get i).valMinAbs).natAbs ≤ ((f.get i).valMinAbs + (-g.get i).valMinAbs).natAbs := ZMod.natAbs_valMinAbs_add_le _ _ _ ≤ ((f.get i).valMinAbs).natAbs + ((-g.get i).valMinAbs).natAbs := Int.natAbs_add_le _ _ _ = ((f.get i).valMinAbs).natAbs + ((g.get i).valMinAbs).natAbs := by rw [ZMod.natAbs_valMinAbs_neg] _ ≤ cInfNorm f + cInfNorm g := by rw [← centeredRepr_eq_valMinAbs, ← centeredRepr_eq_valMinAbs] exact Nat.add_le_add (coeff_le_cInfNorm f i) (coeff_le_cInfNorm g i) ``` ::: [`SpendSecurity.lean:206-220 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L206-L220) ## [`mldsaShort_subtractOffset`](/lean/proofs/spendsecurity#mldsashort_subtractoffset) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem mldsaShort_subtractOffset (b eta : ℕ) (w : (Fin l → Rq) × (Fin k → Rq)) (off : Offset Rq k l) (hw : mldsaShort b w = true) (hoff : mldsaShort eta off = true) : mldsaShort (b + eta) (subtractOffset off w) = true := by ``` :::details[proof] ```lean simp only [mldsaShort, decide_eq_true_eq, subtractOffset, Pi.sub_apply] at hw hoff ⊢ exact ⟨fun i => (cInfNorm_sub_le _ _).trans (Nat.add_le_add (hw.1 i) (hoff.1 i)), fun i => (cInfNorm_sub_le _ _).trans (Nat.add_le_add (hw.2 i) (hoff.2 i))⟩ ``` ::: [`SpendSecurity.lean:222-228 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L222-L228) ## [`mldsa_relatedSpendAdvantage_le`](/lean/proofs/spendsecurity#mldsa_relatedspendadvantage_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem mldsa_relatedSpendAdvantage_le (keyGen : ProbComp (OwnershipKey Rq k l)) (b eta q : ℕ) (offGen : ProbComp (ShortOffset Rq k l (mldsaShort eta))) (adv : RelatedSpendAdv Rq k l (mldsaShort eta) q) {ε : ℝ≥0∞} (hε : ∀ i, spendForgeryAdvantage keyGen (mldsaShort (b + eta)) (relatedSpendReduction keyGen offGen q adv (mldsaShort (b + eta)) i) ≤ ε) : relatedSpendAdvantage keyGen offGen (mldsaShort b) q adv ≤ q * ε := relatedSpendAdvantage_le_mul_spendForgery keyGen offGen (mldsaShort b) q adv (mldsaShort (b + eta)) (mldsaShort_subtractOffset b eta) hε ``` [`SpendSecurity.lean:232-241 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L232-L241) ### 5. The ML-DSA instance ## [`CmaToNmaAssumption`](/lean/proofs/spendsecurity#cmatonmaassumption) Sign conditions under which the CMA-to-NMA loss is a nonnegative real. ```lean structure CmaToNmaAssumption (eps pAbort zetaWide delta : ℝ) : Prop where /-- Commitment-guessing bound. -/ eps_nonneg : 0 ≤ eps /-- Effective abort probability. -/ p_nonneg : 0 ≤ pAbort /-- Abort probability is strictly below one. -/ p_lt_one : pAbort < 1 /-- HVZK simulator error. -/ zeta_nonneg : 0 ≤ zetaWide /-- Regularity failure bound. -/ delta_nonneg : 0 ≤ delta ``` [`SpendSecurity.lean:255-266 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L255-L266) ## [`CmaToNmaLossNN`](/lean/proofs/spendsecurity#cmatonmalossnn) ```lean noncomputable def CmaToNmaLossNN (qS qH : ℕ) (eps pAbort zetaWide delta : ℝ) (h : CmaToNmaAssumption eps pAbort zetaWide delta) : NNReal := ⟨2 * qS * (qH + 1) * eps / (1 - pAbort) + qS * eps * (qS + 1) / (2 * (1 - pAbort) ^ 2) + qS * zetaWide + delta, by have h1 : 0 < 1 - pAbort := by linarith [h.p_lt_one] ``` :::details[proof] ```lean have := h.eps_nonneg have := h.zeta_nonneg have := h.delta_nonneg positivity⟩ ``` ::: [`SpendSecurity.lean:269-279 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L269-L279) ## [`CmaToNmaLossNN_val`](/lean/proofs/spendsecurity#cmatonmalossnn_val) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem CmaToNmaLossNN_val (qS qH : ℕ) (eps pAbort zetaWide delta : ℝ) (h : CmaToNmaAssumption eps pAbort zetaWide delta) : (CmaToNmaLossNN qS qH eps pAbort zetaWide delta h).1 = FiatShamirWithAbort.cmaToNmaLoss qS qH eps pAbort zetaWide delta h.p_lt_one := rfl ``` [`SpendSecurity.lean:281-285 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L281-L285) ## [`TruncationLossNN`](/lean/proofs/spendsecurity#truncationlossnn) ```lean def TruncationLossNN (qS : ℕ) (pAbort : ℝ) (hp : 0 ≤ pAbort) (maxAttempts : ℕ) : NNReal := ⟨qS * pAbort ^ maxAttempts, by positivity⟩ ``` [`SpendSecurity.lean:287-289 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L287-L289) ## [`UnboundedSigningAssumption`](/lean/proofs/spendsecurity#unboundedsigningassumption) ```lean structure UnboundedSigningAssumption (qS maxAttempts : ℕ) (pAbort : ℝ) (hp : 0 ≤ pAbort) (advUnbounded advCapped : ℝ) : Prop where /-- The truncation bridge. -/ bridge : advUnbounded ≤ advCapped + (TruncationLossNN qS pAbort hp maxAttempts).1 ``` [`SpendSecurity.lean:293-297 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SpendSecurity.lean#L293-L297) # SPRTwoHop :::info[Mirrored page] Source: [`lean/docs-proofs/sprtwohop.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/sprtwohop.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: Proved: the two decision-MLWE problems the SPR argument needs, both as VCVio `LearningWithErrors.Problem`s over the seed `rho`; the two reduction adversaries built from an unlinkability distinguisher; that each hop's game gap IS the `LearningWithErrors.advantage` of its reduction adversary; and the resulting four-term bound on `KEM.sprAdv` for ML-KEM-768. Also proved: the real anonymity branch may be reordered to encapsulate first and draw the other key afterwards ([`evalDist_anonSetup_bind_anonBranch`](/lean/proofs/sprtwohop#kemevaldist_anonsetup_bind_anonbranch), by VCVio's relational tactics), so that reorder costs nothing and is NOT folded into the idealization term. Assumed: the two non-lattice terms of that bound stay unbounded here -- the primitive-idealization term (`G` and `PRF_eta` as independent samplers; the seeded shape keeps `SampleNTT` concrete) and the simulator term (compress-and-encode regularity, plus the honest key distribution). Neither is an MLWE statement. See `docs/spr-two-hop.md`. ## [`instNeZeroMlkemModulus`](/lean/proofs/sprtwohop#instnezeromlkemmodulus) The FIPS 203 modulus is nonzero -- needed to see `ZMod modulus` as `FinEnum`, which `modulus` being a `def` hides from instance search. ```lean instance instNeZeroMlkemModulus : NeZero MLKEM.modulus := ⟨by decide⟩ ``` [`SPRTwoHop.lean:37-38 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L37-L38) ## [`instSampleableTypeRq`](/lean/proofs/sprtwohop#instsampleabletyperq) ```lean instance instSampleableTypeRq : SampleableType MLKEM.Rq := inferInstanceAs (SampleableType (Vector MLKEM.Coeff MLKEM.ringDegree)) ``` [`SPRTwoHop.lean:41-43 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L41-L43) ## [`instSampleableTypeTq`](/lean/proofs/sprtwohop#instsampleabletypetq) ```lean instance instSampleableTypeTq : SampleableType MLKEM.Tq := SampleableType.ofEquiv (α := MLKEM.Rq) ⟨fun f => ⟨f⟩, fun t => t.coeffs, fun _ => rfl, fun _ => rfl⟩ ``` [`SPRTwoHop.lean:45-48 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L45-L48) ### Uniform sampling on the ML-KEM ring ## [`LearningWithErrors.advantage_eq_boolDistAdvantage`](/lean/proofs/sprtwohop#learningwitherrorsadvantage_eq_booldistadvantage) The decision-LWE advantage is the distance between the two named games, so it composes with the game-hopping triangle inequalities. (Scoped under `PqStealth`; upstream ask, `docs/vcvio-upstream.md` item 4.) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem LearningWithErrors.advantage_eq_boolDistAdvantage {Sample Secret Output : Type} [Add Output] (problem : LearningWithErrors.Problem Sample Secret Output) (adv : LearningWithErrors.Adversary problem) : LearningWithErrors.advantage problem adv = (LearningWithErrors.game0 problem adv).boolDistAdvantage (LearningWithErrors.game1 problem adv) := by ``` :::details[proof] ```lean have key := ProbComp.boolBiasAdvantage_bind_uniformBool_eq_boolDistAdvantage (pure () : ProbComp Unit) (fun _ => LearningWithErrors.game0 problem adv) (fun _ => LearningWithErrors.game1 problem adv) simp only [pure_bind] at key rw [LearningWithErrors.advantage, ← key] refine congrArg ProbComp.boolBiasAdvantage ?_ simp only [LearningWithErrors.experiment] refine bind_congr fun b => ?_ cases b <;> simp only [if_true, LearningWithErrors.game0, LearningWithErrors.game1, bind_assoc] ``` ::: [`SPRTwoHop.lean:54-70 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L54-L70) ### VCVio's LWE advantage as a game distance ## [`pkOf`](/lean/proofs/sprtwohop#pkof) The K-PKE public key carrying `rho` and the encoding of `t̂`. ```lean def pkOf (rho : Seed32) (tHat : TqVec params.k) : EncapsulationKey params encoding := { tHatEncoded := encoding.byteEncode12Vec tHat, rho := rho } ``` [`SPRTwoHop.lean:80-82 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L80-L82) ## [`ctOf`](/lean/proofs/sprtwohop#ctof) ```lean def ctOf (u : RqVec params.k) (v : Rq) : Ciphertext params encoding := { uEncoded := encoding.byteEncodeDUVec (encoding.compressDU u) vEncoded := encoding.byteEncodeDV (encoding.compressDV v) } ``` [`SPRTwoHop.lean:84-87 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L84-L87) ## [`sampleEta1Hat`](/lean/proofs/sprtwohop#sampleeta1hat) ```lean def sampleEta1Hat (offset : ℕ) : ProbComp (TqVec params.k) := do let sigma ← $ᵗ Seed32 pure (ring.nttVec (prims.sampleVecEta1 sigma offset)) ``` [`SPRTwoHop.lean:91-94 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L91-L94) ## [`sampleNoise`](/lean/proofs/sprtwohop#samplenoise) ```lean def sampleNoise : ProbComp (RqVec params.k × Rq) := do let c₁ ← $ᵗ Coins let c₂ ← $ᵗ Coins pure (prims.sampleVecEta2 c₁ params.k, prims.prfEta2 c₂ (2 * params.k)) ``` [`SPRTwoHop.lean:96-100 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L96-L100) ## [`idealKeygen`](/lean/proofs/sprtwohop#idealkeygen) ```lean def idealKeygen : ProbComp (EncapsulationKey params encoding) := do let rho ← $ᵗ Seed32 let s ← sampleEta1Hat ring encoding prims 0 let e ← sampleEta1Hat ring encoding prims params.k pure (pkOf encoding rho (ring.matVecMul (prims.publicMatrix rho) s + e)) ``` [`SPRTwoHop.lean:103-108 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L103-L108) ## [`idealEncrypt`](/lean/proofs/sprtwohop#idealencrypt) ```lean def idealEncrypt (ek : EncapsulationKey params encoding) : ProbComp (Ciphertext params encoding) := do let y ← sampleEta1Hat ring encoding prims 0 let ne ← sampleNoise encoding prims let m ← $ᵗ Message pure (ctOf encoding (ring.invNTTVec (ring.matTransposeVecMul (prims.publicMatrix ek.rho) y) + ne.1) (ring.invNTT (ring.dot (encoding.byteDecode12Vec ek.tHatEncoded) y) + ne.2 + encoding.decompress1 (encoding.byteDecode1 m))) ``` [`SPRTwoHop.lean:111-120 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L111-L120) ## [`uniformKeygen`](/lean/proofs/sprtwohop#uniformkeygen) ```lean def uniformKeygen : ProbComp (EncapsulationKey params encoding) := do let rho ← $ᵗ Seed32 let t ← $ᵗ (TqVec params.k) pure (pkOf encoding rho t) ``` [`SPRTwoHop.lean:122-126 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L122-L126) ## [`randCt`](/lean/proofs/sprtwohop#randct) ```lean def randCt : ProbComp (Ciphertext params encoding) := do let u ← $ᵗ (RqVec params.k) let v ← $ᵗ Rq let m ← $ᵗ Message pure (ctOf encoding u (v + encoding.decompress1 (encoding.byteDecode1 m))) ``` [`SPRTwoHop.lean:130-135 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L130-L135) ### Semantic constructors and the idealized samplers ## [`idealBranch`](/lean/proofs/sprtwohop#idealbranch) The anonymity branch in idealized form, parametric in how the challenge key and the challenge ciphertext are produced. ```lean def idealBranch (adv : StealthScheme.UnlinkAdv (EncapsulationKey params encoding) (Ciphertext params encoding)) (kb : ProbComp (EncapsulationKey params encoding)) (ct : EncapsulationKey params encoding → ProbComp (Ciphertext params encoding)) (b : Bool) : ProbComp Bool := do let pkb ← kb let c ← ct pkb let pkOther ← idealKeygen ring encoding prims adv (if b then pkOther else pkb) (if b then pkb else pkOther) c ``` [`SPRTwoHop.lean:144-153 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L144-L153) ### The game chain ## [`keyHopProblem`](/lean/proofs/sprtwohop#keyhopproblem) **Key hop.** Given `(rho, t̂)`, decide whether `t̂ = ·ŝ + ê` for `PRF_η₁`-distributed `(ŝ, ê)` or `t̂` is uniform. ```lean def keyHopProblem : LearningWithErrors.Problem Seed32 (TqVec params.k) (TqVec params.k) where sampleChallenge := $ᵗ Seed32 sampleSecret := sampleEta1Hat ring encoding prims 0 sampleError := sampleEta1Hat ring encoding prims params.k noiseless := fun s rho => ring.matVecMul (prims.publicMatrix rho) s sampleUniform := $ᵗ (TqVec params.k) ``` [`SPRTwoHop.lean:161-168 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L161-L168) ## [`ctHopProblem`](/lean/proofs/sprtwohop#cthopproblem) ```lean def ctHopProblem : LearningWithErrors.Problem (Seed32 × TqVec params.k) (TqVec params.k) (RqVec params.k × Rq) where sampleChallenge := do let rho ← $ᵗ Seed32 let t ← $ᵗ (TqVec params.k) pure (rho, t) sampleSecret := sampleEta1Hat ring encoding prims 0 sampleError := sampleNoise encoding prims noiseless := fun y ch => (ring.invNTTVec (ring.matTransposeVecMul (prims.publicMatrix ch.1) y), ring.invNTT (ring.dot ch.2 y)) sampleUniform := do let u ← $ᵗ (RqVec params.k) let v ← $ᵗ Rq pure (u, v) ``` [`SPRTwoHop.lean:171-187 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L171-L187) ### The two decision-MLWE problems ## [`keyHopAdv`](/lean/proofs/sprtwohop#keyhopadv) **Key-hop reduction.** Plant the MLWE challenge as recipient `b`'s encapsulation key, generate the other recipient honestly, encrypt honestly to the planted key. ```lean def keyHopAdv (b : Bool) : LearningWithErrors.Adversary (keyHopProblem ring encoding prims) := fun chal => do let c ← idealEncrypt ring encoding prims (pkOf encoding chal.1 chal.2) let pkOther ← idealKeygen ring encoding prims adv (if b then pkOther else pkOf encoding chal.1 chal.2) (if b then pkOf encoding chal.1 chal.2 else pkOther) c ``` [`SPRTwoHop.lean:196-202 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L196-L202) ## [`ctHopAdv`](/lean/proofs/sprtwohop#cthopadv) ```lean def ctHopAdv (b : Bool) : LearningWithErrors.Adversary (ctHopProblem ring encoding prims) := fun chal => do let m ← $ᵗ Message let pkOther ← idealKeygen ring encoding prims adv (if b then pkOther else pkOf encoding chal.1.1 chal.1.2) (if b then pkOf encoding chal.1.1 chal.1.2 else pkOther) (ctOf encoding chal.2.1 (chal.2.2 + encoding.decompress1 (encoding.byteDecode1 m))) ``` [`SPRTwoHop.lean:206-213 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L206-L213) ### The two reduction adversaries ## [`idealEncrypt_pkOf`](/lean/proofs/sprtwohop#idealencrypt_pkof) Encrypting to a planted key sees `t̂` itself: the `byteEncode12Vec` / `byteDecode12Vec` roundtrip is an `Encoding` field, not a `Laws` hypothesis. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem idealEncrypt_pkOf (rho : Seed32) (t : TqVec params.k) : idealEncrypt ring encoding prims (pkOf encoding rho t) = (do let y ← sampleEta1Hat ring encoding prims 0 let ne ← sampleNoise encoding prims let m ← $ᵗ Message pure (ctOf encoding (ring.invNTTVec (ring.matTransposeVecMul (prims.publicMatrix rho) y) + ne.1) (ring.invNTT (ring.dot t y) + ne.2 + encoding.decompress1 (encoding.byteDecode1 m)))) := by ``` :::details[proof] ```lean simp only [idealEncrypt, pkOf, encoding.byteDecode12Vec_byteEncode12Vec] ``` ::: [`SPRTwoHop.lean:218-228 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L218-L228) ## [`game0_keyHopProblem`](/lean/proofs/sprtwohop#game0_keyhopproblem) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem game0_keyHopProblem (b : Bool) : LearningWithErrors.game0 (keyHopProblem ring encoding prims) (keyHopAdv ring encoding prims adv b) = idealBranch ring encoding prims adv (idealKeygen ring encoding prims) (idealEncrypt ring encoding prims) b := by ``` :::details[proof] ```lean simp only [LearningWithErrors.game0, LearningWithErrors.distr, keyHopProblem, keyHopAdv, idealBranch, idealKeygen, bind_assoc, pure_bind] ``` ::: [`SPRTwoHop.lean:230-237 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L230-L237) ## [`game1_keyHopProblem`](/lean/proofs/sprtwohop#game1_keyhopproblem) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem game1_keyHopProblem (b : Bool) : LearningWithErrors.game1 (keyHopProblem ring encoding prims) (keyHopAdv ring encoding prims adv b) = idealBranch ring encoding prims adv (uniformKeygen encoding) (idealEncrypt ring encoding prims) b := by ``` :::details[proof] ```lean simp only [LearningWithErrors.game1, LearningWithErrors.uniformDistr, keyHopProblem, keyHopAdv, idealBranch, uniformKeygen, bind_assoc, pure_bind] ``` ::: [`SPRTwoHop.lean:239-246 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L239-L246) ## [`game0_ctHopProblem`](/lean/proofs/sprtwohop#game0_cthopproblem) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem game0_ctHopProblem (b : Bool) : LearningWithErrors.game0 (ctHopProblem ring encoding prims) (ctHopAdv ring encoding prims adv b) = idealBranch ring encoding prims adv (uniformKeygen encoding) (idealEncrypt ring encoding prims) b := by ``` :::details[proof] ```lean simp only [LearningWithErrors.game0, LearningWithErrors.distr, ctHopProblem, ctHopAdv, idealBranch, uniformKeygen, idealEncrypt_pkOf, bind_assoc, pure_bind, Prod.fst_add, Prod.snd_add] ``` ::: [`SPRTwoHop.lean:248-256 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L248-L256) ## [`game1_ctHopProblem`](/lean/proofs/sprtwohop#game1_cthopproblem) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem game1_ctHopProblem (b : Bool) : LearningWithErrors.game1 (ctHopProblem ring encoding prims) (ctHopAdv ring encoding prims adv b) = idealBranch ring encoding prims adv (uniformKeygen encoding) (fun _ => randCt encoding) b := by ``` :::details[proof] ```lean simp only [LearningWithErrors.game1, LearningWithErrors.uniformDistr, ctHopProblem, ctHopAdv, idealBranch, uniformKeygen, randCt, bind_assoc, pure_bind] ``` ::: [`SPRTwoHop.lean:258-265 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L258-L265) ### The hop identities ## [`KEM.anonBranchReordered`](/lean/proofs/sprtwohop#kemanonbranchreordered) The real anonymity branch in the idealized games' order: challenge key, its encapsulation, then the other key. ```lean def KEM.anonBranchReordered (b : Bool) : ProbComp Bool := do let pkb ← Prod.fst <$> kem.keygen let c ← Prod.fst <$> kem.encaps pkb let pkOther ← Prod.fst <$> kem.keygen adv (if b then pkOther else pkb) (if b then pkb else pkOther) c ``` [`SPRTwoHop.lean:278-283 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L278-L283) ## [`KEM.evalDist_anonSetup_bind_anonBranch`](/lean/proofs/sprtwohop#kemevaldist_anonsetup_bind_anonbranch) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem KEM.evalDist_anonSetup_bind_anonBranch (b : Bool) : 𝒟[kem.anonSetup >>= kem.anonBranch adv b] = 𝒟[kem.anonBranchReordered adv b] := by ``` :::details[proof] ```lean cases b · simp only [KEM.anonSetup, KEM.anonBranch, KEM.anonBranchReordered, bind_assoc, pure_bind, map_eq_bind_pure_comp, Function.comp_def, Bool.false_eq_true, if_false] by_equiv rvcstep intro a b hab cases hab rvcstep swap left rvcgen · simp only [KEM.anonSetup, KEM.anonBranch, KEM.anonBranchReordered, bind_assoc, pure_bind, map_eq_bind_pure_comp, Function.comp_def, if_true] by_equiv rvcstep swap left rvcstep intro a b hab cases hab rvcstep swap left rvcgen ``` ::: [`SPRTwoHop.lean:285-305 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L285-L305) ## [`KEM.boolDistAdvantage_anonBranch_anonBranchReordered`](/lean/proofs/sprtwohop#kembooldistadvantage_anonbranch_anonbranchreordered) ```lean theorem KEM.boolDistAdvantage_anonBranch_anonBranchReordered (b : Bool) : (kem.anonSetup >>= kem.anonBranch adv b).boolDistAdvantage (kem.anonBranchReordered adv b) = 0 := by ``` :::details[proof] ```lean rw [ProbComp.boolDistAdvantage, probOutput_congr rfl (kem.evalDist_anonSetup_bind_anonBranch adv b), sub_self, abs_zero] ``` ::: [`SPRTwoHop.lean:307-312 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L307-L312) ### The real branch, reordered ## [`primitiveIdealization`](/lean/proofs/sprtwohop#primitiveidealization) The gap between the (reordered) real ML-KEM anonymity branch and its idealized form: `G` as a fresh-seed sampler (key seeds, and the encryption coins `G(m ‖ H(ek))`) and `PRF_η` as an independent sampler. `SampleNTT` stays concrete -- the MLWE problems below are seeded on `rho`. Not a lattice term -- it is the ROM/PRF step. ```lean noncomputable def primitiveIdealization (b : Bool) : ℝ := (KEM.anonBranchReordered (MLKEM.asKEMScheme ring encoding prims) adv b).boolDistAdvantage (idealBranch ring encoding prims adv (idealKeygen ring encoding prims) (idealEncrypt ring encoding prims) b) ``` [`SPRTwoHop.lean:327-331 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L327-L331) ## [`simulatorGap`](/lean/proofs/sprtwohop#simulatorgap) ```lean noncomputable def simulatorGap (b : Bool) : ℝ := (idealBranch ring encoding prims adv (uniformKeygen encoding) (fun _ => randCt encoding) b).boolDistAdvantage (KEM.anonSetup (MLKEM.asKEMScheme ring encoding prims) >>= KEM.simBranch sim adv) ``` [`SPRTwoHop.lean:335-339 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L335-L339) ## [`encodingRegularity`](/lean/proofs/sprtwohop#encodingregularity) ```lean noncomputable def encodingRegularity (b : Bool) : ℝ := (idealBranch ring encoding prims adv (uniformKeygen encoding) (fun _ => randCt encoding) b).boolDistAdvantage (idealBranch ring encoding prims adv (uniformKeygen encoding) (fun _ => sim) b) ``` [`SPRTwoHop.lean:342-346 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L342-L346) ## [`keyRestoration`](/lean/proofs/sprtwohop#keyrestoration) ```lean noncomputable def keyRestoration (b : Bool) : ℝ := (idealBranch ring encoding prims adv (uniformKeygen encoding) (fun _ => sim) b).boolDistAdvantage (KEM.anonSetup (MLKEM.asKEMScheme ring encoding prims) >>= KEM.simBranch sim adv) ``` [`SPRTwoHop.lean:349-352 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L349-L352) ## [`simulatorGap_le`](/lean/proofs/sprtwohop#simulatorgap_le) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem simulatorGap_le (b : Bool) : simulatorGap ring encoding prims adv sim b ≤ encodingRegularity ring encoding prims adv sim b + keyRestoration ring encoding prims adv sim b := ProbComp.boolDistAdvantage_triangle _ _ _ ``` [`SPRTwoHop.lean:354-359 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L354-L359) ### The four named terms ## [`sprAdv_le_two_hop_decomposition`](/lean/proofs/sprtwohop#spradv_le_two_hop_decomposition) **SPR of ML-KEM through the two MLWE hops.** The per-branch SPR advantage is bounded by the primitive-idealization term, the two decision-MLWE advantages of the named reduction adversaries, and the simulator gap. `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem sprAdv_le_two_hop_decomposition (b : Bool) : KEM.sprAdv (MLKEM.asKEMScheme ring encoding prims) sim adv b ≤ primitiveIdealization ring encoding prims adv b + LearningWithErrors.advantage (keyHopProblem ring encoding prims) (keyHopAdv ring encoding prims adv b) + LearningWithErrors.advantage (ctHopProblem ring encoding prims) (ctHopAdv ring encoding prims adv b) + simulatorGap ring encoding prims adv sim b := by ``` :::details[proof] ```lean have hkey : LearningWithErrors.advantage (keyHopProblem ring encoding prims) (keyHopAdv ring encoding prims adv b) = (idealBranch ring encoding prims adv (idealKeygen ring encoding prims) (idealEncrypt ring encoding prims) b).boolDistAdvantage (idealBranch ring encoding prims adv (uniformKeygen encoding) (idealEncrypt ring encoding prims) b) := by rw [LearningWithErrors.advantage_eq_boolDistAdvantage, game0_keyHopProblem, game1_keyHopProblem] have hct : LearningWithErrors.advantage (ctHopProblem ring encoding prims) (ctHopAdv ring encoding prims adv b) = (idealBranch ring encoding prims adv (uniformKeygen encoding) (idealEncrypt ring encoding prims) b).boolDistAdvantage (idealBranch ring encoding prims adv (uniformKeygen encoding) (fun _ => randCt encoding) b) := by rw [LearningWithErrors.advantage_eq_boolDistAdvantage, game0_ctHopProblem, game1_ctHopProblem] rw [KEM.sprAdv, hkey, hct, primitiveIdealization, simulatorGap] set kem : KEM (EncapsulationKey params encoding) (DecapsulationKey params encoding) (Ciphertext params encoding) SharedSecret := MLKEM.asKEMScheme ring encoding prims set G₀ : ProbComp Bool := KEM.anonSetup kem >>= KEM.anonBranch kem adv b set G₀' : ProbComp Bool := KEM.anonBranchReordered kem adv b set G₁ : ProbComp Bool := idealBranch ring encoding prims adv (idealKeygen ring encoding prims) (idealEncrypt ring encoding prims) b set G₂ : ProbComp Bool := idealBranch ring encoding prims adv (uniformKeygen encoding) (idealEncrypt ring encoding prims) b set G₃ : ProbComp Bool := idealBranch ring encoding prims adv (uniformKeygen encoding) (fun _ => randCt encoding) b set G₄ : ProbComp Bool := KEM.anonSetup kem >>= KEM.simBranch sim adv calc ProbComp.boolDistAdvantage G₀ G₄ ≤ ProbComp.boolDistAdvantage G₀ G₀' + ProbComp.boolDistAdvantage G₀' G₄ := ProbComp.boolDistAdvantage_triangle _ _ _ _ = ProbComp.boolDistAdvantage G₀' G₄ := by rw [KEM.boolDistAdvantage_anonBranch_anonBranchReordered kem adv b, zero_add] _ ≤ ProbComp.boolDistAdvantage G₀' G₁ + ProbComp.boolDistAdvantage G₁ G₄ := ProbComp.boolDistAdvantage_triangle _ _ _ _ ≤ ProbComp.boolDistAdvantage G₀' G₁ + (ProbComp.boolDistAdvantage G₁ G₂ + ProbComp.boolDistAdvantage G₂ G₄) := by gcongr exact ProbComp.boolDistAdvantage_triangle _ _ _ _ ≤ ProbComp.boolDistAdvantage G₀' G₁ + (ProbComp.boolDistAdvantage G₁ G₂ + (ProbComp.boolDistAdvantage G₂ G₃ + ProbComp.boolDistAdvantage G₃ G₄)) := by gcongr exact ProbComp.boolDistAdvantage_triangle _ _ _ _ = _ := by ring ``` ::: [`SPRTwoHop.lean:365-418 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L365-L418) ### The bound ## [`mlkem768KeyHopProblem`](/lean/proofs/sprtwohop#mlkem768keyhopproblem) The key hop at ML-KEM-768: `t̂ = ·ŝ + ê` against uniform, over the matrix `SampleNTT` expands from `rho`. ```lean def mlkem768KeyHopProblem : LearningWithErrors.Problem Seed32 (TqVec mlkem768.k) (TqVec mlkem768.k) := keyHopProblem concreteNTTRingOps mlkem768Encoding mlkem768Primitives ``` [`SPRTwoHop.lean:430-433 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L430-L433) ## [`mlkem768CtHopProblem`](/lean/proofs/sprtwohop#mlkem768cthopproblem) ```lean def mlkem768CtHopProblem : LearningWithErrors.Problem (Seed32 × TqVec mlkem768.k) (TqVec mlkem768.k) (RqVec mlkem768.k × Rq) := ctHopProblem concreteNTTRingOps mlkem768Encoding mlkem768Primitives ``` [`SPRTwoHop.lean:436-440 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L436-L440) ## [`mlkem768_sprAdv_le_two_hop_decomposition`](/lean/proofs/sprtwohop#mlkem768_spradv_le_two_hop_decomposition) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem mlkem768_sprAdv_le_two_hop_decomposition (b : Bool) : mlkem768KEM.sprAdv mlkem768UniformCiphertext adv768 b ≤ primitiveIdealization concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 b + LearningWithErrors.advantage mlkem768KeyHopProblem (keyHopAdv concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 b) + LearningWithErrors.advantage mlkem768CtHopProblem (ctHopAdv concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 b) + simulatorGap concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 mlkem768UniformCiphertext b := sprAdv_le_two_hop_decomposition concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 mlkem768UniformCiphertext b ``` [`SPRTwoHop.lean:444-455 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L444-L455) ### At ML-KEM-768 ## [`primitiveIdealizationBound`](/lean/proofs/sprtwohop#primitiveidealizationbound) ROM/PRF assumption: the primitive-idealization term is bounded by `epsilonPrim`. ```lean def primitiveIdealizationBound (epsilonPrim : ℝ) : Prop := ∀ b, primitiveIdealization ring encoding prims adv b ≤ epsilonPrim ``` [`SPRTwoHop.lean:475-477 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L475-L477) ## [`encodingRegularityBound`](/lean/proofs/sprtwohop#encodingregularitybound) ```lean def encodingRegularityBound (epsilonEnc : ℝ) : Prop := ∀ b, encodingRegularity ring encoding prims adv sim b ≤ epsilonEnc ``` [`SPRTwoHop.lean:480-482 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L480-L482) ## [`keyRestorationAdv`](/lean/proofs/sprtwohop#keyrestorationadv) ```lean def keyRestorationAdv (b : Bool) : LearningWithErrors.Adversary (keyHopProblem ring encoding prims) := fun chal => do let simCt ← sim let pkOther ← idealKeygen ring encoding prims adv (if b then pkOther else pkOf encoding chal.1 chal.2) (if b then pkOf encoding chal.1 chal.2 else pkOther) simCt omit [DecidableEq encoding.EncodedTHat] [DecidableEq encoding.EncodedU] [DecidableEq encoding.EncodedV] in ``` [`SPRTwoHop.lean:489-498 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L489-L498) ## [`game0_keyRestorationAdv`](/lean/proofs/sprtwohop#game0_keyrestorationadv) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem game0_keyRestorationAdv (b : Bool) : LearningWithErrors.game0 (keyHopProblem ring encoding prims) (keyRestorationAdv ring encoding prims adv sim b) = idealBranch ring encoding prims adv (idealKeygen ring encoding prims) (fun _ => sim) b := by ``` :::details[proof] ```lean simp only [LearningWithErrors.game0, LearningWithErrors.distr, keyHopProblem, keyRestorationAdv, idealBranch, idealKeygen, bind_assoc, pure_bind] omit [DecidableEq encoding.EncodedTHat] [DecidableEq encoding.EncodedU] [DecidableEq encoding.EncodedV] in ``` ::: [`SPRTwoHop.lean:502-511 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L502-L511) ## [`game1_keyRestorationAdv`](/lean/proofs/sprtwohop#game1_keyrestorationadv) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem game1_keyRestorationAdv (b : Bool) : LearningWithErrors.game1 (keyHopProblem ring encoding prims) (keyRestorationAdv ring encoding prims adv sim b) = idealBranch ring encoding prims adv (uniformKeygen encoding) (fun _ => sim) b := by ``` :::details[proof] ```lean simp only [LearningWithErrors.game1, LearningWithErrors.uniformDistr, keyHopProblem, keyRestorationAdv, idealBranch, uniformKeygen, bind_assoc, pure_bind] ``` ::: [`SPRTwoHop.lean:514-521 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L514-L521) ## [`keyIdealization`](/lean/proofs/sprtwohop#keyidealization) ```lean noncomputable def keyIdealization (b : Bool) : ℝ := (idealBranch ring encoding prims adv (idealKeygen ring encoding prims) (fun _ => sim) b).boolDistAdvantage (KEM.anonSetup (MLKEM.asKEMScheme ring encoding prims) >>= KEM.simBranch sim adv) ``` [`SPRTwoHop.lean:526-530 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L526-L530) ## [`keyRestoration_le_mlwe_add_keyIdealization`](/lean/proofs/sprtwohop#keyrestoration_le_mlwe_add_keyidealization) `axioms: propext, Classical.choice, Quot.sound` · sorry-free ✓ ```lean theorem keyRestoration_le_mlwe_add_keyIdealization (b : Bool) : keyRestoration ring encoding prims adv sim b ≤ LearningWithErrors.advantage (keyHopProblem ring encoding prims) (keyRestorationAdv ring encoding prims adv sim b) + keyIdealization ring encoding prims adv sim b := by ``` :::details[proof] ```lean rw [keyRestoration, keyIdealization, LearningWithErrors.advantage_eq_boolDistAdvantage, game0_keyRestorationAdv, game1_keyRestorationAdv] set Gu : ProbComp Bool := idealBranch ring encoding prims adv (uniformKeygen encoding) (fun _ => sim) b set Gi : ProbComp Bool := idealBranch ring encoding prims adv (idealKeygen ring encoding prims) (fun _ => sim) b set Gr : ProbComp Bool := KEM.anonSetup (MLKEM.asKEMScheme ring encoding prims) >>= KEM.simBranch sim adv calc ProbComp.boolDistAdvantage Gu Gr ≤ ProbComp.boolDistAdvantage Gu Gi + ProbComp.boolDistAdvantage Gi Gr := ProbComp.boolDistAdvantage_triangle _ _ _ _ = ProbComp.boolDistAdvantage Gi Gu + ProbComp.boolDistAdvantage Gi Gr := by rw [ProbComp.boolDistAdvantage_comm Gu Gi] ``` ::: [`SPRTwoHop.lean:535-553 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L535-L553) ## [`keyRestorationMLWE`](/lean/proofs/sprtwohop#keyrestorationmlwe) ```lean def keyRestorationMLWE (epsilonRestore : ℝ) : Prop := ∀ b, keyRestoration ring encoding prims adv sim b ≤ epsilonRestore ``` [`SPRTwoHop.lean:557-559 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L557-L559) ## [`sprAdv_le_mlwe`](/lean/proofs/sprtwohop#spradv_le_mlwe) ```lean theorem sprAdv_le_mlwe (b : Bool) (epsilonPrim epsilonEnc epsilonRestore : ℝ) (hPrim : ∀ b, primitiveIdealization ring encoding prims adv b ≤ epsilonPrim) (hEnc : ∀ b, encodingRegularity ring encoding prims adv sim b ≤ epsilonEnc) (hRestore : ∀ b, keyRestoration ring encoding prims adv sim b ≤ epsilonRestore) : KEM.sprAdv (MLKEM.asKEMScheme ring encoding prims) sim adv b ≤ epsilonPrim + LearningWithErrors.advantage (keyHopProblem ring encoding prims) (keyHopAdv ring encoding prims adv b) + LearningWithErrors.advantage (ctHopProblem ring encoding prims) (ctHopAdv ring encoding prims adv b) + epsilonEnc + epsilonRestore := by ``` :::details[proof] ```lean calc KEM.sprAdv (MLKEM.asKEMScheme ring encoding prims) sim adv b ≤ primitiveIdealization ring encoding prims adv b + LearningWithErrors.advantage (keyHopProblem ring encoding prims) (keyHopAdv ring encoding prims adv b) + LearningWithErrors.advantage (ctHopProblem ring encoding prims) (ctHopAdv ring encoding prims adv b) + simulatorGap ring encoding prims adv sim b := sprAdv_le_two_hop_decomposition ring encoding prims adv sim b _ ≤ epsilonPrim + _ + _ + (encodingRegularity ring encoding prims adv sim b + keyRestoration ring encoding prims adv sim b) := by gcongr · exact hPrim b · exact simulatorGap_le ring encoding prims adv sim b _ ≤ epsilonPrim + _ + _ + (epsilonEnc + epsilonRestore) := by gcongr · exact hEnc b · exact hRestore b _ = _ := by ring ``` ::: [`SPRTwoHop.lean:561-593 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L561-L593) ## [`mlkem768_sprAdv_le_mlwe`](/lean/proofs/sprtwohop#mlkem768_spradv_le_mlwe) **ML-KEM-768 SPR ≤ named assumptions + three MLWE advantages**, against the uniform-ciphertext-bytes simulator [`mlkem768UniformCiphertext`](/lean/proofs/mlkem#mlkem768uniformciphertext) (the same simulator as [`mlkem768_sprAdv_le_two_hop_decomposition`](/lean/proofs/sprtwohop#mlkem768_spradv_le_two_hop_decomposition)). ```lean theorem mlkem768_sprAdv_le_mlwe (b : Bool) (epsilonPrim epsilonEnc epsilonRestore : ℝ) (hPrim : ∀ b, primitiveIdealization concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 b ≤ epsilonPrim) (hEnc : ∀ b, encodingRegularity concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 mlkem768UniformCiphertext b ≤ epsilonEnc) (hRestore : ∀ b, keyRestoration concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 mlkem768UniformCiphertext b ≤ epsilonRestore) : mlkem768KEM.sprAdv mlkem768UniformCiphertext adv768 b ≤ epsilonPrim + LearningWithErrors.advantage mlkem768KeyHopProblem (keyHopAdv concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 b) + LearningWithErrors.advantage mlkem768CtHopProblem (ctHopAdv concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 b) + epsilonEnc + epsilonRestore := sprAdv_le_mlwe concreteNTTRingOps mlkem768Encoding mlkem768Primitives adv768 mlkem768UniformCiphertext b epsilonPrim epsilonEnc epsilonRestore hPrim hEnc hRestore ``` [`SPRTwoHop.lean:605-622 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/SPRTwoHop.lean#L605-L622) # WidenedSigning :::info[Mirrored page] Source: [`lean/docs-proofs/widenedsigning.md`](https://github.com/Skanislav/pq-sap/blob/main/lean/docs-proofs/widenedsigning.md). Edit the source file; this copy is regenerated by `pnpm sync` in `wiki/`. ::: The standard ML-DSA identification scheme rejects when `‖z‖∞ < γ₁ - β` and `‖r₀‖∞ < γ₂ - β`, where `β = τ·η` bounds `‖c·s‖∞` for an `η`-short `s`. In Construction A the blinded secret `(s₁ + s', s₂ + e')` is a sum of two `η`-short vectors, so both challenge products `c·(s₁+s')` and `c·(s₂+e')` are bounded by `β' = 2·β` ([`sampleInBall_smul_widened_bound`](/lean/proofs/widenedsigning#sampleinball_smul_widened_bound)). We therefore run the IDS with both response gates widened: `‖z‖∞ < γ₁ - β'` and `‖r₀‖∞ < γ₂ - β'`. The second widening is what makes `hide_low` go through for the `2η`-short `s₂`; the verifier only checks `z`, so it is unaffected. The mask `y` is drawn from the FIPS 204 cube `[-(γ₁-1), γ₁]` ([`sampleMaskCube`](/lean/proofs/widenedsigning#samplemaskcube)), not from the whole ring as in VCVio's `identificationScheme`, so that the accepted-`z` probability is the one in the Dilithium analysis. Assumed / NOT closed: exact equality of the full commitment/challenge/hint transcript to the simulator (quantified as [`widenedHvzkDistance`](/lean/proofs/widenedsigning#widenedhvzkdistance), currently the trivial `1`). ## [`widenedBeta`](/lean/proofs/widenedsigning#widenedbeta) Widened challenge-product bound for blinded signing keys: `β' = 2·β`. ```lean def widenedBeta : ℕ := 2 * p.beta omit nttOps [SampleableType (RqVec p.l)] [SampleableType (CommitHashBytes p)] in ``` [`WidenedSigning.lean:39-41 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L39-L41) ## [`widenedBeta_eq_tau_two_eta`](/lean/proofs/widenedsigning#widenedbeta_eq_tau_two_eta) ```lean @[simp] theorem widenedBeta_eq_tau_two_eta : widenedBeta p = p.tau * (2 * p.eta) := by ``` :::details[proof] ```lean rw [widenedBeta, Params.beta] ring omit nttOps [SampleableType (RqVec p.l)] [SampleableType (CommitHashBytes p)] in ``` ::: [`WidenedSigning.lean:43-47 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L43-L47) ## [`widenedBeta_eq_two_beta`](/lean/proofs/widenedsigning#widenedbeta_eq_two_beta) ```lean @[simp] theorem widenedBeta_eq_two_beta : widenedBeta p = 2 * p.beta := rfl ``` [`WidenedSigning.lean:49-50 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L49-L50) ### 1. Widened parameters ## [`polyNorm_eq_cInfNorm`](/lean/proofs/widenedsigning#polynorm_eq_cinfnorm) `polyNorm` is the vector-backend centered infinity norm, definitionally. ```lean theorem polyNorm_eq_cInfNorm (f : Rq) : polyNorm f = cInfNorm f := by ``` :::details[proof] ```lean unfold polyNorm normOps cInfNorm simp only [zmodPolyNormOps, normOpsOfCenteredView] rfl omit nttOps [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` ::: [`WidenedSigning.lean:56-62 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L56-L62) ## [`normOps_cInfNorm_eq`](/lean/proofs/widenedsigning#normops_cinfnorm_eq) ```lean theorem normOps_cInfNorm_eq (f : Rq) : normOps.cInfNorm f = cInfNorm f := polyNorm_eq_cInfNorm f omit nttOps [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` [`WidenedSigning.lean:64-68 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L64-L68) ## [`polyNorm_neg`](/lean/proofs/widenedsigning#polynorm_neg) ```lean theorem polyNorm_neg' (f : Rq) : polyNorm (-f) = polyNorm f := by ``` :::details[proof] ```lean unfold polyNorm normOps simp only [LatticeCrypto.zmodPolyNormOps, LatticeCrypto.normOpsOfCenteredView] unfold LatticeCrypto.cInfNormOf apply Finset.sup_congr rfl intro i _ simp only [LatticeCrypto.zmodCenteredCoeffView, coeffRing.coeff_neg] exact LatticeCrypto.centeredRepr_natAbs_neg _ omit nttOps [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` ::: [`WidenedSigning.lean:70-80 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L70-L80) ## [`polyVecNorm_add_le`](/lean/proofs/widenedsigning#polyvecnorm_add_le) ```lean theorem polyVecNorm_add_le {k : ℕ} (u v : RqVec k) : polyVecNorm (u + v) ≤ polyVecNorm u + polyVecNorm v := by ``` :::details[proof] ```lean refine (PolyVec.cInfNorm_le_iff (ops := normOps)).mpr fun j => ?_ have hget : (u + v).get j = u.get j + v.get j := by simp only [Vector.get_eq_getElem, Vector.getElem_add] rw [hget] calc normOps.cInfNorm (u.get j + v.get j) = cInfNorm (u.get j + v.get j) := normOps_cInfNorm_eq _ _ ≤ cInfNorm (u.get j) + cInfNorm (v.get j) := PqStealth.cInfNorm_add_le _ _ _ = normOps.cInfNorm (u.get j) + normOps.cInfNorm (v.get j) := by rw [normOps_cInfNorm_eq, normOps_cInfNorm_eq] _ ≤ polyVecNorm u + polyVecNorm v := Nat.add_le_add (PolyVec.component_cInfNorm_le normOps u j) (PolyVec.component_cInfNorm_le normOps v j) omit [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` ::: [`WidenedSigning.lean:82-98 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L82-L98) ## [`sampleInBall_smul_widened_bound`](/lean/proofs/widenedsigning#sampleinball_smul_widened_bound) ```lean theorem sampleInBall_smul_widened_bound (h_laws : Primitives.Laws prims nttOps) (cTilde : CommitHashBytes p) {k : ℕ} {s a b : RqVec k} (hs : s = a + b) (ha : polyVecBounded a p.eta) (hb : polyVecBounded b p.eta) : polyVecNorm (prims.sampleInBall cTilde • s) ≤ widenedBeta p := by ``` :::details[proof] ```lean haveI := h_laws.transform subst hs change polyVecNorm (nttOps.coeffScalarVecMul _ (a + b)) ≤ _ rw [nttOps.coeffScalarVecMul_add, widenedBeta, two_mul] exact (polyVecNorm_add_le _ _).trans (Nat.add_le_add (h_laws.sampleInBall_smul_bound cTilde a ha) (h_laws.sampleInBall_smul_bound cTilde b hb)) ``` ::: [`WidenedSigning.lean:102-113 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L102-L113) ### 2. Norm facts for sums of short vectors ## [`widenedValidKeyPair`](/lean/proofs/widenedsigning#widenedvalidkeypair) A key pair valid for the widened scheme: the seeds agree, the public-key identity `t₁·2^d + t₀ = A·s₁ + s₂` holds, and each secret vector is a sum of two `η`-short vectors (so is itself `2η`-short, [`widenedValidKeyPair_norm`](/lean/proofs/widenedsigning#widenedvalidkeypair_norm)). No honest seed is required; a blinded stealth key `(s₁+s', s₂+e')` qualifies ([`widenedValidKeyPair_blinded`](/lean/proofs/widenedsigning#widenedvalidkeypair_blinded)). ```lean noncomputable def widenedValidKeyPair (pk : PublicKey p prims) (sk : SecretKey p) : Bool := decide (pk.rho = sk.rho ∧ prims.power2RoundShiftVec pk.t1 + sk.t0 = prims.expandA pk.rho * sk.s1 + sk.s2 ∧ (∃ a b : RqVec p.l, sk.s1 = a + b ∧ polyVecBounded a p.eta ∧ polyVecBounded b p.eta) ∧ (∃ a b : RqVec p.k, sk.s2 = a + b ∧ polyVecBounded a p.eta ∧ polyVecBounded b p.eta)) omit [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in @[simp] theorem widenedValidKeyPair_eq_true_iff (pk : PublicKey p prims) (sk : SecretKey p) : widenedValidKeyPair p prims pk sk = true ↔ pk.rho = sk.rho ∧ prims.power2RoundShiftVec pk.t1 + sk.t0 = prims.expandA pk.rho * sk.s1 + sk.s2 ∧ (∃ a b : RqVec p.l, sk.s1 = a + b ∧ polyVecBounded a p.eta ∧ polyVecBounded b p.eta) ∧ (∃ a b : RqVec p.k, sk.s2 = a + b ∧ polyVecBounded a p.eta ∧ polyVecBounded b p.eta) := by ``` :::details[proof] ```lean simp [widenedValidKeyPair] omit [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` ::: [`WidenedSigning.lean:122-139 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L122-L139) ## [`widenedValidKeyPair_norm`](/lean/proofs/widenedsigning#widenedvalidkeypair_norm) ```lean theorem widenedValidKeyPair_norm {pk : PublicKey p prims} {sk : SecretKey p} (hw : widenedValidKeyPair p prims pk sk = true) : polyVecNorm sk.s1 ≤ 2 * p.eta ∧ polyVecNorm sk.s2 ≤ 2 * p.eta := by ``` :::details[proof] ```lean obtain ⟨-, -, ⟨a1, b1, h1, ha1, hb1⟩, ⟨a2, b2, h2, ha2, hb2⟩⟩ := (widenedValidKeyPair_eq_true_iff p prims pk sk).mp hw refine ⟨?_, ?_⟩ · rw [h1, two_mul] exact (polyVecNorm_add_le _ _).trans (Nat.add_le_add ha1 hb1) · rw [h2, two_mul] exact (polyVecNorm_add_le _ _).trans (Nat.add_le_add ha2 hb2) omit [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` ::: [`WidenedSigning.lean:141-153 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L141-L153) ## [`widenedValidKeyPair_blinded`](/lean/proofs/widenedsigning#widenedvalidkeypair_blinded) ```lean theorem widenedValidKeyPair_blinded (h_laws : Primitives.Laws prims nttOps) (rho key : Bytes 32) (tr : Bytes 64) (s1 s' : RqVec p.l) (s2 e' : RqVec p.k) (hs1 : polyVecBounded s1 p.eta) (hs' : polyVecBounded s' p.eta) (hs2 : polyVecBounded s2 p.eta) (he' : polyVecBounded e' p.eta) : let t := prims.expandA rho * (s1 + s') + (s2 + e') widenedValidKeyPair p prims ⟨rho, (prims.power2RoundVec t).1⟩ ⟨rho, key, tr, s1 + s', s2 + e', (prims.power2RoundVec t).2⟩ = true := by ``` :::details[proof] ```lean intro t rw [widenedValidKeyPair_eq_true_iff] refine ⟨rfl, ?_, ⟨s1, s', rfl, hs1, hs'⟩, ⟨s2, e', rfl, hs2, he'⟩⟩ refine Vector.ext fun i hi => ?_ rw [Vector.getElem_add] simp only [Primitives.power2RoundShiftVec, Primitives.power2RoundVec, Vector.map_map, Vector.getElem_map, Function.comp] exact h_laws.power2Round_decomp _ ``` ::: [`WidenedSigning.lean:156-173 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L156-L173) ### 3. Widened key relation ## [`wApprox_eq_of_keyIdentity`](/lean/proofs/widenedsigning#wapprox_eq_of_keyidentity) ```lean theorem wApprox_eq_of_keyIdentity (h_laws : Primitives.Laws prims nttOps) {pk : PublicKey p prims} {sk : SecretKey p} (h_kg : prims.power2RoundShiftVec pk.t1 + sk.t0 = prims.expandA pk.rho * sk.s1 + sk.s2) (c : Rq) (y : RqVec p.l) : computeWApprox p prims (prims.expandA pk.rho) c (y + c • sk.s1) pk.t1 = (prims.expandA pk.rho) * y - c • sk.s2 + c • sk.t0 := by ``` :::details[proof] ```lean haveI := h_laws.transform let laws := h_laws.transform set aHat := prims.expandA pk.rho simp only [computeWApprox] refine Vector.ext fun i hi => ?_ simp only [Vector.getElem_add, Vector.getElem_sub, nttOps.hatVec_add, nttOps.unhatVec_sub, nttOps.matVecMul_add, nttOps.unhatVec_add] have hAs1_hat : nttOps.matVecMul aHat (nttOps.hatVec sk.s1) = nttOps.hatVec (prims.power2RoundShiftVec pk.t1 + sk.t0 - sk.s2) := by rw [h_kg] refine Vector.ext fun j hj => ?_ simp only [hatVec, HMul.hMul, coeffMatVecMul, unhatVec, matVecMul, Vector.getElem_map, Vector.getElem_sub, Vector.getElem_add] have hcancel : (fromHat (dot nttOps aHat[j] (Vector.map toHat sk.s1)) : Rq) + sk.s2[j] - sk.s2[j] = fromHat (dot nttOps aHat[j] (Vector.map toHat sk.s1)) := by abel rw [hcancel, laws.toHat_fromHat] have hMatScalarComm : nttOps.matVecMul aHat (nttOps.hatVec (c • sk.s1)) = nttOps.scalarVecMul (toHat c) (nttOps.matVecMul aHat (nttOps.hatVec sk.s1)) := by have hNTTSmul : nttOps.hatVec (c • sk.s1) = nttOps.scalarVecMul (toHat c) (nttOps.hatVec sk.s1) := by refine Vector.ext fun j hj => ?_ simp only [hatVec, scalarVecMul, Vector.getElem_map] change nttOps.toHat (nttOps.coeffScalarVecMul c sk.s1)[j] = _ simp only [coeffScalarVecMul, unhatVec, scalarVecMul, hatVec, Vector.map_map, Vector.getElem_map, Function.comp_apply, laws.toHat_fromHat] rw [hNTTSmul] refine Vector.ext fun j hj => ?_ simp only [matVecMul, scalarVecMul, Vector.getElem_map] exact nttOps.dot_scalar_right (toHat c) _ _ simp only [hMatScalarComm, hAs1_hat] change (aHat * y)[i] + (nttOps.coeffScalarVecMul c (prims.power2RoundShiftVec pk.t1 + sk.t0 - sk.s2))[i] - (nttOps.coeffScalarVecMul c (prims.power2RoundShiftVec pk.t1))[i] = (aHat * y)[i] - (nttOps.coeffScalarVecMul c sk.s2)[i] + (nttOps.coeffScalarVecMul c sk.t0)[i] simp only [nttOps.coeffScalarVecMul_sub, nttOps.coeffScalarVecMul_add, Vector.getElem_sub, Vector.getElem_add] abel ``` ::: [`WidenedSigning.lean:184-229 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L184-L229) ### 4. The key identity behind verification ## [`cubeCoeff`](/lean/proofs/widenedsigning#cubecoeff) Centered lift of an index in `Fin (2·γ₁)` onto the mask cube `[-(γ₁-1), γ₁]`. ```lean def cubeCoeff (i : Fin (2 * p.gamma1)) : Coeff := (((i : ℕ) : ℤ) - ((p.gamma1 : ℤ) - 1) : ℤ) ``` [`WidenedSigning.lean:239-241 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L239-L241) ## [`cubeLift`](/lean/proofs/widenedsigning#cubelift) ```lean def cubeLift (v : Fin p.l → Fin ringDegree → Fin (2 * p.gamma1)) : RqVec p.l := Vector.ofFn fun j => (Vector.ofFn fun i => cubeCoeff p (v j i) : Rq) omit nttOps [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` [`WidenedSigning.lean:243-247 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L243-L247) ## [`centeredRepr_cubeCoeff_add`](/lean/proofs/widenedsigning#centeredrepr_cubecoeff_add) ```lean theorem centeredRepr_cubeCoeff_add (hq : 2 * (p.gamma1 + widenedBeta p) < modulus) (x : Fin (2 * p.gamma1)) (d : Coeff) (hd : (centeredRepr d).natAbs ≤ widenedBeta p) : centeredRepr (cubeCoeff p x + d) = ((x : ℕ) : ℤ) - ((p.gamma1 : ℤ) - 1) + centeredRepr d := by ``` :::details[proof] ```lean have hcast : cubeCoeff p x + d = ((((x : ℕ) : ℤ) - ((p.gamma1 : ℤ) - 1) + centeredRepr d : ℤ) : Coeff) := by rw [Int.cast_add, cubeCoeff, ← centeredRepr_intCast d] rw [hcast] apply centeredRepr_intCast_eq_of_natAbs_le _ (b := p.gamma1 + widenedBeta p) _ hq have := x.isLt omega omit nttOps [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` ::: [`WidenedSigning.lean:250-263 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L250-L263) ## [`polyVecNorm_lt_iff`](/lean/proofs/widenedsigning#polyvecnorm_lt_iff) ```lean theorem polyVecNorm_lt_iff {k : ℕ} (w : RqVec k) {B : ℕ} (hB : 0 < B) : polyVecNorm w < B ↔ ∀ j i, (centeredRepr ((w.get j).get i)).natAbs < B := by ``` :::details[proof] ```lean have h1 : polyVecNorm w < B ↔ polyVecNorm w ≤ B - 1 := by omega rw [h1] refine (PolyVec.cInfNorm_le_iff (ops := normOps)).trans (forall_congr' fun j => ?_) rw [normOps_cInfNorm_eq, cInfNorm_le_iff] exact forall_congr' fun i => by omega omit nttOps [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` ::: [`WidenedSigning.lean:265-274 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L265-L274) ## [`cubeLift_add_get`](/lean/proofs/widenedsigning#cubelift_add_get) ```lean theorem cubeLift_add_get (v : Fin p.l → Fin ringDegree → Fin (2 * p.gamma1)) (delta : RqVec p.l) (j : Fin p.l) (i : Fin ringDegree) : ((cubeLift p v + delta).get j).get i = cubeCoeff p (v j i) + (delta.get j).get i := by ``` :::details[proof] ```lean have h1 : (cubeLift p v + delta).get j = (cubeLift p v).get j + delta.get j := by simp only [Vector.get_eq_getElem, Vector.getElem_add] rw [h1, Rq.get_add] congr 1 simp only [cubeLift, Vector.get_eq_getElem, Vector.getElem_ofFn, Fin.eta] ``` ::: [`WidenedSigning.lean:276-284 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L276-L284) ## [`sampleMaskCube`](/lean/proofs/widenedsigning#samplemaskcube) Uniform mask over the FIPS 204 cube `[-(γ₁-1), γ₁]^(l·256)`. ```lean def sampleMaskCube : ProbComp (RqVec p.l) := cubeLift p <$> ($ᵗ (Fin p.l → Fin ringDegree → Fin (2 * p.gamma1))) omit nttOps [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` [`WidenedSigning.lean:288-292 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L288-L292) ## [`card_filter_cube`](/lean/proofs/widenedsigning#card_filter_cube) ```lean theorem card_filter_cube (hB : widenedBeta p < p.gamma1) (hq : 2 * (p.gamma1 + widenedBeta p) < modulus) (d : Coeff) (hd : (centeredRepr d).natAbs ≤ widenedBeta p) : (Finset.univ.filter fun x : Fin (2 * p.gamma1) => (centeredRepr (cubeCoeff p x + d)).natAbs < p.gamma1 - widenedBeta p).card = 2 * (p.gamma1 - widenedBeta p) - 1 := by ``` :::details[proof] ```lean have hpred : ∀ x : Fin (2 * p.gamma1), (centeredRepr (cubeCoeff p x + d)).natAbs < p.gamma1 - widenedBeta p ↔ (((x : ℕ) : ℤ) - ((p.gamma1 : ℤ) - 1) + centeredRepr d).natAbs < p.gamma1 - widenedBeta p := fun x => by rw [centeredRepr_cubeCoeff_add p hq x d hd] set lo : ℕ := ((widenedBeta p : ℤ) - centeredRepr d).toNat with hlo rw [show 2 * (p.gamma1 - widenedBeta p) - 1 = (Finset.Ico lo (lo + (2 * (p.gamma1 - widenedBeta p) - 1))).card by rw [Nat.card_Ico]; omega] refine Finset.card_nbij' (fun x : Fin (2 * p.gamma1) => (x : ℕ)) (fun n => ⟨n % (2 * p.gamma1), Nat.mod_lt _ (NeZero.pos _)⟩) ?_ ?_ ?_ ?_ · intro x hx simp only [Finset.mem_coe, Finset.mem_filter, Finset.mem_univ, true_and] at hx simp only [Finset.mem_coe, Finset.mem_Ico] rw [hpred] at hx have := x.isLt omega · intro n hn simp only [Finset.mem_coe, Finset.mem_Ico] at hn simp only [Finset.mem_coe, Finset.mem_filter, Finset.mem_univ, true_and] rw [hpred] have hnlt : n < 2 * p.gamma1 := by omega simp only [Nat.mod_eq_of_lt hnlt] omega · intro x _ exact Fin.ext (Nat.mod_eq_of_lt x.isLt) · intro n hn simp only [Finset.mem_coe, Finset.mem_Ico] at hn exact Nat.mod_eq_of_lt (by omega) omit nttOps [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] [NeZero (2 * p.gamma1)] in ``` ::: [`WidenedSigning.lean:297-334 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L297-L334) ## [`coefficient_preimage`](/lean/proofs/widenedsigning#coefficient_preimage) ```lean theorem coefficient_preimage {delta z : ℤ} (hd : delta.natAbs ≤ widenedBeta p) (hz : z.natAbs < p.gamma1 - widenedBeta p) : (z - delta).natAbs ≤ p.gamma1 - 1 := by ``` :::details[proof] ```lean have h := Int.natAbs_sub_le z delta omega omit nttOps [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` ::: [`WidenedSigning.lean:338-345 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L338-L345) ## [`cube_shift_accept_prob`](/lean/proofs/widenedsigning#cube_shift_accept_prob) ```lean theorem cube_shift_accept_prob (hB : widenedBeta p < p.gamma1) (hq : 2 * (p.gamma1 + widenedBeta p) < modulus) (delta : RqVec p.l) (hd : polyVecNorm delta ≤ widenedBeta p) : Pr[= true | (do let y ← sampleMaskCube p pure (polyVecNorm (y + delta) < p.gamma1 - widenedBeta p)) ] = (((2 * (p.gamma1 - widenedBeta p) - 1 : ℕ) : ℝ≥0∞) / ((2 * p.gamma1 : ℕ) : ℝ≥0∞)) ^ (p.l * ringDegree) := by ``` :::details[proof] ```lean have hBpos : 0 < p.gamma1 - widenedBeta p := by omega have hdc : ∀ j i, (centeredRepr ((delta.get j).get i)).natAbs ≤ widenedBeta p := by intro j i have h1 := (PolyVec.cInfNorm_le_iff (ops := normOps)).mp hd j rw [normOps_cInfNorm_eq, cInfNorm_le_iff] at h1 exact h1 i -- 1. The computation is a uniform draw of cube indices pushed through the gate. have hcomp : (do let y ← sampleMaskCube p pure (decide (polyVecNorm (y + delta) < p.gamma1 - widenedBeta p)) : ProbComp Bool) = (fun v => decide (polyVecNorm (cubeLift p v + delta) < p.gamma1 - widenedBeta p)) <$> ($ᵗ (Fin p.l → Fin ringDegree → Fin (2 * p.gamma1))) := by simp only [sampleMaskCube, map_eq_bind_pure_comp, bind_assoc, Function.comp_def, pure_bind] rw [hcomp, probOutput_map] simp only [decide_eq_true_eq] rw [probEvent_uniformSample] -- 2. The accepted set is a product of per-coefficient accepted sets. have hfilter : (Finset.univ.filter fun v : Fin p.l → Fin ringDegree → Fin (2 * p.gamma1) => polyVecNorm (cubeLift p v + delta) < p.gamma1 - widenedBeta p) = Fintype.piFinset fun j => Fintype.piFinset fun i => Finset.univ.filter fun x : Fin (2 * p.gamma1) => (centeredRepr (cubeCoeff p x + (delta.get j).get i)).natAbs < p.gamma1 - widenedBeta p := by ext v simp only [Finset.mem_filter, Finset.mem_univ, true_and, Fintype.mem_piFinset] rw [polyVecNorm_lt_iff _ hBpos] simp only [cubeLift_add_get] have hcount : ∀ j i, (Finset.univ.filter fun x : Fin (2 * p.gamma1) => (centeredRepr (cubeCoeff p x + (delta.get j).get i)).natAbs < p.gamma1 - widenedBeta p).card = 2 * (p.gamma1 - widenedBeta p) - 1 := fun j i => card_filter_cube p hB hq _ (hdc j i) rw [hfilter] simp only [Fintype.card_piFinset, hcount, Finset.prod_const, Finset.card_univ, Fintype.card_fin, Fintype.card_pi] -- 3. `(N^256)^l / (M^256)^l = (N / M)^(l·256)` in `ℝ≥0∞`. rw [← pow_mul, ← pow_mul, mul_comm ringDegree p.l, Nat.cast_pow, Nat.cast_pow, div_eq_mul_inv, ENNReal.inv_pow, ← mul_pow, ← div_eq_mul_inv] omit [SampleableType (RqVec p.l)] [SampleableType (Vector prims.Hint p.k)] [SampleableType (CommitHashBytes p)] in ``` ::: [`WidenedSigning.lean:351-398 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L351-L398) ## [`widened_z_accepted_independent`](/lean/proofs/widenedsigning#widened_z_accepted_independent) ```lean theorem widened_z_accepted_independent (h_laws : Primitives.Laws prims nttOps) (hB : widenedBeta p < p.gamma1) (hq : 2 * (p.gamma1 + widenedBeta p) < modulus) (pk : PublicKey p prims) (sk : SecretKey p) (hw : widenedValidKeyPair p prims pk sk = true) (cTilde : CommitHashBytes p) : Pr[= true | (do let y ← sampleMaskCube p let z := y + prims.sampleInBall cTilde • sk.s1 pure (polyVecNorm z < p.gamma1 - widenedBeta p)) ] = (((2 * (p.gamma1 - widenedBeta p) - 1 : ℕ) : ℝ≥0∞) / ((2 * p.gamma1 : ℕ) : ℝ≥0∞)) ^ (p.l * ringDegree) := by ``` :::details[proof] ```lean obtain ⟨-, -, ⟨a, b, hs1, ha, hb⟩, -⟩ := (widenedValidKeyPair_eq_true_iff p prims pk sk).mp hw exact cube_shift_accept_prob p hB hq _ (sampleInBall_smul_widened_bound p prims h_laws cTilde hs1 ha hb) ``` ::: [`WidenedSigning.lean:402-416 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L402-L416) ### 5. Mask sampler over the FIPS 204 cube ## [`widenedIdentificationScheme`](/lean/proofs/widenedsigning#widenedidentificationscheme) The widened IDS: cube-uniform mask, widened `z` and `r₀` gates, and the same verification as the standard scheme (with the widened `z` bound). Completeness holds relative to [`widenedValidKeyPair`](/lean/proofs/widenedsigning#widenedvalidkeypair) ([`widened_ids_complete`](/lean/proofs/widenedsigning#widened_ids_complete)). ```lean def widenedIdentificationScheme [DecidableEq prims.High] : IdenSchemeWithAbort (PublicKey p prims) (SecretKey p) (Commitment p prims) (SigningState p) (CommitHashBytes p) (Response p prims) (widenedValidKeyPair p prims) where commit := fun pk _sk => do let aHat := prims.expandA pk.rho let y ← sampleMaskCube p let w := aHat * y let w1 := prims.highBitsVec w return (w1, ⟨y, w⟩) respond := fun _pk sk st cTilde => do let c := prims.sampleInBall cTilde let cs1 := c • sk.s1 let cs2 := c • sk.s2 let z := st.y + cs1 let r0 := prims.lowBitsVec (st.w - cs2) if polyVecNorm z < p.gamma1 - widenedBeta p ∧ polyVecNorm r0 < p.gamma2 - widenedBeta p then do let ct0 := c • sk.t0 let h := prims.makeHintVec (-ct0) (st.w - cs2 + ct0) if polyVecNorm ct0 < p.gamma2 ∧ prims.hintWeight h ≤ p.omega then return some (z, h) else return none else return none verify := fun pk w1 cTilde (z, h) => let c := prims.sampleInBall cTilde let aHat := prims.expandA pk.rho let wApprox := computeWApprox p prims aHat c z pk.t1 let w1' := prims.useHintVec h wApprox decide (polyVecNorm z < p.gamma1 - widenedBeta p) && decide (w1' = w1) && decide (prims.hintWeight h ≤ p.omega) ``` [`WidenedSigning.lean:422-459 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L422-L459) ### 6. Widened identification scheme ## [`widened_ids_complete`](/lean/proofs/widenedsigning#widened_ids_complete) **Completeness of the widened scheme.** Whenever the honest prover does not abort, the verifier accepts. Same shape as VCVio's `idsWithAbort_complete`, with the widened bound `β'` in place of `β` and the key identity taken from [`widenedValidKeyPair`](/lean/proofs/widenedsigning#widenedvalidkeypair) instead of an honest seed. ```lean theorem widened_ids_complete [DecidableEq prims.High] [IsUniformSpec unifSpec] (h_laws : Primitives.Laws prims nttOps) : (widenedIdentificationScheme p prims).Complete := by ``` :::details[proof] ```lean classical intro pk sk hvalid rw [probOutput_eq_one_iff_forall] refine ⟨probFailure_of_liftM_PMF _, fun b hb => ?_⟩ rw [support_bind] at hb simp only [Set.mem_iUnion] at hb obtain ⟨t?, ht?, hb⟩ := hb rw [support_pure] at hb simp only [Set.mem_singleton_iff] at hb subst hb match t? with | none => rfl | some (w1, cTilde, zh) => simp only [IdenSchemeWithAbort.honestExecution, support_bind, Set.mem_iUnion, support_pure, Set.mem_singleton_iff] at ht? obtain ⟨⟨w1', st⟩, hw1st, cTilde', hcTilde, oz, hoz, heq⟩ := ht? cases oz with | none => simp only [Option.map, reduceCtorEq] at heq | some zh' => simp only [Option.map, Option.some.injEq, Prod.mk.injEq] at heq obtain ⟨rfl, rfl, rfl⟩ := heq -- The algebraic core. obtain ⟨-, h_kg, ⟨a1, b1, hs1, ha1, hb1⟩, ⟨a2, b2, hs2, ha2, hb2⟩⟩ := (widenedValidKeyPair_eq_true_iff p prims pk sk).mp hvalid simp only [widenedIdentificationScheme, support_bind, support_pure, Set.mem_iUnion, Set.mem_singleton_iff, Prod.mk.injEq] at hw1st simp only [widenedIdentificationScheme] at hoz obtain ⟨y, -, hw1, hst⟩ := hw1st subst hst hw1 split_ifs at hoz with hc1 hc2 · rw [support_pure, Set.mem_singleton_iff, Option.some.injEq] at hoz subst hoz dsimp only at hc1 hc2 ⊢ obtain ⟨hz_norm, hr0_norm⟩ := hc1 obtain ⟨hct0_norm, hweight⟩ := hc2 have hcs2_norm : polyVecNorm (prims.sampleInBall cTilde • sk.s2) ≤ widenedBeta p := sampleInBall_smul_widened_bound p prims h_laws cTilde hs2 ha2 hb2 set c := prims.sampleInBall cTilde with hc_def set aHat := prims.expandA pk.rho with haHat_def have hcond_t0 : ∀ j : Fin p.k, polyNorm ((-(c • sk.t0)).get j) ≤ p.gamma2 := by intro j have hneg : (-(c • sk.t0)).get j = -((c • sk.t0).get j) := by simp only [Vector.get_eq_getElem, Vector.getElem_neg] rw [hneg, polyNorm_neg'] exact le_of_lt (lt_of_le_of_lt (LatticeCrypto.PolyVec.component_cInfNorm_le normOps (c • sk.t0) j) hct0_norm) have harith1 : aHat * y - c • sk.s2 + c • sk.t0 + -(c • sk.t0) = aHat * y - c • sk.s2 := by apply Vector.ext; intro i hi simp only [Vector.getElem_add, Vector.getElem_sub, Vector.getElem_neg]; abel have harith2 : aHat * y - c • sk.s2 + c • sk.s2 = aHat * y := by apply Vector.ext; intro i hi simp only [Vector.getElem_add, Vector.getElem_sub]; abel have hhide : prims.highBitsVec (aHat * y - c • sk.s2) = prims.highBitsVec (aHat * y) := by have h := hide_lowVec p prims h_laws (aHat * y - c • sk.s2) (c • sk.s2) (widenedBeta p) (fun j => le_trans (LatticeCrypto.PolyVec.component_cInfNorm_le normOps (c • sk.s2) j) hcs2_norm) (fun j => by have hj := lt_of_le_of_lt (LatticeCrypto.PolyVec.component_cInfNorm_le normOps (prims.lowBitsVec (aHat * y - c • sk.s2)) j) hr0_norm simp only [Primitives.lowBitsVec, Vector.get_eq_getElem, Vector.getElem_map, polyNorm] at hj ⊢ omega) rw [harith2] at h exact h.symm have hwa := wApprox_eq_of_keyIdentity p prims h_laws h_kg c y simp only [widenedIdentificationScheme, Bool.and_eq_true, decide_eq_true_eq] refine ⟨⟨hz_norm, ?_⟩, hweight⟩ rw [hwa, useHintVec_makeHintVec p prims h_laws (-(c • sk.t0)) (aHat * y - c • sk.s2 + c • sk.t0) hcond_t0, harith1, hhide] all_goals (rw [support_pure, Set.mem_singleton_iff] at hoz; exact absurd hoz (by simp)) ``` ::: [`WidenedSigning.lean:467-542 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L467-L542) ### 7. Completeness ## [`widenedHvzkSimulator`](/lean/proofs/widenedsigning#widenedhvzksimulator) Public-key-only simulator for the widened scheme: sample `z` from the inner acceptance cube and program the commitment so verification succeeds. The full transcript distance is quantified separately as [`widenedHvzkDistance`](/lean/proofs/widenedsigning#widenedhvzkdistance). ```lean def widenedHvzkSimulator [DecidableEq prims.High] (pk : PublicKey p prims) : ProbComp (Option (Commitment p prims × CommitHashBytes p × Response p prims)) := do let cTilde ← $ᵗ (CommitHashBytes p) let z ← $ᵗ (RqVec p.l) let h ← $ᵗ (Vector prims.Hint p.k) if polyVecNorm z < p.gamma1 - widenedBeta p then let c := prims.sampleInBall cTilde let aHat := prims.expandA pk.rho let wApprox := computeWApprox p prims aHat c z pk.t1 let w1 := prims.useHintVec h wApprox return some (w1, cTilde, (z, h)) else return none ``` [`WidenedSigning.lean:548-562 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L548-L562) ## [`widenedHvzkDistance`](/lean/proofs/widenedsigning#widenedhvzkdistance) ```lean noncomputable def widenedHvzkDistance : ℝ≥0∞ := 1 ``` [`WidenedSigning.lean:568-569 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L568-L569) ## [`widened_ids_hvzk`](/lean/proofs/widenedsigning#widened_ids_hvzk) ```lean theorem widened_ids_hvzk [DecidableEq prims.High] [IsUniformSpec unifSpec] : (widenedIdentificationScheme p prims).HVZK (fun pk => widenedHvzkSimulator p prims pk) widenedHvzkDistance.toReal := by ``` :::details[proof] ```lean intro s w _ simp only [widenedHvzkDistance, ENNReal.toReal_one] exact tvDist_le_one _ _ ``` ::: [`WidenedSigning.lean:573-581 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L573-L581) ### 8. HVZK ## [`mldsa65_widened_z_accept_prob`](/lean/proofs/widenedsigning#mldsa65_widened_z_accept_prob) **ML-DSA-65 concrete acceptance probability.** With `γ₁ = 2^19` and `widenedBeta = 392`, the per-coefficient acceptance is `1047791 / 1048576`, and there are `5·256 = 1280` coefficients. Kept as an exact rational. ```lean theorem mldsa65_widened_z_accept_prob (prims65 : Primitives mldsa65) [SampleableType (RqVec mldsa65.l)] [SampleableType (Vector prims65.Hint mldsa65.k)] [SampleableType (CommitHashBytes mldsa65)] (h_laws : Primitives.Laws prims65 nttOps) (pk : PublicKey mldsa65 prims65) (sk : SecretKey mldsa65) (hw : widenedValidKeyPair mldsa65 prims65 pk sk = true) (cTilde : CommitHashBytes mldsa65) : Pr[= true | (do let y ← sampleMaskCube mldsa65 let z := y + prims65.sampleInBall cTilde • sk.s1 pure (polyVecNorm z < mldsa65.gamma1 - widenedBeta mldsa65)) ] = (1047791 / 1048576 : ℝ≥0∞) ^ 1280 := by ``` :::details[proof] ```lean rw [widened_z_accepted_independent mldsa65 prims65 h_laws (by simp [widenedBeta, Params.beta, mldsa65, ParameterSet.params]) (by simp [widenedBeta, Params.beta, mldsa65, ParameterSet.params, modulus]) pk sk hw cTilde] simp only [widenedBeta, Params.beta, mldsa65, ParameterSet.params, ringDegree] norm_num ``` ::: [`WidenedSigning.lean:590-609 ↗`](https://github.com/Skanislav/pq-sap/blob/main/lean/PqStealth/WidenedSigning.lean#L590-L609)