Skip to content

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.

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.

HybridVerifyingKey::verify (crates/node/src/crypto/hybrid.rs:337) checks both halves:

HalfSchemeBytes
latticeML-DSA-65, FIPS 2043,309
hash-basedSLH-DSA-SHA2-128s, FIPS 2057,856
signature11,165
public key1,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:

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.

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.

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.

RoundEvery custodian doesPublic output
1draws an ephemeral ML-KEM-768 keyits encapsulation key
——the roster, which fixes the vault id
2draws one contribution and verifiably shares itcommitments + n sealed shares
3opens, verifies, and sumsthe 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.

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.

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.

AdversaryOutcome
any t-1 custodians, colluding foreverlearn nothing about the vault key — information-theoretically, not computationally
a dishonest dealer in round 2caught 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, laterlearns nothing: the commitments are perfectly hiding and the shares were sealed under ML-KEM
a relay or coordinatorsees 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 offlinevault still signs
n - t + 1 offlineShortOfThreshold, with both numbers, so an operator knows how many more to wake
a corrupted share storecaught before signing, by the commitment check
an authenticated custodian claiming another’s indexrefused by tls::CustodianDirectory, if the caller checks — see below
whoever runs the combiner, during a signatureholds 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.

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.

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.

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 loopback
crates/node/tests/custody_parity_tests.rs 8 in the node's suite

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

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

  • 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. respond checks 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. rcgen is a dev-dependency. An institution brings its own PKI; a library that issued custody certificates would be a library deciding who a custodian is.
  1. Wait. The threshold ML-DSA papers above are months old. When one is standardised and implemented against the same parameter set fips204 compiles, the lattice half becomes tractable.
  2. And then still solve the hash-based half, which nobody has.
  3. Or change the signature scheme, which is a consensus change touching every signature on the chain.
  4. 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.