How keys are held
Why this matters
A business holding money through Varnir needs to know who could move it. The answer this section documents: your own signing key is only ever on your device or server, and the keys that control deposit addresses are split so that no single node, operator or place stores a whole one. It also says, plainly, where the running testnet does not yet deliver the full design, so you can weigh the risk yourself rather than take it on trust.
Who this is for
This page is written for someone doing technical due diligence. It describes what actually happens to key material in the code that is running, names the files it happens in, and is explicit about the difference between the design and the current testnet deployment. What is actually running has the second of those in one table, and it is the page to read first if you only read one.
Two different kinds of key
Almost every confusing question about how Varnir holds keys dissolves once you separate these two. They are held in completely different ways, for completely different reasons.
| Identity key | L1 wallet key | |
|---|---|---|
| What it is | A keypair from a 12-word BIP39 phrase: secp256k1, or post-quantum Falcon-512 / ML-DSA-65, depending on when and how the identity was created (see Quantum safety) | An EVM (Ethereum, BNB Smart Chain and others), Tron or Bitcoin private key controlling an on-chain address |
| What it authorises | Ledger transactions: onboarding, claiming a wallet, moving balance | Native L1 transactions from the deposit address itself |
| Who generates it | You, in your own process | The confidential VM, inside encrypted memory |
| Who holds it | Only you. It never reaches a Varnir server | Nobody holds it whole. See the rest of this page |
| If it is lost | The identity is unrecoverable. Varnir cannot help, because Varnir never had it | Recoverable from a quorum of shares |
The first column is the property the introduction leads with and the
signing model describes mechanically: your identity key is
generated in your browser, your phone or your own server, signs the $tx
object locally, and the signed transaction is POSTed directly to a chain
node. Varnir's API is never in a position to sign for you, because it is
never given anything to sign with. There is nothing further to distribute — the
key exists in exactly one place, which is yours.
That single key is the owner key — the one this table describes, generated at onboarding. An identity can also carry other, narrower keys alongside it, each restricted to a role and — for signing keys — a spend policy; how you generate, hold and revoke those is your own affair, not something Varnir's key handling touches, since none of them can do more than the owner key itself explicitly grants. See Keys for that spectrum.
The second column is the interesting one, and the reason this page exists.
How L1 wallet keys are held
When someone pays an invoice, they send USDT or a native asset to a real address on any supported network, such as Ethereum Sepolia or Tron Nile. Something has to be able to sign the transaction that later moves value out of that address. That signing capability cannot live only in your browser — deposits arrive when you are not looking, and settlement has to proceed without a human present.
You hold the keys to your Varnir balance. They are made and kept on your own device, never sent to Varnir, and nothing moves that balance without your signature. The L1 wallets are a shared pool that settles those balances on-chain. A pool wallet's key is never stored whole: it is kept only as shares spread across the ledger nodes. For Standard wallets the key is reassembled briefly inside a confidential VM to make each signature and then discarded; Advanced wallets use two-party MPC, so the key is never whole anywhere, even while signing. Advanced runs on testnet only, on the cggmp24 protocol (what runs today).
Who runs those machines matters as much as how the key is split. Testnet is
built for speed and delivery: Varnir runs every node, and both Advanced
confidential VMs, on one host, and the confidential VMs run without hardware
attestation (MOCKCVM), so features ship quickly and cheaply
(what is actually running
has the detail). Production is designed to spread the nodes across separate
operators, cloud providers and countries, much like Tron's super
representatives or Bitcoin's miners, on attested AMD SEV-SNP machines, with the
two Advanced VMs run by different organisations. Then no single party, Varnir
included, and no single government can reassemble a key: it takes 3 of the 4
independent operators. SEV-SNP keeps the VM's memory
unreadable to the host and the cloud provider, a guarantee that rests on AMD's
firmware and the provider's hardware being sound. The engineering
question is how many independent parties would have to collude to abuse that
signing capability, and the answer is designed to be "more than any one of
them, including Varnir itself."
The standard method
key_method: "standard" — the default. (It was called "shamir" until
recently; the rename is cosmetic, the mechanism is unchanged, and both methods
described on this page use Shamir's Secret Sharing.)
Generation
Key generation happens inside the confidential VM
(apps/confidentialVM/src/generator.ts), never on a chain node and never on an
application server. The sequence, from apps/confidentialVM/src/index.ts:
- Each chain node independently executes the
Varnir.Createcontract and POSTs to the VM's/generateroute with the same transaction id (umid). - The VM does nothing until a threshold of nodes has independently asked.
The counter is
++waiting >= needed. With the network's configured threshold of 3, two nodes asking produces no key at all. - On the third request, one keypair is generated —
Wallet.createRandom()for EVM networks,genPriKey()for Tron,generateBitcoinKey()for Bitcoin. - The private key is immediately split with Shamir's Secret Sharing into 4 shares with a reconstruction threshold of 3.
delete generatedKey.key.prv— the whole key is dropped from the VM's memory as soon as the shares exist.- A SHA-256 hash of every share is computed and returned to every caller.
- Each node's response carries exactly one share, removed from the array as it
is handed out (
parts.shift()), so no two nodes receive the same share and the VM's copy shrinks with each response.
The splitting implementation is apps/shamir-secret-sharing — Shamir over
GF(2⁸) using the standard 0x11d reduction polynomial, operating byte-wise on
the secret, with coefficients drawn from crypto.randomBytes. It derives from
secrets.js. The property it provides is the standard one
and it is information-theoretic, not computational: 2 shares of a 3-of-4
split reveal nothing whatsoever about the key. Not "reveal it slowly", not
"reveal it to a well-resourced attacker" — the two-share view is consistent
with every possible key, so there is nothing there to attack.
Where the shares end up
Each node stores its single share in the key stream's volatile state
(Varnir.Create.ts, this.key.setVolatile({private: {part}})). Volatile state
is node-local: it is not replicated by consensus, unlike the setState call
immediately above it that writes the address, network and owner to the shared
ledger.
That asymmetry is the whole design:
Shared, consensus-replicated ledger state
├── address, network, owner, keyMethod ......... every node sees this
└── SHA-256 hashes of all 4 shares ............. every node sees this
Node-local volatile state
├── node 1: share #1 ┐
├── node 2: share #2 │ each node sees exactly one,
├── node 3: share #3 │ and only its own
└── node 4: share #4 ┘
The published hashes let any node verify that the share it received belongs to
the set — Varnir.Create rejects the key generation outright if
hashes.indexOf(getHash(part)) === -1 — without those hashes revealing
anything about the shares themselves.
This is also what Varnir.KeyHealthCheck exists to police. It is a
permissionless, $unanimous contract: every node hashes its own share, compares
it against the published hash table, and the network learns how many valid
shares still exist without any node ever disclosing one. If enough have been
lost, Varnir.KeyHealthFix and the VM's /redistribute route reconstruct from
a quorum inside the VM, re-split, and hand out a fresh set — the key itself
never leaving encrypted memory, and the old shares becoming useless.
A new key should not take deposits until every node is known to hold its
share. Varnir.DiffConsensus checks that: it commits only when all four nodes
have voted that they hold their own share, each vote carrying its share's hash,
and four distinct nodes must cover the four distinct hashes in the table. On
commit it marks the key stream diffConfirmed and emits key-confirmed, and a
gateway adds a pre-generated key, Standard or Advanced, to its pool only on
that event. This rule is live on testnet (Varnir.DiffConsensus 1.0.101,
2026-10-09). The keys already in the pools were checked against it then: all
814 were confirmed, and none was dropped (docs/runbooks/diffcon-pool-proof.md).
An Advanced key gets a stronger version of this check (since 1.0.104,
2026-10-10). Each node holds two parts of it, one for each confidential VM, so
all four nodes must show that they hold both: four distinct nodes covering
both tables of part hashes. In the same ledger transaction, the two VMs must
also sign a test message tied to that transaction (a hash of a fixed label,
the transaction's id and the key's id, which can never be a chain
transaction). Every node checks that signature against the key's public key
before it votes yes, and every gateway checks it again, together with the
key's address, before it offers the wallet. The signature is stored on the
ledger with the proof (diffConfirmed.sig), so anyone who can read the key's
ledger record can check it. On testnet, since 2026-10-10, an Advanced key
without this signed proof cannot be assigned to a user, consolidated into,
used as the destination of a Bitcoin move, or swept into. The keys already in
the pools were given it then: all 47 were signed, and none was dropped
(docs/runbooks/diffcon-pool-proof.md).
Signing
Signing inverts the same flow. Each node POSTs its share to the VM's /sign
route alongside the transaction payload it believes it is signing. The VM:
- Fixes the payload on the first submission for a given
umidand rejects any later share submitted against a different payload —Not Acceptable - Invalid Payload. A node that has been compromised into wanting to sign something else cannot contribute its share to the honest quorum's signature, and cannot recruit the honest nodes' shares into its own. - Waits until the threshold of shares has arrived. Below threshold, the request blocks; it does not sign.
- Reconstructs the key in memory, produces the signature, then sets
key = undefinedand empties the collected shares.
The reconstructed key exists for the duration of one signature, inside a memory-encrypted VM, and is never written to disk.
What an attacker needs, standard method: the cooperation of 3 of the 4 chain nodes and the confidential VM. Compromising one node yields one share, which is nothing. Compromising the VM alone yields nothing either — it stores no key material between requests and cannot manufacture shares it was not sent.
The advanced method
key_method: "mpc-tss" — two-party threshold ECDSA. Slower and more expensive
per key, which is why it runs as a separate, smaller
pre-generated pool
alongside the standard one rather than replacing it.
The distinction is not "more Shamir". It is that under this method the private key is never assembled anywhere, at any point, including at the moment of signing.
Advanced wallets have run on cggmp24 since the testnet cutover on
2026-10-09: an implementation of the published CGGMP threshold-ECDSA protocol,
used here in its two-party form between two confidential VMs, CVM-A and CVM-B.
The earlier two-party protocol, Varnir's own implementation, was deleted from
the confidential VM at that cutover, and the wallets made with it were retired
(docs/runbooks/mpc2-cutover.md). Advanced is a testnet feature: production
does not offer it yet.
What runs today
The code is in apps/confidentialVM/src/mpc2/, whose README carries the full
flow, the threat model and the reason for every check. This section is the
summary.
Who takes part
| Part | What it does | What it holds |
|---|---|---|
| CVM-A and CVM-B | Each runs one side of the protocol against the other | Nothing at rest. Its own party share, only for the length of one keygen or signature |
Signer sidecar (apps/mpc-signer), one inside each CVM | A Rust process running the cggmp24 library (cggmp24 0.7.0-alpha.3). The CVM talks to it over a local Unix socket | The share while a session runs, the Paillier material and every nonce |
| Ledger nodes 1-4 | Run the contracts that ask for keys and approve each signature | For each key, a 3-of-4 Shamir part of CVM-A's share and a 3-of-4 part of CVM-B's, in node-local volatile state |
Egress agent (infra/mpc-egress), one per node host | Carries its node's requests to the CVMs. It signs each with the node operator's key, verifies each CVM's attestation evidence (packages/cvm-attest), and talks to it over mutual TLS pinned to the certificate that evidence binds | The node operator's keys |
The two CVMs reach each other over the same kind of channel: mutual TLS pinned to each other's attestation evidence, with every message signed.
Key generation
-
Each node executes
Varnir.Create, which posts to its own egress agent on loopback. The agent signs the request and sends it to both CVMs. -
Neither CVM does anything until 3 of the 4 pinned nodes have asked for the same key. Each counts one vote per node.
-
The two CVMs run cggmp24 key generation with each other. Each ends with its own share and derives the public key
Qitself; they compareQ, and any difference aborts the keygen and discards the shares. -
Each CVM splits its own share 3-of-4 with Shamir, wipes the share, and seals part i to node i's operator key, so only that node's agent can open it.
-
The CVMs swap their tables of part hashes, and each signs one answer naming
Qand both tables: once with its attested key, and once with a secp256k1 signer key whose address is compiled into the contracts. -
Varnir.Createaccepts the key only if both CVMs returned the sameQ, both signatures (signerSig) recover to the two pinned signer addresses, the node's own parts hash to entries in the tables, and the address it computes fromQis the one it stores. -
Before the wallet can be handed to anyone,
Varnir.DiffConsensusmust show that all four nodes hold both CVMs' parts, and the two CVMs must sign a test message tied to that ledger transaction (a fixed label, the transaction's id and the key's id, hashed: it can never be a chain transaction). Every node and every gateway checks the signature againstQand the key's address, and it is stored on the ledger as evidence anyone can check. That shows the parts the nodes hold really rebuild the shares behindQ. Since 2026-10-10 on testnet, a key without this signed proof cannot be assigned to a user, consolidated into, used as a Bitcoin move's destination or swept into. A separate test signing (the canary) still runs afterwards as a health check.What this does not prove:
- Three nodes' parts make the signature. A signature needs only 3 of the 4 parts of each share, so it shows those three rebuild the shares. That the fourth node holds its parts rests on the part hashes, which on testnet travel in unsigned consensus messages.
- A bad part with its true hash would pass. A misbehaving CVM could hand one node a part that is useless but whose hash is correct, and both the hash check and the signature would still pass. Only verifiable secret sharing would catch that, and it is not built yet.
- Testnet only. Advanced wallets are off in production.
Signing
Varnir.CryptoTransferrefuses a frozen or halted key before calling anything. Otherwise each node sends the full transaction, never a hash of it, to its egress agent, which seals the node's part of CVM-A's share to CVM-A and its part of CVM-B's share to CVM-B.- Each CVM waits until 3 nodes have sent the identical payload, then rebuilds only its own share from their 3 parts.
- Each CVM builds the digest to sign from the full transaction itself. No message between the CVMs carries a digest, so a CVM that saw a different payload derives a different session and the protocol never starts.
- The CVMs run cggmp24 signing. The result is an ordinary ECDSA signature,
which each checks recovers to
Q. Varnir.CryptoTransferchecks it again (mpc2AssertSigned): the signed transaction must parse toQ's address with every field the nodes approved. Only then is it used.
An observer on Ethereum or Tron sees an ordinary signature from an ordinary
address. The threshold structure is entirely off-chain. Advanced signing runs
on testnet's EVM networks and Tron Nile, and since 2026-10-10 on Bitcoin
Signet (btc-signet, listed in mpc2BtcNetworks on Varnir.Create). Advanced
Bitcoin stays off on every other Bitcoin network, testnet4 included.
Rules the CVMs enforce on their own
- A failed session freezes the key. In two-party ECDSA an honest party cannot tell which of its peer's messages was bad, and a peer allowed to retry could learn the honest party's share one aborted session at a time. So any end of a signing session after its first protocol message, other than a signature, a stall included, freezes that key in both CVMs. A frozen key signs nothing until the CVM restarts.
- A misbehaving peer is locked out. Two aborts blamed on the peer within 24 hours stop all MPC signing and key generation on that CVM until it restarts.
- Rate limits. One session per key at a time, at most 30 signing sessions per key per hour, and a request budget per node.
- Memory only. The CVM writes nothing to disk, so a restart forgets every freeze. In their place: the peer CVM, which froze the same session; the ledger's own freeze record, when one exists; and a 9-minute hold after every boot in which the CVM signs nothing. Core dumps are refused at start.
The part that compounds
Here is the detail that makes this method meaningfully stronger rather than
just differently shaped: each party's share is then itself Shamir-split
3-of-4 across the node quorum, exactly as a whole key is under the standard
method (Varnir.Create.ts stores both part and part2).
Neither CVM retains its own party share between operations. To sign, CVM-A must be sent enough parts by the nodes to rebuild its share, CVM-B must independently be sent enough to rebuild its own, and only then can the two run the protocol against each other.
L1 address (public key Q = xA·G + xB·G)
|
+--------------------+--------------------+
| |
CVM-A's share (xA) CVM-B's share (xB)
rebuilt only inside CVM-A, rebuilt only inside CVM-B,
for one keygen or signature for one keygen or signature
| |
Shamir 3-of-4, each part Shamir 3-of-4, each part
sent sealed to one node's key sent sealed to one node's key
| |
+----+----+----+----+ +----+----+----+----+
| n1 | n2 | n3 | n4 | | n1 | n2 | n3 | n4 |
+----+----+----+----+ +----+----+----+----+
A signature requires: 3 of 4 nodes approving the same transaction
and sending their parts to each CVM, AND both CVMs running cggmp24
with each other. Even with all of it, the private key is never computed.
Testnet and production
The protocol, the ledger checks and the CVM rules above are the code that runs on testnet. What testnet does not give you is independence between the two parties: one host runs both CVMs and all four nodes, under mock attestation, so that host can read both CVMs' memory. Testnet holds testnet coins only.
| Testnet today | Production, as designed | |
|---|---|---|
| Where the CVMs run | Both on the single testnet VM, beside the four nodes | CVM-A run by Varnir on GCP; CVM-B run by a partner organisation on Azure; both AMD SEV-SNP |
| Attestation | Mock: evidence signed with a mock evidence key, plus the CVM channel's shared secret (MPC_PEER_SECRET). No hardware guarantee | SEV-SNP reports checked against AMD's root key and the measured image. The image proof is not built yet, so production attestation accepts no CVM yet |
| Node operator keys | Varnir holds all four | Each node operator holds its own |
| cggmp24 security level | A reduced test level: 2048-bit Paillier and ring-Pedersen moduli, about 112-bit strength (docs/decisions/2026-10-09-testnet-lower-security-level.md) | The library's 128-bit level, with 3072-bit moduli. A production image cannot be built with the test level |
| Paillier primes | Made once off the host and reused across restarts; copies sit in the host's configuration and in Secret Manager (docs/decisions/2026-10-09-testnet-premade-primes.md) | Searched for fresh inside the CVM at every boot |
| Keygen answer signer | A mock key per CVM, from the host's environment | A key bound to the measured image. Not built yet, so a production CVM refuses key generation |
The 2026-10-09 cutover is the testnet evidence: in its watch window 51 mpc2
keys were made and 51 Nile payouts signed and landed on chain, with no freeze
(docs/runbooks/mpc2-cutover.md, results table).
Safeguards for Advanced wallets
An Advanced key has no backup. It is lost if two nodes lose their parts of the
same party's share, and an honest CVM refuses a key it has frozen, so the coins
behind it can be stuck. These safeguards bound that exposure and give frozen
coins a way out. Standard wallets do not have them: a Standard key is rebuilt
from any 3 of its 4 shares, and Varnir.KeyHealthFix can re-split it.
Status. These are deployed on testnet only (2026-10-09). Both ledger flags are off, and the ceiling runs at its essentially-off defaults.
| Safeguard | What it does | Status |
|---|---|---|
| The CVMs' own refusal | A CVM that sees its peer misbehave, or a session stall, freezes the key in memory and signs nothing more with it (above) | Always on. Not behind any flag |
| Balance ceiling | A gateway moves the excess from an Advanced wallet over its network's ceiling into a fresh Advanced wallet (on Bitcoin, a Standard change wallet when no Advanced one fits), through the ledger's consolidation path. The ledger checks the destination, and on Bitcoin the ceiling itself | Set essentially off: 21M BTC on Bitcoin, and 10¹² whole units on the EVM and Tron testnets. Balances are watched by hand meanwhile |
| Ledger freeze | A gateway records a key the CVMs froze on the ledger (mpcFrozen), so every node refuses it even after a CVM restart | Off (mpc2FreezeEnabled). Instead the gateways page the operators that the key should be frozen |
| New deposit address on freeze | The owner of a frozen Advanced Bitcoin deposit wallet is given a fresh wallet (Advanced if one is free, otherwise Standard). Bitcoin deposits that still arrive at the frozen address are held, and credited once a sweep moves them | Off, with the ledger freeze |
| Frozen-key sweep | Moves a frozen key's coins to a fresh, unassigned Advanced pool wallet named in an authorisation the contract owner signs and the node quorum commits. A gateway starts each run, and each run is armed on the ledger first. The CVM checks for itself that the transaction pays only that address and spends only that key. Only a key frozen for a stall can be swept, never one frozen because the peer misbehaved, and the number of signings is capped (1 to 8 per authorisation, 32 over the key's life) | Off, with the ledger freeze |
| Ledger halt | Records a CVM's lockout of its peer on the ledger (mpc2Halt), so every mpc2 signing is refused until the contract owner clears it | Off (mpc2LedgerHaltEnabled). Instead the gateways page the operators |
Moves Varnir makes for these safeguards, a ceiling move or a sweep, never credit anyone: they are marked as internal moves before any deposit sighting.
While the flags are off, a key a CVM refuses stays unsigned until a person
reviews it and acts. That is deliberate: manual oversight while MPC is new
(docs/decisions/2026-10-09-mpc-freeze-behind-flag.md). Turning the ledger
freeze on needs drills on testnet first, then the contract owner's write of the
flag.
What it takes to move funds
| Standard | Advanced (cggmp24) | |
|---|---|---|
| Independent share-holders | 4 nodes | 4 nodes × 2 parties |
| Quorum to sign | 3 of 4 nodes, plus the VM | 3 of 4 nodes approving the same transaction, plus both CVMs running the protocol |
| Key ever assembled | Briefly, in encrypted memory, per signature | Never, anywhere |
| A single compromised node yields | One share — information-theoretically nothing | One part of each party's share — nothing |
| A single compromised VM yields | Nothing at rest; it stores no key material | Nothing at rest. While running, one party's share, with which it cannot sign alone |
| Signature checked by the ledger | Not separately: the VM signs the payload the node quorum matched | Yes: Varnir.CryptoTransfer checks it against the approved transaction |
| Cost | Fast, cheap | Slower, heavier — hence smaller pools |
Neither method has a break-glass path. There is no master key, no operator override, and no code path in which a Varnir employee, or a VM, or any single node produces a signature on its own. The frozen-key sweep is not one either: it still needs 3 nodes and both CVMs, and can pay only the one address the ledger names. That is a structural property of the protocol, not an access-control policy that could be reconfigured. It protects you as designed only once the parties are independent: on testnet, Varnir runs all of them on one host.
Known hardening items
Stating these plainly is more useful to you than omitting them, and you would find them in the code anyway:
- Standard method. The
/signroute matches submitted payloads across nodes using an MD5 hash (apps/confidentialVM/src/index.ts). It is used for equality-matching between callers rather than as a signature primitive, but it is the wrong hash to be using at a trust boundary and should be a SHA-256. - Standard method. The confidential VM's main HTTPS server, which serves the Standard routes, uses a self-signed 5-year certificate that it regenerates on boot. Callers therefore authenticate it by network position and attestation rather than by PKI. The Advanced routes are not on that server: they sit on a separate listener whose certificate the attestation evidence binds.
- The set of callers permitted to reach the VM's HTTPS port on a production
host is not yet fixed — it is noted as an open item in
infra/confidential-vm/README.md. On testnet the VMs are reachable only from the host's loopback and its internal container network, and the Advanced listener accepts only the four pinned node addresses and operator keys. - Advanced method.
cggmp240.7.0-alpha.3 is an alpha release, and no audit covers it yet. A known upstream issue in its signing proof checks (docs/security/2026-09-28-cggmp24-proof-check.md) is accepted for testnet and must be fixed in the pinned release before mainnet. - Advanced method. Refreshing the node parts of an Advanced key (redistribute) is built but switched off until verifiable secret sharing exists, so an Advanced key's parts are not yet re-split when a node loses one. That is part of why the balance ceiling exists.
Next
- The confidential VM — the hardware and attestation the generation step depends on, and how you can verify from outside that the code running is the code published.
- Distribution and jurisdiction — where the quorum physically and legally sits, what is designed, and what is actually running today.