Threshold custody for Maya2C
What was asked for was an MPC threshold signature scheme. What exists is threshold custody, and the difference is not cosmetic.
This document says what was built, what it does and does not protect against, and why the thing the brief described cannot be built today. It is written so the conclusion can be checked rather than taken on trust.
The one-sentence version
Section titled “The one-sentence version”A vault key is generated by five custodians with no dealer, held by nobody, and split so that any two of them learn nothing about it — but to sign, a quorum’s shares are reconstructed in one place, and for the length of one signature that machine can sign anything the vault owns.
Why not a real threshold signature scheme
Section titled “Why not a real threshold signature scheme”A Maya2C signature is two signatures
Section titled “A Maya2C signature is two signatures”HybridVerifyingKey::verify (crates/node/src/crypto/hybrid.rs:337) checks both halves:
| Half | Scheme | Bytes |
|---|---|---|
| lattice | ML-DSA-65, FIPS 204 | 3,309 |
| hash-based | SLH-DSA-SHA2-128s, FIPS 205 | 7,856 |
| signature | 11,165 | |
| public key | 1,984 |
A construction that thresholds one half produces something every node rejects.
“Assemble a valid Maya2C ML-DSA signature” — item 3 of the brief — is therefore
not a smaller version of the job. It is a vault that cannot make a valid
transaction, which is the same finding docs/ledger-feasibility.md reached from
the other direction.
The lattice half: solved, recently, by nobody’s production code
Section titled “The lattice half: solved, recently, by nobody’s production code”Threshold ML-DSA was an open problem, and the reason is structural. ML-DSA is
Fiat–Shamir with aborts: signing draws a masking vector, computes a candidate,
and rejects and retries if the result would leak the secret. For ML-DSA-65
the two bound checks pass together about 20% of the time. On top of that,
Decompose/HighBits is non-linear, which defeats the additive-share technique
that makes FROST work for Ed25519.
That changed in 2025–2026. There are now at least three constructions producing signatures verifiable by an unmodified FIPS 204 verifier:
- Quorus: Efficient, Scalable Threshold ML-DSA Signatures from MPC
- Efficient Threshold ML-DSA — up to 6 parties, ~1 MB communication per party (NIST PQC 2025 talk)
- TALUS: Threshold ML-DSA with One-Round Online Signing
- FIPS 204-Compatible Threshold ML-DSA via Masked Lagrange Reconstruction
None is standardised. None has a production Rust implementation. And none of
that is reachable from here anyway: fips204 — the crate consensus is pinned to,
and the only pure-Rust ML-DSA in this workspace — exposes try_keygen,
keygen_from_seed, try_sign, try_sign_with_seed, and nothing that
decomposes into partial signatures. Implementing one of these papers means
reimplementing ML-DSA’s internals and then convincing everyone that the
reimplementation agrees with the one consensus uses, bit for bit.
The hash-based half: not solved
Section titled “The hash-based half: not solved”There is no threshold SLH-DSA. Not “unstandardised” — no construction. A hash-based signature is a walk down a Merkle structure driven by a secret seed; thresholding it means evaluating the whole hash tree inside generic MPC, at a cost nobody has proposed as practical.
So even a perfect threshold ML-DSA leaves this chain with half a signature.
What makes the problem tractable anyway
Section titled “What makes the problem tractable anyway”A whole Maya2C identity is 32 bytes.
crypto::hybrid::signing_key_from_seed (crates/node/src/crypto/hybrid.rs:469) takes one
32-byte chain key and derives both halves from it through two
domain-separated BLAKE3 seeds. Protect those 32 bytes and both schemes are
protected at once — no per-scheme threshold construction needed, and the
question becomes threshold custody of a small secret rather than threshold
signing under two incompatible algorithms.
That is the design custody-mpc implements.
The protocol
Section titled “The protocol”Generation: no dealer, ever
Section titled “Generation: no dealer, ever”| Round | Every custodian does | Public output |
|---|---|---|
| 1 | draws an ephemeral ML-KEM-768 key | its encapsulation key |
| — | — | the roster, which fixes the vault id |
| 2 | draws one contribution and verifiably shares it | commitments + n sealed shares |
| 3 | opens, verifies, and sums | the vault’s summed commitments |
The vault secret is the sum of all n contributions. Each custodian’s share of
it is the sum of the n shares it received. Nobody ever holds the sum, and no
machine computes it during generation. The whole thing rests on one line of
algebra: secret sharing is linear, so a sum of shares of n secrets is a share
of their sum — and the same holds for the commitments.
Sharing: Pedersen, not Feldman
Section titled “Sharing: Pedersen, not Feldman”Feldman VSS publishes a_k·G. It is smaller and simpler, and it is wrong for
this chain: a_0·G is a discrete-log commitment to the master secret, so an
adversary who records a 2026 ceremony transcript and solves a discrete log in
2040 recovers the vault seed. On a chain built on the premise that captured
traffic must survive a quantum computer, that transcript is indefensible.
Pedersen commits as a_k·G + r_k·H. It is perfectly hiding: for every
candidate secret there is a blinding factor that explains the transcript, so the
transcript contains no information about the secret at all — not “hard to
extract”, none — and no future computation changes that. Binding becomes
computational instead, which is a ceremony-time attack by a present participant
against a curve that is not broken today. That is the right way round.
H is derived from a BLAKE3 XOF over a fixed domain string, so nobody knows its
discrete log — including whoever wrote the file.
Confidentiality: sealed to the recipient, not to the hop
Section titled “Confidentiality: sealed to the recipient, not to the hop”Item 3 of the brief said “over TLS”. TLS protects a hop; a share crosses more
than one, and every relay, queue, and coordinator in between terminates the
session and sees plaintext. So each share is sealed to its recipient under
ML-KEM-768 + ChaCha20-Poly1305 before it reaches any transport — the same
KEM the node’s own p2p layer uses (crates/node/src/network/pq/handshake.rs), so no new
primitive enters the tree.
TLS then does what it is actually good for. tls::server_config requires a
client certificate, and offers no way to turn that off: without it, “custodian 3
contributed” means “somebody claimed to be custodian 3”, and that claim is the
entire audit trail of a ceremony.
Signing: one round, and one combiner
Section titled “Signing: one round, and one combiner”A combiner opens a session, publishes an ephemeral KEM key, and each custodian returns one sealed share. Custodians never talk to each other.
The brief asked for non-interactive threshold signatures, and this is genuinely non-interactive for the custodians — one message each, no rounds between them. Real threshold ML-DSA constructions are not: they need preprocessing rounds, because the rejection sampling has to be agreed on. The interactivity did not vanish. It moved into the combiner, and that is the trade.
The security model, stated plainly
Section titled “The security model, stated plainly”| Adversary | Outcome |
|---|---|
any t-1 custodians, colluding forever | learn nothing about the vault key — information-theoretically, not computationally |
| a dishonest dealer in round 2 | caught by every recipient independently, at deal time, using public data only; the error names the dealer |
| a passive recorder of the whole ceremony, with a quantum computer, later | learns nothing: the commitments are perfectly hiding and the shares were sealed under ML-KEM |
| a relay or coordinator | sees sealed blobs and routing labels; cannot open, cannot re-route (the AAD binds dealer, recipient, and ceremony), cannot replay into another session |
n - t custodians offline | vault still signs |
n - t + 1 offline | ShortOfThreshold, with both numbers, so an operator knows how many more to wake |
| a corrupted share store | caught before signing, by the commitment check |
| an authenticated custodian claiming another’s index | refused by tls::CustodianDirectory, if the caller checks — see below |
| whoever runs the combiner, during a signature | holds the vault key and can sign anything |
The last row is the design’s whole cost. It is not mitigated anywhere in this crate, and an institution that cannot accept it should not deploy this.
The one check the caller must make
Section titled “The one check the caller must make”Nothing inside a dealing or a contribution proves who sent it: shares are
sealed to their recipients, not signed by their dealers. So dealer: 3 is
exactly as true as “this connection is custodian 3”, and only the transport
knows that. A security review found that the first version gave the caller no
way to ask — tls.rs required a client certificate and then exposed nothing
about it.
tls::peer_identity now returns a hash of the connection’s certificate, and
tls::CustodianDirectory maps each index to the certificate issued for it.
Every frame’s claimed index goes through CustodianDirectory::authorize before
it reaches the ceremony. Skipping that leaks no key, but it lets one
authenticated custodian take another’s slot and have the real one refused as a
duplicate — which breaks the promise every error in this crate makes, that it
names who to call. crates/node/tests/tls_tests.rs shows the check refusing exactly that.
What the combiner row means in practice
Section titled “What the combiner row means in practice”Reasonable deployments make the combiner the thing that is hardest to compromise — an HSM, or the machine that already holds the treasury policy — and accept that its compromise is total. That is a smaller claim than “MPC”, and it is the true one.
A reconstruction is checked before it is used
Section titled “A reconstruction is checked before it is used”Interpolating from too few shares does not fail; it returns a different secret, silently. Every path reconstructs the blinding factor alongside the secret and checks the pair against the vault’s public commitment before a key is derived, then checks the derived address against the vault descriptor. A short quorum, a corrupted safe, a mixed pair of vaults, and a dealer whose shares were never on one polynomial all stop there rather than producing a signature under a key that owns nothing.
What was built
Section titled “What was built”crates/custody-mpc/ crates/node/src/vss.rs Pedersen VSS over Ristretto perfectly hiding crates/node/src/seal.rs ML-KEM-768 + ChaCha20-Poly1305 sealing crates/node/src/dkg.rs the dealerless ceremony crates/node/src/session.rs the quorum that signs, and its checks crates/node/src/hybrid.rs chain key -> hybrid signing key mirrors the node crates/node/src/transport.rs the wire format, every length bounded crates/node/src/tls.rs mutual TLS, behind the `tls` feature crates/node/tests/sharing_tests.rs 16 the arithmetic crates/node/tests/ceremony_tests.rs 21 3-of-5 end to end, and every failure crates/node/tests/transport_tests.rs 10 round trips and malformed frames crates/node/tests/tls_tests.rs 2 a real mTLS ceremony on loopbackcrates/node/tests/custody_parity_tests.rs 8 in the node's suiteAll 57 run and pass. The TLS tests are not #[ignore]d and are not aspirational
— they open sockets, complete handshakes, and carry a 3-of-5 ceremony.
What is verified against the node
Section titled “What is verified against the node”crates/node/tests/custody_parity_tests.rs is the load-bearing file. custody-mpc cannot
depend on custom-l1-node (RocksDB), so it reimplements the chain-key
expansion, and duplication of a consensus-critical derivation is dangerous only
when undetected. The test signs with a quorum and verifies with
HybridVerifyingKey::verify, compares the vault’s address against
hybrid::address_of, and asserts that the same chain key produces the same
public key and the same bytes of signature in both crates.
It also pins the two mistakes this repository has already made once each:
- a guessed address domain (the wasm SDK’s
maya-address-v3), and blake3::Hasher::new_derive_keywhere the node prefixes into a plain hasher (the Ledger app).
Both produced well-formed addresses nothing could spend from, reported by
nothing. a_keyed_derivation_would_not_have_matched fails loudly if a future
tidy-up reintroduces the second.
Deliberately not built
Section titled “Deliberately not built”- No partial ML-DSA signatures. They would require reimplementing FIPS 204’s internals and then proving the reimplementation agrees with the one consensus uses. That is a project, not a module, and it does not solve the hash-based half.
- No consensus change to make a lattice-only signature valid. That would discard the hybrid scheme’s entire reason for existing.
- No approval workflow.
respondchecks that a request names the custodian’s own vault and nothing else. Whether a message should be signed is a policy question, and an approval checkbox living in a crypto library would be a checkbox rather than a control. - No key refresh or share rotation. Proactive resharing is the obvious next thing — the linearity that makes the dealerless ceremony work also makes resharing work — but it is not written, and pretending otherwise would be worse than the gap.
- No certificate minting.
rcgenis a dev-dependency. An institution brings its own PKI; a library that issued custody certificates would be a library deciding who a custodian is.
If a real MPC-TSS is wanted
Section titled “If a real MPC-TSS is wanted”- Wait. The threshold ML-DSA papers above are months old. When one is
standardised and implemented against the same parameter set
fips204compiles, the lattice half becomes tractable. - And then still solve the hash-based half, which nobody has.
- Or change the signature scheme, which is a consensus change touching every signature on the chain.
- Or accept the combiner, which is what this crate does, with the trust boundary written down instead of implied.
Option 4 is the honest default today. The others are decisions somebody makes deliberately, with this document in front of them.