The post-quantum-only change is a breaking one for every consumer. The keyring
and util-crypto surfaces both shrink, so it gets its own version rather than a
republish of quantus.2. @quantus/crypto moves to ^0.3.0, the version the
extension already resolves (wormhole addresses and nullifiers), so the two
share one copy of the WASM.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
KeypairType is now 'dilithium65' | 'dilithium87'. Upstream's four types are
gone with their primitives, not merely unoffered. Each falls to Shor's
algorithm, and a keyring that can hold such a key invites someone to keep funds
under it, inside a tool whose premise is that this is unsafe.
This was deferred until post-quantum signing was proven end to end (real
transfers on Heisenberg, a real mainnet wallet in the extension), so that
tearing out half of util-crypto could not muddy the diagnosis of a first
rejected extrinsic. That has happened.
util-crypto, removed:
- ed25519/, sr25519/, secp256k1/, and signature/ (signatureVerify, which only
knew those three; dilithiumVerify is the verifier);
- hd/ethereum and hd/ledger;
- key/fromPath and keyHdkd{Ecdsa,Ed25519,Sr25519}, the junction derivation;
- address/derive (sr25519 soft derivation);
- mnemonic/toMiniSecret, Substrate's classical seeding.
util-crypto, kept because none of it holds a key:
- ethereumEncode, isEthereumAddress and isEthereumChecksum. @polkadot/types and
the identicon renderer format 20-byte addresses with them, and a wallet has
to be able to show an Ethereum address to recognise and refuse one.
ethereumEncode now refuses a secp256k1 public key, saying why.
- evm ↔ substrate address conversion (its blake2/keccak hasher moved out of
secp256k1/ into address/), derived and multi addresses, BIP39, and suri
parsing.
keyring:
- Only ML-DSA arms remain. The default type is dilithium65.
- Every entry point (constructor, createFromUri, createFromPair, addFromAddress
and, above all, createFromJson for a backup the user chose) refuses a
quantum-unsafe type with "<type> keys are not quantum-safe and cannot be held
here", not "unknown crypto type", which reads like a bug in this software.
- pair.verify handles ML-DSA, a bare signature or signature ‖ publicKey, under
a context that defaults to the empty raw-bytes one. Derivation and VRF
refuse.
- The test keyring is the Quantus dev accounts (crystal_alice, dilithium_bob,
crystal_charlie: ML-DSA-87 from seeds of 0, 1 and 2) in place of sr25519
Alice…Ferdie and ethereum Alith…Faith.
Fixed along the way: addFromAddress passed the decoded address as a public key.
That is the same bytes on Substrate. Here it is the account id, a hash of the
key, so every watch-only account reported the hash of its own address. It now
carries the address as an account id.
Specs:
- Upstream's per-scheme keyring specs (index, pair, encode, decode, toJson,
vrf, suri, testingPairs) are replaced by keyring.spec.ts. It covers refusals
at every entry point (including a polkadot{.js} ed25519 JSON backup), the
dev-account addresses, watch-only addresses, JSON round trips for both
schemes, verify in both signature forms and failing under the wrong context
or signer, and the absence of derivation and VRF.
- The "classical paths unchanged" pins in the ML-DSA specs are gone with the
paths.
- The BIP39 vectors toEntropy.spec used moved from sr25519/ to
mnemonic/bip39Vectors.spec.ts.
yarn test: 2747 passing, 0 failing. yarn lint: clean.
hw-ledger and hw-ledger-transports remain. They are device transports holding
no primitives, and nothing consumes them.
Closes#6. Refs #1: signatureVerify is gone rather than made to take a
context; dilithiumVerify is the replacement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Filtering `filterAvailable` by `isQuantumSafe` removed Polkadot and Kusama from
`selectableNetworks`, and `@polkadot/types-known` throws for a chain it holds
upgrade history for but cannot find:
Error: Unable to find info for chain kusama
at mapRaw (@polkadot/types-known/upgrades/index.js:13:15)
which made `@polkadot/api` fail at *import* — nothing to do with Quantus, and
not diagnosable from the message.
The mistake was gating a library primitive. These lists say what Substrate
knows about; what a wallet offers is the wallet's decision, and `isQuantumSafe`
stays exported so the extension can make it in its own chain list. Keeping them
complete also means a pasted Polkadot address still decodes and renders as what
it is — recognisable, and therefore refusable. A wallet that cannot decode an
address it will not accept can only say "invalid", which reads as a typo rather
than a warning.
`index.spec.ts` is restored from upstream with only the testnet-exposure and
shared-prefix deltas re-applied, since its network-ordering assertions had
started passing vacuously once Polkadot and Kusama left the list.
Also corrects Heisenberg's token symbol to HEI; `system_properties` on
wss://a1-heisenberg.quantus.cat says HEI, not QTC.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Publishing under upstream's exact version was not merely confusing in a lockfile
— it was unsafe.
@polkadot/util-crypto pins @polkadot/networks at an exact version, and
@polkadot/keyring pins util-crypto the same way. With every fork numbered
14.0.3, a consumer that forgot one resolution would have yarn quietly satisfy
that pin from npm: the *unforked* upstream package, installing cleanly, with no
Quantus network entry or no isDilithium, and nothing to indicate anything was
wrong until something failed much later for an unrelated-looking reason.
Renaming the forks makes that a resolution failure instead. Verified by deleting
the @polkadot/networks resolution in quantus/ui and running an install:
YN0082: @polkadot/networks@npm:14.0.3-quantus.1: No candidates found
Unforked dependencies keep upstream's numbers — @polkadot/util, x-bigint,
x-randomvalues, wasm-* — because they come from npm and should.
Published with `--tag quantus`, which npm requires for a prerelease version and
which is the honest dist-tag anyway: these are not upstream's `latest`. The tag
is informational, since consumers pin by tarball URL. The old 14.0.3 artifacts
have been deleted from the registry so nothing can resolve to them.
Refs quantus/extension#2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
createFromUri now derives ML-DSA pairs from the mnemonic along a hardened BIP44
path rather than from a seed along a junction chain. Lattice keys have no public
derivability, so there is no soft junction to emulate and the chain's own
Pair::derive refuses for the same reason.
The path is m/44'/189189'/<account>'/0'/<0 for 87 | 1 for 65>'. Two things about
it are unusual and both are deliberate: the account index sits at the third level
rather than the last, and the trailing index carries the scheme rather than an
address index. It is what quantus-cli and the mobile wallet already derive, so
anything else produces addresses no other Quantus tool can find.
The ML-DSA branch sits before the existing seeding, not after, because that
seeding is the wrong one for us. mnemonicToMiniSecret is Substrate's variant and
the default reach in this file; using it here yields a perfectly well-formed key
for an account nobody owns. We need the 64-byte BIP39 seed and the HMAC-SHA512
chain keyed with "Dilithium seed", which is what the hdwallet crate does.
Accepted suri forms are nothing (account 0, matching quantus-cli's default),
//<n> for an account index, and //m/44'/… for a full path. Everything else
throws. That matters more than it looks: the syntax was built for curve
junctions, where /foo is soft and //foo hard over arbitrary bytes, and quietly
reinterpreting //Alice as something would hand back an address no other tool
derives and no seed phrase obviously recovers.
A raw hex seed is taken undederived — that is how the dev-genesis accounts are
defined — and combining one with a derivation path is refused rather than
guessed, since whether such a seed is the master or already derived is
unanswerable.
pair.derive() refuses with its own message. Not "not implemented" but not
possible: a child would have to come from this pair's key material, and ML-DSA
keys are not derivable from one another at all. The caller needs the mnemonic, so
the error says so rather than being generically unhelpful.
Verified against quantus-cli for both schemes from the public Substrate dev
phrase, plus index separation, explicit paths agreeing with indices, passphrase
sensitivity, and every refusal.
Refs quantus/common#4
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Before this, an exported Quantus account could not be imported by anything.
decodePair located its divider by trying the two secret lengths every curve
scheme uses, 64 then 32; an ML-DSA secret is 4032 or 4896 bytes, so neither
matched and restore threw "Invalid encoding divider found in body". That is a
data-durability bug rather than a convenience one, which is why it lands before
any build lets a user create an account.
decodePair now takes an optional secretLength. The caller passes it rather than
this function searching for the divider: searching would work almost always and
fail catastrophically when it did not, since PAIR_DIV is five bytes and a
4032-byte secret contains a false match about once in 270 million keys — and the
result would be a silently wrong key rather than an error. The public key is read
as the remainder, because its length also varies and the body ends there.
The harder half is ordering. createFromJson returns a *locked* pair and callers
read pair.address off it long before any password appears. Every other scheme
manages because the address is the public key; an ML-DSA account id is a one-way
Poseidon2 hash and the public key is inside the encrypted blob. So PairInfo gains
an optional accountId, carried as data and used for the address while locked,
which decodePkcs8 clears once the real key arrives.
It also checks them against each other, and that check is not paranoia. For the
curve schemes a JSON file with an edited `address` field cannot decode at all.
Here it decodes perfectly and yields a pair reporting an address its key does not
control — a user would see someone else's address in their own wallet and believe
they held it. Pinned by a test that tampers exactly that field.
One more silent-wrong-key path closed: decodePkcs8 decided "secret key or seed?"
by length, so a 4032-byte ML-DSA secret took the seed branch and was fed to keygen
as entropy, producing a valid and entirely wrong key. The type knows the answer,
so it is asked.
Refs quantus/common#3
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Wires @quantus/crypto into the four TYPE_* maps, so a keyring pair of type
dilithium65/dilithium87 can be created, addressed, signed with and verified.
The curve arms are untouched: quantus/common#6 removes them, but only once
post-quantum signing is proven end to end, so that a rejected first extrinsic has
two suspects rather than three.
util-crypto gains thin dilithium/ wrappers so keyring composes primitives the way
it already does for ed25519, rather than reaching into @quantus/crypto directly.
TYPE_ADDRESS is the interesting arm. Every other entry is identity or a cheap
re-encoding because on Substrate the address *is* the public key; ML-DSA takes
1952 or 2592 bytes to 32 with no way back. dilithiumVerify therefore mirrors the
runtime's Verify::verify rather than inventing anything: split sig||pk, hash the
embedded public key, check it equals the account being verified against, and only
then check the signature. Skipping the account check would accept a valid
signature from *some* key rather than *the* key, which is the whole property.
TYPE_PREFIX gets 0 for dilithium87 and 1 for dilithium65. For the curve types
those index Substrate's MultiSignature; here they index the runtime's
DilithiumSignatureScheme — a different enum reached by the same withType
mechanism, so nothing upstream of createPair needs to know.
Signing takes a mandatory context, which is quantus/common#5 arriving early
because the two are inseparable: TYPE_SIGNATURE cannot have an ML-DSA arm without
one. There is deliberately no default. Extrinsics on spec >= 148 verify under
QUANTUS_EXTRINSIC and earlier ones under the empty context, and the wrong choice
is a valid signature the chain rejects, indistinguishable locally. Only the
caller knows the spec version.
The curve sign functions are wrapped rather than passed by reference: ed25519Sign
and sr25519Sign take a third argument of their own (onlyJs), and letting it line
up with the context slot would silently reinterpret a Uint8Array as a boolean.
VRF signing refuses for ML-DSA. The construction fakes a VRF from an ordinary
signature, which is sound only given properties ML-DSA's signature does not have;
producing something that looks like a VRF proof and cannot be verified as one is
worse than refusing.
Two runtime whitelists sat behind the KeypairType union and silently rejected the
new types — the Keyring constructor and createFromJson. The compiler cannot see
those, and the first failed at construction with a message listing four types.
Both now use one named KEYPAIR_TYPES so the next change to the union has a single
place to look and the message cannot drift from the check.
Verified against quantus-cli, not against ourselves: keyring.addFromSeed with 32
zero bytes yields crystal_alice's address, signs to the runtime's wire length,
verifies under the extrinsic context and fails under the spec-147 one, and
another account's signature is rejected for it.
Refs quantus/common#2, quantus/common#5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
Adds dilithium65 and dilithium87 to KeypairType, and Quantus to the network
registry at SS58 prefix 189.
Two types rather than one because the parameter set is not an implementation
detail: it selects the signature enum variant byte the chain reads off the wire,
it selects the trailing hardened index of the derivation path, and it fixes the
key and signature lengths. New accounts use dilithium65; dilithium87 is legacy,
used by accounts predating the recorded scheme and by the dev-genesis accounts,
so it is supported but never chosen.
Opening the union turns every table keyed by KeypairType into a type error,
which is the point — it enumerates precisely the places needing a post-quantum
answer. Where that answer is not written yet, assertSubstrateType() expresses the
gap as a named, typed refusal at the call site rather than a silent undefined
from an object index. It throws rather than degrading because all of these paths
produce key material or an address, and a wrong-but-plausible value means funds
at an address nobody controls. Call sites disappear as quantus/common#2 and #4
land. keyFromPath throws for the same reason: silently ignoring a derivation
path would hand back the parent key under a child's name.
Quantus is appended to the registry rather than merged — @substrate/ss58-registry
does not carry it and nothing occupies prefixes 185..195, so there is nothing to
override, and appending means a registry bump cannot silently drop it.
standardAccount is 'ml-dsa', a value upstream's closed union does not allow,
hence the widened KnownSubstrate; it is load-bearing, since a missing
standardAccount makes toExpanded mark the network isIgnored.
Testnets are first-class in this fork. Upstream drops any testnet from
availableNetworks however complete its entry is, which suits a production chain
picker and not us: Quantus is under active development and a wallet that cannot
reach Heisenberg can only be tested against real funds. The exception is an
allowlist (exposedTestnet) rather than removing the rule — removing it exposed
all fifteen of upstream's testnets too, moving the sorted tail from 'ZERO' to
'ZERO Alphaville', which is a change we have no reason to make. isTestnet is
still reported honestly so consumers can label or filter; we just stop deciding
for them.
Genesis hashes come from blackbeard.observer's config, which records their
provenance: mainnet read from the node with chain_getBlockHash(0) on 2026-09-09,
its launch day, and explicitly not the staging chain that telemetry calls
"Quantus Staging Mainnet". Symbol and decimals (QTC, 12) agree between the
chain's chain_spec.rs and the Dart SDK's AppConstants.
Three upstream tests encoded rules this fork no longer follows. Each is rewritten
to assert the new rule rather than deleted:
- genesis entries for testnets are now allowed, for ones we opted into
- an exposed testnet must still be labelled a testnet, since a user cannot tell
play money from real money by looking at an address
- one network per SS58 prefix stops holding when a chain has a testnet;
mainnet and Heisenberg are both 189 because an address is valid on either,
and consumers key on genesisHash rather than prefix
Refs quantus/common#1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
* Set pbkdf2Encode rounds to default to 210,000
* Cap amount of rounds to 2048 when onlyJS = true to prevent overflow issues.
* Patch tests to use a consistent amount of encoding rounds.
* lint
* Add additional docs
* lint
---------
Co-authored-by: Valentin Fernandez <tinchofernandez8@gmail.com>