Skip to main content

How keys are held

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 Varnir's custody model dissolves once you separate these two. They are held in completely different ways, for completely different reasons.

Identity keyL1 wallet key
What it issecp256k1 keypair, derived from a 12-word BIP39 phraseAn Ethereum or Tron 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.

The second column is the interesting one, and the reason this page exists.

Why L1 wallet keys need a custody model at all

When someone pays an invoice, they send USDT or a native asset to a real address on 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.

So the network holds those keys. This is custody, and calling it anything else would be dishonest. The engineering question is not whether it is custody — it is how many independent parties must collude to abuse it, 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 Ethereum, genPriKey() for Tron.
  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.

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

Key generation

Implemented in apps/confidentialVM/src/mpc/keygen.ts, driven between two separate confidential VM hosts over HTTP by mpc/http.ts. It is a Lindell-style two-party ECDSA protocol using Paillier additively-homomorphic encryption (paillier-bigint, 2048-bit modulus). Four rounds:

RoundHostWhat happens
1Party 1Generates a 2048-bit Paillier keypair; opens the session
2Party 2Draws a random scalar x₂ mod the secp256k1 group order, sends the point Q₂ = x₂·G
3Party 1Draws its own scalar x₁, computes Q = x₁·Q₂, sends Q₁ = x₁·G and Enc(x₁) under its Paillier public key
4Party 2Computes Q = x₂·Q₁, arriving at the same public key

Both parties independently derive the same public key Q = x₁·x₂·G and therefore the same Ethereum or Tron address — while party 1 knows only x₁, party 2 knows only x₂, and the actual private key x₁·x₂ is a number that has never been computed on any machine. Party 2 holds Enc(x₁) but not the Paillier private key needed to open it; party 1 holds the Paillier private key but never receives x₂.

Signing

mpc/keysign.ts, five rounds relayed host-to-host. Party 2 operates homomorphically on the encrypted share, party 1 decrypts to complete. The result is an ordinary ECDSA signature that verifies against an ordinary address — an observer on Ethereum or Tron sees nothing unusual, because there is nothing unusual to see on-chain. The threshold structure is entirely off-chain.

The two hosts must be genuinely separate processes with independent state. Running both roles in one process would make the protocol two-party in form only, and the deployment treats that as a hard requirement rather than a preference.

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 confidential VM host retains its own party share between operations. To sign, host 1 must be sent enough shares by the nodes to reconstitute its party data, host 2 must independently be sent enough to reconstitute its own, and only then can the two hosts run the interactive protocol against each other.

L1 address (public key Q = x1 · x2 · G)
|
+--------------------+--------------------+
| |
party 1 share (x1) party 2 share (x2)
held by CVM host 1 held by CVM host 2
| |
Shamir 3-of-4 Shamir 3-of-4
| |
+----+----+----+----+ +----+----+----+----+
| n1 | n2 | n3 | n4 | | n1 | n2 | n3 | n4 |
+----+----+----+----+ +----+----+----+----+

A signature requires: 3 of 4 nodes for x1, AND 3 of 4 nodes for x2,
AND both CVM hosts running the protocol. Even with all of it,
x1 · x2 is never computed.

What it takes to move funds

StandardAdvanced (MPC-TSS)
Independent share-holders4 nodes4 nodes × 2 parties
Quorum to sign3 of 4, plus the VM3 of 4 for each party, plus both VM hosts
Key ever assembledBriefly, in encrypted memory, per signatureNever, anywhere
A single compromised node yieldsOne share — information-theoretically nothingOne share of one party — nothing
A single compromised VM host yieldsNothing at rest; it stores no key materialNothing, and it cannot sign alone even with its own party share
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 the VM, or any single node produces a signature on its own. That is a structural property of the protocol, not an access-control policy that could be reconfigured.

Known hardening items

Stating these plainly is more useful to you than omitting them, and you would find them in the code anyway:

  • The /sign route matches submitted payloads across nodes using an MD5 hash. 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.
  • The confidential VM serves a self-signed 5-year TLS certificate that it regenerates on boot. Callers therefore authenticate it by network position and attestation rather than by PKI.
  • The two MPC host addresses are committed as placeholder constants in Varnir.Create.ts and must be set deliberately at deploy time.
  • The set of callers permitted to reach the VM's port 443 is not yet fixed — it is noted as an open item in infra/confidential-vm/README.md.

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.