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 key | L1 wallet key | |
|---|---|---|
| What it is | secp256k1 keypair, derived from a 12-word BIP39 phrase | An Ethereum or Tron 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.
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:
- 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 Ethereum,genPriKey()for Tron. - 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.
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.
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:
| Round | Host | What happens |
|---|---|---|
| 1 | Party 1 | Generates a 2048-bit Paillier keypair; opens the session |
| 2 | Party 2 | Draws a random scalar x₂ mod the secp256k1 group order, sends the point Q₂ = x₂·G |
| 3 | Party 1 | Draws its own scalar x₁, computes Q = x₁·Q₂, sends Q₁ = x₁·G and Enc(x₁) under its Paillier public key |
| 4 | Party 2 | Computes 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
| Standard | Advanced (MPC-TSS) | |
|---|---|---|
| Independent share-holders | 4 nodes | 4 nodes × 2 parties |
| Quorum to sign | 3 of 4, plus the VM | 3 of 4 for each party, plus both VM hosts |
| Key ever assembled | Briefly, in encrypted memory, per signature | Never, anywhere |
| A single compromised node yields | One share — information-theoretically nothing | One share of one party — nothing |
| A single compromised VM host yields | Nothing at rest; it stores no key material | Nothing, and it cannot sign alone even with its own party share |
| 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 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
/signroute 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.tsand 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.