feat: init docs

This commit is contained in:
Prad Nukala
2026-09-02 15:29:51 -04:00
parent d2390a8aad
commit 69425e2b7a
45 changed files with 11790 additions and 18 deletions
+332
View File
@@ -0,0 +1,332 @@
---
title: did:key Identifiers
description: Encode a public key as a self-describing did:key string, parse it back, and derive verification material — plus a frank assessment of the keys/parsers package.
sidebar:
order: 2
icon: id-card
---
`github.com/sonr-io/crypto/keys` turns a public key into a stable, self-describing string and back
again. A `did:key` identifier needs no registry and no network lookup: the key material *is* the
identifier, so resolving one is a pure decode. The package wraps libp2p's
`github.com/libp2p/go-libp2p/core/crypto.PubKey` interface, which gives it RSA, Ed25519, and
secp256k1 support for free, and adds a secp256k1-specific path for public keys that arrive as raw
bytes from an [MPC enclave](/identity/mpc-enclave).
**Reach for this when** you need a canonical identifier for a key you already hold — a UCAN issuer,
a log line, a database column, a delegation audience.
**Do not reach for this when** you need a DID with mutable state (rotation, service endpoints,
multiple verification methods). `did:key` is immutable by construction: change the key, change the
identifier. The `DIDMethod` enum in this package names other methods, but only `did:key` is
implemented here.
## Encoding
`DID.String()` builds the identifier in three steps:
1. `id.Raw()` — the raw public key bytes from libp2p (33 or 65 bytes for secp256k1, 32 for Ed25519,
DER PKIX for RSA).
2. An unsigned-varint multicodec prefix identifying the key type is prepended.
3. The whole buffer is multibase-encoded with base58btc, which yields the leading `z`.
So every identifier this package produces looks like `did:key:z…`. `Parse` reverses exactly those
steps and rejects any multibase encoding other than base58btc.
| Key type | Constant | Multicodec | Accepted raw lengths |
| --- | --- | --- | --- |
| RSA (`rsa-x509-pub`) | `MulticodecKindRSAPubKey` | `0x1205` | DER, parsed via `x509.ParsePKIXPublicKey` |
| Ed25519 (`ed25519-pub`) | `MulticodecKindEd25519PubKey` | `0xed` | 32 |
| secp256k1 (`secp256k1-pub`) | `MulticodecKindSecp256k1PubKey` | `0xe7` | 33 (compressed) or 65 (uncompressed) |
`KeyPrefix` is the string constant `"did:key"`. `GetMulticodecType(keyType int)` maps an
`int(crypto.RSA)` / `int(crypto.Ed25519)` / `int(crypto.Secp256k1)` to the values above and errors on
anything else.
:::note
Canonical `did:key` for secp256k1 uses the **compressed** 33-byte point. `Parse` and `NewFromMPCPubKey`
also accept the 65-byte uncompressed form, which means two distinct `did:key` strings can name the
same key. If you compare identifiers as strings, normalise through `CompressedPubKey()` first.
:::
## Constructors
<TypeTable
type={{
"NewDID": {
type: "func(pub crypto.PubKey) (DID, error)",
description: "Wraps a libp2p public key. Accepts Ed25519, RSA, Secp256k1; errors on any other key type."
},
"NewFromPubKey": {
type: "func(pub PubKey) DID",
description: "Wraps this package's own PubKey (a curves.Point-backed secp256k1 key). Infallible."
},
"NewFromMPCPubKey": {
type: "func(pubKeyBytes []byte) (DID, error)",
description: "Unmarshals 33- or 65-byte secp256k1 public key bytes straight from an MPC enclave. Errors on any other length."
},
"Parse": {
type: "func(keystr string) (DID, error)",
description: "Decodes a did:key string. Requires the did:key prefix, base58btc multibase, and a recognised multicodec."
},
"ValidateFormat": {
type: "func(didString string) error",
description: "Prefix check followed by a full Parse. Use when you only need a yes/no on a string."
},
}}
/>
## The `DID` type
`DID` embeds `crypto.PubKey`, so every libp2p method (`Raw`, `Type`, `Equals`, `Verify`, `Bytes`) is
promoted onto it. On top of that:
<TypeTable
type={{
"String": {
type: "func() string",
description: "The did:key identifier. Returns \"\" — not an error — if Raw() or multibase encoding fails."
},
"PublicKey": {
type: "func() crypto.PubKey",
description: "The embedded libp2p public key."
},
"MulticodecType": {
type: "func() uint64",
description: "The multicodec for this key type. PANICS on an unrecognised key type rather than returning an error."
},
"CompressedPubKey": {
type: "func() ([]byte, error)",
description: "33-byte compressed point for secp256k1 (converting from 65 bytes if needed); raw bytes for every other key type."
},
"VerifyKey": {
type: "func() (any, error)",
description: "*rsa.PublicKey for RSA, ed25519.PublicKey for Ed25519, and the raw []byte for secp256k1."
},
"Address": {
type: "func() (string, error)",
description: "A \"sonr1\"-prefixed string. See the caveat below — it is not a hash and not bech32."
},
}}
/>
:::warning[`MulticodecType` panics]
`String()` calls `MulticodecType()` unconditionally. A `DID` holding a key type outside
`{RSA, Ed25519, secp256k1}` will panic with `"unexpected crypto type"` when stringified. `NewDID`
guards against this, but a `DID` constructed as a struct literal (`keys.DID{PubKey: k}`) does not.
:::
## Round trip
Grounded in `TestDIDStringFormat` and `TestMPCIntegration` in `keys/didkey_test.go`:
```go didkey_roundtrip.go
package main
import (
"crypto/rand"
"fmt"
p2pcrypto "github.com/libp2p/go-libp2p/core/crypto"
"github.com/sonr-io/crypto/keys"
)
func main() {
priv, _, err := p2pcrypto.GenerateSecp256k1Key(rand.Reader)
if err != nil {
panic(err)
}
did, err := keys.NewDID(priv.GetPublic())
if err != nil {
panic(err)
}
s := did.String() // "did:key:z..."
fmt.Println(s)
parsed, err := keys.Parse(s)
if err != nil {
panic(err)
}
// The encoding is canonical for a given input: re-stringifying is identical.
fmt.Println("stable:", parsed.String() == s)
fmt.Println("same type:", parsed.Type() == did.Type())
// Cheap validity check on an untrusted string.
fmt.Println("valid:", keys.ValidateFormat(s) == nil)
compressed, err := parsed.CompressedPubKey()
fmt.Println("compressed len:", len(compressed), err) // 33
}
```
For a key that arrives from an enclave rather than a libp2p keypair, swap the constructor:
```go
did, err := keys.NewFromMPCPubKey(enclave.PubKeyBytes())
```
## `DIDMethod`
A plain string enum, verbatim from `keys/methods.go`. It carries no behaviour beyond `String()`, and
nothing else in the package consumes it — it exists for callers that need to tag which method a DID
string belongs to.
```go
const (
DIDMethodKey DIDMethod = "key"
DIDMethodSonr DIDMethod = "sonr"
DIDMehthodBitcoin DIDMethod = "btcr"
DIDMethodEthereum DIDMethod = "ethr"
DIDMethodCbor DIDMethod = "cbor"
DIDMethodCID DIDMethod = "cid"
DIDMethodIPFS DIDMethod = "ipfs"
)
```
:::note
`DIDMehthodBitcoin` is misspelled in the source. It is exported, so fixing it would be a breaking
change; use it as written.
:::
## The `PubKey` interface
Separate from libp2p's type, `keys.PubKey` adapts a [`curves.Point`](/foundations/curves) into
something `DID` can embed. `NewPubKey(pk curves.Point) PubKey` is the only constructor.
<TypeTable
type={{
"Bytes": { type: "func() []byte", description: "point.ToAffineCompressed() — 33 bytes on secp256k1." },
"Raw": { type: "func() ([]byte, error)", description: "Identical to Bytes; the error is always nil." },
"Hex": { type: "func() string", description: "Hex of the compressed point." },
"Type": { type: "func() p2ppb.KeyType", description: "Hardcoded to KeyType_Secp256k1 regardless of the point's actual curve." },
"Equals": { type: "func(b p2pcrypto.Key) bool", description: "Compares Raw() bytes." },
"Verify": { type: "func(msg, sig []byte) (bool, error)", description: "ECDSA verify over a SHA3-256 digest. Signature layout below." },
}}
/>
### The 66-byte signature layout
`PubKey.Verify` does **not** accept a standard 64-byte `r || s` signature. Reading
`keys/pubkey.go` and `keys/utils.go`, it:
1. Requires the signature to be **exactly 66 bytes**, rejecting anything else with
`"malformed signature: not the correct size"`.
2. Parses it as `V || R || S`, where `V` is a single recovery-id byte at offset 0, `R` is
`sig[1:33]`, and `S` is `sig[33:66]`.
3. Hashes the message with **SHA3-256** (not SHA-256) and calls `ecdsa.Verify` on that digest,
ignoring `V` entirely.
4. Reconstructs the ECDSA public key by slicing the compressed point as `x = bytes[1:33]`,
`y = bytes[33:]` on `curves.K256()`.
:::danger[`keys.PubKey.Verify` cannot verify `mpc.Enclave.Sign` output]
`mpc.SerializeSignature` produces a fixed **64-byte** `r || s` buffer, and `keys.deserializeSignature`
rejects anything that is not 66 bytes. So `keys.NewPubKey(point).Verify(msg, enclaveSig)` always
returns `("malformed signature: not the correct size")`. Verify enclave signatures with
`enclave.Verify(data, sig)` or `mpc.VerifyWithPubKey(enclave.PubKeyBytes(), data, sig)` instead — both
use the 64-byte layout. See [MPC Enclave](/identity/mpc-enclave).
Step 4 above is also wrong for a genuinely compressed point: on a 33-byte compressed encoding,
`bytes[33:]` is empty, so `y` decodes as zero. `Verify` therefore only works if `Bytes()` happens to
return 65 bytes — which it never does, since `ToAffineCompressed()` returns 33. Treat
`keys.PubKey.Verify` as non-functional.
:::
## `Address()` does not do what its comment says
The doc comment promises "a blockchain-compatible address" and an inline comment claims
"first 20 bytes of Keccak-256 hash (Ethereum-style)". The code does neither:
```go
// keys/didkey.go, secp256k1 branch, verbatim:
return fmt.Sprintf("sonr1%x", rawPubBytes[:8]), nil
```
:::danger[`Address()` is a truncated hex prefix, not an address]
For all three key types the function returns `"sonr1"` followed by the hex of the **first 8 bytes of
the raw public key**. There is no hash, no Keccak, and no bech32 encoding despite the bech32-looking
`sonr1` prefix. Consequences:
- It is **not one-way**: the output leaks 8 bytes of the public key verbatim.
- It has **no checksum**, so a typo is undetectable.
- 64 bits of collision space, birthday-bounded at roughly 2<sup>32</sup> keys.
- For secp256k1 it compresses a 65-byte key first, so the compressed and uncompressed forms of the
same key produce the same address — but an Ed25519 and a secp256k1 key sharing a first-8-byte
prefix also collide.
`ucan` uses this value as the address in `MPCTokenBuilder.GetAddress()` and `KeyshareSource.Address()`.
Do not treat it as a chain address on any real network.
:::
## Avoid `keys/parsers`
`keys/parsers` looks like a set of per-chain address parsers. It is not. Verified by reading every
file in the directory:
| File | Lines | Contents |
| --- | --- | --- |
| `btc_parser.go` | 1 | `package parsers` |
| `eth_parser.go` | 1 | `package parsers` |
| `fil_parser.go` | 1 | `package parsers` |
| `sol_parser.go` | 1 | `package parsers` |
| `ton_parser.go` | 1 | `package parsers` |
| `cosmos_parser.go` | 12 | A `CosmosPrefix` string type and six bech32 HRP constants. No functions. |
| `key_parser.go` | 157 | A near-verbatim copy of `keys/didkey.go`, exporting `DIDKey` instead of `DID`. |
:::danger[`keys/parsers` duplicates `keys` with an incompatible multicodec]
`keys/parsers` redeclares the multicodec constants, and one of them disagrees:
```go
// keys/didkey.go
MulticodecKindSecp256k1PubKey = 0xe7 // secp256k1-pub, the registered value
// keys/parsers/key_parser.go
MulticodecKindSecp256k1PubKey = 0x1206 // not secp256k1-pub
```
A secp256k1 `did:key` produced by `parsers.DIDKey.String()` carries a different varint prefix, so
`keys.Parse` rejects it with `"unrecognized key type multicodec prefix"`, and vice versa. The two
packages produce **mutually unparseable identifiers for the same key**. `keys` uses the registered
multicodec table value; `parsers` does not.
The five empty files mean the package name promises chain address parsing that does not exist:
`parsers` exports only `KeyPrefix`, the multicodec constants, `CosmosPrefix` and its six constants,
`DIDKey` with `NewKeyDID`/`MulticodecType`/`String`/`VerifyKey`, and `Parse`.
**Use `github.com/sonr-io/crypto/keys`. Do not import `keys/parsers`.**
:::
## Caveats
:::warning[Silent failure in `String()`]
`String()` returns the empty string on any internal error rather than reporting it. An empty
identifier where you expected `did:key:z…` means `Raw()` or multibase encoding failed; check the key
with `NewDID` first, or call `ValidateFormat` on the result.
:::
:::warning[`Parse` error message reads the wrong byte]
The fallthrough error is `fmt.Errorf("unrecognized key type multicodec prefix: %x", data[0])`, but
the multicodec was decoded as a multi-byte varint into `keyType`. For prefixes above `0x7f` — RSA's
`0x1205`, for example — the reported byte is the first varint byte, not the codec. The error is
cosmetic; the rejection itself is correct.
:::
:::info[What is actually covered by tests]
`keys/didkey_test.go` exercises `NewFromMPCPubKey` length validation, the `0xe7` constant,
`Address`, `CompressedPubKey`, `ValidateFormat`, `GetMulticodecType`, and the string/parse round trip.
There is **no** test for `NewFromPubKey`, `NewPubKey`, or `PubKey.Verify` — which is consistent with
the signature-layout defect above going unnoticed.
:::
## Next
<CardGroup cols={2}>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
Where `NewFromMPCPubKey`'s input comes from, and how to sign with the key behind the identifier.
</Card>
<Card title="UCAN Tokens" href="/identity/ucan" icon="ticket">
Using a `did:key` as a token issuer and delegation audience.
</Card>
</CardGroup>
+196
View File
@@ -0,0 +1,196 @@
---
title: ECIES
description: Encrypt a payload to a secp256k1 public key. A thin wrapper over github.com/ecies/go/v2 with one significant seed hazard.
sidebar:
order: 5
icon: mail
---
`github.com/sonr-io/crypto/ecies` is a **thin wrapper** — three files, 70 lines of code — over
[`github.com/ecies/go/v2`](https://github.com/ecies/go). ECIES (Elliptic Curve Integrated Encryption
Scheme) is hybrid public-key encryption: the sender generates an ephemeral keypair, does ECDH against
the recipient's static public key, derives a symmetric key, and encrypts the payload under an AEAD.
The recipient needs no prior interaction — just their own private key and the ciphertext.
**Reach for this when** you need to encrypt a payload to a public key you already have, with no
handshake and no shared state.
**Do not reach for this when** you need forward secrecy for the recipient, authenticated sender
identity (ECIES gives you confidentiality, not sender authentication — sign separately with
[`mpc`](/identity/mpc-enclave) or an [ECDSA](/signatures/ecdsa) key), or a symmetric key you already
share (use [AEAD](/symmetric/aead) directly).
## The API surface
```go
type PrivateKey = eciesgo.PrivateKey // type ALIAS, not a wrapper struct
type PublicKey = eciesgo.PublicKey // type ALIAS
func GenerateKey() (*PrivateKey, error)
func GenerateKeyFromSeed(seed []byte) (*PrivateKey, error)
func HashSeed(seed []byte) []byte
func Encrypt(pub *PublicKey, plaintext []byte) ([]byte, error)
func Decrypt(priv *PrivateKey, ciphertext []byte) ([]byte, error)
```
That is the entire package. `Encrypt` and `Decrypt` are one-line forwards to `eciesgo.Encrypt` and
`eciesgo.Decrypt`.
:::note[The key types are aliases, so the upstream API is yours]
`PrivateKey` and `PublicKey` are Go **type aliases** (`type PrivateKey = eciesgo.PrivateKey`), not
distinct named types. Everything the upstream library defines on those types is directly available:
`priv.Bytes()`, `priv.Hex()`, `priv.PublicKey`, `priv.ECDH(pub)`, `pub.Bytes(compressed bool)`,
`pub.Hex(compressed bool)`, `eciesgo.NewPrivateKeyFromHex`, `eciesgo.NewPublicKeyFromBytes`, and so
on.
Consult [`github.com/ecies/go/v2`](https://github.com/ecies/go) for:
- **key serialization** — this package exposes no marshal/unmarshal helpers of its own;
- **the ciphertext wire format** — the ephemeral-key encoding, KDF and AEAD choices are entirely
upstream's, and are not restated or pinned here.
:::
## Curve
`GenerateKey` and `GenerateKeyFromSeed` both build their key on `curves.SP256()`, which returns
`ecc.P256k1()` from `github.com/dustinxie/ecc` — i.e. **secp256k1**, the same curve as
[`mpc`](/identity/mpc-enclave) and secp256k1 `did:key` identifiers. See
[Foundations → Curves](/foundations/curves) for the curve abstraction.
Note the constructors bypass `eciesgo.GenerateKey` and assemble the struct by hand from
`ecdsa.GenerateKey(curve, rand.Reader)`:
```go
p, err := ecdsa.GenerateKey(curve, rand.Reader)
return &PrivateKey{
PublicKey: &PublicKey{Curve: curve, X: p.X, Y: p.Y},
D: p.D,
}, nil
```
## Usage
```go ecies_roundtrip.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/ecies"
)
func main() {
// Recipient generates a keypair and publishes the public key.
priv, err := ecies.GenerateKey()
if err != nil {
panic(err)
}
// Sender encrypts to the public key. No prior interaction needed.
ciphertext, err := ecies.Encrypt(priv.PublicKey, []byte("hello"))
if err != nil {
panic(err)
}
// Recipient decrypts with the private key.
plaintext, err := ecies.Decrypt(priv, ciphertext)
if err != nil {
panic(err)
}
fmt.Println(string(plaintext)) // hello
}
```
`GenerateKey` is grounded in `TestGenerateKey` and `GenerateKeyFromSeed` in `TestGenerateFromSeed`
(`ecies/keys_test.go`). The encrypt/decrypt round trip above is **not** covered by any test in the
package — see the caveats.
## `HashSeed` and seeded keys
`HashSeed(seed []byte) []byte` is `blake3.Sum512(seed)` from `lukechampine.com/blake3`, returned as a
64-byte slice. Its purpose is to stretch an arbitrary-length input up to enough bytes for
`GenerateKeyFromSeed`, which reads from the seed as an entropy source:
```go
seed := ecies.HashSeed([]byte("some high-entropy passphrase or master secret"))
priv, err := ecies.GenerateKeyFromSeed(seed)
```
:::note[The seed is key material]
`GenerateKeyFromSeed` treats its argument as the sole entropy input. Whoever holds the seed can
recompute the private key. Store, transmit and destroy a seed exactly as you would a private key —
and note that `HashSeed` is a plain hash, **not** a password KDF: it has no salt, no work factor and
no memory hardness. Do not feed it a human-chosen password. For password-derived keys use a real KDF
from [Key Derivation](/symmetric/key-derivation).
:::
:::danger[`GenerateKeyFromSeed` is not deterministic]
Despite the name, this function does not reliably produce the same key from the same seed on current
Go toolchains. `ecdsa.GenerateKey(curve, bytes.NewReader(seed))` passes the seed reader into
`crypto/ecdsa`, but the standard library does not use it as given:
- On Go 1.26 and later, `crypto/ecdsa` routes a caller-supplied reader through
`crypto/internal/rand.CustomReader`, which **returns the system CSPRNG and discards the supplied
reader** unless the `GODEBUG` setting `cryptocustomrand=1` is active. The `cryptocustomrand`
default became `0` in Go 1.26, so a program whose main module declares `go 1.26` or later gets a
fully random key and the seed is ignored entirely.
- Under the older behaviour (`cryptocustomrand=1`, i.e. a main module declaring an earlier Go
version), `randutil.MaybeReadByte` consumes a byte from the reader with roughly 50% probability
before key generation, which shifts the whole byte stream. Measured against this package: 20
successive calls with an identical seed produced the same private key only **13 times out of 20**.
Both behaviours were confirmed empirically against this package on Go 1.27.
`ecies/keys_test.go`'s `TestGenerateFromSeed` calls `GenerateKeyFromSeed` twice with the same seed
but only asserts that neither call errors — it never compares the two keys, which is why the defect
is not caught.
**Do not use `GenerateKeyFromSeed` for deterministic key derivation.** If you need a key
reproducible from a seed, derive the scalar yourself with a KDF from
[Key Derivation](/symmetric/key-derivation) and construct the key from those bytes via
`eciesgo.NewPrivateKeyFromBytes`.
:::
## Caveats
:::warning[`GenerateKeyFromSeed` errors on a short seed]
The implementation slices `seed[:]` and hands it to `bytes.NewReader`. `randFieldElement` then calls
`io.ReadFull`, which returns `io.ErrUnexpectedEOF` on a seed shorter than 32 bytes — surfaced as
`"cannot generate key pair: unexpected EOF"`. A `nil` seed is worse: `seed[:]` on a nil slice is
legal, so you get the same EOF error rather than a clear "nil seed" message. Always pass
`HashSeed(...)` output (64 bytes) rather than a raw seed. Under the Go 1.26+ behaviour described
above the reader is never consulted, so short seeds succeed there — which makes the failure mode
toolchain-dependent.
:::
:::warning[No round-trip test]
`ecies/keys_test.go` is 24 lines and contains two tests: `TestGenerateKey` and
`TestGenerateFromSeed`. **Neither `Encrypt` nor `Decrypt` is tested at all**, and there is no test
that the hand-assembled `PrivateKey`/`PublicKey` structs are accepted by the upstream library. The
round trip does work — it was verified directly against this package — but the package ships no
regression coverage for its two most important functions.
:::
:::info[No authentication of the sender]
ECIES ciphertext is confidential and integrity-protected against tampering, but **anyone** with the
recipient's public key can produce a valid ciphertext. If the recipient needs to know who sent a
message, sign the plaintext (or the ciphertext) separately and transmit the signature alongside it.
:::
## Next
<CardGroup cols={2}>
<Card title="AEAD" href="/symmetric/aead" icon="lock">
The symmetric layer, for when you already share a key.
</Card>
<Card title="Key Derivation" href="/symmetric/key-derivation" icon="git-branch">
Real KDFs, for deriving keys from seeds or passwords.
</Card>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
Signing, to pair with encryption for sender authentication.
</Card>
<Card title="Curves" href="/foundations/curves" icon="binary">
The secp256k1 curve this package builds on.
</Card>
</CardGroup>
+147
View File
@@ -0,0 +1,147 @@
---
title: Identity & Authorization
description: The application-facing layer — threshold key enclaves, did:key identifiers, UCAN capability tokens, payload encryption, and WebAssembly code signing.
sidebar:
order: 1
icon: fingerprint
---
Everything below this section is code you call directly from an application. The primitives in
[Foundations](/foundations), [Signatures](/signatures), and [Threshold](/threshold) are the machinery;
these five packages are the assembled product: a key that lives in two shares, an identifier derived
from its public point, tokens that delegate narrow slices of authority over that key, and two
supporting utilities for encrypting payloads and pinning executable code.
## How the pieces compose
<Steps>
<Step title="An enclave holds the key">
[`mpc.NewEnclave()`](/identity/mpc-enclave) runs a 2-of-2 DKLs18 threshold ECDSA key generation on
secp256k1 and returns an `Enclave`. The private key never exists as a single scalar: it lives as a
validator share and a user share. Signing is a two-party protocol; refreshing rotates both shares
while leaving the public key fixed.
</Step>
<Step title="Its public point becomes an identifier">
`enclave.PubKeyBytes()` yields the uncompressed public point. `keys.NewFromMPCPubKey` turns those
bytes into a [`keys.DID`](/identity/did-key), whose `String()` is a `did:key:z…` identifier — a
multicodec varint prefix plus multibase base58btc. That string is the stable, resolvable name for
the key.
</Step>
<Step title="The identifier issues capability tokens">
A [UCAN](/identity/ucan) token is a JWT whose issuer is that `did:key`, signed by the enclave.
Its `att` claim is a list of attenuations — `(capability, resource)` pairs. A holder can mint a
delegated token that *narrows* the set, never widens it, and attaches the parent as a proof.
</Step>
<Step title="Payloads and code get their own primitives">
[`ecies`](/identity/ecies) encrypts a payload to a secp256k1 public key without any prior
handshake. [`wasm`](/identity/wasm-modules) signs and hash-pins WebAssembly module bytes so a host
can refuse to load code it does not recognise.
</Step>
</Steps>
## Choosing a package
| You want to… | Use | Notes |
| --- | --- | --- |
| Hold a signing key without a single point of compromise | `mpc` | 2-of-2 only; secp256k1 only |
| Name a public key with a stable string | `keys` | RSA, Ed25519, secp256k1 |
| Grant another party scoped, expiring authority | `ucan` | JWT-based, `ucv` header `0.9.0` |
| Encrypt a message to someone's public key | `ecies` | Thin wrapper over `github.com/ecies/go/v2` |
| Verify that a `.wasm` blob is the one you approved | `wasm` | Ed25519 signing + SHA-256 pinning |
| Parse a chain-specific address | — | `keys/parsers` is unfinished; see [did:key](/identity/did-key) |
## A minimal end-to-end shape
```go
package main
import (
"fmt"
"github.com/sonr-io/crypto/keys"
"github.com/sonr-io/crypto/mpc"
)
func main() {
// 1. Threshold key: both shares generated locally.
enclave, err := mpc.NewEnclave()
if err != nil {
panic(err)
}
// 2. Identifier derived from the enclave's public point.
did, err := keys.NewFromMPCPubKey(enclave.PubKeyBytes())
if err != nil {
panic(err)
}
fmt.Println("issuer:", did.String()) // did:key:z...
// 3. Two-party signature over a message, verified against the public key.
sig, err := enclave.Sign([]byte("hello"))
if err != nil {
panic(err)
}
ok, err := enclave.Verify([]byte("hello"), sig)
fmt.Println("valid:", ok, err)
}
```
Every one of these packages is generic over, or built on, the curve abstraction described in
[Foundations → Curves](/foundations/curves). `Curve`, `Point`, and `Scalar` are not re-explained here.
## Read this before you ship
This section is the least finished part of the repository. The pages below document the rough edges
in place rather than around them, because several of them are the kind that silently weaken a
security property instead of failing loudly.
:::danger[The short version]
- An `mpc.Enclave` value holds **both** keyshares in one process. It is a key-management construct,
not a distributed-trust boundary.
- `mpc.EnclaveData.Unmarshal` **panics** on `Marshal()` output, so a persisted enclave cannot be
restored through the package's own codec.
- `ucan.GenerateJWTToken` / `VerifyJWTToken` sign with **HS256 under a hardcoded secret** compiled
into the package.
- The UCAN verifier's caveat checks are placeholders that always succeed, so caveat restrictions are
**not enforced**.
- `ucan.MPCTokenBuilder.CreateDelegatedToken` will sign a child token that grants **more** than its
parent; only `KeyshareSource.NewAttenuatedToken` enforces attenuation.
- `ucan.MPCVerifier.VerifyMPCToken` fails outright — the `"MPC256"` signing method is never
registered with `golang-jwt`.
- `keys/parsers` contains five empty files and a secp256k1 multicodec constant that disagrees with
`keys`.
- `ecies.GenerateKeyFromSeed` is **not** deterministic on current Go toolchains.
- `wasm.SecurityPolicy.Validate` only checks module size; its other fields are ignored.
- `keys.DID.Address()` is a truncated hex prefix of the public key, not a hashed or checksummed
address, despite its comment claiming Keccak-256.
Each of these was verified against the source and confirmed by running it, and is documented in
detail on the page for its package. They are also aggregated on
[Reference → Security](/reference/security).
:::
## Pages
<CardGroup cols={2}>
<Card title="did:key Identifiers" href="/identity/did-key" icon="id-card">
Multicodec + multibase encoding, the `DID` and `PubKey` types, the non-standard 66-byte signature
layout, and why to avoid `keys/parsers`.
</Card>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
2-of-2 threshold ECDSA lifecycle: keygen, sign, verify, refresh, import/export, and the real
security model.
</Card>
<Card title="UCAN Tokens" href="/identity/ucan" icon="ticket">
Capabilities, attenuation, delegation chains, templates, MPC signing, and which authorization
checks are not actually implemented.
</Card>
<Card title="ECIES" href="/identity/ecies" icon="mail">
Encrypt to a secp256k1 public key. A thin, honest wrapper — plus one seed hazard.
</Card>
<Card title="WASM Module Signing" href="/identity/wasm-modules" icon="package-check">
Ed25519 code signing and SHA-256 hash pinning for WebAssembly supply-chain verification.
</Card>
<Card title="Package Index" href="/reference/packages" icon="list">
Every package in the module with its status at a glance.
</Card>
</CardGroup>
+8
View File
@@ -0,0 +1,8 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Identity & Authorization",
icon: "fingerprint",
order: 7,
pages: ["index", "did-key", "mpc-enclave", "ucan", "ecies", "wasm-modules"],
});
+644
View File
@@ -0,0 +1,644 @@
---
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.
<TypeTable
type={{
"GetData": { type: "func() *EnclaveData", description: "Returns the receiver. Gives access to GetPubPoint, which is not on the interface." },
"GetEnclave": { type: "func() Enclave", description: "Returns the receiver as an Enclave. Identity function." },
"IsValid": { type: "func() bool", description: "True iff both ValShare and UserShare are non-nil. Does not validate the shares." },
"PubKeyHex": { type: "func() string", description: "Hex of the compressed public point (PubHex)." },
"PubKeyBytes": { type: "func() []byte", description: "The uncompressed 65-byte public point (PubBytes)." },
"Sign": { type: "func(data []byte) ([]byte, error)", description: "Runs 2-party DKLs18 signing. Returns 64 bytes, r || s." },
"Verify": { type: "func(data, sig []byte) (bool, error)", description: "ecdsa.Verify over SHA3-256(data). Errors only on malformed input; an invalid signature returns (false, nil)." },
"Refresh": { type: "func() (Enclave, error)", description: "Rotates both shares, returns a NEW Enclave. Does not mutate the receiver." },
"Encrypt": { type: "func(key []byte) ([]byte, error)", description: "AES-256-GCM over Marshal() output, using the enclave's stored Nonce." },
"Decrypt": { type: "func(key, encryptedData []byte) ([]byte, error)", description: "Inverse of Encrypt. Returns plaintext JSON; does not populate the receiver." },
"Marshal": { type: "func() ([]byte, error)", description: "encoding/json over EnclaveData — both shares included, in the clear." },
"Unmarshal": { type: "func(data []byte) error", description: "encoding/json into the receiver. PANICS on Marshal() output — see the callout above." },
}}
/>
:::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.
<TypeTable
type={{
"WithInitialShares": {
type: "func(valKeyshare, userKeyshare Message, curve CurveName) ImportOption",
description: "Build a fresh enclave from two DKG results. Derives PubBytes/PubHex from the validator share and generates a new random 12-byte Nonce."
},
"WithEnclaveData": {
type: "func(data *EnclaveData) ImportOption",
description: "Adopt an existing *EnclaveData verbatim. Errors only if data is nil."
},
"WithEncryptedData": {
type: "func(data, key []byte) ImportOption",
description: "Intended to restore from Encrypt() output. Broken — see the callout below."
},
}}
/>
`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.
<TypeTable
type={{
"RunProtocol": {
type: "func(firstParty, secondParty protocol.Iterator) (error, error)",
description: "Cranks two iterators against each other until both return protocol.ErrProtocolFinished. Returns (aErr, bErr)."
},
"CheckIteratedErrors": {
type: "func(aErr, bErr error) error",
description: "Collapses RunProtocol's pair: nil if both are ErrProtocolFinished, otherwise the first real error."
},
"ExecuteSigning": {
type: "func(signFuncVal, signFuncUser SignFunc) ([]byte, error)",
description: "Runs both sign iterators, takes the USER side's result, decodes it, and serializes to 64 bytes."
},
"ExecuteRefresh": {
type: "func(refreshFuncVal, refreshFuncUser RefreshFunc, curve CurveName) (Enclave, error)",
description: "Runs both refresh iterators and re-imports the two results as a new enclave."
},
"GetAliceSignFunc": { type: "func(k *EnclaveData, bz []byte) (SignFunc, error)", description: "dklsv1.NewAliceSign on k.Curve with sha3.New256 over bz." },
"GetBobSignFunc": { type: "func(k *EnclaveData, bz []byte) (SignFunc, error)", description: "dklsv1.NewBobSign — hardcodes curves.K256(); see caveat." },
"GetAliceRefreshFunc": { type: "func(k *EnclaveData) (RefreshFunc, error)", description: "dklsv1.NewAliceRefresh on k.Curve." },
"GetBobRefreshFunc": { type: "func(k *EnclaveData) (RefreshFunc, error)", description: "dklsv1.NewBobRefresh — hardcodes curves.K256(); see caveat." },
}}
/>
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
<CardGroup cols={2}>
<Card title="UCAN Tokens" href="/identity/ucan" icon="ticket">
Signing capability tokens with an enclave, and what the verifier does and does not check.
</Card>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="users">
The `tecdsa/dklsv1` protocol underneath, for when you need to run the two parties apart.
</Card>
<Card title="did:key Identifiers" href="/identity/did-key" icon="id-card">
Turning `PubKeyBytes()` into a stable identifier.
</Card>
<Card title="AEAD" href="/symmetric/aead" icon="lock">
Encrypting a marshalled enclave properly, with a fresh nonce per operation.
</Card>
</CardGroup>
+751
View File
@@ -0,0 +1,751 @@
---
title: UCAN Capability Tokens
description: JWT-based User-Controlled Authorization Network tokens signed by an MPC enclave — capabilities, attenuation, delegation chains, templates, and the authorization checks that are not implemented.
sidebar:
order: 4
icon: ticket
---
`github.com/sonr-io/crypto/ucan` implements UCAN — capability tokens where authority flows from a key
rather than from a server-side ACL. A token is a JWT whose issuer (`iss`) is a
[`did:key`](/identity/did-key), whose audience (`aud`) is the recipient's DID, and whose `att` claim
is a list of *attenuations*: `(capability, resource)` pairs. The holder of a token can mint a new
token that grants a **subset** of its own authority to someone else, attaching the parent token as a
proof in `prf`. Verification walks that chain back to a root the verifier trusts.
Tokens carry the UCAN version in a `ucv` JWT header. In this package that value is the literal
`"0.9.0"`, written inline in `ucan/source.go`; the only exported constant naming it is
`spec.UCANVersion` in [`mpc/spec`](/identity/mpc-enclave).
**Reach for this when** you need offline-verifiable, expiring, narrowable authorization derived from
a key you control.
## Read this first
:::danger[Four authorization gaps]
The package's own authorization logic has holes that a reader would not guess from the API surface.
Each was verified by reading the source and confirmed by running it; each is detailed below.
1. **`GenerateJWTToken`, `GenerateModuleJWTToken`, `VerifyJWTToken` and `VerifyModuleJWTToken` sign
and verify with HS256 under the hardcoded secret `"sonr-ucan-secret"`**, which is compiled into
the package and therefore known to anyone with the source. Any party can mint a token these
functions accept.
2. **Caveat validation is a no-op.** Every `validate*Caveat` helper in `verifier.go` returns `nil`
unconditionally, so a caveat such as `"owner"` or `"max-amount"` restricts nothing.
3. **`MPCTokenBuilder.CreateDelegatedToken` does not enforce attenuation** — it will happily sign a
child token that grants *more* than its parent. Only `KeyshareSource.NewAttenuatedToken` checks
the subset property.
4. **`RevokeCapability` effectively does nothing**, because it revokes a *freshly minted* token
string rather than the one you issued.
The MPC-signed path has real cryptography behind it, but two further constraints apply: verification
as written requires possession of the signer's enclave, and `MPCVerifier.VerifyMPCToken` currently
fails outright because `"MPC256"` is never registered with `golang-jwt`. Both are covered under
[MPC signing and verification](#mpc-signing-and-verification).
:::
## The capability model
Two interfaces carry the whole model.
```go
type Capability interface {
GetActions() []string // the actions this capability grants
Grants(abilities []string) bool // does it grant all of these?
Contains(other Capability) bool // does it subsume another capability?
String() string
}
type Resource interface {
GetScheme() string // "ipfs", "did", "dwn", "service", ...
GetValue() string // the path/identifier
GetURI() string // the full "scheme://value"
Matches(other Resource) bool // equivalence, by URI
}
type Attenuation struct {
Capability Capability `json:"can"`
Resource Resource `json:"with"`
}
```
`AttenuationList` is `[]Attenuation` with query helpers:
<TypeTable
type={{
"Contains": { type: "func(resourceURI string) bool", description: "Is there any attenuation whose resource URI matches exactly?" },
"GetCapabilitiesForResource": { type: "func(resourceURI string) []Capability", description: "All capabilities attached to that exact URI." },
"CanPerform": { type: "func(resourceURI string, actions []string) bool", description: "Does any capability on that URI grant every one of these actions?" },
"IsSubsetOf": { type: "func(parent AttenuationList) bool", description: "Every child attenuation must be matched by a parent whose resource Matches and whose capability Contains it." },
}}
/>
### Attenuation
Attenuation is the invariant that makes UCAN safe to hand around: **a delegated token may only
narrow its parent's authority, never widen it.** `IsSubsetOf` is the check:
```go
parent := ucan.AttenuationList{
ucan.CreateMultiAttenuation([]string{"read", "write", "delete"}, "service://api"),
}
child := ucan.AttenuationList{
ucan.CreateSimpleAttenuation("read", "service://api"),
}
child.IsSubsetOf(parent) // true — narrower
parent.IsSubsetOf(child) // false — wider
```
The rule composes: for every attenuation in the child list there must exist a parent attenuation
whose `Resource.Matches` is true *and* whose `Capability.Contains` is true. Resource matching is
plain URI string equality (`SimpleResource.Matches`), so there is no prefix or wildcard matching at
the resource level — only at the action level, via `"*"`.
### Capability types
Every type below implements `Capability`. The module-specific ones exist so that a verifier can pick
the right caveat and serialization path from the resource scheme.
| Type | Shape | Grants semantics |
| --- | --- | --- |
| `SimpleCapability` | `{Action string}` | Grants exactly its one action |
| `MultiCapability` | `{Actions []string}` | Grants every requested action present in the set |
| `VaultCapability` | `Action`, `Actions`, `VaultAddress`, `Caveats`, `EnclaveDataCID`, `Metadata` | Vault operations; JSON tags `can`/`vault`/`cavs` |
| `DIDCapability` | `Action`, `Actions`, `Caveats`, `Metadata` | DID document operations |
| `DWNCapability` | `Action`, `Actions`, `Caveats`, `Metadata` | Decentralized Web Node records |
| `DEXCapability` | plus `MaxAmount string` | Swap/liquidity operations with an amount cap |
| `CrossModuleCapability` | `{Modules map[string]Capability}` | Composes per-module capabilities |
| `GaslessCapability` | embeds `Capability`, plus `AllowGasless bool`, `GasLimit uint64` | Decorator; adds `SupportsGasless()` and `GetGasLimit()` |
`GetActions()` on the module types returns `Actions` when non-empty and `[]string{Action}` otherwise;
`Grants` short-circuits to `true` when `Action == "*"`.
Resources mirror them, each embedding `SimpleResource`: `VaultResource` (`VaultAddress`,
`EnclaveDataCID`), `VaultResourceExt`, `DIDResource` (`DIDMethod`, `DIDSubject`), `DWNResource`
(`RecordType`, `Protocol`, `Owner`), `DEXResource` (`PoolID`, `AssetPair`, `OrderID`), and
`ServiceResource` (`ServiceID`, `Domain`, plus `SupportsDelegate()`).
### Constructors
<TypeTable
type={{
"CreateSimpleAttenuation": { type: "func(action, resourceURI string) Attenuation", description: "SimpleCapability + a SimpleResource parsed from the URI." },
"CreateMultiAttenuation": { type: "func(actions []string, resourceURI string) Attenuation", description: "MultiCapability + SimpleResource." },
"CreateVaultAttenuation": { type: "func(actions []string, enclaveDataCID, vaultAddress string) Attenuation", description: "MultiCapability + VaultResource with scheme \"ipfs\" and URI \"ipfs://<cid>\"." },
"CreateDIDAttenuation": { type: "func(actions []string, didPattern string, caveats []string) Attenuation", description: "DIDCapability + DIDResource with URI \"did:<pattern>\"." },
"CreateDWNAttenuation": { type: "func(actions []string, recordPattern string, caveats []string) Attenuation", description: "DWNCapability + DWNResource." },
"CreateDEXAttenuation": { type: "func(actions []string, poolPattern string, caveats []string, maxAmount string) Attenuation", description: "DEXCapability + DEXResource." },
"CreateServiceAttenuation": { type: "func(actions []string, serviceID, domain string) Attenuation", description: "MultiCapability + ServiceResource with URI \"service://<id>\"." },
"NewCapability": { type: "func(issuer, resource string, abilities []string) (Attenuation, error)", description: "MultiCapability + SimpleResource with scheme \"generic\". The issuer argument is IGNORED and the error is always nil." },
"VaultAttenuationConstructor": { type: "func(m map[string]any) (Attenuation, error)", description: "Builds a vault attenuation from a decoded claim map, running ValidateVaultCapability first." },
}}
/>
:::note
`CreateVaultAttenuation(actions, enclaveDataCID, vaultAddress)` takes the CID **before** the address.
`MPCTokenBuilder.CreateVaultCapabilityToken(aud, vaultAddress, enclaveDataCID, ...)` takes them in
the opposite order. Getting these backwards produces a token whose resource URI is
`ipfs://<vault-address>`, which will pass CID-format validation only if the address happens to look
like a CID — usually it silently fails later.
:::
## The `Token` type
```go
type Token struct {
Raw string `json:"raw"`
Issuer string `json:"iss"`
Audience string `json:"aud"`
ExpiresAt int64 `json:"exp,omitempty"`
NotBefore int64 `json:"nbf,omitempty"`
Attenuations []Attenuation `json:"att"`
Proofs []Proof `json:"prf,omitempty"`
Facts []Fact `json:"fct,omitempty"`
}
type Proof string // a JWT string or a CID
type Fact struct{ Data json.RawMessage `json:"data"` }
```
`Raw` is the encoded JWT when the token came from a verifier or a signing builder, and `""` when it
came from `TokenBuilder`, which does not sign.
### `TokenBuilder`
`TokenBuilder` and `TokenBuilderInterface` (`CreateOriginToken`, `CreateDelegatedToken`) live in
`ucan/stubs.go` and are exactly what the filename says: they assemble a `*Token` struct with
`Raw: ""` and no signature. `CreateDelegatedToken` copies `parentToken.Raw` into `Proofs` if it is
non-empty and sets `Audience: parentToken.Issuer`.
They exist because `NewVaultAdminToken(builder TokenBuilderInterface, vaultOwnerDID, vaultAddress,
enclaveDataCID string, exp time.Time)` takes the interface. Pass an `MPCTokenBuilder`-backed
implementation if you need a signed result; `&TokenBuilder{}` gives you an unsigned struct.
## MPC signing and verification
This is the path with real cryptography. `MPCSigningMethod` plugs an
[`mpc.Enclave`](/identity/mpc-enclave) into `golang-jwt/jwt/v5`:
```go
func NewMPCSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod
func (m *MPCSigningMethod) Alg() string // returns m.Name; "MPC256" everywhere in this package
func (m *MPCSigningMethod) Sign(signingString string, key any) ([]byte, error)
func (m *MPCSigningMethod) Verify(signingString string, signature []byte, key any) error
```
`Sign` computes `sha256.Sum256(signingString)` and passes that digest to `enclave.Sign`, which
hashes again with SHA3-256 internally. `Verify` does the mirror image via `enclave.Verify`.
:::danger[MPC verification requires the signer's enclave]
`MPCSigningMethod.Verify` **ignores its `key` argument entirely** and calls `m.enclave.Verify(...)`.
`MPCVerifier.verifyWithMPC` likewise constructs `NewMPCSigningMethod("MPC256", v.enclave)` and hands
`jwt.Parse` a key func that returns `(nil, nil)`.
So a relying party can only verify an MPC-signed token if it holds an `mpc.Enclave` for the *same
key* — and an enclave holds both keyshares. That inverts the point of public-key verification: the
public key alone is sufficient information to verify (`mpc.VerifyWithPubKey(pubBytes, digest, sig)`
does exactly that), but this method does not take that path.
Compounding it, the `ucan` package never calls `jwt.RegisterSigningMethod("MPC256", ...)`. jwt/v5
resolves a token's `alg` header through its global registry, so `jwt.Parse` inside
`verifyWithMPC` fails with an unavailable-signing-method error unless something else has registered
`"MPC256"`. The only registration in the module is in `mpc/spec`'s `init()`, and that one installs a
*broken* implementation with a nil enclave (see [`mpc/spec`](/identity/mpc-enclave)).
**Practical consequence: `MPCVerifier.VerifyMPCToken` does not currently verify MPC-signed tokens.**
Measured against a token freshly minted by `MPCTokenBuilder.CreateOriginToken`, it returns:
```text
MPC token verification failed: token is unverifiable: signing method (alg) is unavailable
```
To validate a signature yourself, extract the parts and check them directly. The digest chain is
`SHA3-256(SHA-256(signingString))`, so pass the SHA-256 digest as `data` and let
`VerifyWithPubKey` apply the SHA3-256 layer:
```go
unsigned, err := ucan.ExtractUnsignedToken(tokenString) // header.payload
sig, err := ucan.ExtractSignature(tokenString) // decoded bytes
digest := sha256.Sum256([]byte(unsigned))
ok, err := mpc.VerifyWithPubKey(enclave.PubKeyBytes(), digest[:], sig)
```
That path was verified end to end against this package: it returns `(true, nil)` for a real
`MPCTokenBuilder` token and `(false, nil)` when a byte of the signing string is altered.
:::
### Builders and validators
<TypeTable
type={{
"NewMPCTokenBuilder": { type: "func(enclave mpc.Enclave) (*MPCTokenBuilder, error)", description: "Errors if !enclave.IsValid(). Derives the issuer DID and address from enclave.PubKeyBytes()." },
"MPCTokenBuilder.CreateOriginToken": { type: "func(audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)", description: "Root token: no proofs." },
"MPCTokenBuilder.CreateDelegatedToken": { type: "func(parent *Token, audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)", description: "Attaches the parent as a proof. Does NOT check the subset property — see the callout below." },
"MPCTokenBuilder.CreateVaultCapabilityToken": { type: "func(audienceDID, vaultAddress, enclaveDataCID string, actions []string, expiresAt time.Time) (*Token, error)", description: "Convenience origin token carrying a single vault attenuation." },
"MPCTokenBuilder.GetIssuerDID": { type: "func() string", description: "The did:key derived from the enclave public key." },
"MPCTokenBuilder.GetAddress": { type: "func() string", description: "keys.DID.Address() — a truncated hex prefix, not a chain address." },
"NewMPCCapabilityBuilder": { type: "func(enclave mpc.Enclave) (*MPCCapabilityBuilder, error)", description: "Emits vault attenuations: CreateVaultAdminCapability, CreateVaultReadOnlyCapability, CreateVaultSigningCapability, CreateCustomCapability." },
"NewMPCKeyshareSource": { type: "func(enclave mpc.Enclave) (KeyshareSource, error)", description: "The higher-level source interface — see below." },
}}
/>
`KeyshareSource` bundles identity and token minting over one enclave:
```go
type KeyshareSource interface {
Address() string
Issuer() string
ChainCode() ([]byte, error)
OriginToken() (*Token, error)
SignData(data []byte) ([]byte, error)
VerifyData(data []byte, sig []byte) (bool, error)
Enclave() mpc.Enclave
NewOriginToken(audienceDID string, att []Attenuation, fct []Fact, notBefore, expires time.Time) (*Token, error)
NewAttenuatedToken(parent *Token, audienceDID string, att []Attenuation, fct []Fact, nbf, exp time.Time) (*Token, error)
}
```
`ChainCode()` signs the address string with the enclave. Because DKLs18 ECDSA signing is randomized,
**`ChainCode()` returns different 32 bytes on every call** despite the doc comment calling it
deterministic — measured directly: two successive calls on the same source disagree. Treat it as a
fresh signature, not a derivation.
:::danger[Only `KeyshareSource` enforces attenuation at issuance]
There are two delegation APIs and they behave differently. `mpcKeyshareSource.NewAttenuatedToken`
checks the subset property first:
```go
// ucan/source.go
if !isAttenuationSubset(att, parent.Attenuations) {
return nil, fmt.Errorf("scope of ucan attenuations must be less than its parent")
}
```
`MPCTokenBuilder.CreateDelegatedToken` does **not**. Its only pre-step is
`prepareDelegationProofs(parent, attenuations)`, which is the stub in `ucan/stubs.go` that ignores
its `capabilities` argument entirely and returns `[]Proof{parent.Raw}`. Nothing compares the child's
attenuations against the parent's.
Measured against this package, with a parent granting only `read` on `service://api` and a child
asking for `read, delete`:
| API | Result |
| --- | --- |
| `MPCTokenBuilder.CreateDelegatedToken` | `nil` — **widened token issued and signed** |
| `KeyshareSource.NewAttenuatedToken` | `"scope of ucan attenuations must be less than its parent"` |
A widened token from `MPCTokenBuilder` is a validly signed token whose `att` claims more authority
than its proof grants. Whether that is caught depends entirely on the relying party calling
`VerifyDelegationChain` — and nothing in `MPCTokenBuilder` makes that happen.
**Use `ucan.NewMPCKeyshareSource(enclave).NewAttenuatedToken(...)` for delegation.** Note it also
flattens the chain: it appends `parent.Raw` *and* all of `parent.Proofs`, so the child carries the
whole ancestry rather than a single link.
:::
### Verification plumbing
```go
type DIDResolver interface {
ResolveDIDKey(ctx context.Context, did string) (keys.DID, error)
}
```
| Resolver | Behaviour |
| --- | --- |
| `StringDIDResolver{}` | `keys.Parse(didStr)` — pure decode, no network |
| `MPCDIDResolver` (`NewMPCDIDResolver(enclave, fallback)`) | Short-circuits its own enclave-derived DID; otherwise delegates to `fallback`, or `keys.Parse` if `fallback` is nil |
`Verifier` is the general path:
<TypeTable
type={{
"NewVerifier": { type: "func(didResolver DIDResolver) *Verifier", description: "Constructs a verifier over a DID resolver." },
"VerifyToken": { type: "func(ctx, tokenString string) (*Token, error)", description: "jwt.Parse with a resolver-backed key func, then parses att/prf/fct and checks iss, aud, at least one attenuation, nbf and exp." },
"VerifyCapability": { type: "func(ctx, tokenString, resource string, abilities []string) (*Token, error)", description: "VerifyToken plus: some attenuation's resource URI equals `resource` exactly and its capability Grants all `abilities`." },
"VerifyDelegationChain": { type: "func(ctx, tokenString string) error", description: "Verifies the token, then every JWT in Proofs, then the delegation relationship between each pair." },
}}
/>
:::warning[`Verifier` supports only RSA and Ed25519 issuers]
`Verifier.keyFunc` switches on the token's signing method and handles exactly `RS256`, `RS384`,
`RS512` and `EdDSA`; anything else returns `"unsupported signing method"`. Since a `did:key` derived
from an MPC enclave is a **secp256k1** key, `getRSAPublicKey` and `getEd25519PublicKey` both reject
it. `Verifier.VerifyToken` therefore cannot verify tokens issued by an enclave — which is why
`MPCVerifier.VerifyMPCToken` tries `VerifyToken` first and falls through to `verifyWithMPC`.
:::
`MPCVerifier` and `MPCTokenValidator` layer on top:
```go
func NewMPCVerifier(enclave mpc.Enclave) *MPCVerifier
func (v *MPCVerifier) VerifyMPCToken(ctx context.Context, tokenString string) (*Token, error)
func NewMPCTokenValidator(enclave mpc.Enclave, enableEnclaveValidation bool) *MPCTokenValidator
func (v *MPCTokenValidator) ValidateTokenForResource(ctx, tokenString, resourceURI string, requiredAbilities []string) (*Token, error)
func (v *MPCTokenValidator) ValidateTokenForVaultOperation(ctx, tokenString, enclaveDataCID, requiredAction, vaultAddress string) (*Token, error)
```
`ValidateTokenForVaultOperation` is the most complete check in the package, in five ordered steps:
verify the token, `ValidateVaultTokenCapability`, optionally match the enclave-data CID, optionally
match the vault address, and finally `VerifyDelegationChain` if `Proofs` is non-empty. The two
"optionally" steps run only when `enableEnclaveValidation` was true at construction — pass `true`
unless you know why not.
### Signature helpers
<TypeTable
type={{
"SupportedSigningMethods": { type: "func() []jwt.SigningMethod", description: "RS256, RS384, RS512, EdDSA. Note: no ECDSA and no MPC256." },
"ValidateSignature": { type: "func(tokenString string, verifyKey any) error", description: "Parses and validates the signature against a supplied key." },
"ExtractUnsignedToken": { type: "func(tokenString string) (string, error)", description: "The \"header.payload\" prefix — the exact bytes that were signed." },
"ExtractSignature": { type: "func(tokenString string) ([]byte, error)", description: "The decoded third segment." },
"ExtractSignatureInfo": { type: "func(tokenString string, verifyKey any) (*SignatureInfo, error)", description: "Algorithm, key type, signing string, signature, and validity in one struct." },
"GetHashAlgorithmForMethod": { type: "func(method jwt.SigningMethod) (crypto.Hash, error)", description: "The crypto.Hash a signing method expects." },
"CreateHasher": { type: "func(hashAlg crypto.Hash) (hash.Hash, error)", description: "Instantiates that hash." },
"VerifyEd25519Signature": { type: "func(signingString string, signature []byte, publicKey ed25519.PublicKey) error", description: "Raw Ed25519 verification over the signing string." },
"VerifyRSASignature": { type: "func(signingString string, signature []byte, publicKey *rsa.PublicKey, hashAlg crypto.Hash) error", description: "Raw RSA verification." },
"NewSigningValidator": { type: "func() *SigningValidator", description: "Allows every method in SupportedSigningMethods. ValidateSigningMethod and ValidateTokenSignature." },
"NewKeyValidator": { type: "func() *KeyValidator", description: "ValidateEd25519PublicKey and ValidateRSAPublicKey." },
}}
/>
### `SecurityConfig`
<TypeTable
type={{
"AllowedSigningMethods": {
type: "[]jwt.SigningMethod",
required: true,
description: "Permitted JWT algorithms.",
default: "SupportedSigningMethods() — RS256, RS384, RS512, EdDSA"
},
"MinRSAKeySize": {
type: "int",
required: true,
description: "Smallest accepted RSA modulus in bits. ValidateSecurityConfig rejects anything below 1024.",
default: "2048"
},
"MaxRSAKeySize": {
type: "int",
required: true,
description: "Largest accepted RSA modulus. Must be >= MinRSAKeySize and <= 16384.",
default: "8192"
},
"RequireSecureAlgs": {
type: "bool",
required: true,
description: "Marks the config as rejecting weak algorithms.",
default: "true"
},
}}
/>
`RestrictiveSecurityConfig()` narrows those to `{RS256, EdDSA}`, `MinRSAKeySize: 3072`,
`MaxRSAKeySize: 4096`, `RequireSecureAlgs: true`. `ValidateSecurityConfig(config)` enforces the
bounds noted above.
:::warning
`SecurityConfig` is a value object with a validator. Nothing in the package *consumes* it — neither
`Verifier` nor `MPCVerifier` nor `SigningValidator` takes one. Constructing and validating a config
does not change how any verification behaves; wire the allow-list yourself with
`NewSigningValidatorWithMethods(config.AllowedSigningMethods)`.
:::
## Templates and policy
`CapabilityTemplate` is an allow-list of actions per resource scheme, plus lifetime bounds.
<TypeTable
type={{
"AllowedActions": {
type: "map[string][]string",
required: true,
description: "resource scheme -> permitted actions. A scheme that is ABSENT from the map is allowed unconditionally.",
default: "empty map"
},
"DefaultExpiration": {
type: "time.Duration",
required: true,
description: "Used by GetDefaultExpirationTime().",
default: "24h"
},
"MaxExpiration": {
type: "time.Duration",
required: true,
description: "ValidateExpiration rejects an exp further out than this.",
default: "720h (30 days)"
},
}}
/>
```go
tpl := ucan.NewCapabilityTemplate()
tpl.AddAllowedActions("service", []string{"read", "write"})
err := tpl.ValidateAttenuation(ucan.CreateSimpleAttenuation("delete", "service://api"))
// -> "action delete not allowed for resource type service"
err = tpl.ValidateExpiration(tpl.GetDefaultExpirationTime()) // nil
```
`ValidateExpiration` treats `expiresAt == 0` as "no expiration" and returns `nil`; a past timestamp
errors, and one beyond `MaxExpiration` errors. `"*"` in an attenuation is only accepted if `"*"` is
itself in the allow-list for that scheme.
:::warning[Unknown schemes are allowed, not denied]
`ValidateAttenuation` returns `nil` when the resource scheme is missing from `AllowedActions`, with
the comment "Allow unknown resource types for backward compatibility". A template is therefore a
*deny-list of known-bad actions on known schemes*, not an allow-list. `CreateSimpleAttenuation("nuke",
"unknown://everything")` validates cleanly against every template in the package.
:::
Prebuilt templates, each a `NewCapabilityTemplate()` with one or two schemes populated:
| Function | Schemes populated |
| --- | --- |
| `StandardVaultTemplate()` | `ipfs`, `vault` |
| `StandardServiceTemplate()` | `service`, `https`, `http` |
| `StandardDIDTemplate()` | `did` |
| `StandardDWNTemplate()` | `dwn` |
| `StandardDEXTemplate()` | `dex` |
| `EnhancedServiceTemplate()` | `service`, with delegation actions |
`StandardTemplate` is a package-level `var` populated in `ucan/jwt.go`'s `init()` with actions for
`vault`, `service`, `did`, `dwn`, `dex`, `pool` and `svc`. It is the template that
`VerifyJWTToken` and `VerifyModuleJWTToken` validate against.
:::danger[`StandardTemplate` is mutable global state]
It is an exported pointer, and `AddAllowedActions` mutates it in place. Any code — including a test,
as `ucan/ucan_test.go` does — can widen the allow-list that every `VerifyJWTToken` call in the
process then honours. Build your own template with `NewCapabilityTemplate()` for anything that
matters.
:::
## Vault and IPFS integration
Vault capabilities address an enclave backup stored in IPFS, so the resource URI is `ipfs://<CID>`.
<TypeTable
type={{
"VaultCapabilitySchema": { type: "z.Struct", description: "A zog schema requiring `can` from a fixed action set, `with` as a valid ipfs:// URI, a non-empty `vault`, and optional `actions`/`cavs`." },
"ValidateVaultCapability": { type: "func(att map[string]any) error", description: "Runs a decoded attenuation map through VaultCapabilitySchema." },
"ValidateVaultTokenCapability": { type: "func(token *Token, enclaveDataCID, requiredAction string) error", description: "Requires requiredAction in {read, write, sign, export, import, delete} and an attenuation on ipfs://<cid> granting it." },
"GetEnclaveDataCID": { type: "func(token *Token) (string, error)", description: "The first attenuation resource with an ipfs:// prefix, minus the prefix." },
"ValidateIPFSCID": { type: "func(value *string, ctx z.Ctx) bool", description: "zog TestFunc: requires an ipfs:// prefix and a well-formed CID." },
"ValidateEnclaveDataCIDIntegrity": { type: "func(enclaveDataCID string, enclaveData []byte) error", description: "Recomputes the CID over the bytes and compares. Errors on an empty CID, empty data, a malformed CID, or a mismatch." },
"ValidateEnclaveDataIntegrity": { type: "func(enclaveData *mpc.EnclaveData, expectedCID string) error", description: "Structural checks on the EnclaveData (non-nil, non-empty PubBytes) before the CID comparison." },
}}
/>
`VaultAdminAction` is the constant `"vault/admin"`. Note that the vault schema's `can` set uses
slash-prefixed values (`vault/read`, `vault/sign`, …) while `ValidateVaultTokenCapability` and the
templates use bare ones (`read`, `sign`, …); they are different vocabularies applied at different
layers.
`TestValidateEnclaveDataCIDIntegrity` in `ucan/ucan_test.go` is the one genuinely end-to-end test in
the package, covering empty-CID, empty-data, malformed-CID, matching and mismatching cases.
## End-to-end example
Enclave → issuer DID → signed origin token → narrowed delegated token → manual signature check.
This uses `KeyshareSource`, the delegation API that actually enforces attenuation. Every line was
run against this package; the printed values below are the observed output.
```go ucan_delegation.go
package main
import (
"crypto/sha256"
"fmt"
"time"
"github.com/sonr-io/crypto/keys"
"github.com/sonr-io/crypto/mpc"
"github.com/sonr-io/crypto/ucan"
)
func main() {
enclave, err := mpc.NewEnclave()
if err != nil {
panic(err)
}
// KeyshareSource enforces the subset property on delegation.
src, err := ucan.NewMPCKeyshareSource(enclave)
if err != nil {
panic(err)
}
fmt.Println("issuer:", src.Issuer()) // did:key:z...
// The delegate's identity — here just another enclave's DID.
delegateEnclave, err := mpc.NewEnclave()
if err != nil {
panic(err)
}
delegateDID, err := keys.NewFromMPCPubKey(delegateEnclave.PubKeyBytes())
if err != nil {
panic(err)
}
now := time.Now()
// Origin token: broad authority over one service resource.
origin, err := src.NewOriginToken(
delegateDID.String(),
[]ucan.Attenuation{
ucan.CreateMultiAttenuation([]string{"read", "write", "delete"}, "service://api"),
},
nil, now, now.Add(time.Hour),
)
if err != nil {
panic(err)
}
// Widening is rejected at issuance.
_, err = src.NewAttenuatedToken(origin, delegateDID.String(),
[]ucan.Attenuation{
ucan.CreateMultiAttenuation([]string{"read", "write", "delete", "admin"}, "service://api"),
},
nil, now, now.Add(time.Hour))
fmt.Println("widening rejected:", err)
// -> "scope of ucan attenuations must be less than its parent"
// Narrowing is accepted: read only, half the lifetime.
delegated, err := src.NewAttenuatedToken(origin, delegateDID.String(),
[]ucan.Attenuation{ucan.CreateSimpleAttenuation("read", "service://api")},
nil, now, now.Add(30*time.Minute))
if err != nil {
panic(err)
}
fmt.Println("proofs:", len(delegated.Proofs)) // 1 — the origin token
// The attenuation invariant, checked locally.
child := ucan.AttenuationList(delegated.Attenuations)
parent := ucan.AttenuationList(origin.Attenuations)
fmt.Println("narrows:", child.IsSubsetOf(parent)) // true
fmt.Println("widens:", parent.IsSubsetOf(child)) // false
fmt.Println("can read:", child.CanPerform("service://api", []string{"read"})) // true
fmt.Println("can delete:", child.CanPerform("service://api", []string{"delete"})) // false
// Signature verification, done directly against the public key.
unsigned, err := ucan.ExtractUnsignedToken(delegated.Raw)
if err != nil {
panic(err)
}
sig, err := ucan.ExtractSignature(delegated.Raw)
if err != nil {
panic(err)
}
digest := sha256.Sum256([]byte(unsigned))
ok, err := mpc.VerifyWithPubKey(enclave.PubKeyBytes(), digest[:], sig)
fmt.Println("signature valid:", ok, err) // true <nil>
}
```
:::warning
There is **no test in the repository** that mints an MPC-signed token and verifies it back through
the package's own verifier — and per the callout above, `VerifyMPCToken` does not currently work.
The manual check at the end of this program is the path that does, and it was confirmed to return
`(true, nil)` for a genuine token and `(false, nil)` for a tampered signing string.
:::
## Not actually implemented
Each item below was verified by reading the named source file and then confirmed by running it.
Delegation enforcement is covered separately, under
[Only `KeyshareSource` enforces attenuation at issuance](#mpc-signing-and-verification).
:::danger[`GenerateJWTToken` / `VerifyJWTToken` use a hardcoded HS256 secret]
In `ucan/jwt.go`, all four of `GenerateJWTToken`, `GenerateModuleJWTToken`, `VerifyJWTToken` and
`VerifyModuleJWTToken` do this:
```go
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte("sonr-ucan-secret"))
```
```go
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
// Dummy secret verification - replace with proper key validation
return []byte("sonr-ucan-secret"), nil
}, jwt.WithLeeway(5*time.Minute))
```
The secret is a string literal in the package source. Anyone who can read this repository can mint a
token that `VerifyJWTToken` accepts, with any issuer, audience and attenuation set — bounded only by
`StandardTemplate`, which is itself mutable.
`GenerateJWTToken` additionally hardcodes `"iss": "did:sonr:local"` and ignores any notion of an
audience, and it base64-encodes a single `{can, with}` object into a non-standard `can` claim rather
than emitting a UCAN `att` array. `GenerateModuleJWTToken` does use `att` and takes real issuer and
audience arguments — but signs with the same shared secret.
**Treat all four as demo scaffolding.** Use `MPCTokenBuilder` for issuance and verify signatures
explicitly.
:::
:::danger[Caveat restrictions are not enforced]
`Verifier.checkCapabilities` calls `validateCaveats(cap, resource)`, which dispatches by resource
scheme into `validateDIDCaveats`, `validateDWNCaveats`, `validateDEXCaveats`,
`validateServiceCaveats` and `validateVaultCaveats`. Those iterate the capability's `Caveats` slice
and call a per-caveat helper. **Every one of those helpers is a stub that returns `nil`:**
```go
// Caveat validation helper methods (placeholders for actual implementation)
func (v *Verifier) validateOwnerCaveat(resource Resource) error { return nil }
func (v *Verifier) validateControllerCaveat(resource Resource) error { return nil }
func (v *Verifier) validateRecordOwnership(resource Resource) error { return nil }
func (v *Verifier) validateProtocolCaveat(resource Resource) error { return nil }
func (v *Verifier) validateMaxAmountCaveat(maxAmount string) error { return nil }
func (v *Verifier) validatePoolMembershipCaveat(resource Resource) error { return nil }
func (v *Verifier) validateVaultOwnership(vaultAddress string) error { return nil }
func (v *Verifier) validateEnclaveIntegrity(enclaveDataCID string) error { return nil }
```
`validateServiceCaveats` returns `nil` without inspecting anything at all, and `validateCaveats`
returns `nil` for any scheme outside its switch.
The same holds on the delegation path. `areCaveatsMoreRestrictive(childCaveats, parentCaveats)`
builds a set from the parent, then loops over the child caveats with `continue` as the only
statement in the loop body, and returns `true` — it is structurally incapable of returning `false`.
`isAmountLessOrEqual(childAmount, parentAmount)` is commented "placeholder implementation" and
returns `true`. `isModuleCapabilityContained` returns `true` in its `default` branch for any unknown
scheme.
**A caveat in a UCAN token issued or verified by this package restricts nothing.** If you rely on
caveats for authorization — an amount cap, an ownership constraint, pool membership — you must
enforce them in your own code after `VerifyCapability` returns.
:::
:::danger[`RevokeCapability` revokes the wrong token]
`ucan/jwt.go` keeps an unexported `revokedTokens map[string]bool` keyed on the **full JWT string**,
which `VerifyJWTToken` and `VerifyModuleJWTToken` consult first. But the only way to add an entry is:
```go
func RevokeCapability(attenuation Attenuation) error {
token, err := GenerateJWTToken(attenuation, time.Hour)
if err != nil {
return err
}
revokedTokens[token] = true
return nil
}
```
It mints a *brand-new* token from the attenuation and revokes that string. Since the claims include
`iat` and `exp` derived from `time.Now()`, the regenerated string only equals a previously issued one
if that token was created in the same wall-clock second with the identical one-hour duration.
That is exactly the window `TestCapabilityRevocation` happens to hit — it calls
`GenerateJWTToken(att, time.Hour)` and `RevokeCapability(att)` back to back, so the two strings
match and the assertion passes. **The test passes for an incidental reason; the mechanism does not
work.** There is no API to revoke a token you actually hold, and the map is process-local,
unbounded and never persisted.
:::
Measured against this package:
| Sequence | `VerifyJWTToken` after revoking |
| --- | --- |
| Issue, wait 1.5 s, `RevokeCapability` | `nil` — **still accepted** |
| Issue and `RevokeCapability` in the same second | `"token has been revoked"` |
| Issue with a 2 h duration, `RevokeCapability` (which uses 1 h) | `nil` — **still accepted** |
:::warning[`ucan/stubs.go` — what is a stub]
The file declares four things. `TokenBuilderInterface` and `TokenBuilder` are real but do not sign
(they set `Raw: ""`). The two unexported helpers are labelled stubs in the source:
- `isValidDID(did string) bool` — "Basic DID validation stub". Returns
`did != "" && len(did) > 5 && did[:4] == "did:"`. No method check, no multibase check, no key
validation. `"did:xxxxxxxx"` passes. It gates the `audienceDID` argument in
`mpcKeyshareSource.newToken` and in `NewVaultAdminToken`.
- `prepareDelegationProofs(token, capabilities)` — "Minimal stub implementation". Ignores
`capabilities` entirely and returns `[]Proof{token.Raw}` when `Raw` is non-empty.
:::
:::warning[No signature check on `Fact` or proof CIDs]
`Proof` is `string` and may hold either a JWT or a CID. `VerifyDelegationChain` passes every proof to
`VerifyToken`, which calls `jwt.Parse` — so a CID-form proof fails to parse rather than being
resolved. The package has no proof-resolution path; CID proofs are unusable.
:::
## Next
<CardGroup cols={2}>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
The signing key behind the issuer DID, and why `mpc/spec` should be avoided.
</Card>
<Card title="did:key Identifiers" href="/identity/did-key" icon="id-card">
How issuer and audience strings are encoded and parsed.
</Card>
<Card title="Security Notes" href="/reference/security" icon="triangle-alert">
Every stub and defect in the module, in one place.
</Card>
<Card title="ECIES" href="/identity/ecies" icon="mail">
Encrypting a payload to the holder of a key, rather than authorizing them.
</Card>
</CardGroup>
+444
View File
@@ -0,0 +1,444 @@
---
title: WASM Module Signing
description: Ed25519 code signing and SHA-256 hash pinning for WebAssembly module bytes — supply-chain verification, not a JavaScript binding layer.
sidebar:
order: 6
icon: package-check
---
## This is not a js/wasm binding layer
The package name misleads. `github.com/sonr-io/crypto/wasm` contains no `//go:build js,wasm`
constraint, does not import `syscall/js`, and exposes nothing that runs inside a browser. Verified
by reading both source files (`signer.go`, `verifier.go`): the only imports are `crypto/ed25519`,
`crypto/rand`, `crypto/sha256`, `encoding/base64`, `encoding/hex`, `encoding/json`, `fmt`, `sync`,
and `time`.
What it actually is: **Ed25519 code signing and SHA-256 hash pinning over WebAssembly module bytes.**
It answers one question — *is this `.wasm` blob the one I approved?* — before a host embeds and
executes it. That is supply-chain verification, and it is plain Go that compiles and runs on any
target.
**Reach for this when** your program loads WASM plugins or modules from disk, a registry, or the
network and must refuse anything it does not recognise.
**Do not reach for this when** you need sandboxing or capability control over what a module can *do*
once loaded — that is the runtime's job, not this package's. Verification tells you *which* code you
are about to run, never what it will do.
## Trust model
<Steps>
<Step title="Provision trust out of band">
A `SignatureVerifier` starts empty and rejects everything with `"no trusted keys configured"`.
Verification is only as strong as the key set you install with `AddTrustedKey` /
`AddTrustedKeyFromHex`. Those public keys must reach the verifier through a channel you already
trust — baked into the binary, delivered by your config management, pinned in your deployment
manifest. A key learned from the same place as the module buys you nothing.
</Step>
<Step title="Sign at build time">
The publisher holds an Ed25519 private key and calls `SignModule` or `CreateSignatureManifest`
over the exact bytes that will be shipped.
</Step>
<Step title="Verify at load time">
The host recomputes the SHA-256 hash, compares it against the recorded one, and then checks the
Ed25519 signature against a trusted key.
</Step>
<Step title="Pin hashes as an independent check">
`HashVerifier` is deliberately separate from signing. A pinned hash constrains you to one exact
build even if a signing key is later compromised — it is a second, non-overlapping control, not a
weaker substitute for a signature.
</Step>
</Steps>
## Signing
```go
func NewSigner() (*Signer, error)
func NewSignerFromPrivateKey(privateKey ed25519.PrivateKey) (*Signer, error)
func (s *Signer) Sign(wasmBytes []byte) ([]byte, error)
func (s *Signer) GetPublicKey() []byte
func (s *Signer) GetPublicKeyHex() string
func (s *Signer) ExportPrivateKey() []byte
```
`NewSigner` generates a fresh Ed25519 keypair from `crypto/rand`. `NewSignerFromPrivateKey` requires
exactly `ed25519.PrivateKeySize` (64) bytes and derives the public key from it, erroring with
`"invalid private key size: expected 64, got N"` otherwise. `Sign` produces a 64-byte
`ed25519.Sign(priv, wasmBytes)` over the **raw module bytes** — not over the hash, and with no domain
separation prefix.
:::warning[`ExportPrivateKey` hands out the raw signing key]
It returns `s.privateKey` directly — the live 64-byte `ed25519.PrivateKey` slice, not a copy. The
caller can read it, and can also **mutate the signer's key in place** through the returned slice.
Anything that receives this value can forge signatures for every module your key covers. Do not log
it, serialize it, or pass it across a trust boundary; if you must persist a signing key, encrypt it
with [AEAD](/symmetric/aead) and keep the plaintext lifetime as short as possible.
:::
## Signed modules
```go
type SignedModule struct {
Module []byte `json:"-"` // WASM bytecode, EXCLUDED from JSON
Hash string `json:"hash"` // hex SHA-256 of Module
Signature []byte `json:"signature"` // Ed25519, 64 bytes
SignerID string `json:"signer_id"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
}
func SignModule(signer *Signer, module []byte, signerID, version string) (*SignedModule, error)
func VerifySignedModule(verifier *SignatureVerifier, module *SignedModule) error
```
`VerifySignedModule` runs two checks in order:
1. Recompute the SHA-256 hash over `module.Module` and compare with `module.Hash`; mismatch yields
`"hash mismatch: expected …, got …"`.
2. If `SignerID` is non-empty, `verifier.VerifyWithKey(SignerID, Module, Signature)`; otherwise
`verifier.Verify(Module, Signature)`, which tries every trusted key in turn.
Grounded in `TestSignedModule` (`wasm/signer_test.go`):
```go wasm_sign_verify.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/wasm"
)
func main() {
// Publisher side.
signer, err := wasm.NewSigner()
if err != nil {
panic(err)
}
module := []byte("test wasm module") // in practice, the .wasm file contents
signed, err := wasm.SignModule(signer, module, "test-signer", "v1.0.0")
if err != nil {
panic(err)
}
fmt.Println("hash:", signed.Hash)
publicKeyHex := signer.GetPublicKeyHex() // ship this out of band
// Host side: trust is provisioned from the out-of-band key, not from `signed`.
verifier := wasm.NewSignatureVerifier()
if err := verifier.AddTrustedKeyFromHex("test-signer", publicKeyHex); err != nil {
panic(err)
}
fmt.Println("trusted:", verifier.GetTrustedKeyIDs())
fmt.Println("ok:", wasm.VerifySignedModule(verifier, signed)) // nil
// Tampering is caught at the hash check.
signed.Module = []byte("tampered")
fmt.Println("tampered:", wasm.VerifySignedModule(verifier, signed)) // "hash mismatch"
}
```
:::note[`Module` is not serialized]
`SignedModule.Module` carries `json:"-"`, so marshalling a `SignedModule` drops the bytecode. The
JSON is metadata only; ship the `.wasm` file alongside it and reattach it to `Module` before calling
`VerifySignedModule`, or the hash check compares against an empty module.
:::
## The `SignatureVerifier`
```go
func NewSignatureVerifier() *SignatureVerifier
func (v *SignatureVerifier) AddTrustedKey(keyID string, publicKey ed25519.PublicKey) error
func (v *SignatureVerifier) AddTrustedKeyFromHex(keyID, publicKeyHex string) error
func (v *SignatureVerifier) RemoveTrustedKey(keyID string)
func (v *SignatureVerifier) GetTrustedKeyIDs() []string
func (v *SignatureVerifier) Verify(wasmBytes, signature []byte) error
func (v *SignatureVerifier) VerifyWithKey(keyID string, wasmBytes, signature []byte) error
```
`AddTrustedKey` requires exactly `ed25519.PublicKeySize` (32) bytes. The map is guarded by a
`sync.RWMutex`, so a verifier is safe for concurrent use.
Prefer `VerifyWithKey` over `Verify`. `Verify` iterates the whole trusted set and succeeds if *any*
key validates, so it tells you the module is signed by someone you trust but not by **whom** — and it
does not report which key matched. `VerifyWithKey` binds the check to an expected signer.
## Manifests
A manifest decouples signature metadata from the module file, and supports multiple signatures.
```go
type SignatureManifest struct {
ModuleHash string `json:"module_hash"`
Signatures []SignatureEntry `json:"signatures"`
TrustedKeys []TrustedKeyEntry `json:"trusted_keys"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type SignatureEntry struct {
Signature string `json:"signature"` // base64 std encoding
SignerID string `json:"signer_id"`
Timestamp time.Time `json:"timestamp"`
Algorithm string `json:"algorithm"` // always "Ed25519"
}
type TrustedKeyEntry struct {
KeyID string `json:"key_id"`
PublicKey string `json:"public_key"` // base64 std encoding
AddedAt time.Time `json:"added_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Purpose string `json:"purpose"` // e.g. "code-signing"
}
func CreateSignatureManifest(module []byte, signer *Signer, signerID string) (*SignatureManifest, error)
func ExportManifest(manifest *SignatureManifest) ([]byte, error)
func ImportManifest(data []byte) (*SignatureManifest, error)
func VerifyWithManifest(module []byte, manifest *SignatureManifest) error
```
`CreateSignatureManifest` emits a manifest with exactly one `SignatureEntry` and one
`TrustedKeyEntry` (`Purpose: "code-signing"`). `VerifyWithManifest` checks the module hash, then
`ExpiresAt` on the manifest, then builds a fresh verifier from `manifest.TrustedKeys`, skipping any
entry whose own `ExpiresAt` has passed.
:::danger[`VerifyWithManifest` trusts the keys inside the manifest]
It constructs its verifier from `manifest.TrustedKeys` — keys carried by the very document whose
authenticity is in question. An attacker who can replace both the module and its manifest simply
signs the replacement with their own key, lists that key in `TrustedKeys`, and
`VerifyWithManifest` returns `nil`.
`VerifyWithManifest` therefore establishes only **internal consistency**: this manifest describes
this module. It establishes **no trust**. To get a real decision, verify against a key set you
provisioned yourself:
```go
manifest, err := wasm.ImportManifest(manifestJSON)
if err != nil {
return err
}
// Independent trust anchor — not manifest.TrustedKeys.
verifier := wasm.NewSignatureVerifier()
if err := verifier.AddTrustedKeyFromHex("release-key", pinnedPublicKeyHex); err != nil {
return err
}
// Confirm the manifest describes this module and has not expired.
if err := wasm.VerifyWithManifest(module, manifest); err != nil {
return err
}
// Then check at least one signature against YOUR key.
verified := false
for _, entry := range manifest.Signatures {
sig, err := base64.StdEncoding.DecodeString(entry.Signature)
if err != nil {
continue
}
if verifier.VerifyWithKey("release-key", module, sig) == nil {
verified = true
break
}
}
if !verified {
return errors.New("no signature from a pinned key")
}
```
:::
:::warning[Expiry is optional and unauthenticated]
`ExpiresAt` is a `*time.Time`; a `nil` value means "never expires" and `VerifyWithManifest` accepts
it. Since the manifest is unsigned as a whole, an attacker rewriting the manifest can also clear or
extend `ExpiresAt`. Only the individual `Signature` values are cryptographically protected, and each
covers the module bytes alone — not `ModuleHash`, not `SignerID`, not `Timestamp`, and not any
expiry field.
:::
## Hash pinning
```go
func NewHashVerifier() *HashVerifier
func (v *HashVerifier) ComputeHash(wasmBytes []byte) string // hex SHA-256
func (v *HashVerifier) AddTrustedHash(name, hash string)
func (v *HashVerifier) GetTrustedHash(name string) (string, bool)
func (v *HashVerifier) VerifyHash(name string, wasmBytes []byte) error
func (v *HashVerifier) VerifyHashWithFallback(name string, wasmBytes []byte, fallbackHashes []string) error
func (v *HashVerifier) ClearTrustedHashes()
```
`VerifyHash` errors with `"no trusted hash found for WASM module: <name>"` when the name is unknown —
so an unregistered module is denied by default, which is the right behaviour. The map is
`sync.RWMutex`-guarded.
:::danger[`VerifyHashWithFallback` mutates your pin set]
On a fallback match it calls `AddTrustedHash(name, computedHash)`, **overwriting the pinned hash for
that name**:
```go
for _, fallbackHash := range fallbackHashes {
if computedHash == fallbackHash {
v.AddTrustedHash(name, computedHash) // pin replaced
return nil
}
}
```
Every subsequent `VerifyHash(name, …)` now accepts the fallback build and rejects the original. Two
consequences:
- Pinning becomes trust-on-first-use with silent promotion. If the fallback list is ever wider than
you intended — read from config, a response body, a rollback table — the pin follows it.
- The change is invisible: nothing is returned or logged to say the pin moved.
If you need to accept several builds, keep them in your own set and call `VerifyHash` (or compare
`ComputeHash` output) against each, so the pin set stays under your control.
:::
## Hash chains
```go
type HashEntry struct {
Version string `json:"version"`
Hash string `json:"hash"`
PreviousHash string `json:"previous_hash"`
Timestamp int64 `json:"timestamp"`
}
func NewHashChain() *HashChain
func (hc *HashChain) AddEntry(version, hash string, timestamp int64) error
func (hc *HashChain) GetLatestEntry() (*HashEntry, error)
func (hc *HashChain) VerifyChain() error
```
`AddEntry` appends an entry whose `PreviousHash` is copied from the previous entry's `Hash` (empty
for the first). `VerifyChain` accepts an empty chain, requires the first entry's `PreviousHash` to be
empty, and then checks that each `PreviousHash` equals the preceding `Hash`. `GetLatestEntry` returns
a **copy** of the last entry, or `"hash chain is empty"`.
:::warning[The chain is a linkage check, not a cryptographic commitment]
`AddEntry` always sets `PreviousHash` correctly, so `VerifyChain` **cannot fail** for a chain built
through `AddEntry`. It only becomes meaningful for a chain deserialized from an untrusted source —
which is exactly how `TestHashChain_BrokenChain` exercises it, by assigning the internal slice
directly.
Even then, `PreviousHash` is a plain string field, not a hash *over* the previous entry. Nothing
binds `Version` or `Timestamp` to anything, and no entry is signed. An attacker who can rewrite the
chain can produce a self-consistent chain of their own choosing. Treat it as an audit-trail
convenience for update ordering, and get your integrity from `SignatureVerifier` and `HashVerifier`.
:::
## `SecurityPolicy`
<TypeTable
type={{
"RequireHashVerification": {
type: "bool",
required: true,
description: "Intended to require a hash check. NOT read by Validate.",
default: "true"
},
"RequireSignature": {
type: "bool",
required: true,
description: "Intended to require a signature. NOT read by Validate. Source comment: \"Will be enabled in next phase\".",
default: "false"
},
"AllowedHashes": {
type: "[]string",
required: true,
description: "Intended allow-list of module hashes. NOT read by Validate.",
default: "[] (empty)"
},
"MaxModuleSize": {
type: "int64",
required: true,
description: "Maximum module size in bytes. The ONLY field Validate enforces; skipped entirely when <= 0.",
default: "10485760 (10 MiB)"
},
}}
/>
:::danger[`Validate` only checks the size]
`SecurityPolicy.Validate(wasmBytes []byte) error` is, in full:
```go
func (p *SecurityPolicy) Validate(wasmBytes []byte) error {
if p.MaxModuleSize > 0 && int64(len(wasmBytes)) > p.MaxModuleSize {
return fmt.Errorf("WASM module size %d exceeds maximum allowed size %d",
len(wasmBytes), p.MaxModuleSize)
}
return nil
}
```
`RequireHashVerification`, `RequireSignature` and `AllowedHashes` are never read — not here, and
nowhere else in the package. Setting `RequireSignature: true` and calling `Validate` gives you a
size check and nothing else, while reading like an enforced signature requirement.
`TestSecurityPolicy` asserts exactly this and no more: a 1 KiB module passes, an 11 MiB module fails.
**Do not use `SecurityPolicy` as a gate.** Sequence the checks yourself:
```go
if err := policy.Validate(moduleBytes); err != nil { // size only
return err
}
if err := hashes.VerifyHash(name, moduleBytes); err != nil {
return err
}
if err := signatures.VerifyWithKey(signerID, moduleBytes, sig); err != nil {
return err
}
```
:::
## `VerificationError`
A structured error type for reporting a failed check. It is exported and its `Error()` renders
module, reason, expected and actual hash — but **no function in the package returns it**. Every
failure path uses `fmt.Errorf` instead. Use it in your own verification wrapper if you want typed
errors:
```go
return &wasm.VerificationError{
Module: name,
ExpectedHash: expected,
ActualHash: verifier.ComputeHash(moduleBytes),
Reason: "pinned hash mismatch",
}
```
## Caveats summary
:::info[What the tests cover]
`wasm/signer_test.go` and `wasm/verifier_test.go` are reasonably thorough for this package: signer
construction and key-size validation, signing and tamper detection, trusted-key add/remove/list,
`SignModule`/`VerifySignedModule`, manifest creation, `VerifyWithManifest` including hash mismatch
and expiry, manifest JSON round trip, hash computation and pinning, fallback verification, hash
chains including a broken chain, `SecurityPolicy` size limits, and `VerificationError` formatting.
What they do not cover is the *semantics* of the gaps above: no test asserts that
`RequireSignature: true` is enforced (it is not), or that `VerifyWithManifest` establishes trust (it
does not), or that a fallback match leaves the pin set unchanged (it does not).
:::
## Next
<CardGroup cols={2}>
<Card title="AEAD" href="/symmetric/aead" icon="lock">
Encrypting a signing key at rest.
</Card>
<Card title="Signatures" href="/signatures" icon="fingerprint">
Ed25519's siblings, and when a different signature scheme fits better.
</Card>
<Card title="Security Notes" href="/reference/security" icon="triangle-alert">
Every stub and defect in the module, in one place.
</Card>
<Card title="Identity Overview" href="/identity" icon="fingerprint">
How this fits with enclaves, DIDs and capability tokens.
</Card>
</CardGroup>