Building Threshold ECDSA from Scratch: The Cryptography Behind DefiShard
A deep technical walkthrough of the cryptographic primitives powering DefiShard — from elliptic curve fundamentals through Shamir's Secret Sharing to a full 2-of-2 threshold ECDSA implementation where the private key never exists.
When we set out to build DefiShard, the core engineering challenge was clear: produce valid ECDSA signatures across two independent devices without ever assembling the private key. Not encrypted. Not split-and-recombined. Never existing — at any point, on any device, in any form.
This post walks through exactly how we solved that. We'll start from elliptic curve fundamentals, build up through secret sharing, arrive at a full distributed key generation protocol, and end with threshold ECDSA signing. Every diagram and code snippet reflects the actual protocol we implemented.
Elliptic Curve Foundations
Ethereum (and all EVM chains) use the secp256k1 elliptic curve for digital signatures. The curve is defined by:
y² = x³ + 7 (mod p)
where p is a 256-bit prime. The key properties we rely on:
- There exists a generator point
Gon the curve - Multiplying
Gby a scalarkgives another pointk·G— this is fast to compute - Given
k·G, recoveringkis computationally infeasible — this is the Elliptic Curve Discrete Logarithm Problem (ECDLP)
This asymmetry is the foundation of all elliptic curve cryptography: going forward (scalar multiplication) is cheap, going backward (discrete log) is practically impossible.
// The core asymmetry
const privateKey = randomScalar() // secret scalar k
const publicKey = curve.multiply(G, privateKey) // k·G = public point
// Given publicKey, recovering privateKey is infeasible
// This is what makes the entire system workStandard ECDSA Signing
In a normal single-key ECDSA signature, the signer who knows the private key d produces a signature (r, s) for a message hash e:
- Pick a random nonce
k - Compute
R = k·Gand setr = R.x mod n - Compute
s = k⁻¹ · (e + r·d) mod n - The signature is
(r, s)
Verification uses only the public key Q = d·G:
u₁ = e·s⁻¹ mod n
u₂ = r·s⁻¹ mod n
R' = u₁·G + u₂·Q
valid if R'.x ≡ r (mod n)
The Problem We're Solving
Standard ECDSA requires the signer to know d — the full private key. Our goal is to produce the exact same (r, s) signature without any single party ever knowing d. The signature must be indistinguishable from one produced by a single signer.
Shamir's Secret Sharing
The first building block is Shamir's Secret Sharing (1979). It allows a secret to be split into n shares such that any t shares can reconstruct the secret, but t-1 shares reveal nothing.
The insight is elegant: a polynomial of degree t-1 is uniquely determined by t points.
For our 2-of-2 scheme, we use a polynomial of degree 1 (a line):
f(x) = a₀ + a₁·x (mod n)
where a₀ is the secret value. Two points on this line are sufficient to reconstruct a₀ via Lagrange interpolation.
Lagrange Interpolation
Given two points (1, y₁) and (2, y₂) on a degree-1 polynomial, the secret f(0) is recovered as:
f(0) = y₁ · λ₁ + y₂ · λ₂
where the Lagrange coefficients are:
λ₁ = (0 - 2) / (1 - 2) = 2
λ₂ = (0 - 1) / (2 - 1) = -1
So f(0) = 2·y₁ - y₂. These coefficients are public and fixed for any 2-of-2 scheme — they depend only on the evaluation points, not the secret.
function lagrangeCoefficient(
i: number,
points: number[],
x: number,
order: bigint
): bigint {
let num = 1n
let den = 1n
for (const j of points) {
if (j !== i) {
num = (num * BigInt(x - j)) % order
den = (den * BigInt(i - j)) % order
}
}
return (num * modInverse(den, order)) % order
}
// For 2-of-2 with points [1, 2], evaluating at x=0:
// λ₁ = (0-2)/(1-2) = 2
// λ₂ = (0-1)/(2-1) = -1 ≡ (n-1) mod nCritical Property
Shamir's scheme is information-theoretically secure: a single share reveals zero information about the secret. This isn't a computational assumption — it's a mathematical proof. Even with unlimited computing power, one share alone is useless.
Feldman's Verifiable Secret Sharing
Standard Shamir's has a problem: a malicious dealer could distribute inconsistent shares. Feldman's VSS (1987) adds verification by publishing commitments to the polynomial coefficients.
For a polynomial f(x) = a₀ + a₁·x, the dealer publishes:
C₀ = a₀·G (commitment to the secret)
C₁ = a₁·G (commitment to the coefficient)
Each party receiving share sᵢ = f(i) can verify:
sᵢ·G == C₀ + i·C₁
This works because of the homomorphic property of elliptic curve scalar multiplication:
f(i)·G = (a₀ + a₁·i)·G = a₀·G + i·(a₁·G) = C₀ + i·C₁
Each party can verify their share is consistent with the published commitments without learning the secret a₀ — they only see a₀·G, not a₀ itself.
interface FeldmanVSS {
commitments: Point[] // [a₀·G, a₁·G]
share: bigint // f(i) for this party
}
function verifyShare(
vss: FeldmanVSS,
partyIndex: number,
curve: EllipticCurve
): boolean {
const lhs = curve.multiply(G, vss.share) // sᵢ·G
// C₀ + i·C₁
const rhs = curve.add(
vss.commitments[0],
curve.multiply(vss.commitments[1], BigInt(partyIndex))
)
return curve.pointEquals(lhs, rhs)
}Distributed Key Generation
Now we combine these primitives into a full DKG protocol. The challenge: we want both parties to contribute randomness to the key, so neither party alone determines the private key.
The Protocol
In our 2-of-2 DKG, each party acts as both dealer and recipient:
Step by step:
-
Each party generates a random polynomial of degree 1
- Extension:
f₁(x) = a₁₀ + a₁₁·x - Mobile:
f₂(x) = a₂₀ + a₂₁·x
- Extension:
-
Each party publishes Feldman commitments to their polynomial coefficients
-
Each party sends an encrypted evaluation of their polynomial to the other party
- Extension sends
f₁(2)to Mobile - Mobile sends
f₂(1)to Extension
- Extension sends
-
Each party verifies the received share against the published commitments
-
Each party computes their final share by summing the evaluations:
- Extension:
s₁ = f₁(1) + f₂(1) - Mobile:
s₂ = f₁(2) + f₂(2)
- Extension:
The combined secret key is d = a₁₀ + a₂₀ — which neither party knows. They only know their own a₁₀ or a₂₀, never the sum. The public key Q = d·G = (a₁₀ + a₂₀)·G is computable by both parties from the published commitments: Q = C₁₀ + C₂₀.
async function distributedKeyGeneration(
party: PartyRole,
channel: EncryptedChannel
): Promise<{ share: bigint; publicKey: Point }> {
// Step 1: Generate random polynomial
const a0 = randomScalar() // secret contribution
const a1 = randomScalar() // random coefficient
const poly = [a0, a1]
// Step 2: Publish Feldman commitments
const commitments = poly.map(coeff => curve.multiply(G, coeff))
await channel.send({ type: 'commitments', data: commitments })
// Step 3: Receive other party's commitments
const otherCommitments = await channel.receive('commitments')
// Step 4: Exchange encrypted shares
const otherIndex = party === 'extension' ? 2 : 1
const myIndex = party === 'extension' ? 1 : 2
const shareForOther = evaluatePolynomial(poly, otherIndex)
await channel.sendEncrypted({ type: 'share', data: shareForOther })
const receivedShare = await channel.receiveEncrypted('share')
// Step 5: Verify received share against commitments
if (!verifyFeldmanShare(receivedShare, myIndex, otherCommitments)) {
throw new Error('Share verification failed — abort DKG')
}
// Step 6: Compute final share and public key
const myShare = evaluatePolynomial(poly, myIndex)
const finalShare = (myShare + receivedShare) % curveOrder
const publicKey = curve.add(commitments[0], otherCommitments[0])
return { share: finalShare, publicKey }
}Key Property
After DKG completes, neither party knows the private key d = a₁₀ + a₂₀. Each party contributed randomness independently. Even a malicious party cannot bias the key — they can only influence their own aᵢ₀ contribution, and the other party's contribution remains uniformly random.
Why This Matters for Security
The DKG protocol guarantees several properties:
| Property | Guarantee |
|---|---|
| Correctness | Both shares lie on the same degree-1 polynomial; they can produce valid signatures |
| Secrecy | Neither party learns the other's secret contribution aᵢ₀ |
| Verifiability | Feldman commitments let each party verify received shares are consistent |
| Unbiasability | Neither party can influence the final key beyond their own random contribution |
| Robustness | If verification fails, the protocol aborts cleanly — no partial state is leaked |
Threshold ECDSA Signing
This is the hardest part of the entire system. Recall the standard ECDSA signature equation:
s = k⁻¹ · (e + r·d) mod n
We need to compute this where:
dis secret-shared as(s₁, s₂)— neither party knowsdkmust also be secret-shared — if either party knows the full noncek, they can derivedfrom the final signature- The modular inverse
k⁻¹must be computed without revealingk
The Nonce Problem
This is where threshold ECDSA gets subtle. In standard ECDSA, the signer picks a random k, computes R = k·G, and uses k⁻¹ in the signature equation. If we naively split k into shares k₁ + k₂ = k, we face a problem:
Computing k⁻¹ from shares of k is not straightforward. The inverse of a sum is not the sum of inverses: (k₁ + k₂)⁻¹ ≠ k₁⁻¹ + k₂⁻¹.
Multiplicative-to-Additive Share Conversion
The standard approach uses a technique called multiplicative-to-additive (MtA) share conversion. The idea:
- Both parties hold additive shares of
k: Extension hask₁, Mobile hask₂, wherek₁ + k₂ = k - Both parties also generate additive shares of a random value
γ:γ₁ + γ₂ = γ - Using an MtA protocol, they convert their additive shares of
kandγinto additive shares of the productδ = k·γ - They reveal
δ(which leaks nothing aboutkbecauseγmasks it) - Now
k⁻¹ = γ · δ⁻¹— and each party holds a share ofγ, andδ⁻¹is public
The Full Signing Protocol
The final signature (r, s) is mathematically identical to one produced by a single signer holding d. No verifier can distinguish it from a standard ECDSA signature — because it is a standard ECDSA signature.
async function thresholdSign(
txHash: bigint,
myShare: bigint,
partyIndex: number,
channel: EncryptedChannel
): Promise<ECDSASignature> {
// Phase 1: Generate nonce share
const k_i = randomScalar()
const gamma_i = randomScalar()
const R_i = curve.multiply(G, k_i)
// Exchange nonce commitments
await channel.send({ type: 'nonce_commitment', data: hash(R_i) })
const otherCommitment = await channel.receive('nonce_commitment')
await channel.send({ type: 'nonce_point', data: R_i })
const R_other = await channel.receive('nonce_point')
// Verify commitment
if (hash(R_other) !== otherCommitment) {
throw new Error('Nonce commitment mismatch — possible attack')
}
// Phase 2: MtA for k·γ
const { delta_i } = await multiplicativeToAdditive(
k_i, gamma_i, channel
)
// Phase 3: Reveal masked product
await channel.send({ type: 'delta', data: delta_i })
const delta_other = await channel.receive('delta')
const delta = (delta_i + delta_other) % curveOrder
const deltaInv = modInverse(delta, curveOrder)
// Compute R and r
const R = curve.add(R_i, R_other)
const r = R.x % curveOrder
// Compute partial signature
const lambda = lagrangeCoefficient(partyIndex, [1, 2], 0, curveOrder)
const sigma_i = (
(k_i * gamma_i % curveOrder) * deltaInv % curveOrder *
(txHash + r * myShare * lambda % curveOrder)
) % curveOrder
// Exchange partial signatures
await channel.send({ type: 'partial_sig', data: sigma_i })
const sigma_other = await channel.receive('partial_sig')
const s = (sigma_i + sigma_other) % curveOrder
// Verify before returning
if (!verifySignature(txHash, { r, s }, publicKey)) {
throw new Error('Combined signature invalid — protocol error')
}
return { r, s }
}Nonce Security
The nonce k must be generated with the same care as the private key. If the full nonce is ever revealed — or if the same nonce is used twice — the private key can be derived from the signature. This is why nonce shares are committed before being revealed, and why we use a coin-flipping protocol to ensure neither party can bias the nonce.
Security Analysis of the Protocol
What an Attacker Learns
Consider each attack scenario against our threshold signing protocol:
| Scenario | What Attacker Obtains | Can They Sign? | Can They Derive d? |
|---|---|---|---|
| Compromise Extension | s₁ (key share), k₁ (nonce share) | No — needs s₂ | No — s₁ alone reveals nothing about d |
| Compromise Mobile | s₂ (key share), k₂ (nonce share) | No — needs s₁ | No — s₂ alone reveals nothing about d |
| Compromise Relay | Encrypted ciphertext only | No — cannot decrypt | No — sees only opaque bytes |
| Compromise Extension + Relay | s₁ + encrypted messages | No — still needs s₂ | No — relay data is encrypted |
| Observe all signatures | Public (r, s) values | No | No — standard ECDSA security |
Simulation-Based Security
The formal security argument follows the simulation paradigm: for any adversary who corrupts one party, there exists a simulator that can produce a transcript indistinguishable from a real protocol execution, without knowing the honest party's input. This means the corrupted party learns nothing beyond what it could compute from its own input and the final output.
In practical terms: if you compromise the Extension, everything you see during signing could have been generated by a simulator that doesn't know Mobile's share. The protocol messages carry zero information about the other share.
Engineering Decisions
Building the protocol is one thing. Making it work reliably across a browser extension and a mobile app over an unreliable network is another. Some key decisions:
Deterministic Nonce Derivation
We use RFC 6979 style deterministic nonce derivation seeded with both the share and the message hash, combined with additional randomness. This prevents nonce reuse (which would be catastrophic) while still allowing the MtA protocol to work:
function deriveNonceShare(
share: bigint,
messageHash: bigint,
extraEntropy: Uint8Array
): bigint {
const hmac = createHmac('sha256', bigintToBytes(share))
hmac.update(bigintToBytes(messageHash))
hmac.update(extraEntropy) // additional randomness
return bytesToBigint(hmac.digest()) % curveOrder
}Abort Handling
If any protocol step fails verification — commitment mismatch, Feldman check failure, invalid partial signature — the protocol aborts immediately with no retry. Partial state is discarded. This is deliberate: retrying with the same nonce state could leak information. A fresh signing session must be initiated.
Message Ordering
The relay server must deliver messages in order. We use monotonically increasing sequence numbers within each signing session, and each message includes a session identifier and step counter. Out-of-order or duplicate messages are rejected.
What This Enables
The result of all this cryptographic machinery: DefiShard produces standard ECDSA signatures that are indistinguishable from single-signer signatures, while providing guarantees that no traditional wallet can offer:
- The private key
dnever exists — not in memory, not on disk, not during signing - Compromising a single device yields a share that is information-theoretically useless
- The relay server is a pure encrypted message forwarder — it has no access to cryptographic material
- Signatures are valid on any EVM chain — no smart contracts, no on-chain overhead, no privacy leakage
Every transaction signed by DefiShard is a small proof that practical cryptography can eliminate classes of vulnerabilities rather than just mitigate them.
The cryptographic protocol described here is based on established research in threshold ECDSA, particularly building on the work of Gennaro & Goldfeder (2019) and Lindell (2017). DefiShard's implementation is open source under the MIT license.
Technical questions or want to discuss the protocol? Reach out at info@defishard.com