Skip to main content

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 keyL1 wallet key
What it isA 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 authorisesLedger transactions: onboarding, claiming a wallet, moving balanceNative L1 transactions from the deposit address itself
Who generates itYou, in your own processThe confidential VM, inside encrypted memory
Who holds itOnly you. It never reaches a Varnir serverNobody holds it whole. See the rest of this page
If it is lostThe identity is unrecoverable. Varnir cannot help, because Varnir never had itRecoverable 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:

  1. Each chain node independently executes the Varnir.Create contract and POSTs to the VM's /generate route with the same transaction id (umid).
  2. 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.
  3. On the third request, one keypair is generated — Wallet.createRandom() for EVM networks, genPriKey() for Tron, generateBitcoinKey() for Bitcoin.
  4. The private key is immediately split with Shamir's Secret Sharing into 4 shares with a reconstruction threshold of 3.
  5. delete generatedKey.key.prv — the whole key is dropped from the VM's memory as soon as the shares exist.
  6. A SHA-256 hash of every share is computed and returned to every caller.
  7. 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 umid and 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 = undefined and 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​

PartWhat it doesWhat it holds
CVM-A and CVM-BEach runs one side of the protocol against the otherNothing at rest. Its own party share, only for the length of one keygen or signature
Signer sidecar (apps/mpc-signer), one inside each CVMA Rust process running the cggmp24 library (cggmp24 0.7.0-alpha.3). The CVM talks to it over a local Unix socketThe share while a session runs, the Paillier material and every nonce
Ledger nodes 1-4Run the contracts that ask for keys and approve each signatureFor 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 hostCarries 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 bindsThe 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​

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

  2. Neither CVM does anything until 3 of the 4 pinned nodes have asked for the same key. Each counts one vote per node.

  3. The two CVMs run cggmp24 key generation with each other. Each ends with its own share and derives the public key Q itself; they compare Q, and any difference aborts the keygen and discards the shares.

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

  5. The CVMs swap their tables of part hashes, and each signs one answer naming Q and both tables: once with its attested key, and once with a secp256k1 signer key whose address is compiled into the contracts.

  6. Varnir.Create accepts the key only if both CVMs returned the same Q, 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 from Q is the one it stores.

  7. Before the wallet can be handed to anyone, Varnir.DiffConsensus must 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 against Q and 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 behind Q. 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​

  1. Varnir.CryptoTransfer refuses 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.
  2. Each CVM waits until 3 nodes have sent the identical payload, then rebuilds only its own share from their 3 parts.
  3. 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.
  4. The CVMs run cggmp24 signing. The result is an ordinary ECDSA signature, which each checks recovers to Q.
  5. Varnir.CryptoTransfer checks it again (mpc2AssertSigned): the signed transaction must parse to Q'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 todayProduction, as designed
Where the CVMs runBoth on the single testnet VM, beside the four nodesCVM-A run by Varnir on GCP; CVM-B run by a partner organisation on Azure; both AMD SEV-SNP
AttestationMock: evidence signed with a mock evidence key, plus the CVM channel's shared secret (MPC_PEER_SECRET). No hardware guaranteeSEV-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 keysVarnir holds all fourEach node operator holds its own
cggmp24 security levelA 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 primesMade 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 signerA mock key per CVM, from the host's environmentA 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.

SafeguardWhat it doesStatus
The CVMs' own refusalA 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 ceilingA 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 itselfSet essentially off: 21M BTC on Bitcoin, and 10¹² whole units on the EVM and Tron testnets. Balances are watched by hand meanwhile
Ledger freezeA gateway records a key the CVMs froze on the ledger (mpcFrozen), so every node refuses it even after a CVM restartOff (mpc2FreezeEnabled). Instead the gateways page the operators that the key should be frozen
New deposit address on freezeThe 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 themOff, with the ledger freeze
Frozen-key sweepMoves 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 haltRecords a CVM's lockout of its peer on the ledger (mpc2Halt), so every mpc2 signing is refused until the contract owner clears itOff (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​

StandardAdvanced (cggmp24)
Independent share-holders4 nodes4 nodes × 2 parties
Quorum to sign3 of 4 nodes, plus the VM3 of 4 nodes approving the same transaction, plus both CVMs running the protocol
Key ever assembledBriefly, in encrypted memory, per signatureNever, anywhere
A single compromised node yieldsOne share — information-theoretically nothingOne part of each party's share — nothing
A single compromised VM yieldsNothing at rest; it stores no key materialNothing at rest. While running, one party's share, with which it cannot sign alone
Signature checked by the ledgerNot separately: the VM signs the payload the node quorum matchedYes: Varnir.CryptoTransfer checks it against the approved transaction
CostFast, cheapSlower, 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 /sign route 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. cggmp24 0.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.