mirror of
https://github.com/sonr-io/crypto.git
synced 2026-09-17 01:26:23 +00:00
feat: init docs
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user