Skip to content
Sonr Crypto
Esc
navigateopen⌘Jpreview
On this page

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.

PropType
GetData?func() *EnclaveData

Returns the receiver. Gives access to GetPubPoint, which is not on the interface.

Typefunc() *EnclaveData
GetEnclave?func() Enclave

Returns the receiver as an Enclave. Identity function.

Typefunc() Enclave
IsValid?func() bool

True iff both ValShare and UserShare are non-nil. Does not validate the shares.

Typefunc() bool
PubKeyHex?func() string

Hex of the compressed public point (PubHex).

Typefunc() string
PubKeyBytes?func() []byte

The uncompressed 65-byte public point (PubBytes).

Typefunc() []byte
Sign?func(data []byte) ([]byte, error)

Runs 2-party DKLs18 signing. Returns 64 bytes, r || s.

Typefunc(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).

Typefunc(data, sig []byte) (bool, error)
Refresh?func() (Enclave, error)

Rotates both shares, returns a NEW Enclave. Does not mutate the receiver.

Typefunc() (Enclave, error)
Encrypt?func(key []byte) ([]byte, error)

AES-256-GCM over Marshal() output, using the enclave's stored Nonce.

Typefunc(key []byte) ([]byte, error)
Decrypt?func(key, encryptedData []byte) ([]byte, error)

Inverse of Encrypt. Returns plaintext JSON; does not populate the receiver.

Typefunc(key, encryptedData []byte) ([]byte, error)
Marshal?func() ([]byte, error)

encoding/json over EnclaveData — both shares included, in the clear.

Typefunc() ([]byte, error)
Unmarshal?func(data []byte) error

encoding/json into the receiver. PANICS on Marshal() output — see the callout above.

Typefunc(data []byte) error

GetPubPoint() 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.

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

Typefunc(valKeyshare, userKeyshare Message, curve CurveName) ImportOption
WithEnclaveData?func(data *EnclaveData) ImportOption

Adopt an existing *EnclaveData verbatim. Errors only if data is nil.

Typefunc(data *EnclaveData) ImportOption
WithEncryptedData?func(data, key []byte) ImportOption

Intended to restore from Encrypt() output. Broken — see the callout below.

Typefunc(data, key []byte) ImportOption

Apply() 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:

  1. Shares change. Both ValShare and UserShare are replaced by the refresh outputs.
  2. The public key does not. PubKeyHex() and PubKeyBytes() are byte-identical before and after.
  3. 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.

PropType
RunProtocol?func(firstParty, secondParty protocol.Iterator) (error, error)

Cranks two iterators against each other until both return protocol.ErrProtocolFinished. Returns (aErr, bErr).

Typefunc(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.

Typefunc(aErr, bErr error) error
ExecuteSigning?func(signFuncVal, signFuncUser SignFunc) ([]byte, error)

Runs both sign iterators, takes the USER side's result, decodes it, and serializes to 64 bytes.

Typefunc(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.

Typefunc(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.

Typefunc(k *EnclaveData, bz []byte) (SignFunc, error)
GetBobSignFunc?func(k *EnclaveData, bz []byte) (SignFunc, error)

dklsv1.NewBobSign — hardcodes curves.K256(); see caveat.

Typefunc(k *EnclaveData, bz []byte) (SignFunc, error)
GetAliceRefreshFunc?func(k *EnclaveData) (RefreshFunc, error)

dklsv1.NewAliceRefresh on k.Curve.

Typefunc(k *EnclaveData) (RefreshFunc, error)
GetBobRefreshFunc?func(k *EnclaveData) (RefreshFunc, error)

dklsv1.NewBobRefresh — hardcodes curves.K256(); see caveat.

Typefunc(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

Last updated on September 2, 2026

Was this page helpful?