MPC Enclave
A batteries-included 2-of-2 threshold ECDSA wrapper over tecdsa/dklsv1 — keygen, signing, share refresh, serialization, and the security model it actually provides.
github.com/sonr-io/crypto/mpc is the convenience layer over
tecdsa/dklsv1. Where dklsv1 hands you two protocol iterators and
makes you drive the message loop yourself, mpc hands you a single Enclave value with Sign,
Verify, Refresh, Marshal, and Unmarshal. It is hardwired to a 2-of-2 DKLs18 threshold
ECDSA key on secp256k1, signing over a SHA3-256 digest.
Reach for this when you want a signing key that is never materialised as a single scalar in memory, and you are willing to accept a fixed 2-of-2 shape and a secp256k1 curve.
Do not reach for this when you need t-of-n for any other t/n (use
secret sharing plus DKG), a different curve, Ed25519
signatures (see threshold Ed25519), or a live two-party protocol
across a network — NewEnclave runs both sides locally in one process.
Read this first
Lifecycle
package main
import (
"fmt"
"github.com/sonr-io/crypto/mpc"
)
func main() {
// Keygen: runs both DKG sides locally, returns an Enclave holding both shares.
enclave, err := mpc.NewEnclave()
if err != nil {
panic(err)
}
fmt.Println("valid:", enclave.IsValid())
fmt.Println("pub:", enclave.PubKeyHex())
// Sign: two-party DKLs18 signing over SHA3-256(msg). 64 bytes, r || s.
msg := []byte("test message before refresh")
sig, err := enclave.Sign(msg)
if err != nil {
panic(err)
}
fmt.Println("sig len:", len(sig)) // 64
ok, err := enclave.Verify(msg, sig)
fmt.Println("verified:", ok, err)
// Refresh: rotates both shares. The public key is invariant.
refreshed, err := enclave.Refresh()
if err != nil {
panic(err)
}
fmt.Println("pubkey unchanged:", refreshed.PubKeyHex() == enclave.PubKeyHex())
// Signatures cross-verify in both directions across the refresh boundary.
newSig, err := refreshed.Sign([]byte("test message after refresh"))
if err != nil {
panic(err)
}
preOK, _ := refreshed.Verify(msg, sig)
postOK, _ := enclave.Verify([]byte("test message after refresh"), newSig)
fmt.Println("old sig under new enclave:", preOK)
fmt.Println("new sig under old enclave:", postOK)
// Serialization: Marshal works. Unmarshal PANICS — see the callout below.
blob, err := enclave.GetData().Marshal()
if err != nil {
panic(err)
}
fmt.Println("marshalled bytes:", len(blob))
}
Every assertion in that program was verified by running it. TestEnclaveData_RefreshAndSign in
mpc/enclave_test.go is the source for the invariant public key and the bidirectional
cross-verification.
The Enclave interface
Enclave is satisfied by *EnclaveData, and GetData()/GetEnclave() are just casts between the
two views of the same pointer.
GetData?func() *EnclaveData
Returns the receiver. Gives access to GetPubPoint, which is not on the interface.
func() *EnclaveDataGetEnclave?func() Enclave
Returns the receiver as an Enclave. Identity function.
func() EnclaveIsValid?func() bool
True iff both ValShare and UserShare are non-nil. Does not validate the shares.
func() boolPubKeyHex?func() string
Hex of the compressed public point (PubHex).
func() stringPubKeyBytes?func() []byte
The uncompressed 65-byte public point (PubBytes).
func() []byteSign?func(data []byte) ([]byte, error)
Runs 2-party DKLs18 signing. Returns 64 bytes, r || s.
func(data []byte) ([]byte, error)Verify?func(data, sig []byte) (bool, error)
ecdsa.Verify over SHA3-256(data). Errors only on malformed input; an invalid signature returns (false, nil).
func(data, sig []byte) (bool, error)Refresh?func() (Enclave, error)
Rotates both shares, returns a NEW Enclave. Does not mutate the receiver.
func() (Enclave, error)Encrypt?func(key []byte) ([]byte, error)
AES-256-GCM over Marshal() output, using the enclave's stored Nonce.
func(key []byte) ([]byte, error)Decrypt?func(key, encryptedData []byte) ([]byte, error)
Inverse of Encrypt. Returns plaintext JSON; does not populate the receiver.
func(key, encryptedData []byte) ([]byte, error)Marshal?func() ([]byte, error)
encoding/json over EnclaveData — both shares included, in the clear.
func() ([]byte, error)Unmarshal?func(data []byte) error
encoding/json into the receiver. PANICS on Marshal() output — see the callout above.
func(data []byte) errorGetPubPoint() is available on *EnclaveData but not on the interface:
point, err := enclave.GetData().GetPubPoint() // curves.Point on k.Curve
It reconstructs the point with curve.NewIdentityPoint().FromAffineUncompressed(k.PubBytes), which
is why PubBytes must stay uncompressed.
Roles
const (
RoleVal = "validator"
RoleUser = "user"
)
type Role string
The mapping is fixed and worth memorising, because the field names and the protocol names differ:
| Field | Role constant | DKLs18 party | Sign func | Refresh func |
|---|---|---|---|---|
ValShare |
RoleVal |
Alice | GetAliceSignFunc |
GetAliceRefreshFunc |
UserShare |
RoleUser |
Bob | GetBobSignFunc |
GetBobRefreshFunc |
Role and the two constants are declared but nothing in the package consumes them — they are there
for callers that need to label a share. Note the constants are untyped strings, not Role values.
Import and export
ImportEnclave applies a variadic list of options and dispatches on which one was set. Options
holds only unexported fields, so ImportEnclave (or Options{}.Apply(), which sees a zero value) is
the intended entry point.
WithInitialShares?func(valKeyshare, userKeyshare Message, curve CurveName) ImportOption
Build a fresh enclave from two DKG results. Derives PubBytes/PubHex from the validator share and generates a new random 12-byte Nonce.
func(valKeyshare, userKeyshare Message, curve CurveName) ImportOptionWithEnclaveData?func(data *EnclaveData) ImportOption
Adopt an existing *EnclaveData verbatim. Errors only if data is nil.
func(data *EnclaveData) ImportOptionWithEncryptedData?func(data, key []byte) ImportOption
Intended to restore from Encrypt() output. Broken — see the callout below.
func(data, key []byte) ImportOptionApply() resolves in a fixed precedence: encrypted data first, then initial shares, then enclave
data. ImportEnclave with zero options errors with "no import options provided"; with only
WithEnclaveData(nil) it errors with "enclave data cannot be nil".
The three lower-level constructors are exported and callable directly:
// Assemble from two protocol results (what NewEnclave does internally).
e, err := mpc.BuildEnclave(valShare, userShare, mpc.Options{})
// Adopt a deserialized struct.
e, err := mpc.RestoreEnclaveFromData(data)
// Decrypt and adopt. Does not work; see below.
e, err := mpc.RestoreEncryptedEnclave(ciphertext, key)
Encryption at rest
Encrypt / Decrypt are AES-256-GCM. The key is derived by GetHashKey, which is
sha3.New256(key) truncated to 32 bytes. The nonce is EnclaveData.Nonce — 12 random bytes
generated once, at BuildEnclave time, and then reused for every call.
EncryptKeyshare / DecryptKeyshare are the single-share equivalents, and they take the nonce as an
explicit parameter, which is the right shape:
func EncryptKeyshare(msg Message, key []byte, nonce []byte) ([]byte, error)
func DecryptKeyshare(msg []byte, key []byte, nonce []byte) ([]byte, error)
func GetHashKey(key []byte) []byte // SHA3-256(key)[:32]
EncryptKeyshare runs protocol.EncodeMessage(msg) first, so it operates on the wire encoding of a
*protocol.Message, not on JSON.
Refresh
Refresh() runs the DKLs18 key-refresh protocol on both sides and returns a fresh Enclave:
func (k *EnclaveData) Refresh() (Enclave, error) {
refreshFuncVal, _ := GetAliceRefreshFunc(k)
refreshFuncUser, _ := GetBobRefreshFunc(k)
return ExecuteRefresh(refreshFuncVal, refreshFuncUser, k.Curve)
}
Three properties, all asserted in TestEnclaveData_RefreshAndSign:
- Shares change. Both
ValShareandUserShareare replaced by the refresh outputs. - The public key does not.
PubKeyHex()andPubKeyBytes()are byte-identical before and after. - Signatures are interchangeable. A signature made before the refresh verifies under the refreshed enclave and vice versa, because verification only touches the public key.
This is proactive security: an attacker who exfiltrated one share before the refresh holds a share that no longer combines with anything.
Driving the protocol yourself
Everything above is assembled from these exported pieces. Use them when the two shares live in
different processes and you need to shuttle *protocol.Message values between them.
RunProtocol?func(firstParty, secondParty protocol.Iterator) (error, error)
Cranks two iterators against each other until both return protocol.ErrProtocolFinished. Returns (aErr, bErr).
func(firstParty, secondParty protocol.Iterator) (error, error)CheckIteratedErrors?func(aErr, bErr error) error
Collapses RunProtocol's pair: nil if both are ErrProtocolFinished, otherwise the first real error.
func(aErr, bErr error) errorExecuteSigning?func(signFuncVal, signFuncUser SignFunc) ([]byte, error)
Runs both sign iterators, takes the USER side's result, decodes it, and serializes to 64 bytes.
func(signFuncVal, signFuncUser SignFunc) ([]byte, error)ExecuteRefresh?func(refreshFuncVal, refreshFuncUser RefreshFunc, curve CurveName) (Enclave, error)
Runs both refresh iterators and re-imports the two results as a new enclave.
func(refreshFuncVal, refreshFuncUser RefreshFunc, curve CurveName) (Enclave, error)GetAliceSignFunc?func(k *EnclaveData, bz []byte) (SignFunc, error)
dklsv1.NewAliceSign on k.Curve with sha3.New256 over bz.
func(k *EnclaveData, bz []byte) (SignFunc, error)GetBobSignFunc?func(k *EnclaveData, bz []byte) (SignFunc, error)
dklsv1.NewBobSign — hardcodes curves.K256(); see caveat.
func(k *EnclaveData, bz []byte) (SignFunc, error)GetAliceRefreshFunc?func(k *EnclaveData) (RefreshFunc, error)
dklsv1.NewAliceRefresh on k.Curve.
func(k *EnclaveData) (RefreshFunc, error)GetBobRefreshFunc?func(k *EnclaveData) (RefreshFunc, error)
dklsv1.NewBobRefresh — hardcodes curves.K256(); see caveat.
func(k *EnclaveData) (RefreshFunc, error)Type aliases, from mpc/codec.go:
type (
AliceOut *dkg.AliceOutput
BobOut *dkg.BobOutput
Point curves.Point
Message *protocol.Message
Signature *curves.EcdsaSignature
RefreshFunc interface{ protocol.Iterator }
SignFunc interface{ protocol.Iterator }
)
Decoding DKG results:
func GetAliceOut(msg *protocol.Message) (AliceOut, error)
func GetBobOut(msg *protocol.Message) (BobOut, error)
func GetAlicePublicPoint(msg *protocol.Message) (Point, error)
func GetBobPubPoint(msg *protocol.Message) (Point, error)
Both parties derive the same public key, so GetAlicePublicPoint and GetBobPubPoint on the
respective DKG outputs agree; BuildEnclave uses the Alice side.
Signature encoding
func SerializeSignature(sig *curves.EcdsaSignature) ([]byte, error)
func DeserializeSignature(sigBytes []byte) (*curves.EcdsaSignature, error)
func GetECDSAPoint(pubKey []byte) (*curves.EcPoint, error)
func VerifyWithPubKey(pubKeyCompressed, data, sig []byte) (bool, error)
SerializeSignature emits a fixed 64-byte buffer: r left-zero-padded to 32 bytes, then s
left-zero-padded to 32 bytes. No V byte, no DER, no length prefix. DeserializeSignature rejects
anything that is not exactly 64 bytes with
"invalid signature length: expected 64 bytes, got N". The EcdsaSignature.V field is left zero on
the deserialize path.
Signatures are not compatible with keys.PubKey.Verify, which requires a
66-byte V || R || S layout.
CurveName
type CurveName string
const (
K256Name CurveName = "secp256k1"
BLS12381G1Name CurveName = "BLS12381G1"
BLS12381G2Name CurveName = "BLS12381G2"
BLS12831Name CurveName = "BLS12831"
P256Name CurveName = "P-256"
ED25519Name CurveName = "ed25519"
PallasName CurveName = "pallas"
BLS12377G1Name CurveName = "BLS12377G1"
BLS12377G2Name CurveName = "BLS12377G2"
BLS12377Name CurveName = "BLS12377"
)
Curve() maps each name to a *curves.Curve. String() is the underlying
string. The mapping has two quirks worth knowing:
| Name | Maps to | Note |
|---|---|---|
BLS12831Name |
curves.BLS12381G1() |
"BLS12831" is a transposition of 12381; aliased to G1 |
BLS12377Name |
curves.BLS12377G1() |
Aggregate name aliased to G1 |
| anything else | curves.K256() |
Silent default — including the empty string |
Signing digests and double hashing
GetAliceSignFunc/GetBobSignFunc pass sha3.New256() and the raw message into dklsv1, which
hashes internally; Verify independently computes sha3.New256(data) and calls ecdsa.Verify on
that digest. So Sign(m)/Verify(m, sig) are consistent, and the digest is SHA3-256 — not SHA-256.
This matters for UCAN: ucan.MPCSigningMethod hashes the JWT signing string with
SHA-256 and then calls enclave.Sign(digest), which hashes that 32-byte digest again with
SHA3-256. The composition is SHA3-256(SHA-256(signingString)). It verifies correctly because
Verify does the same thing, but any external verifier must replicate both hashes.
mpc/spec
mpc/spec is a near-duplicate fork of the UCAN types and MPC JWT plumbing that also lives in
github.com/sonr-io/crypto/ucan. It redeclares Token, Attenuation, Proof, Fact, the
Capability and Resource interfaces, SimpleCapability, SimpleResource, KeyshareSource, and
CreateSimpleAttenuation, and adds:
const (
UCANVersion = "0.9.0"
UCANVersionKey = "ucv"
PrfKey = "prf"
FctKey = "fct"
AttKey = "att"
CapKey = "cap"
)
func NewSource(enclave mpc.Enclave) (KeyshareSource, error)
func NewJWTSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod
func NewMPCSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod // alias
func RegisterMPCMethod(alg string)
func (m *MPCSigningMethod) WithEnclave(enclave mpc.Enclave) *MPCSigningMethod
spec is the only place in the module that names UCANVersion as a constant — ucan writes the
literal "0.9.0" inline into the ucv JWT header.
Use github.com/sonr-io/crypto/ucan, not mpc/spec. spec is a maintenance hazard: two copies
of the same type set that will drift, one of which is broken. It has no tests. Note that the
duplication also means ucan.Attenuation and spec.Attenuation are distinct, non-interconvertible
types.
Caveats
Next
UCAN Tokens
Signing capability tokens with an enclave, and what the verifier does and does not check.
Threshold ECDSA
The tecdsa/dklsv1 protocol underneath, for when you need to run the two parties apart.
did:key Identifiers
Turning PubKeyBytes() into a stable identifier.
AEAD
Encrypting a marshalled enclave properly, with a fresh nonce per operation.