--- title: MPC Enclave description: A batteries-included 2-of-2 threshold ECDSA wrapper over tecdsa/dklsv1 — keygen, signing, share refresh, serialization, and the security model it actually provides. sidebar: order: 3 icon: shield --- `github.com/sonr-io/crypto/mpc` is the convenience layer over [`tecdsa/dklsv1`](/threshold/threshold-ecdsa). 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](/threshold/secret-sharing) plus [DKG](/threshold/dkg)), a different curve, Ed25519 signatures (see [threshold Ed25519](/threshold/threshold-ed25519)), or a live two-party protocol across a network — `NewEnclave` runs both sides locally in one process. ## Read this first :::danger[`Enclave` is key management, not distributed trust] `mpc.NewEnclave()` runs *both* DKG parties in the calling process (`protocol.go`: it constructs `dklsv1.NewAliceDkg` and `dklsv1.NewBobDkg` and cranks them against each other with `RunProtocol`), then stores both results in one struct: ```go type EnclaveData struct { PubHex string `json:"pub_hex"` PubBytes []byte `json:"pub_bytes"` ValShare Message `json:"val_share"` // validator / Alice share UserShare Message `json:"user_share"` // user / Bob share Nonce []byte `json:"nonce"` Curve CurveName `json:"curve"` } ``` `Sign` likewise builds both `GetAliceSignFunc(k, data)` and `GetBobSignFunc(k, data)` from the same `*EnclaveData` and runs them against each other locally. **An `Enclave` that can sign holds the entire signing capability.** `Marshal()` emits both shares as JSON. The threshold property — that compromising one party is not enough to forge a signature — only materialises if you split `ValShare` and `UserShare` across separate trust domains and drive the protocol with `RunProtocol` across the wire. In its packaged form, `mpc` buys you: a key that never exists as one scalar, and proactive share rotation via `Refresh()`. It does **not** buy you a distributed-trust boundary. ::: ## Lifecycle ```go enclave_lifecycle.go 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. :::danger[`Unmarshal` panics on `Marshal` output] A marshalled enclave **cannot be read back**. `EnclaveData.Unmarshal` is `json.Unmarshal` into the struct, whose `ValShare`/`UserShare` fields are `*protocol.Message` — and [`protocol.Message`](/foundations/protocol) has a custom `UnmarshalJSON` with unchecked type assertions that can never hold: ```go // core/protocol/protocol.go var obj map[string]any if err := json.Unmarshal(data, &obj); err != nil { return err } for k, v := range obj { switch k { case "payloads": m.Payloads = v.(map[string][]byte) // <- always the wrong dynamic type case "metadata": m.Metadata = v.(map[string]string) // <- likewise ``` Decoding into `map[string]any` yields `map[string]any` for a nested object, never `map[string][]byte`, so the assertion fails and the program **panics** rather than returning an error: ```text panic: interface conversion: interface {} is map[string]interface {}, not map[string][]uint8 core/protocol/protocol.go:92 mpc/enclave.go:154 (EnclaveData.Unmarshal) ``` `TestEnclaveData_MarshalUnmarshal` in `mpc/enclave_test.go` currently **fails** with exactly this panic — confirmed by running `go test ./mpc/ -run TestEnclaveData_MarshalUnmarshal`. Note that `MarshalJSON` on `protocol.Message` is fine, so you can persist an enclave but not restore it through this path. The blast radius is **anything that JSON-decodes a `protocol.Message`**, not one specific helper. `mpc.EnclaveData.Unmarshal` reaches the panic through `encoding/json` calling `Message.UnmarshalJSON` directly, and `protocol.DecodeMessage` panics for the same underlying reason. `mpc.RestoreEncryptedEnclave` inherits it too, on top of already being broken for the reasons in the next section. Workarounds: 1. Keep the live `Enclave` value in memory and avoid the JSON boundary entirely, or hand an in-memory `*EnclaveData` to `mpc.RestoreEnclaveFromData` — it adopts the pointer and never touches JSON. 2. If you must persist, write your own codec. `protocol.EncodeMessage` works on the way out, but do **not** pair it with `protocol.DecodeMessage`; decode into a shadow struct with the same JSON tags as `protocol.Message` and copy the fields across yourself. See [Foundations → Protocol](/foundations/protocol) for the full explanation and a worked decode. 3. If you cannot avoid `Unmarshal`, wrap it in a `recover()` — it panics rather than returning an error, so an error check alone will not save you. ::: ## The `Enclave` interface `Enclave` is satisfied by `*EnclaveData`, and `GetData()`/`GetEnclave()` are just casts between the two views of the same pointer. :::note[The interface doc comments are shuffled] In `mpc/codec.go` the `Unmarshal` line is commented `// Verify returns true if the signature is valid` and `Marshal` is commented `// Serialize returns the serialized keyEnclave`. The behaviour is what the method names say; the comments are stale. ::: `GetPubPoint()` is available on `*EnclaveData` but not on the interface: ```go 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 ```go 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. `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: ```go // 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) ``` :::warning[`BuildEnclave` with a bare `Options{}` records an empty curve] `BuildEnclave` copies `options.curve` into `EnclaveData.Curve`. A zero `Options` leaves that as the empty string. `CurveName("").Curve()` falls through to `curves.K256()`, so signing still works on secp256k1 — but the persisted JSON records `"curve": ""`. Prefer `mpc.ImportEnclave(mpc.WithInitialShares(val, user, mpc.K256Name))`, which sets it explicitly. ::: :::danger[The encrypted-import path cannot succeed] `RestoreEncryptedEnclave` is unreachable-working by construction: ```go func RestoreEncryptedEnclave(data []byte, key []byte) (Enclave, error) { keyclave := &EnclaveData{} err := keyclave.Unmarshal(data) // <- JSON-parses the CIPHERTEXT if err != nil { return nil, fmt.Errorf("failed to unmarshal enclave: %w", err) } decryptedData, err := keyclave.Decrypt(key, data) ... } ``` `data` is the AES-256-GCM output of `Encrypt` — indistinguishable from random bytes. `json.Unmarshal` on it fails, and the function returns before ever decrypting. Even if that line were removed, the next one could not work either: `Decrypt` reads the nonce from `k.Nonce`, which is a *field of the still-encrypted struct* and is therefore nil at that point, so `aesgcm.Open` would fail on a zero-length nonce. Consequently `mpc.ImportEnclave(mpc.WithEncryptedData(ct, key))` also always fails, since `Apply()` routes straight to `RestoreEncryptedEnclave`. Nothing in the repository calls either one — grepping the module, the only references are the definitions themselves and the `Apply()` dispatch, and `mpc/enclave_test.go` never exercises them. **There is no working round trip through this package.** You can decrypt — but the plaintext is the JSON produced by `Marshal()`, and `Unmarshal` panics on it (see the previous section). Decryption on its own works if you keep the nonce: ```go data := enclave.GetData() nonce := data.Nonce // you MUST persist this alongside the ciphertext ct, err := data.Encrypt(key) // ... later, in a fresh process ... shell := &mpc.EnclaveData{Nonce: nonce} plaintext, err := shell.Decrypt(key, ct) // plaintext == the original Marshal() JSON if err != nil { return err } // plaintext CANNOT be fed to (*EnclaveData).Unmarshal — it panics. ``` `TestEnclaveData_EncryptDecrypt` passes precisely because it stops here: it compares the decrypted bytes against `Marshal()` output and never decodes them. To actually restore an enclave, encrypt and decode with your own codec as described in the panic callout above. ::: ## 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. :::danger[Fixed per-enclave nonce] GCM security collapses if a `(key, nonce)` pair is ever reused for two different plaintexts: the keystream repeats, XOR-ing two ciphertexts reveals the XOR of the plaintexts, and the GHASH authentication key becomes recoverable, which lets an attacker forge tags. Because `Nonce` is fixed for the enclave's whole lifetime, calling `Encrypt(key)` twice with the same `key` on **different** enclave contents — most obviously before and after a `Refresh()`, or after any field changes — reuses `(key, nonce)`. Encrypting the *same* bytes twice is merely deterministic; encrypting *different* bytes twice is a break. Mitigations, in order of preference: 1. Do not use these methods. Marshal the enclave and encrypt with a fresh random nonce per operation using [`aead`](/symmetric/aead). 2. If you must use them, use a distinct `key` for every encryption, and never reuse a key across a refresh. Note also that `Refresh()` returns a new `Enclave` with a new random nonce, while the original value keeps the old one — so the hazard is per-value, not per-key-lifetime. ::: `EncryptKeyshare` / `DecryptKeyshare` are the single-share equivalents, and they take the nonce as an explicit parameter, which is the right shape: ```go 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`: ```go 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. :::warning[`Refresh` returns; it does not rotate in place] The receiver is unchanged. If you keep using the old value you keep using the old shares, and the old shares still sign valid signatures. Replace your reference and destroy the old serialization. ::: ## 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. Type aliases, from `mpc/codec.go`: ```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: ```go 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 ```go 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. :::warning[`VerifyWithPubKey`'s parameter name is wrong] The parameter is named `pubKeyCompressed`, but it is passed to `GetECDSAPoint`, which slices `x = pubKey[1:33]` and `y = pubKey[33:]` — that is the **uncompressed** 65-byte layout. Pass `enclave.PubKeyBytes()` (uncompressed), not `PubKeyHex()`-decoded bytes (compressed). Supplying 33 bytes yields `y = 0` and verification silently returns `false`. `GetECDSAPoint` also always uses `curves.K256()`, ignoring the enclave's `Curve` field, and does no length or on-curve check. ::: Signatures are **not** compatible with [`keys.PubKey.Verify`](/identity/did-key), which requires a 66-byte `V || R || S` layout. ## `CurveName` ```go 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`](/foundations/curves). `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 | :::danger[Only secp256k1 actually works] `CurveName` advertises ten curves, but the package is secp256k1-only in practice: - `NewEnclave()` hardcodes `K256Name`. - `GetBobSignFunc` and `GetBobRefreshFunc` ignore `k.Curve` and pass `curves.K256()`, while the Alice side honours `k.Curve`. Setting `Curve` to anything else therefore puts the two parties on different curves. - `GetECDSAPoint`, used by both `Verify` and `VerifyWithPubKey`, always uses `curves.K256()`. - The `default` branch of `Curve()` returns `curves.K256()` instead of erroring, so a typo in a persisted `"curve"` field is silently coerced rather than rejected. Treat every constant other than `K256Name` as unimplemented. ::: ## 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](/identity/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: ```go 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. :::danger[`mpc/spec`'s signing method violates the jwt/v5 contract] `golang-jwt/jwt/v5` requires `SigningMethod.Sign` to return the **raw** signature bytes (the library base64url-encodes them) and passes `Verify` the **already-decoded** bytes. `spec`'s implementation does the encoding itself in both directions: ```go // Sign encoded := base64.RawURLEncoding.EncodeToString(sig) return []byte(encoded), nil // Verify sig, err := base64.RawURLEncoding.DecodeString(string(signature)) ``` So a token minted through `spec` carries base64-of-base64 in its signature segment, and `Verify` base64-decodes bytes that jwt/v5 already decoded. `ucan.MPCSigningMethod` gets this right — it returns and consumes raw bytes. Worse, `spec`'s `init()` registers this implementation **globally**: ```go func init() { jwt.RegisterSigningMethod("MPC256", func() jwt.SigningMethod { return &MPCSigningMethod{Name: "MPC256"} // enclave is nil }) } ``` Any program that imports `mpc/spec`, even transitively and even without calling anything in it, installs a global `"MPC256"` method whose factory produces a method with a nil enclave — so `jwt.Parse` on an MPC-signed token resolves to it and fails with `"MPC enclave not available for signature verification"`. `RegisterMPCMethod(alg)` does the same for an arbitrary algorithm name. ::: **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 :::warning[`randNonce` ignores its error] `mpc/codec.go`: `rand.Read(nonce)` is called without checking the return values. On a platform where `crypto/rand` fails, the nonce would be all zeros. In practice `crypto/rand.Read` on modern Go does not fail, but the omission is real. ::: :::warning[`IsValid` is a nil check] `IsValid()` returns `k.ValShare != nil && k.UserShare != nil`. It does not check that the shares belong to the same key, that `PubBytes` matches them, or that `Curve` is set. Any `*EnclaveData` with two non-nil share pointers reports as "valid" and then fails at sign time. ::: :::warning[`Result` can return `(nil, nil)`] `dklsv1`'s `Result(version)` returns `(nil, nil)` when the protocol has not finished — its completion check runs before its initialization check. `NewEnclave`, `ExecuteSigning` and `ExecuteRefresh` all call `Result` immediately after `CheckIteratedErrors` returns nil, so on the happy path this does not bite. But if you drive the iterators yourself, an `err == nil` from `Result` does **not** guarantee a non-nil `*protocol.Message`, and passing nil into `GetAliceOut`/`GetBobOut`/`GetAlicePublicPoint`/`GetBobPubPoint` or `dklsv1.DecodeSignature` nil-dereferences. Always nil-check the message as well as the error. ::: :::warning[`RunProtocol`'s error pair is asymmetric] `RunProtocol(firstParty, secondParty)` returns `(aErr, bErr)` where `aErr` tracks the *second* argument and `bErr` the *first*. On an early real error it returns `(nil, bErr)` or `(aErr, nil)` — so always funnel the pair through `CheckIteratedErrors` rather than inspecting the two values positionally. Note also that `NewEnclave` calls `RunProtocol(userKs, valKs)`, i.e. the user side is `firstParty`. ::: :::info[Marshal is plaintext JSON] `Marshal()` serializes both keyshares in the clear. If you persist that output, it is the complete signing key. Protect it accordingly — and given the fixed-nonce hazard above, prefer an independent AEAD over the built-in `Encrypt`. ::: ## Next Signing capability tokens with an enclave, and what the verifier does and does not check. The `tecdsa/dklsv1` protocol underneath, for when you need to run the two parties apart. Turning `PubKeyBytes()` into a stable identifier. Encrypting a marshalled enclave properly, with a fresh nonce per operation.