Skip to content
PQ Stealth Addresses

Key exchange and address derivation — technical reference

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 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.

2. Commitment profile and sizes

Source: Python commit module, mirrored by TypeScript commit module.

FieldML-KEM-768 sizeMeaning
Version1 byte0x02; distinct from the target ERC-5564 scheme ID 2
spend_key32 bytesPublic spending-key value or commitment, interpreted by the account profile
kem_ek1,184 bytesML-KEM encapsulation key
Meta-address1,217 bytes`0x02
ephemeralPubKey1,088 bytesML-KEM ciphertext
View tag1 byteSHA-256(ss)[0:1]
Stealth address20 bytesDerived 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.

3. Implemented receive and scan flow

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

Profileopen_domaincommit_domain
C13 commitment (sphincs-c13-commit/v0)pq-stealth/sphincs-c13/open/v0pq-stealth/sphincs-c13/commit/v0
Preimage demo (preimage/v0)pq-stealth/preimage/open/v0pq-stealth/preimage/commit/v0
ML-DSA-44 committed key (ml-dsa-44-commit/v0)pq-stealth/ml-dsa-44/open/v0pq-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:

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 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.

The ML-DSA-44 committed-key profile (§4, D-027) has its own deterministic set in vectors/v0/mldsa44_commit_vectors.json, generated by 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 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 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. The spending research guide records those boundaries. They do not require changing the KEM announcement format, but they have their own deployment and network requirements.