From 69425e2b7adb9986f7f68f9b6190634a175e7acb Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Wed, 2 Sep 2026 15:29:51 -0400 Subject: [PATCH] feat: init docs --- .gitignore | 2 + README.md | 91 +++- docs/01-getting-started.mdx | 163 ++++++ docs/foundations/arithmetic.mdx | 285 ++++++++++ docs/foundations/curves.mdx | 300 ++++++++++ docs/foundations/index.mdx | 172 ++++++ docs/foundations/meta.ts | 8 + docs/foundations/protocol.mdx | 312 +++++++++++ docs/identity/did-key.mdx | 332 ++++++++++++ docs/identity/ecies.mdx | 196 +++++++ docs/identity/index.mdx | 147 +++++ docs/identity/meta.ts | 8 + docs/identity/mpc-enclave.mdx | 644 ++++++++++++++++++++++ docs/identity/ucan.mdx | 751 ++++++++++++++++++++++++++ docs/identity/wasm-modules.mdx | 444 +++++++++++++++ docs/index.mdx | 122 +++++ docs/reference/meta.ts | 8 + docs/reference/packages.mdx | 109 ++++ docs/reference/security.mdx | 390 +++++++++++++ docs/signatures/bbs.mdx | 475 ++++++++++++++++ docs/signatures/bls.mdx | 469 ++++++++++++++++ docs/signatures/chain-schemes.mdx | 476 ++++++++++++++++ docs/signatures/ecdsa.mdx | 377 +++++++++++++ docs/signatures/index.mdx | 179 ++++++ docs/signatures/meta.ts | 8 + docs/signatures/vrf.mdx | 244 +++++++++ docs/symmetric/aead.mdx | 174 ++++++ docs/symmetric/deterministic-aead.mdx | 196 +++++++ docs/symmetric/index.mdx | 127 +++++ docs/symmetric/key-derivation.mdx | 341 ++++++++++++ docs/symmetric/meta.ts | 8 + docs/symmetric/secrets.mdx | 386 +++++++++++++ docs/threshold/dkg.mdx | 440 +++++++++++++++ docs/threshold/index.mdx | 119 ++++ docs/threshold/meta.ts | 15 + docs/threshold/oblivious-transfer.mdx | 428 +++++++++++++++ docs/threshold/secret-sharing.mdx | 343 ++++++++++++ docs/threshold/threshold-ecdsa.mdx | 516 ++++++++++++++++++ docs/threshold/threshold-ed25519.mdx | 390 +++++++++++++ docs/zero-knowledge/accumulator.mdx | 452 ++++++++++++++++ docs/zero-knowledge/bulletproof.mdx | 367 +++++++++++++ docs/zero-knowledge/index.mdx | 80 +++ docs/zero-knowledge/meta.ts | 8 + docs/zero-knowledge/paillier.mdx | 431 +++++++++++++++ docs/zero-knowledge/schnorr.mdx | 275 ++++++++++ 45 files changed, 11790 insertions(+), 18 deletions(-) create mode 100644 docs/01-getting-started.mdx create mode 100644 docs/foundations/arithmetic.mdx create mode 100644 docs/foundations/curves.mdx create mode 100644 docs/foundations/index.mdx create mode 100644 docs/foundations/meta.ts create mode 100644 docs/foundations/protocol.mdx create mode 100644 docs/identity/did-key.mdx create mode 100644 docs/identity/ecies.mdx create mode 100644 docs/identity/index.mdx create mode 100644 docs/identity/meta.ts create mode 100644 docs/identity/mpc-enclave.mdx create mode 100644 docs/identity/ucan.mdx create mode 100644 docs/identity/wasm-modules.mdx create mode 100644 docs/index.mdx create mode 100644 docs/reference/meta.ts create mode 100644 docs/reference/packages.mdx create mode 100644 docs/reference/security.mdx create mode 100644 docs/signatures/bbs.mdx create mode 100644 docs/signatures/bls.mdx create mode 100644 docs/signatures/chain-schemes.mdx create mode 100644 docs/signatures/ecdsa.mdx create mode 100644 docs/signatures/index.mdx create mode 100644 docs/signatures/meta.ts create mode 100644 docs/signatures/vrf.mdx create mode 100644 docs/symmetric/aead.mdx create mode 100644 docs/symmetric/deterministic-aead.mdx create mode 100644 docs/symmetric/index.mdx create mode 100644 docs/symmetric/key-derivation.mdx create mode 100644 docs/symmetric/meta.ts create mode 100644 docs/symmetric/secrets.mdx create mode 100644 docs/threshold/dkg.mdx create mode 100644 docs/threshold/index.mdx create mode 100644 docs/threshold/meta.ts create mode 100644 docs/threshold/oblivious-transfer.mdx create mode 100644 docs/threshold/secret-sharing.mdx create mode 100644 docs/threshold/threshold-ecdsa.mdx create mode 100644 docs/threshold/threshold-ed25519.mdx create mode 100644 docs/zero-knowledge/accumulator.mdx create mode 100644 docs/zero-knowledge/bulletproof.mdx create mode 100644 docs/zero-knowledge/index.mdx create mode 100644 docs/zero-knowledge/meta.ts create mode 100644 docs/zero-knowledge/paillier.mdx create mode 100644 docs/zero-knowledge/schnorr.mdx diff --git a/.gitignore b/.gitignore index 8e33aaa..8a34ad6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ CLAUDE.md .opencode +node_modules/ +.blume/ diff --git a/README.md b/README.md index d38a9e6..0b14ea9 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,81 @@ # Sonr Crypto -Sonr Crypto is a collection of cryptographic primitives that are used by Sonr. +Cryptographic primitives used by Sonr: elliptic-curve arithmetic, threshold signatures, +multi-party computation, zero-knowledge proofs, and the identity layer built on top of them. + +**Documentation: https://sonr-io.github.io/crypto** + +```bash +go get github.com/sonr-io/crypto +``` + +Requires Go 1.24.7 or newer. + +> [!WARNING] +> This library has no public security audit, and several packages contain stubs or known defects. +> Read the [security notes](https://sonr-io.github.io/crypto/reference/security) before depending on +> any of it. ## Packages -- [Accumulator](./accumulator): Accumulator is a cryptographic accumulator that allows for efficient verification of large sets of data. -- [Bulletproof](./bulletproof): Bulletproof is a zero-knowledge proof system that allows for efficient verification of large sets of data. -- [Core](./core): Core is a collection of cryptographic primitives that are used by Sonr. -- [Daed](./daed): Daed is a distributed key generation protocol that allows for efficient verification of large sets of data. -- [Dkg](./dkg): Dkg is a distributed key generation protocol that allows for efficient verification of large sets of data. -- [Ecies](./ecies): Ecies is a symmetric encryption algorithm that allows for efficient verification of large sets of data. -- [Keys](./keys): Keys is a collection of cryptographic primitives that are used by Sonr. -- [Mpc](./mpc): Mpc is a collection of cryptographic primitives that are used by Sonr. -- [Ot](./ot): Ot is a collection of cryptographic primitives that are used by Sonr. -- [Paillier](./paillier): Paillier is a cryptographic algorithm that allows for efficient verification of large sets of data. -- [Sharing](./sharing): Sharing is a collection of cryptographic primitives that are used by Sonr. -- [Signatures](./signatures): Signatures is a collection of cryptographic primitives that are used by Sonr. -- [Subtle](./subtle): Subtle is a collection of cryptographic primitives that are used by Sonr. -- [Tecdsa](./tecdsa): Tecdsa is a collection of cryptographic primitives that are used by Sonr. -- [Ted25519](./ted25519): Ted25519 is a collection of cryptographic primitives that are used by Sonr. -- [Ucan](./ucan): Ucan is a collection of cryptographic primitives that are used by Sonr. -- [Zkp](./zkp): Zkp is a collection of cryptographic primitives that are used by Sonr. +### Foundations + +- [`core/curves`](./core/curves): the `Curve` / `Point` / `Scalar` abstraction every other package is + written against, with secp256k1, NIST P-256, Ed25519, Pallas, BLS12-381, and BLS12-377. +- [`core`](./core): modular arithmetic, hash-to-field, HMAC commitments, and safe-prime generation. +- [`core/protocol`](./core/protocol): the message and iterator types that drive multi-round protocols. + +### Symmetric and key derivation + +- [`aead`](./aead): AES-256-GCM with generated, prepended nonces. +- [`daed`](./daed): AES-SIV-CMAC deterministic AEAD (RFC 5297). +- [`argon2`](./argon2): Argon2id password hashing and key derivation. +- [`subtle`](./subtle): HKDF, X25519, and hash/curve name helpers. +- [`salt`](./salt), [`password`](./password), [`secure`](./secure): salt handling, password policy, + and memory-hygiene helpers. + +### Signatures + +- [`signatures/bls`](./signatures/bls): BLS signatures with aggregation, proof of possession, and + threshold key generation. +- [`signatures/bbs`](./signatures/bbs): BBS+ signatures for selective disclosure and blind signing. +- [`signatures/schnorr`](./signatures/schnorr): Mina (Pallas/Poseidon) and NEM (Ed25519-Keccak) schemes. +- [`ecdsa`](./ecdsa): low-S canonicalization and RFC 6979 deterministic signing. +- [`vrf`](./vrf): verifiable random function over Edwards25519. + +### Threshold cryptography and MPC + +- [`sharing`](./sharing): Shamir, Feldman, and Pedersen secret sharing. +- [`dkg`](./dkg): FROST and Gennaro distributed key generation. +- [`tecdsa`](./tecdsa): two-party threshold ECDSA (DKLs18) with key refresh. +- [`ted25519`](./ted25519): threshold Ed25519 and FROST threshold Schnorr signing. +- [`ot`](./ot): base oblivious transfer and correlated OT extension — building blocks for the above. +- [`mpc`](./mpc): threshold ECDSA packaged as a single enclave value: keygen, sign, verify, refresh. + +### Zero-knowledge + +- [`zkp/schnorr`](./zkp/schnorr): non-interactive proof of knowledge of a discrete log. +- [`accumulator`](./accumulator): pairing-based accumulator with constant-size membership proofs. +- [`bulletproof`](./bulletproof): inner-product argument and range proofs. +- [`paillier`](./paillier): additively homomorphic encryption with a square-free modulus proof. + +### Identity + +- [`keys`](./keys): `did:key` encoding and decoding over libp2p public keys. +- [`ucan`](./ucan): UCAN capability tokens, attenuation, and verification. +- [`ecies`](./ecies): ECIES payload encryption over secp256k1. +- [`wasm`](./wasm): Ed25519 signing and hash pinning for WebAssembly modules. + +## Documentation + +The site under `docs/` is built with [Blume](https://useblume.dev) and deployed to GitHub Pages by +`.github/workflows/docs.yml`. + +```bash +npm ci +npm run dev # local preview with hot reload +npm run build # static output to dist/ +``` ## License diff --git a/docs/01-getting-started.mdx b/docs/01-getting-started.mdx new file mode 100644 index 0000000..36df7aa --- /dev/null +++ b/docs/01-getting-started.mdx @@ -0,0 +1,163 @@ +--- +title: Getting started +description: Install the module, choose a curve, and learn the conventions — constructors, round-based protocols, serialization, and error handling — that every package in this library shares. +sidebar: + label: Getting started + order: 1 + icon: rocket +--- + +## Install + +```bash +go get github.com/sonr-io/crypto +``` + +Requires **Go 1.24.7 or newer**. Every package is imported under the module path +`github.com/sonr-io/crypto/`: + +```go +import ( + "github.com/sonr-io/crypto/core/curves" + "github.com/sonr-io/crypto/sharing" + "github.com/sonr-io/crypto/mpc" +) +``` + +There is no top-level façade package — the module root holds only a cross-package security test suite. +Import the specific primitive you need. + +## Pick a curve first + +Most constructors take a `*curves.Curve`. That single argument determines the group, the scalar +field, and the serialization width of everything downstream, so it is the first decision you make: + +```go +curve := curves.K256() // secp256k1 — Bitcoin, Ethereum, Cosmos +curve := curves.P256() // NIST P-256 — WebAuthn, FIDO2, TLS +curve := curves.ED25519() // Ed25519 — Sonr identity keys +``` + +Some primitives need a **pairing-friendly** curve instead, because they rely on a bilinear map. +BBS+ signatures and the accumulator both fall in this group and take a `*curves.PairingCurve`: + +```go +pairingCurve := curves.BLS12381(curves.BLS12381G1().NewGeneratorPoint()) +``` + +Passing a plain `*curves.Curve` where a `*curves.PairingCurve` is required will not compile, which is +the intended guardrail. See [Curves](/foundations/curves) for the full catalog and interface reference. + +:::note +Curve choice is rarely free to change later. A key generated on secp256k1 has no meaning on Ed25519, +and stored shares, commitments, and serialized proofs are all curve-specific. +::: + +## Conventions worth knowing + +### Constructors validate, so check the error + +Constructors do real work — parameter validation, generator derivation, table precomputation — and +return an `error` rather than panicking on bad input. A `NewShamir` with a threshold above its limit +fails at construction, not at `Split` time: + +```go +scheme, err := sharing.NewShamir(3, 5, curves.K256()) +if err != nil { + return fmt.Errorf("invalid sharing parameters: %w", err) +} +``` + +### Two generations of API coexist + +The library carries an older, curve-specific API alongside the modern generic one. You will meet both +in `go doc` output, and mixing them does not type-check: + +| Modern | Legacy | Used by | +| --- | --- | --- | +| `curves.Point`, `curves.Scalar` | `curves.EcPoint`, `curves.EcScalar` | `sharing/v1`, `dkg/gennaro` | +| `sharing` | `sharing/v1` | `dkg/gennaro`, `dkg/gennaro2p` | +| operates on `curves.Scalar` | operates on `[]byte` and `curves.Element` | — | + +Prefer the modern API for new code. Reach for the legacy layer only when a package you depend on +forces it. [Foundations](/foundations) explains the split in detail. + +### Multi-party protocols are explicit round objects + +Nothing in this library hides the network. A multi-party protocol is a stateful object whose methods +are the rounds, and you move the messages between parties yourself. Two shapes appear: + + + + Each round is a distinct method. You call them in order and route the outputs — some broadcast to + everyone, some point-to-point to one peer. Used by [`dkg/frost`](/threshold/dkg), + [`dkg/gennaro`](/threshold/dkg), and [`ted25519/frost`](/threshold/threshold-ed25519). + + ```go + bcast, p2p, err := participant.Round1(secret) + // broadcast `bcast` to all; send p2p[peerID] privately to each peer + ``` + + + The protocol is a `protocol.Iterator`: you feed it the counterparty's message and it returns the + next one, until it signals completion and you read `Result`. Used by + [`tecdsa/dklsv1`](/threshold/threshold-ecdsa) and, wrapped up entirely, by + [`mpc`](/identity/mpc-enclave). + + ```go + msg, err := alice.Next(bobMsg) + ``` + + + +Calling rounds out of order is an error, not undefined behavior — the objects track their own state. +See [Protocol messages](/foundations/protocol) for the iterator contract. + +### Serialization is per-type, not reflective + +Keys, shares, proofs, and signatures implement their own codecs — usually +`MarshalBinary`/`UnmarshalBinary`, sometimes `MarshalJSON`/`UnmarshalJSON`, and in the DKG packages +`Encode`/`Decode`. Use them rather than reflecting over struct fields with `encoding/gob` or a +generic JSON marshal, because unexported field state and curve identity would be lost. + +Unmarshalling frequently needs to know the curve up front, since the wire bytes alone do not identify +it. The idiom is to initialize an empty value on the right curve, then unmarshal into it: + +```go +sig := new(bbs.Signature).Init(pairingCurve) +if err := sig.UnmarshalBinary(data); err != nil { + return err +} +``` + +### Randomness is injected + +Anything that consumes entropy takes an `io.Reader`, so tests can be deterministic and production +code is explicit about its source. Pass `crypto/rand.Reader` unless you have a specific reason not to: + +```go +shares, err := scheme.Split(secret, rand.Reader) +``` + +:::danger +A deterministic or repeated reader in production is a key-recovery bug, not a performance +optimization. Several protocols here — Ed25519 nonce generation especially — leak the signing key +outright if the same randomness is used for two different messages. +::: + +## Where to next + + + + The curve, point, and scalar model that the rest of the library is written against. + + + Every importable package and the page that documents it. + + + Stubs, known defects, and non-constant-time paths found while documenting the code. + + + The highest-level entry point: threshold ECDSA as a single value. + + diff --git a/docs/foundations/arithmetic.mdx b/docs/foundations/arithmetic.mdx new file mode 100644 index 0000000..9968b06 --- /dev/null +++ b/docs/foundations/arithmetic.mdx @@ -0,0 +1,285 @@ +--- +title: Arithmetic & Commitments +description: The core package — modular arithmetic over big.Int with explicit moduli, constant-time comparison, hash-to-field, Fiat–Shamir, safe primes, and the HMAC commitment scheme. +sidebar: + order: 3 + icon: sigma +--- + +`core` is the one package in this library that does **not** use the [curve abstraction](/foundations/curves). It works directly on `math/big` integers with an explicit modulus, and it exists because a handful of constructions — Paillier, the legacy `sharing/v1` and `dkg/gennaro` layers, the older threshold ECDSA code — need integer arithmetic in a group whose order is not a curve order. + +**Reach for `core` when** you are implementing something over the integers mod `m` for an `m` you chose yourself, need a byte-level HMAC commitment, or need RFC-shaped hash-to-field. **Do not reach for it** to do scalar arithmetic on a curve — `curve.Scalar` is faster, constant-time-oriented, and cannot silently escape its field. + +## Modular arithmetic + +Every helper takes the modulus as its *last* argument and returns `(*big.Int, error)`. The error is not decoration: it is how the package refuses nil inputs instead of panicking. + + 1.", + }, + "In(x, m)": { + type: "error", + description: "Membership test: nil if 0 <= x < m, otherwise internal.ErrZmMembership.", + }, + "AnyNil(values ...)": { + type: "bool", + description: "true if any argument is nil. Used as the guard clause in every function above.", + }, + }} +/> + +Package-level integer constants are provided so you are not allocating them in loops: `core.Zero`, `core.One`, `core.Two`. + +```go title="modular.go" +package main + +import ( + "fmt" + "math/big" + + "github.com/sonr-io/crypto/core" +) + +func main() { + m, _ := new(big.Int).SetString( + "208351617316091241234326746312124448251235562226470491514186331217050270460481", 10) + + a, err := core.Rand(m) + if err != nil { + panic(err) + } + b, err := core.Rand(m) + if err != nil { + panic(err) + } + + ab, _ := core.Mul(a, b, m) + aInv, err := core.Inv(a, m) + if err != nil { + panic(err) // a shares a factor with m + } + + // (a*b) * a^-1 == b + back, _ := core.Mul(ab, aInv, m) + fmt.Println(core.ConstantTimeEq(back, b)) // true + fmt.Println(core.In(ab, m) == nil) // true +} +``` + +:::warning[`Add`, `Mul`, and `Exp` treat a nil modulus as "no reduction"] +This is deliberate — the source comment says *"we leave the value as an unbound integer"* — and it is a live footgun. `core.Add(x, y, nil)` succeeds and returns an unreduced integer that will fail a later `In` check or leak the un-modded value into a transcript. `Neg` and `Inv` require the modulus and error on nil. Do not rely on the guard clauses to catch a forgotten modulus. +::: + +:::danger[`Rand` never returns 0 or 1] +The range is strictly `1 < r < m`. The source explains why: a 1 offers no hiding when multiplied into a Fiat–Shamir combination, and a 0 collapses the result. This is the right default for blinding factors, but it means `core.Rand` is **not** a uniform sample over the whole of `Z_m` — if a protocol's soundness argument needs uniformity over the full range, this is the wrong function. +::: + +## Constant-time comparison + +```go +func ConstantTimeEqByte(a, b *big.Int) byte // 0x1 if equal, 0x0 otherwise +func ConstantTimeEq(a, b *big.Int) bool // ConstantTimeEqByte(a, b) == 1 +``` + +Both compare `a.Bytes()` against `b.Bytes()` via `crypto/subtle.ConstantTimeCompare` **and** compare `Sign()`. Two nil arguments compare equal; one nil compares unequal. + +:::warning[Constant time in the byte comparison only] +`big.Int.Bytes()` returns the minimal big-endian encoding, so its *length* leaks the magnitude of the value. `subtle.ConstantTimeCompare` also returns 0 immediately when the two lengths differ. So these functions are constant-time with respect to the *contents* of equal-length values, not with respect to bit length. For comparing secrets of unknown width, pad to a fixed width first. +::: + +## Hashing and hash-to-field + +| Function | Signature | Purpose | +| --- | --- | --- | +| `Hash` | `Hash(msg []byte, curve elliptic.Curve) (*big.Int, error)` | Hash-to-field: one field element for the given curve. | +| `ExpandMessageXmd` | `ExpandMessageXmd(f func() hash.Hash, msg, DST []byte, lenInBytes int) ([]byte, error)` | `expand_message_xmd` from the CFRG hash-to-curve draft, §5.4.1. | +| `I2OSP` | `I2OSP(b, n int) []byte` | Integer-to-octet-string, `n` bytes, big-endian. | +| `OS2IP` | `OS2IP(os []byte) *big.Int` | Octet-string-to-integer. | +| `FiatShamir` | `FiatShamir(values ...*big.Int) ([]byte, error)` | Iterated HKDF challenge derivation; 32-byte output. | +| `ComputeHMAC` | `ComputeHMAC(f func() hash.Hash, msg, k []byte) ([]byte, error)` | HMAC with an explicit hash constructor. | +| `Size` | `const Size = sha256.Size` | 32 — the width of commitments and nonces in this package. | +| `HashField` | `struct{ Order, Characteristic, ExtensionDegree *big.Int }` | Describes the field `F_p^k` for the curve being hashed to. | +| `Params` | `struct{ F *HashField; SecurityParameter int; Hash func() hash.Hash; L int }` | Per-curve hash-to-field parameters. | + +### `Hash` — curve support and its fixed DST + +`Hash` looks up a `Params` for the curve, then runs `expand_message_xmd` and reduces to one field element. Supported curves and their parameters, read from `getParams`: + +| Curve (`Params().Name`) | Security parameter | Hash | `L` (bytes) | +| --- | --- | --- | --- | +| `secp256k1` (btcec) | 128 | SHA-256 | 48 | +| `P-256` | 128 | SHA-256 | 48 | +| `P-384` / `secp384r1` | 192 | SHA3-384 | 72 | +| `P-521` / `secp521r1` | 256 | SHA-512 | 98 | +| `Bls12381G1` | 128 | SHA-256 | 48 | +| `ed25519` | 128 | SHA-256 | 48 | + +Any other curve returns `unsupported curve: `. + +:::danger[`Hash` uses a hard-coded domain separation tag] +The DST is the literal string `Coinbase_tECDSA`, baked into `hashToField`. There is no parameter to change it. That means: + +- You get **no domain separation** between two different protocols that both call `core.Hash`. Two unrelated proofs over the same curve with the same message produce the same field element. +- It is not interoperable with any standard hash-to-curve suite ID, so it will not match another implementation's `hash_to_field`. + +If you need a DST you control, call `ExpandMessageXmd` directly with your own tag and reduce yourself. +::: + +```go title="hash_to_field.go" +// Custom DST, correct expansion, your own reduction. +okm, err := core.ExpandMessageXmd(sha256.New, msg, []byte("MYPROTO-V01-CS01"), 48) +if err != nil { + return nil, err +} +e := new(big.Int).Mod(core.OS2IP(okm), fieldCharacteristic) +``` + +`ExpandMessageXmd` errors only when `ceil(lenInBytes / hashSize) > 255`. It takes the hash *constructor*, not a `hash.Hash`, and it will nil-dereference if you pass `nil` — there is no guard. + +### `FiatShamir` + +Derives a 32-byte challenge from a sequence of integers. The construction is an iterated HKDF-SHA256: for each value, `okm_i = HKDF(f_i || value_i || okm_{i-1})`, where `f_i` is a 32-byte prefix whose leading byte decrements per iteration (`0xFF`, then `0xFE`, …). The source cites Signal's [X3DH](https://signal.org/docs/specifications/x3dh/#cryptographic-notation) and [XEdDSA](https://signal.org/docs/specifications/xeddsa/#hash-functions) notes as the design source. `info` is the fixed string `Coinbase tECDSA 1.0`; the salt is 32 zero bytes. + +```go +challenge, err := core.FiatShamir(commitment, publicKey, nonce) +``` + +:::warning[Chaining, not concatenation — and the prefix trick is unusual] +Because each value is folded in separately and the previous output is appended to the next input, `FiatShamir(a, b)` is **not** `FiatShamir(concat(a, b))` — good, that is the point. But note the values are folded as `value.Bytes()`, the minimal big-endian encoding, so a value's length is not committed to. Two different value *sequences* whose concatenated minimal encodings coincide are still distinguished by the chaining, but the `info` string is fixed at `Coinbase tECDSA 1.0`, so there is again no per-protocol domain separation. Prefix your own protocol label as the first `*big.Int` if you need it. +::: + +## Safe primes + +```go +func GenerateSafePrime(bits uint) (*big.Int, error) +``` + +Returns a prime `p = 2q + 1` where `q` is also prime (a Sophie Germain prime), with `p` of the requested bit length. `bits` must be at least 3. The implementation picks a `bits-1`-bit prime `q`, computes `2q + 1`, and retries until `ProbablyPrime` accepts it with `max(bits/16, 8)` Miller–Rabin rounds. + +:::warning[Expensive by construction] +This is a rejection loop over `rand.Prime`, and the density of safe primes makes it dramatically slower than generating an ordinary prime of the same size. It is the dominant cost of [Paillier](/zero-knowledge/paillier) key generation, which needs two of them. Generate keys ahead of time, off the request path; never call this inside a handler. +::: + +## Commitments + +`core` ships one commitment scheme, and it is a hash commitment — not Pedersen, not polynomial. It commits to *bytes*, not to a group element. + +```go +type Commitment []byte // 32 bytes: HMAC-SHA256(key = nonce, msg) + +type Witness struct { + Msg []byte + // unexported: r [32]byte, the random nonce +} + +func Commit(msg []byte) (Commitment, *Witness, error) +func Open(c Commitment, d Witness) (bool, error) +``` + +`Commit` draws a 32-byte nonce from `crypto/rand` and returns `HMAC-SHA256(msg, key = nonce)` as the commitment, with the nonce hidden inside the `Witness`. `Open` recomputes the HMAC from `d.Msg` and the witness nonce and compares against `c` with `subtle.ConstantTimeCompare`. + +```go title="commit.go" +package main + +import ( + "encoding/json" + "fmt" + + "github.com/sonr-io/crypto/core" +) + +func main() { + // Committer: publish c, keep w secret until the reveal phase. + c, w, err := core.Commit([]byte("bid: 42")) + if err != nil { + panic(err) + } + fmt.Println(len(c) == core.Size) // true, 32 bytes + + // Witness marshals to JSON (msg + nonce) so it can be sent on reveal. + wire, _ := json.Marshal(w) + + // Verifier: after receiving the witness. + var got core.Witness + if err := json.Unmarshal(wire, &got); err != nil { + panic(err) + } + ok, err := core.Open(c, got) + if err != nil { + panic(err) + } + fmt.Println(ok) // true +} +``` + +**Properties as implemented.** Hiding rests on HMAC-SHA256 being a PRF under the fresh 32-byte random key — the commitment is a PRF evaluation keyed by a secret nonce, so it reveals nothing about `msg` to anyone without the nonce. Binding rests on collision resistance: to open the same 32-byte commitment to a different message you would need `HMAC(msg', k') == HMAC(msg, k)`. + +:::warning[Length is not committed independently, and `Open` only length-checks the commitment] +`Open` rejects a commitment whose length is not exactly `core.Size` (32), then does a constant-time compare. It performs no validation on the witness beyond that. In particular: + +- The nonce is unexported and has no accessor — the only way to move a `Witness` between processes is its JSON marshalling. The wire shape has **no `json` tags**, so the field names are the Go defaults: `{"Msg":"","R":[209,218,...]}` — capital `Msg`, capital `R`, and the nonce as a 32-element JSON array of numbers, not base64 (it is a `[32]byte` array, not a slice). Anything reimplementing this format in another language must match that exactly. +- `UnmarshalJSON` performs no validation. A witness with an all-zero `R` decodes fine, and `Open` will then verify any commitment that was (incorrectly) produced with a zero nonce. Since `Commit` is the only way to get a nonce and it always reads 32 bytes from `crypto/rand`, this only bites if you hand-construct witnesses. +- There is no transcript or context binding. If your protocol has multiple concurrent commitments, include a session/index label inside `msg` yourself; the scheme will not do it for you. +::: + +:::note[This is not a Pedersen commitment] +If you need additive homomorphism, or to commit to a scalar so that the commitment can be combined in the group, use the Pedersen VSS machinery in [secret sharing](/threshold/secret-sharing) or the Pedersen vector commitments inside [Bulletproofs](/zero-knowledge/bulletproof). `core.Commit` is the right tool for "reveal these bytes later" and nothing more. +::: + +## The `internal` package + +`go doc github.com/sonr-io/crypto/internal` lists a handful of tempting helpers: + +``` +func B10(s string) *big.Int +func BigInt2Ed25519Point(y *big.Int) (*edwards25519.Point, error) +func BigInt2Ed25519Scalar(x *big.Int) (*edwards25519.Scalar, error) +func ByteSub(b []byte) +func CalcFieldSize(curve elliptic.Curve) int +func Hash(info []byte, values ...[]byte) ([]byte, error) +func ReverseScalarBytes(inBytes []byte) []byte +``` + +plus the sentinel errors this library returns from `core`: `ErrNotOnCurve`, `ErrPointsDistinctCurves`, `ErrZmMembership`, `ErrResidueOne`, `ErrNCannotBeZero`, `ErrNilArguments`, `ErrZeroValue`, `ErrInvalidRound`, `ErrIncorrectCount`, `ErrInvalidJson`. There are also two vendored Ed25519 helper packages, `internal/ed25519/edwards25519` and `internal/ed25519/extra25519` (the latter with `PrivateKeyToCurve25519`, `PublicKeyToCurve25519`, `HashToEdwards`, `RepresentativeToPublicKey`, `ScalarBaseMult`). + +:::danger[You cannot import any of these] +Go's `internal/` visibility rule confines them to `github.com/sonr-io/crypto/...`. Downstream modules cannot import `github.com/sonr-io/crypto/internal` at all — the compiler rejects it. This matters because `core`'s errors are *values from that package*: `core.In` returns `internal.ErrZmMembership` and `core.Add` returns `internal.ErrNilArguments`, but you have no way to name those variables in your own code. + +**Workaround:** compare the message (`err.Error() == "x ∉ Z_m"`), or — better — treat these as opaque failures and validate your inputs before calling. Do not build control flow on `errors.Is` against a sentinel you cannot reference. +::: + +`internal.ReverseScalarBytes` and `internal.CalcFieldSize` are the two you will most want and most miss; both are two lines and trivially reimplemented (`(curve.Params().BitSize + 7) / 8` for the latter). + +## Next + + + + How interactive protocols in this library are cranked round by round. + + + The main consumer of `GenerateSafePrime` and the modular arithmetic helpers. + + diff --git a/docs/foundations/curves.mdx b/docs/foundations/curves.mdx new file mode 100644 index 0000000..4afd6dc --- /dev/null +++ b/docs/foundations/curves.mdx @@ -0,0 +1,300 @@ +--- +title: Curves +description: Every named curve constructor in core/curves, the complete Point and Scalar method sets, pairing curves, and a map of the low-level native field arithmetic underneath. +sidebar: + order: 2 + icon: circle-dot +--- + +`core/curves` is the catalog. It exposes one constructor per supported group, all of which hand back a `*curves.Curve` (or a `*curves.PairingCurve` for the pairing-friendly ones). Everything on this page was read out of `go doc github.com/sonr-io/crypto/core/curves`. + +**Reach for this page when** you need to know which curve a package will accept, what a serialized point looks like on the wire, or which method on `Point`/`Scalar` does the thing you want. **You do not need this page** if you are just passing a curve through — `curves.K256()` and go. + +## Named curves + +| Constructor | `Name` value | Constant | Notes | +| --- | --- | --- | --- | +| `curves.K256()` | `secp256k1` | `K256Name` | Bitcoin/Ethereum curve. 33-byte compressed points. | +| `curves.P256()` | `P-256` | `P256Name` | NIST P-256 / secp256r1. | +| `curves.ED25519()` | `ed25519` | `ED25519Name` | Edwards curve; 32-byte compressed points. | +| `curves.BLS12381G1()` | `BLS12381G1` | `BLS12381G1Name` | G1 of BLS12-381; 48-byte compressed points. | +| `curves.BLS12381G2()` | `BLS12381G2` | `BLS12381G2Name` | G2 of BLS12-381; 96-byte compressed points. | +| `curves.BLS12377G1()` | `BLS12377G1` | `BLS12377G1Name` | G1 of BLS12-377 (gnark-crypto backed). | +| `curves.BLS12377G2()` | `BLS12377G2` | `BLS12377G2Name` | G2 of BLS12-377. | +| `curves.PALLAS()` | `pallas` | `PallasName` | Pasta/Pallas curve. | + +Two extra string constants exist for "the pairing construction, group unspecified": `BLS12831Name = "BLS12831"` and `BLS12377Name = "BLS12377"`. + +### Lookup by name + +```go +curve := curves.GetCurveByName(curves.K256Name) +if curve == nil { + return fmt.Errorf("unsupported curve") +} +``` + +`GetCurveByName` accepts every constant above. `BLS12831Name` and `BLS12377Name` both resolve to the **G1** curve. Anything else returns `nil`. + +:::warning[`GetCurveByName` returns nil, not an error] +There is no second return value. If you feed it a name from user input or a wire format, you must nil-check before dereferencing, or you get a nil-pointer panic on the first field access. +::: + +:::note[`BLS12831Name` is a typo that is now load-bearing] +The constant is spelled `BLS12831` (digits transposed) and its *value* is the string `"BLS12831"`. This is not cosmetic: `curves.BLS12381(...)` sets `PairingCurve.Name` to that string, so a BLS12-381 pairing curve reports `Name == "BLS12831"`. If you round-trip a pairing curve through its name, use the constant — never a hand-typed `"BLS12381"`. +::: + +## Pairing curves + +BBS+ and the accumulator need a pairing, so they take a `*curves.PairingCurve` — a *different type* from `*curves.Curve`. Passing `curves.BLS12381G1()` where a `*PairingCurve` is wanted will not compile. + +```go +type PairingCurve struct { + Scalar PairingScalar + PointG1 PairingPoint + PointG2 PairingPoint + GT Scalar + Name string +} +``` + + + +`PairingPoint` and `PairingScalar` are extensions of the ordinary interfaces, so every `Point`/`Scalar` method is still available: + +```go +type PairingPoint interface { + Point + OtherGroup() PairingPoint // G1 <-> G2 + Pairing(rhs PairingPoint) Scalar // e(self, rhs) as a GT element + MultiPairing(...PairingPoint) Scalar +} + +type PairingScalar interface { + Scalar + SetPoint(p Point) PairingScalar +} +``` + +Note that `Pairing` returns a `Scalar`, not a distinct GT type — the target group element is modelled as a `ScalarBls12381Gt`. It supports the `Scalar` arithmetic surface (`Mul`, `Add`, `Invert`, `Bytes`) but it is a group element in the target group `GT`, not a field element mod the group order. Do not feed it back into `ScalarBaseMult`. + +```go title="pairing.go" +package main + +import ( + "crypto/rand" + "fmt" + + "github.com/sonr-io/crypto/core/curves" +) + +func main() { + pc := curves.BLS12381(curves.BLS12381G1().NewIdentityPoint()) + + s := pc.NewScalar().Random(rand.Reader) + g1 := pc.ScalarG1BaseMult(s) // s·G1 + g2 := pc.NewG2GeneratorPoint() + + gt := g1.Pairing(g2) // e(s·G1, G2) + fmt.Println(pc.Name, len(gt.Bytes())) + fmt.Println(g1.OtherGroup().CurveName()) // BLS12381G2 +} +``` + +## The `Point` interface + +Twenty methods, no error returns except on the two deserializers and `Set`. + +| Method | Signature | Purpose | +| --- | --- | --- | +| `Random` | `Random(reader io.Reader) Point` | Uniform random group element from the reader. | +| `Hash` | `Hash(bytes []byte) Point` | Hash-to-curve. Deterministic; the domain separation tag is fixed inside each implementation. | +| `Identity` | `Identity() Point` | Point at infinity. | +| `Generator` | `Generator() Point` | Group generator. | +| `IsIdentity` | `IsIdentity() bool` | Identity test. | +| `IsNegative` | `IsNegative() bool` | Sign-of-`y` test, curve-specific convention. | +| `IsOnCurve` | `IsOnCurve() bool` | Curve-equation check. | +| `Double` | `Double() Point` | `2·self`. | +| `Scalar` | `Scalar() Scalar` | A zero scalar of the matching field — a convenience constructor, **not** a discrete log. | +| `Neg` | `Neg() Point` | `-self`. | +| `Add` / `Sub` | `Add(rhs Point) Point` | Group law. | +| `Mul` | `Mul(rhs Scalar) Point` | Variable-base scalar multiplication. | +| `Equal` | `Equal(rhs Point) bool` | Group equality (compares in affine, handles differing projective representations). | +| `Set` | `Set(x, y *big.Int) (Point, error)` | Build from affine coordinates; errors if off-curve. | +| `ToAffineCompressed` | `ToAffineCompressed() []byte` | Canonical short encoding. | +| `ToAffineUncompressed` | `ToAffineUncompressed() []byte` | Canonical long encoding. | +| `FromAffineCompressed` | `FromAffineCompressed(bytes []byte) (Point, error)` | Inverse of the above. | +| `FromAffineUncompressed` | `FromAffineUncompressed(bytes []byte) (Point, error)` | Inverse of the above. | +| `CurveName` | `CurveName() string` | The `Name` string of the owning curve. | +| `SumOfProducts` | `SumOfProducts(points []Point, scalars []Scalar) Point` | Multi-scalar multiplication. | + +### Serialization + +Concrete point types also implement `MarshalBinary`/`UnmarshalBinary`, `MarshalText`/`UnmarshalText`, and `MarshalJSON`/`UnmarshalJSON` — that is how the higher layers (BBS+ proofs, accumulator witnesses, DKG round messages) persist points. The interface itself does not declare them, so if you need marshalling through the interface you type-assert to `encoding.BinaryMarshaler`. + +:::tip[Compressed lengths worth memorizing] +K256 and P-256: 33 bytes compressed, 65 uncompressed. Ed25519 and Pallas: 32 / 64. BLS12-381 and BLS12-377 G1: 48 / 96. BLS12-381 and BLS12-377 G2: 96 / 192. Scalars are 32 bytes on every curve in the catalog. Always deserialize via `curve.Point.FromAffineCompressed` — the prototype knows the expected length and will reject a short or wrong-curve buffer. +::: + +### `SumOfProducts` — multi-scalar multiplication + +This is the MSM entry point, and the reason Bulletproofs and the accumulator are tractable. Call it on the curve's point prototype; the receiver's own value is ignored. + +```go +// Computes sum(scalars[i] · points[i]) using a 4-bit windowed bucket +// (Pippenger-style) multi-exponentiation, not n independent scalar mults. +result := curve.Point.SumOfProducts(points, scalars) +if result == nil { + return errors.New("length mismatch or foreign point/scalar type") +} +``` + +:::warning[`SumOfProducts` signals failure with a nil return] +The interface method has no error channel. It returns `nil` if the two slices differ in length, or if any element is not the concrete `Point`/`Scalar` type belonging to this curve. Since `nil` is a valid-looking `Point` interface value until you call a method on it, an unchecked result turns a length bug into a nil-pointer panic several frames away. Check it. +::: + +## The `Scalar` interface + +The doc comment describes it as "an element of the scalar field `F_q` of the elliptic curve construction" — that is, arithmetic is mod the **group order**, not the field characteristic. + +| Group | Methods | +| --- | --- | +| Construction | `Random(io.Reader)`, `Hash([]byte)`, `Zero()`, `One()`, `New(value int)`, `Clone()` | +| Predicates | `IsZero()`, `IsOne()`, `IsOdd()`, `IsEven()`, `Cmp(rhs) int` | +| Arithmetic | `Add`, `Sub`, `Mul`, `Div`, `Neg`, `Double`, `Square`, `Cube`, `MulAdd(y, z)` | +| Fallible arithmetic | `Invert() (Scalar, error)`, `Sqrt() (Scalar, error)` | +| Conversion | `SetBigInt(*big.Int) (Scalar, error)`, `BigInt() *big.Int`, `Bytes() []byte`, `SetBytes([]byte) (Scalar, error)`, `SetBytesWide([]byte) (Scalar, error)` | +| Crossing over | `Point() Point` — the associated point type's prototype | + +Three behaviours that catch people: + +- **`Cmp` returns `-2`** if the two scalars belong to different fields. It is the library's only cross-curve mismatch signal. `-1`/`0`/`1` are the usual ordering. +- **`New(value int)` takes a signed int** and reduces it, so `New(-1)` is `q - 1`. Since `q` is odd for these curves, `New(-1).IsEven()` is `true` — the parity predicates describe the *reduced representative*, not the integer you passed. +- **`SetBytes` demands the exact width**, while `SetBytesWide` wants double the width and reduces. Use `SetBytesWide` when converting hash output into a scalar without modulo bias; use `Hash` if you just want "bytes to scalar" done correctly. + +```go +// Uniform scalar from arbitrary input, no bias, no length constraints: +s := curve.Scalar.Hash([]byte("some transcript bytes")) + +// Exact-width canonical decoding, e.g. reading a stored private key: +s, err := curve.Scalar.SetBytes(keyBytes) // len(keyBytes) must be exactly 32 for K256 +``` + +## The `crypto/elliptic` bridge + +Some code (Go's `crypto/ecdsa`, X.509 marshalling, the legacy `EcPoint` API) needs an `elliptic.Curve`. Several shims exist, and they are not interchangeable: + +| Function | Returns | Backing implementation | +| --- | --- | --- | +| `curves.K256Curve()` | `*Koblitz256` | native k256 field arithmetic | +| `curves.NistP256Curve()` | `*NistP256` | native p256 field arithmetic | +| `curves.SP256()` | `elliptic.Curve` | `github.com/dustinxie/ecc` secp256k1 | +| `secp256k1.S256()` | `*secp256k1.BitCurve` | the vendored Koblitz `a=0` implementation | +| `curves.Pallas()` | `*PallasCurve` | Pallas as an `elliptic.Curve` | + +All of them satisfy `elliptic.Curve`. `Curve.ToEllipticCurve()` is the generic entry point: + +```go +ec, err := curves.K256().ToEllipticCurve() // -> *Koblitz256, nil +ec, err = curves.ED25519().ToEllipticCurve() // -> nil, "can't convert ed25519" +``` + +:::danger[`ToEllipticCurve` only supports two curves] +Only `K256Name` and `P256Name` return a curve. `ED25519`, `PALLAS`, and all four BLS variants return `nil` plus the error `can't convert ` — which is correct, since none of them are short-Weierstrass curves over a prime field in the `crypto/elliptic` sense. Handle the error; do not assume it is a curve-agnostic conversion. +::: + +:::warning[`NistP256.ScalarMult` is not the native implementation] +`*NistP256` defines `ScalarMul` — missing the trailing `t`. So the `elliptic.Curve` interface method `ScalarMult` resolves to the promoted `*elliptic.CurveParams.ScalarMult`, the generic deprecated `math/big` implementation, rather than the native p256 code the type was written to use. `ScalarBaseMult`, `Add`, `Double`, and `IsOnCurve` *are* wired to the native path. If you care about the variable-base path on P-256, use `curves.P256()` and the `Point.Mul` interface instead of the `elliptic.Curve` shim. +::: + +`secp256k1.BitCurve` additionally offers `Marshal(x, y) []byte` / `Unmarshal(data) (x, y)` and exposes its parameters as public fields (`P`, `N`, `B`, `Gx`, `Gy`, `BitSize`). + +## `core/curves/native` — the layer below + +`native` is the constant-time-oriented field and point arithmetic that the modern `Point`/`Scalar` implementations sit on. It is a *building block*, and almost nothing outside `core/curves` should import it. + +Fields are represented as four 64-bit limbs in the Montgomery domain: + +```go +const ( + FieldBytes = 32 // canonical byte width + FieldLimbs = 4 // uint64 limbs + WideFieldBytes = 64 // width for bias-free reduction + MaxDstLen = 255 +) + +type Field struct { + Value [FieldLimbs]uint64 + Params *FieldParams // R, R2, R3, Modulus, BiModulus + Arithmetic FieldArithmetic // per-curve limb routines +} +``` + +`Field` provides `Add`, `Sub`, `Mul`, `Square`, `Double`, `Neg`, `Exp`, `Invert`, `Sqrt`, `CMove`, `Equal`, `Cmp`, plus `SetBytes`/`SetBytesWide`/`SetBigInt`/`SetLimbs`/`SetRaw` and their `Bytes`/`BigInt`/`Raw` inverses. `EllipticPoint` provides Weierstrass point arithmetic in Jacobian coordinates (`Add`, `Double`, `Generator`, `Hash`, `Equal`, `BigInt`, `GetX`, `GetY`). + +Which fields and groups are actually implemented: + +| Package | Contents | +| --- | --- | +| `native/bls12381` | `G1`, `G2`, `Gt`, the pairing `Engine`, `Fq`, `Bls12381FqNew()` | +| `native/k256` | `K256PointNew()`; subpackages `k256/fp` (base field) and `k256/fq` (scalar field) | +| `native/p256` | `P256PointNew()`; subpackages `p256/fp` and `p256/fq` | +| `native/pasta` | Pallas/Vesta point code; subpackages `pasta/fp` and `pasta/fq` | + +### Hash-to-curve hashers + +`EllipticPointHasher` bundles a hash function with its expansion mode. It is what `Point.Hash` uses internally, and the only reason to construct one yourself is if you are calling `native.ExpandMsgXmd` / `native.ExpandMsgXof` or `EllipticPoint.Hash` directly. + +| Constructor | `Name()` | `Type()` | +| --- | --- | --- | +| `EllipticPointHasherSha256()` | `SHA-256` | XMD | +| `EllipticPointHasherSha512()` | `SHA-512` | XMD | +| `EllipticPointHasherSha3256()` | `SHA3-256` | XMD | +| `EllipticPointHasherSha3384()` | `SHA3-384` | XMD | +| `EllipticPointHasherSha3512()` | `SHA3-512` | XMD | +| `EllipticPointHasherBlake2b()` | `BLAKE2b` | XMD | +| `EllipticPointHasherShake128()` | `SHAKE-128` | XOF | +| `EllipticPointHasherShake256()` | `SHAKE-256` | XOF | + +`ExpandMsgXmd` and `ExpandMsgXof` implement §5.4.1 and §5.4.2 of the CFRG hash-to-curve draft (the source links to `draft-irtf-cfrg-hash-to-curve-13`). Domain separation tags longer than `MaxDstLen` are hashed down using the `OversizeDstSalt` prefix `H2C-OVERSIZE-DST-`. + +:::warning[`native` is unforgiving] +`ExpandMsgXmd` and `ExpandMsgXof` return `[]byte` with **no error channel** and will nil-dereference on a nil hasher. `Field` methods write into the receiver and return it, so aliasing the output with an input is only safe where the implementation says so. `Pow` and `Pow2k` are documented as "public only for convenience for some internal implementations". Treat the whole package as internal and use `Point`/`Scalar` instead. +::: + +## Legacy curve types + +For completeness, since they show up in `go doc` next to everything above. These belong to the older API described on the [foundations overview](/foundations) and are used by `sharing/v1`, `dkg/gennaro`, and `ted25519` keygen. + +- `EcPoint{Curve elliptic.Curve; X, Y *big.Int}` with `NewScalarBaseMult`, `PointFromBytesUncompressed`, `Add`, `Neg`, `ScalarMult`, `Bytes`, `Equals`, `IsOnCurve`, `IsIdentity`, `IsBasePoint`, `IsValid`, and binary/JSON marshalling (plus `EcPointJSON` as the wire shape). +- `Field`/`Element` — generic `big.Int` modular arithmetic over an explicit modulus, with `ElementJSON` for serialization. +- `EcScalar` — a strategy interface (`Add`, `Sub`, `Neg`, `Mul`, `Div`, `Hash`, `Random`, `IsValid`, `Bytes`) implemented by `NewK256Scalar()`, `NewP256Scalar()`, `NewEd25519Scalar()`, `NewBls12381Scalar()`, and `NewPallasScalar()`. +- `EcdsaSignature`, `EcdsaVerify`, and `VerifyEcdsa(pk *EcPoint, hash []byte, sig *EcdsaSignature) bool` — the verification hook used by threshold ECDSA. See [ECDSA](/signatures/ecdsa). +- `Ed25519Order() *big.Int` — the Ed25519 group order as a `big.Int`. diff --git a/docs/foundations/index.mdx b/docs/foundations/index.mdx new file mode 100644 index 0000000..a7f62ce --- /dev/null +++ b/docs/foundations/index.mdx @@ -0,0 +1,172 @@ +--- +title: Foundations +description: The curve abstraction, the arithmetic helpers, and the protocol iterator — the three things almost every other package in this library is built on top of. +sidebar: + order: 1 + icon: layers +--- + +Nearly every package in this repository is generic over one type: `*curves.Curve`. BLS signatures, BBS+, Shamir sharing, Feldman/Pedersen VSS, Schnorr proofs, the accumulator, threshold ECDSA, and the DID key layer all take a curve value and do their work through two interfaces — `curves.Point` and `curves.Scalar`. If you understand those three things, the rest of the library reads as variations on a theme. + +This section covers the shared substrate: + + + + Every named curve constructor, the full `Point` / `Scalar` method sets, pairing curves, and the low-level `native` field arithmetic. + + + The `core` package: modular arithmetic over `big.Int`, hash-to-field, Fiat–Shamir, safe primes, and the HMAC commitment scheme. + + + The `Iterator` / `Message` crank pattern that drives every DKLs18-family interactive protocol. + + + +## The `Curve` value + +`curves.Curve` is a plain struct, not an interface. It is a *bundle of prototypes*: + +```go +type Curve struct { + Scalar Scalar + Point Point + Name string +} +``` + +`Scalar` and `Point` are not "the" scalar or "the" point — they are zero-valued exemplars you call constructor-shaped methods on. This is how the library gets generic behaviour without Go generics: `curve.Scalar.Random(rand.Reader)` dispatches to the K256 or Ed25519 or BLS12-381 implementation depending on which curve you were handed. + +Curve constructors are memoized behind `sync.Once`, so `curves.K256()` returns the same pointer on every call and is safe to call in a hot loop. + + + +## Arithmetic on K256 + +`Point` and `Scalar` methods are chainable and return new values — they never mutate the receiver, so you can hold onto intermediates freely. + +```go title="arith.go" +package main + +import ( + "crypto/rand" + "fmt" + + "github.com/sonr-io/crypto/core/curves" +) + +func main() { + curve := curves.K256() + + // Two random field elements. + x := curve.Scalar.Random(rand.Reader) + y := curve.Scalar.Random(rand.Reader) + + // Scalar field arithmetic: mod q, where q is the group order. + sum := x.Add(y) + xInv, err := x.Invert() + if err != nil { + panic(err) // only fails for zero + } + fmt.Println(x.Mul(xInv).IsOne()) // true + + // Group arithmetic. Note the homomorphism: + // (x + y)·G == x·G + y·G + P := curve.ScalarBaseMult(x) + Q := curve.NewGeneratorPoint().Mul(y) + fmt.Println(P.Add(Q).Equal(curve.ScalarBaseMult(sum))) // true + + // Identity behaves as expected. + fmt.Println(P.Sub(P).Equal(curve.NewIdentityPoint())) // true + + // Serialization round-trip: 33 bytes compressed for K256. + enc := P.ToAffineCompressed() + P2, err := curve.Point.FromAffineCompressed(enc) + if err != nil { + panic(err) + } + fmt.Println(len(enc), P2.Equal(P), P.CurveName()) // 33 true secp256k1 +} +``` + +Two habits worth forming immediately: + +- **Deserialize through the curve's prototype**, i.e. `curve.Point.FromAffineCompressed(b)` and `curve.Scalar.SetBytes(b)`. These are the only entry points that know which concrete type to produce. +- **Check the error on `Invert`, `Sqrt`, `SetBytes`, and `SetBigInt`.** The arithmetic methods (`Add`, `Mul`, `Neg`, `Double`) return no error and will happily produce garbage if you fed them a value from a different curve. + +:::warning[Cross-curve values do not panic] +`Scalar.Cmp` returns `-2` when the two scalars belong to different fields — that is the only place the library tells you about a curve mismatch. `Add`, `Mul`, and friends have no error channel. Mixing a `ScalarK256` into a P-256 computation produces a silently wrong result, so keep a single `*curves.Curve` threaded through a computation rather than calling constructors ad hoc. +::: + +## Two generations of API coexist + +This is the single most important orientation fact about the repository. There are **two** unrelated curve APIs in `core/curves`, and which one you get depends entirely on which package you called. + + + +Interface-based, generic over the curve, supports every curve in the catalog including pairing-friendly ones. + +```go +curve := curves.K256() +s := curve.Scalar.Random(rand.Reader) // curves.Scalar +P := curve.ScalarBaseMult(s) // curves.Point +``` + +Used by: `signatures/bbs`, `signatures/bls/bls_sig`, `signatures/schnorr/mina`, `signatures/schnorr/nem`, `sharing`, `dkg/frost`, `zkp/schnorr`, `accumulator`, `bulletproof`, `tecdsa/dklsv1`, `ted25519/frost`, `ot/*`. + + +Concrete structs over `crypto/elliptic` and `math/big`. No pairing support, no hash-to-curve, and scalars are raw `*big.Int` wrapped by an `EcScalar` strategy object. + +```go +// EcPoint wraps an elliptic.Curve plus affine X, Y as *big.Int. +P, err := curves.NewScalarBaseMult(btcec.S256(), k) + +// Field/Element is generic modular arithmetic over an explicit modulus. +f := curves.NewField(order) +e := f.NewElement(big.NewInt(3)) +e = e.Mul(f.NewElement(big.NewInt(4))) // 12 mod order +``` + +Used by: `sharing/v1`, `dkg/gennaro`, `dkg/gennaro2p`, `ted25519/ted25519` keygen, `paillier` (`psf.go`), and the ECDSA public-key conversion helpers in `keys` and `mpc`. + + + +The two worlds share nothing. There is no conversion helper between `curves.Point` and `*curves.EcPoint`, and no helper between `curves.Scalar` and `*curves.Element`. If you need to move a value across, you go through bytes or `big.Int` yourself and take responsibility for the encoding. + +:::danger[The legacy `Field` is not constant time] +The package documentation for `core/curves` says so outright: *"Field implementation IS NOT constant time as it leverages math/big for big number operations."* This applies to `Field`, `Element`, `EcPoint`, and everything built on them — which includes `sharing/v1` and `dkg/gennaro`. Do not use those packages on secret-dependent inputs where timing is observable by an attacker. Prefer the modern `Point`/`Scalar` path for new code. +::: + +## Where the pieces are used + +| Layer | Packages | What it needs from foundations | +| --- | --- | --- | +| [Signatures](/signatures) | `signatures/bls/bls_sig`, `signatures/bbs`, `signatures/schnorr/*` | `*curves.Curve`, or `*curves.PairingCurve` for BBS+ | +| [Threshold](/threshold) | `sharing`, `dkg/*`, `tecdsa/dklsv1`, `ted25519/*`, `ot/*` | `*curves.Curve`, plus `core/protocol` for `tecdsa/dklsv1` | +| [Zero-knowledge](/zero-knowledge) | `zkp/schnorr`, `accumulator`, `bulletproof` | `*curves.Curve`; the accumulator needs a `*PairingCurve` | +| [Identity](/identity) | `keys`, `mpc`, `ucan`, `ecies`, `wasm` | `keys` and `mpc` use `curves`; `mpc` also uses `core/protocol`. `ucan`, `wasm`, and most of `ecies` do not touch the curve abstraction at all. | +| [Symmetric](/symmetric) | `aead`, `daed`, `argon2`, `subtle`, `secure`, `salt`, `password` | nothing — these are pure `[]byte` APIs | + +Two more packages sit outside the curve abstraction entirely: `ecdsa` and `vrf` do not import `core/curves` at all, and `paillier` — like `core` itself — works directly over `math/big` integers with an explicit modulus. See [arithmetic](/foundations/arithmetic) for that world. diff --git a/docs/foundations/meta.ts b/docs/foundations/meta.ts new file mode 100644 index 0000000..5847766 --- /dev/null +++ b/docs/foundations/meta.ts @@ -0,0 +1,8 @@ +import { defineMeta } from "blume"; + +export default defineMeta({ + title: "Foundations", + icon: "layers", + order: 2, + pages: ["index", "curves", "arithmetic", "protocol"], +}); diff --git a/docs/foundations/protocol.mdx b/docs/foundations/protocol.mdx new file mode 100644 index 0000000..580f7cf --- /dev/null +++ b/docs/foundations/protocol.mdx @@ -0,0 +1,312 @@ +--- +title: Protocol Iterator +description: core/protocol — the Iterator and Message types that drive every interactive round-based protocol in this library, plus the crank loop you write to run them. +sidebar: + order: 4 + icon: arrow-left-right +--- + +`core/protocol` is 110 lines and contains no cryptography. It is the transport contract for interactive protocols: a two-method interface, an envelope struct, base64/JSON codecs, and two sentinel errors. Everything in [threshold ECDSA](/threshold/threshold-ecdsa) and the [MPC enclave](/identity/mpc-enclave) is driven through it. + +**Reach for this page when** you are wiring a DKLs18 DKG, sign, or refresh into your own transport (HTTP, gRPC, a queue) and need to know what to serialize, when to stop, and how to get the result out. + +## The `Iterator` interface + +```go +type Iterator interface { + // Next runs the next round of the protocol. + // Returns `ErrProtocolFinished` when protocol has completed. + Next(input *Message) (*Message, error) + + // Result returns the final result, if any, of the completed protocol. + // Returns nil if the protocol has not yet terminated. + // Returns an error if an error was encountered during protocol execution. + Result(version uint) (*Message, error) +} +``` + +That is the whole abstraction. A protocol participant is a state machine holding a list of round functions and an index; `Next` runs the current round and advances. The concrete implementation in `tecdsa/dklsv1` is a `protoStepper`: + +```go +type protoStepper struct { + steps []func(input *protocol.Message) (*protocol.Message, error) + step int +} + +func (p *protoStepper) Next(input *protocol.Message) (*protocol.Message, error) { + if p.step >= len(p.steps) { + return nil, protocol.ErrProtocolFinished + } + output, err := p.steps[p.step](input) + if err != nil { + return nil, err + } + p.step++ + return output, nil +} +``` + +The implications are worth stating plainly: + +- **The iterator is stateful and single-use.** There is no reset. One `AliceDkg` value runs one DKG. +- **It is not safe for concurrent use.** `step` is a plain `int`. One goroutine per participant. +- **`ErrProtocolFinished` is a success signal, not a failure.** It means "I have no more rounds". Any *other* non-nil error is a real failure and the protocol must be abandoned. +- **`Next(nil)` is how you start.** The first speaker receives a nil input message. + +## `Message` + +```go +type Message struct { + Payloads map[string][]byte `json:"payloads"` + Metadata map[string]string `json:"metadata"` + Protocol string `json:"protocol"` + Version uint `json:"version"` +} +``` + + + +### Protocol name constants + +Verbatim from `core/protocol`: + +| Constant | Value | +| --- | --- | +| `protocol.Dkls18Dkg` | `"DKLs18-DKG"` | +| `protocol.Dkls18Sign` | `"DKLs18-Sign"` | +| `protocol.Dkls18Refresh` | `"DKLs18-Refresh"` | + +Those are the only three. There is no constant for the Ed25519 threshold scheme, FROST, or the Gennaro DKG — those packages do not use this envelope. + +### Version constants + +| Constant | Value | Note | +| --- | --- | --- | +| `protocol.Version0` | `100` | Defined but not implemented by any serializer. | +| `protocol.Version1` | `200` | The only working value. Pass this to `NewAliceDkg`, `Result`, and the `Encode*`/`Decode*` helpers. | + +The source explains the numbering: *"versions will increment in 100 intervals, to leave room for adding other versions in between them if it is ever needed in the future."* Note the doc comment on `Version1` reads "Version1 is version 2!" — that is a copy-paste slip in the comment, not a semantic claim; the value is `200`. + +:::warning[`Version0` is a dead constant, and the two version checks disagree] +No serializer implements a `Version0` layout. Constructing an iterator with it fails at the first round: + +```go +bob := dklsv1.NewBobDkg(curves.K256(), protocol.Version0) +m, err := bob.Next(nil) // m == nil, err == "only version 1 is supported" +``` + +The DKG and sign serializers gate on strict equality (`if version != protocol.Version1`). The refresh serializers instead use `versionIsSupported`, which rejects only `messageVersion < protocol.Version1` — so a hypothetical `300` would sail past the refresh check and then fail somewhere deeper. Pass `protocol.Version1` everywhere, never hardcode `200`, and store the version alongside any persisted keyshare. +::: + +### Sentinel errors + +```go +var ( + ErrNotInitialized = fmt.Errorf("object has not been initialized") + ErrProtocolFinished = fmt.Errorf("the protocol has finished") +) +``` + +Those two are the complete set. `ErrProtocolFinished` is returned by `Next` once the step list is exhausted. `ErrNotInitialized` is returned by `Result` when the iterator's inner protocol object is nil — i.e. you constructed the wrapper but the underlying `dkg.Alice`/`dkg.Bob` was never built. + +Both are `fmt.Errorf` values with no wrapping, so `errors.Is` and `==` are equivalent for them. The repository's own loops use `!=`; `errors.Is` is the better habit for your code. + +## The crank pattern + +Two `Iterator`s pass one `*protocol.Message` back and forth. Whatever `first.Next` returns becomes the input to `second.Next`, and vice versa, until both report `ErrProtocolFinished`. + + + + Both sides need the same `*curves.Curve` and the same version. For DKG that is all the input there is. + + + **Who speaks first depends on the protocol.** For DKLs18 DKG, Bob starts. For sign and refresh, Alice starts. Getting this backwards makes the first round fail on an unexpected input. + + + The message returned by one `Next` is the input to the other's `Next`. This is where your transport goes: `EncodeMessage` on the way out, `DecodeMessage` on the way in. + + + Not one — both. A participant can finish a round earlier than its peer, so the loop condition is a conjunction of two "still not finished" tests. + + + `Result(version)` hands back a `*Message` carrying the serialized output. Feed it to the package's `Decode*` helper to get a typed struct. + + + +```go title="crank.go" +package main + +import ( + "errors" + "fmt" + + "github.com/sonr-io/crypto/core/curves" + "github.com/sonr-io/crypto/core/protocol" + "github.com/sonr-io/crypto/tecdsa/dklsv1" +) + +// crank drives two Iterators against each other until both are finished. +// `first` is whoever speaks first: Bob for DKG, Alice for sign and refresh. +func crank(first, second protocol.Iterator) error { + var ( + msg *protocol.Message + firstErr error + secondErr error + ) + + for !errors.Is(firstErr, protocol.ErrProtocolFinished) || + !errors.Is(secondErr, protocol.ErrProtocolFinished) { + + msg, firstErr = first.Next(msg) + if firstErr != nil && !errors.Is(firstErr, protocol.ErrProtocolFinished) { + return firstErr + } + + msg, secondErr = second.Next(msg) + if secondErr != nil && !errors.Is(secondErr, protocol.ErrProtocolFinished) { + return secondErr + } + } + return nil +} + +func main() { + curve := curves.K256() + + alice := dklsv1.NewAliceDkg(curve, protocol.Version1) + bob := dklsv1.NewBobDkg(curve, protocol.Version1) + + // Bob speaks first for DKG. + if err := crank(bob, alice); err != nil { + panic(err) + } + + aliceResult, err := alice.Result(protocol.Version1) + if err != nil { + panic(err) + } + fmt.Println(aliceResult.Protocol, aliceResult.Version, len(aliceResult.Payloads)) + // DKLs18-DKG 200 1 + + out, err := dklsv1.DecodeAliceDkgResult(aliceResult) + if err != nil { + panic(err) + } + fmt.Println(out.PublicKey.CurveName()) // secp256k1 +} +``` + +This is exactly the shape of `mpc.RunProtocol(firstParty, secondParty)` and of `runIteratedProtocol` in `tecdsa/dklsv1`'s own tests. `mpc.CheckIteratedErrors(aErr, bErr)` is the helper that collapses the two returned errors into a single `error` (nil when both are `ErrProtocolFinished`). + +:::danger[`Result` returns `(nil, nil)` if the protocol has not finished] +Calling `Result` on a fresh, un-cranked iterator returns a **nil message and a nil error** — the completion check comes before the initialization check. Verified against `dklsv1.AliceDkg.Result`: + +```go +m, err := dklsv1.NewAliceDkg(curve, protocol.Version1).Result(protocol.Version1) +// m == nil, err == nil +``` + +Every `Decode*` helper will then nil-dereference on `m.Payloads`. Always nil-check the message, not just the error. +::: + +## Crossing a real network + +Over a wire you serialize the envelope. `EncodeMessage` produces a base64-encoded JSON string: + +```go +wire, err := protocol.EncodeMessage(msg) // base64(json(msg)) +if err != nil { + return err +} +// ... send `wire` to the peer ... +``` + +:::danger[`DecodeMessage` panics on any non-trivial message — do not use it] +`Message.UnmarshalJSON` decodes into a `map[string]any` and then type-asserts the values: + +```go +case "payloads": + m.Payloads = v.(map[string][]byte) // v is always map[string]interface{} +case "metadata": + m.Metadata = v.(map[string]string) // same problem +``` + +`encoding/json` never produces `map[string][]byte` or `map[string]string` when decoding into `any` — it produces `map[string]interface{}`. So the assertion always fails, and because it is an unchecked single-value assertion it **panics** rather than erroring. + +Reproduced against the current source: encoding a message with one payload succeeds, and decoding it panics with + +``` +interface conversion: interface {} is map[string]interface {}, not map[string][]uint8 +``` + +`DecodeMessage` has **zero callers inside this repository**, which is why the defect has survived — `mpc` calls `EncodeMessage` on the way out but never `DecodeMessage` on the way in. + +**Workaround.** Do not call `protocol.DecodeMessage`. Because `Message` has correct `json` struct tags, plain `encoding/json` against a *shadow struct* works fine — you just have to bypass the broken method: + +```go +type wireMessage struct { + Payloads map[string][]byte `json:"payloads"` + Metadata map[string]string `json:"metadata"` + Protocol string `json:"protocol"` + Version uint `json:"version"` +} + +func decode(s string) (*protocol.Message, error) { + bz, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return nil, err + } + var w wireMessage + if err := json.Unmarshal(bz, &w); err != nil { + return nil, err + } + return &protocol.Message{ + Payloads: w.Payloads, + Metadata: w.Metadata, + Protocol: w.Protocol, + Version: w.Version, + }, nil +} +``` + +(`EncodeMessage` is fine — `MarshalJSON` uses a type alias and produces correct output, with `[]byte` payloads base64-encoded per Go's normal rules.) +::: + +:::warning[The envelope carries no authentication or replay protection] +`Message` is a plaintext struct. There is no MAC, no sender identity, and no session id — `Metadata` carries only `{"round": "N"}`, written by the serializer and never read back, so you cannot repurpose it without colliding with that key. The DKLs18 rounds are designed for an authenticated channel; the library gives you none. Run this over an authenticated, ordered, confidential transport and bind messages to a session at that layer. `mpc` layers AES-GCM over `EncodeMessage` output for keyshare storage (`mpc.EncryptKeyshare`), but that is at-rest encryption of a *result*, not channel security for the rounds. +::: + +## Who consumes this + + + + `tecdsa/dklsv1` — `AliceDkg`/`BobDkg`, `AliceSign`/`BobSign`, `AliceRefresh`/`BobRefresh` all implement `Iterator`, plus the `Encode*`/`Decode*` result helpers. + + + `mpc` wraps the DKLs18 iterators with `RunProtocol`, `CheckIteratedErrors`, and keyshare encryption. + + + +Protocols that do **not** use `core/protocol`: `dkg/frost`, `dkg/gennaro`, `dkg/gennaro2p`, `ted25519`, and the `ot/*` packages all expose their own round methods directly. If you are working with those, you write the round sequencing by hand rather than in a crank loop. diff --git a/docs/identity/did-key.mdx b/docs/identity/did-key.mdx new file mode 100644 index 0000000..befbabc --- /dev/null +++ b/docs/identity/did-key.mdx @@ -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 + + + +## The `DID` type + +`DID` embeds `crypto.PubKey`, so every libp2p method (`Raw`, `Type`, `Equals`, `Verify`, `Bytes`) is +promoted onto it. On top of that: + + + +:::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. + + + +### 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 232 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 + + + + Where `NewFromMPCPubKey`'s input comes from, and how to sign with the key behind the identifier. + + + Using a `did:key` as a token issuer and delegation audience. + + diff --git a/docs/identity/ecies.mdx b/docs/identity/ecies.mdx new file mode 100644 index 0000000..ebe0372 --- /dev/null +++ b/docs/identity/ecies.mdx @@ -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 + + + + The symmetric layer, for when you already share a key. + + + Real KDFs, for deriving keys from seeds or passwords. + + + Signing, to pair with encryption for sender authentication. + + + The secp256k1 curve this package builds on. + + diff --git a/docs/identity/index.mdx b/docs/identity/index.mdx new file mode 100644 index 0000000..e212bb6 --- /dev/null +++ b/docs/identity/index.mdx @@ -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 + + + + [`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. + + + `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. + + + 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. + + + [`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. + + + +## 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 + + + + Multicodec + multibase encoding, the `DID` and `PubKey` types, the non-standard 66-byte signature + layout, and why to avoid `keys/parsers`. + + + 2-of-2 threshold ECDSA lifecycle: keygen, sign, verify, refresh, import/export, and the real + security model. + + + Capabilities, attenuation, delegation chains, templates, MPC signing, and which authorization + checks are not actually implemented. + + + Encrypt to a secp256k1 public key. A thin, honest wrapper — plus one seed hazard. + + + Ed25519 code signing and SHA-256 hash pinning for WebAssembly supply-chain verification. + + + Every package in the module with its status at a glance. + + diff --git a/docs/identity/meta.ts b/docs/identity/meta.ts new file mode 100644 index 0000000..5b99515 --- /dev/null +++ b/docs/identity/meta.ts @@ -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"], +}); diff --git a/docs/identity/mpc-enclave.mdx b/docs/identity/mpc-enclave.mdx new file mode 100644 index 0000000..40980d8 --- /dev/null +++ b/docs/identity/mpc-enclave.mdx @@ -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. + + + +:::note[The interface doc comments are shuffled] +In `mpc/codec.go` the `Unmarshal` line is commented `// Verify returns true if the signature is valid` +and `Marshal` is commented `// Serialize returns the serialized keyEnclave`. The behaviour is what +the method names say; the comments are stale. +::: + +`GetPubPoint()` is available on `*EnclaveData` but not on the interface: + +```go +point, err := enclave.GetData().GetPubPoint() // curves.Point on k.Curve +``` + +It reconstructs the point with `curve.NewIdentityPoint().FromAffineUncompressed(k.PubBytes)`, which +is why `PubBytes` must stay uncompressed. + +## Roles + +```go +const ( + RoleVal = "validator" + RoleUser = "user" +) + +type Role string +``` + +The mapping is fixed and worth memorising, because the field names and the protocol names differ: + +| Field | Role constant | DKLs18 party | Sign func | Refresh func | +| --- | --- | --- | --- | --- | +| `ValShare` | `RoleVal` | Alice | `GetAliceSignFunc` | `GetAliceRefreshFunc` | +| `UserShare` | `RoleUser` | Bob | `GetBobSignFunc` | `GetBobRefreshFunc` | + +`Role` and the two constants are declared but nothing in the package consumes them — they are there +for callers that need to label a share. Note the constants are untyped strings, not `Role` values. + +## Import and export + +`ImportEnclave` applies a variadic list of options and dispatches on which one was set. `Options` +holds only unexported fields, so `ImportEnclave` (or `Options{}.Apply()`, which sees a zero value) is +the intended entry point. + + + +`Apply()` resolves in a fixed precedence: encrypted data first, then initial shares, then enclave +data. `ImportEnclave` with zero options errors with `"no import options provided"`; with only +`WithEnclaveData(nil)` it errors with `"enclave data cannot be nil"`. + +The three lower-level constructors are exported and callable directly: + +```go +// Assemble from two protocol results (what NewEnclave does internally). +e, err := mpc.BuildEnclave(valShare, userShare, mpc.Options{}) + +// Adopt a deserialized struct. +e, err := mpc.RestoreEnclaveFromData(data) + +// Decrypt and adopt. Does not work; see below. +e, err := mpc.RestoreEncryptedEnclave(ciphertext, key) +``` + +:::warning[`BuildEnclave` with a bare `Options{}` records an empty curve] +`BuildEnclave` copies `options.curve` into `EnclaveData.Curve`. A zero `Options` leaves that as the +empty string. `CurveName("").Curve()` falls through to `curves.K256()`, so signing still works on +secp256k1 — but the persisted JSON records `"curve": ""`. Prefer +`mpc.ImportEnclave(mpc.WithInitialShares(val, user, mpc.K256Name))`, which sets it explicitly. +::: + +:::danger[The encrypted-import path cannot succeed] +`RestoreEncryptedEnclave` is unreachable-working by construction: + +```go +func RestoreEncryptedEnclave(data []byte, key []byte) (Enclave, error) { + keyclave := &EnclaveData{} + err := keyclave.Unmarshal(data) // <- JSON-parses the CIPHERTEXT + if err != nil { + return nil, fmt.Errorf("failed to unmarshal enclave: %w", err) + } + decryptedData, err := keyclave.Decrypt(key, data) + ... +} +``` + +`data` is the AES-256-GCM output of `Encrypt` — indistinguishable from random bytes. `json.Unmarshal` +on it fails, and the function returns before ever decrypting. Even if that line were removed, the +next one could not work either: `Decrypt` reads the nonce from `k.Nonce`, which is a *field of the +still-encrypted struct* and is therefore nil at that point, so `aesgcm.Open` would fail on a +zero-length nonce. + +Consequently `mpc.ImportEnclave(mpc.WithEncryptedData(ct, key))` also always fails, since `Apply()` +routes straight to `RestoreEncryptedEnclave`. Nothing in the repository calls either one — grepping +the module, the only references are the definitions themselves and the `Apply()` dispatch, and +`mpc/enclave_test.go` never exercises them. + +**There is no working round trip through this package.** You can decrypt — but the plaintext is the +JSON produced by `Marshal()`, and `Unmarshal` panics on it (see the previous section). Decryption on +its own works if you keep the nonce: + +```go +data := enclave.GetData() +nonce := data.Nonce // you MUST persist this alongside the ciphertext + +ct, err := data.Encrypt(key) +// ... later, in a fresh process ... +shell := &mpc.EnclaveData{Nonce: nonce} +plaintext, err := shell.Decrypt(key, ct) // plaintext == the original Marshal() JSON +if err != nil { + return err +} +// plaintext CANNOT be fed to (*EnclaveData).Unmarshal — it panics. +``` + +`TestEnclaveData_EncryptDecrypt` passes precisely because it stops here: it compares the decrypted +bytes against `Marshal()` output and never decodes them. To actually restore an enclave, encrypt and +decode with your own codec as described in the panic callout above. +::: + +## Encryption at rest + +`Encrypt` / `Decrypt` are AES-256-GCM. The key is derived by `GetHashKey`, which is +`sha3.New256(key)` truncated to 32 bytes. The nonce is `EnclaveData.Nonce` — 12 random bytes +generated **once**, at `BuildEnclave` time, and then reused for every call. + +:::danger[Fixed per-enclave nonce] +GCM security collapses if a `(key, nonce)` pair is ever reused for two different plaintexts: the +keystream repeats, XOR-ing two ciphertexts reveals the XOR of the plaintexts, and the GHASH +authentication key becomes recoverable, which lets an attacker forge tags. + +Because `Nonce` is fixed for the enclave's whole lifetime, calling `Encrypt(key)` twice with the same +`key` on **different** enclave contents — most obviously before and after a `Refresh()`, or after any +field changes — reuses `(key, nonce)`. Encrypting the *same* bytes twice is merely deterministic; +encrypting *different* bytes twice is a break. + +Mitigations, in order of preference: + +1. Do not use these methods. Marshal the enclave and encrypt with a fresh random nonce per operation + using [`aead`](/symmetric/aead). +2. If you must use them, use a distinct `key` for every encryption, and never reuse a key across a + refresh. + +Note also that `Refresh()` returns a new `Enclave` with a new random nonce, while the original value +keeps the old one — so the hazard is per-value, not per-key-lifetime. +::: + +`EncryptKeyshare` / `DecryptKeyshare` are the single-share equivalents, and they take the nonce as an +explicit parameter, which is the right shape: + +```go +func EncryptKeyshare(msg Message, key []byte, nonce []byte) ([]byte, error) +func DecryptKeyshare(msg []byte, key []byte, nonce []byte) ([]byte, error) +func GetHashKey(key []byte) []byte // SHA3-256(key)[:32] +``` + +`EncryptKeyshare` runs `protocol.EncodeMessage(msg)` first, so it operates on the wire encoding of a +`*protocol.Message`, not on JSON. + +## Refresh + +`Refresh()` runs the DKLs18 key-refresh protocol on both sides and returns a fresh `Enclave`: + +```go +func (k *EnclaveData) Refresh() (Enclave, error) { + refreshFuncVal, _ := GetAliceRefreshFunc(k) + refreshFuncUser, _ := GetBobRefreshFunc(k) + return ExecuteRefresh(refreshFuncVal, refreshFuncUser, k.Curve) +} +``` + +Three properties, all asserted in `TestEnclaveData_RefreshAndSign`: + +1. **Shares change.** Both `ValShare` and `UserShare` are replaced by the refresh outputs. +2. **The public key does not.** `PubKeyHex()` and `PubKeyBytes()` are byte-identical before and after. +3. **Signatures are interchangeable.** A signature made before the refresh verifies under the + refreshed enclave and vice versa, because verification only touches the public key. + +This is proactive security: an attacker who exfiltrated one share before the refresh holds a share +that no longer combines with anything. + +:::warning[`Refresh` returns; it does not rotate in place] +The receiver is unchanged. If you keep using the old value you keep using the old shares, and the old +shares still sign valid signatures. Replace your reference and destroy the old serialization. +::: + +## Driving the protocol yourself + +Everything above is assembled from these exported pieces. Use them when the two shares live in +different processes and you need to shuttle `*protocol.Message` values between them. + + + +Type aliases, from `mpc/codec.go`: + +```go +type ( + AliceOut *dkg.AliceOutput + BobOut *dkg.BobOutput + Point curves.Point + Message *protocol.Message + Signature *curves.EcdsaSignature + RefreshFunc interface{ protocol.Iterator } + SignFunc interface{ protocol.Iterator } +) +``` + +Decoding DKG results: + +```go +func GetAliceOut(msg *protocol.Message) (AliceOut, error) +func GetBobOut(msg *protocol.Message) (BobOut, error) +func GetAlicePublicPoint(msg *protocol.Message) (Point, error) +func GetBobPubPoint(msg *protocol.Message) (Point, error) +``` + +Both parties derive the same public key, so `GetAlicePublicPoint` and `GetBobPubPoint` on the +respective DKG outputs agree; `BuildEnclave` uses the Alice side. + +## Signature encoding + +```go +func SerializeSignature(sig *curves.EcdsaSignature) ([]byte, error) +func DeserializeSignature(sigBytes []byte) (*curves.EcdsaSignature, error) +func GetECDSAPoint(pubKey []byte) (*curves.EcPoint, error) +func VerifyWithPubKey(pubKeyCompressed, data, sig []byte) (bool, error) +``` + +`SerializeSignature` emits a **fixed 64-byte** buffer: `r` left-zero-padded to 32 bytes, then `s` +left-zero-padded to 32 bytes. No `V` byte, no DER, no length prefix. `DeserializeSignature` rejects +anything that is not exactly 64 bytes with +`"invalid signature length: expected 64 bytes, got N"`. The `EcdsaSignature.V` field is left zero on +the deserialize path. + +:::warning[`VerifyWithPubKey`'s parameter name is wrong] +The parameter is named `pubKeyCompressed`, but it is passed to `GetECDSAPoint`, which slices +`x = pubKey[1:33]` and `y = pubKey[33:]` — that is the **uncompressed** 65-byte layout. Pass +`enclave.PubKeyBytes()` (uncompressed), not `PubKeyHex()`-decoded bytes (compressed). Supplying 33 +bytes yields `y = 0` and verification silently returns `false`. + +`GetECDSAPoint` also always uses `curves.K256()`, ignoring the enclave's `Curve` field, and does no +length or on-curve check. +::: + +Signatures are **not** compatible with [`keys.PubKey.Verify`](/identity/did-key), which requires a +66-byte `V || R || S` layout. + +## `CurveName` + +```go +type CurveName string + +const ( + K256Name CurveName = "secp256k1" + BLS12381G1Name CurveName = "BLS12381G1" + BLS12381G2Name CurveName = "BLS12381G2" + BLS12831Name CurveName = "BLS12831" + P256Name CurveName = "P-256" + ED25519Name CurveName = "ed25519" + PallasName CurveName = "pallas" + BLS12377G1Name CurveName = "BLS12377G1" + BLS12377G2Name CurveName = "BLS12377G2" + BLS12377Name CurveName = "BLS12377" +) +``` + +`Curve()` maps each name to a [`*curves.Curve`](/foundations/curves). `String()` is the underlying +string. The mapping has two quirks worth knowing: + +| Name | Maps to | Note | +| --- | --- | --- | +| `BLS12831Name` | `curves.BLS12381G1()` | `"BLS12831"` is a transposition of 12381; aliased to G1 | +| `BLS12377Name` | `curves.BLS12377G1()` | Aggregate name aliased to G1 | +| *anything else* | `curves.K256()` | Silent default — including the empty string | + +:::danger[Only secp256k1 actually works] +`CurveName` advertises ten curves, but the package is secp256k1-only in practice: + +- `NewEnclave()` hardcodes `K256Name`. +- `GetBobSignFunc` and `GetBobRefreshFunc` ignore `k.Curve` and pass `curves.K256()`, while the Alice + side honours `k.Curve`. Setting `Curve` to anything else therefore puts the two parties on + different curves. +- `GetECDSAPoint`, used by both `Verify` and `VerifyWithPubKey`, always uses `curves.K256()`. +- The `default` branch of `Curve()` returns `curves.K256()` instead of erroring, so a typo in a + persisted `"curve"` field is silently coerced rather than rejected. + +Treat every constant other than `K256Name` as unimplemented. +::: + +## Signing digests and double hashing + +`GetAliceSignFunc`/`GetBobSignFunc` pass `sha3.New256()` and the raw message into `dklsv1`, which +hashes internally; `Verify` independently computes `sha3.New256(data)` and calls `ecdsa.Verify` on +that digest. So `Sign(m)`/`Verify(m, sig)` are consistent, and the digest is SHA3-256 — not SHA-256. + +This matters for [UCAN](/identity/ucan): `ucan.MPCSigningMethod` hashes the JWT signing string with +**SHA-256** and then calls `enclave.Sign(digest)`, which hashes that 32-byte digest again with +SHA3-256. The composition is `SHA3-256(SHA-256(signingString))`. It verifies correctly because +`Verify` does the same thing, but any external verifier must replicate both hashes. + +## `mpc/spec` + +`mpc/spec` is a **near-duplicate fork** of the UCAN types and MPC JWT plumbing that also lives in +`github.com/sonr-io/crypto/ucan`. It redeclares `Token`, `Attenuation`, `Proof`, `Fact`, the +`Capability` and `Resource` interfaces, `SimpleCapability`, `SimpleResource`, `KeyshareSource`, and +`CreateSimpleAttenuation`, and adds: + +```go +const ( + UCANVersion = "0.9.0" + UCANVersionKey = "ucv" + PrfKey = "prf" + FctKey = "fct" + AttKey = "att" + CapKey = "cap" +) + +func NewSource(enclave mpc.Enclave) (KeyshareSource, error) +func NewJWTSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod +func NewMPCSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod // alias +func RegisterMPCMethod(alg string) +func (m *MPCSigningMethod) WithEnclave(enclave mpc.Enclave) *MPCSigningMethod +``` + +`spec` is the only place in the module that names `UCANVersion` as a constant — `ucan` writes the +literal `"0.9.0"` inline into the `ucv` JWT header. + +:::danger[`mpc/spec`'s signing method violates the jwt/v5 contract] +`golang-jwt/jwt/v5` requires `SigningMethod.Sign` to return the **raw** signature bytes (the library +base64url-encodes them) and passes `Verify` the **already-decoded** bytes. `spec`'s implementation +does the encoding itself in both directions: + +```go +// Sign +encoded := base64.RawURLEncoding.EncodeToString(sig) +return []byte(encoded), nil + +// Verify +sig, err := base64.RawURLEncoding.DecodeString(string(signature)) +``` + +So a token minted through `spec` carries base64-of-base64 in its signature segment, and `Verify` +base64-decodes bytes that jwt/v5 already decoded. `ucan.MPCSigningMethod` gets this right — it +returns and consumes raw bytes. + +Worse, `spec`'s `init()` registers this implementation **globally**: + +```go +func init() { + jwt.RegisterSigningMethod("MPC256", func() jwt.SigningMethod { + return &MPCSigningMethod{Name: "MPC256"} // enclave is nil + }) +} +``` + +Any program that imports `mpc/spec`, even transitively and even without calling anything in it, +installs a global `"MPC256"` method whose factory produces a method with a nil enclave — so +`jwt.Parse` on an MPC-signed token resolves to it and fails with +`"MPC enclave not available for signature verification"`. `RegisterMPCMethod(alg)` does the same for +an arbitrary algorithm name. +::: + +**Use `github.com/sonr-io/crypto/ucan`, not `mpc/spec`.** `spec` is a maintenance hazard: two copies +of the same type set that will drift, one of which is broken. It has no tests. Note that the +duplication also means `ucan.Attenuation` and `spec.Attenuation` are distinct, non-interconvertible +types. + +## Caveats + +:::warning[`randNonce` ignores its error] +`mpc/codec.go`: `rand.Read(nonce)` is called without checking the return values. On a platform where +`crypto/rand` fails, the nonce would be all zeros. In practice `crypto/rand.Read` on modern Go does +not fail, but the omission is real. +::: + +:::warning[`IsValid` is a nil check] +`IsValid()` returns `k.ValShare != nil && k.UserShare != nil`. It does not check that the shares +belong to the same key, that `PubBytes` matches them, or that `Curve` is set. Any `*EnclaveData` with +two non-nil share pointers reports as "valid" and then fails at sign time. +::: + +:::warning[`Result` can return `(nil, nil)`] +`dklsv1`'s `Result(version)` returns `(nil, nil)` when the protocol has not finished — its +completion check runs before its initialization check. `NewEnclave`, `ExecuteSigning` and +`ExecuteRefresh` all call `Result` immediately after `CheckIteratedErrors` returns nil, so on the +happy path this does not bite. But if you drive the iterators yourself, an `err == nil` from +`Result` does **not** guarantee a non-nil `*protocol.Message`, and passing nil into +`GetAliceOut`/`GetBobOut`/`GetAlicePublicPoint`/`GetBobPubPoint` or `dklsv1.DecodeSignature` +nil-dereferences. Always nil-check the message as well as the error. +::: + +:::warning[`RunProtocol`'s error pair is asymmetric] +`RunProtocol(firstParty, secondParty)` returns `(aErr, bErr)` where `aErr` tracks the *second* +argument and `bErr` the *first*. On an early real error it returns `(nil, bErr)` or `(aErr, nil)` — +so always funnel the pair through `CheckIteratedErrors` rather than inspecting the two values +positionally. Note also that `NewEnclave` calls `RunProtocol(userKs, valKs)`, i.e. the user side is +`firstParty`. +::: + +:::info[Marshal is plaintext JSON] +`Marshal()` serializes both keyshares in the clear. If you persist that output, it is the complete +signing key. Protect it accordingly — and given the fixed-nonce hazard above, prefer an independent +AEAD over the built-in `Encrypt`. +::: + +## Next + + + + Signing capability tokens with an enclave, and what the verifier does and does not check. + + + The `tecdsa/dklsv1` protocol underneath, for when you need to run the two parties apart. + + + Turning `PubKeyBytes()` into a stable identifier. + + + Encrypting a marshalled enclave properly, with a fresh nonce per operation. + + diff --git a/docs/identity/ucan.mdx b/docs/identity/ucan.mdx new file mode 100644 index 0000000..b3baa26 --- /dev/null +++ b/docs/identity/ucan.mdx @@ -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: + + + +### 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 + +\"." }, + "CreateDIDAttenuation": { type: "func(actions []string, didPattern string, caveats []string) Attenuation", description: "DIDCapability + DIDResource with URI \"did:\"." }, + "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://\"." }, + "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://`, 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 + + + +`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: + + + +:::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 + + + +### `SecurityConfig` + += 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. + + 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://`. + + 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 +} +``` + +:::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 + + + + The signing key behind the issuer DID, and why `mpc/spec` should be avoided. + + + How issuer and audience strings are encoded and parsed. + + + Every stub and defect in the module, in one place. + + + Encrypting a payload to the holder of a key, rather than authorizing them. + + diff --git a/docs/identity/wasm-modules.mdx b/docs/identity/wasm-modules.mdx new file mode 100644 index 0000000..057b754 --- /dev/null +++ b/docs/identity/wasm-modules.mdx @@ -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 + + + + 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. + + + The publisher holds an Ed25519 private key and calls `SignModule` or `CreateSignatureManifest` + over the exact bytes that will be shipped. + + + The host recomputes the SHA-256 hash, compares it against the recorded one, and then checks the + Ed25519 signature against a trusted key. + + + `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. + + + +## 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: "` 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` + + + +:::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 + + + + Encrypting a signing key at rest. + + + Ed25519's siblings, and when a different signature scheme fits better. + + + Every stub and defect in the module, in one place. + + + How this fits with enclaves, DIDs and capability tokens. + + diff --git a/docs/index.mdx b/docs/index.mdx new file mode 100644 index 0000000..e792f67 --- /dev/null +++ b/docs/index.mdx @@ -0,0 +1,122 @@ +--- +title: Sonr Crypto +description: A Go cryptography library for threshold signatures, multi-party computation, zero-knowledge proofs, and decentralized identity — built on a single pluggable elliptic-curve abstraction. +sidebar: + label: Overview + icon: book-open +--- + +`github.com/sonr-io/crypto` is the cryptographic foundation of Sonr. It bundles roughly 60 Go packages +spanning elliptic-curve arithmetic, secret sharing, distributed key generation, threshold ECDSA and +Ed25519, BLS and BBS+ signatures, range proofs, accumulators, homomorphic encryption, and the +identity layer that turns a threshold key into a `did:key` identifier issuing UCAN capability tokens. + +Almost everything is generic over one abstraction — the `Curve` / `Point` / `Scalar` triple in +[`core/curves`](/foundations/curves). Learn that first and the rest of the library reads consistently. + +## Install + +```bash +go get github.com/sonr-io/crypto +``` + +The module requires **Go 1.24.7 or newer** and is licensed Apache 2.0. + +:::warning[Read before you deploy] +This library carries no public security audit, and several packages contain stubs, known defects, +or deliberately non-constant-time code paths. The +[security notes](/reference/security) page enumerates every one we found while documenting it — +read it before you build anything load-bearing on these primitives. +::: + +## A first example + +Threshold ECDSA is the library's headline capability. The [`mpc`](/identity/mpc-enclave) package wraps +the DKLs18 two-party protocol into a single value you can create, sign with, verify, and rotate: + +```go +package main + +import ( + "fmt" + "log" + + "github.com/sonr-io/crypto/mpc" +) + +func main() { + // Runs both sides of the 2-of-2 distributed key generation. + enclave, err := mpc.NewEnclave() + if err != nil { + log.Fatal(err) + } + + sig, err := enclave.Sign([]byte("transfer 100 to bob")) + if err != nil { + log.Fatal(err) + } + + ok, err := enclave.Verify([]byte("transfer 100 to bob"), sig) + if err != nil { + log.Fatal(err) + } + + fmt.Println("public key:", enclave.PubKeyHex(), "valid:", ok) +} +``` + +## The layers + + + + The curve abstraction, modular arithmetic, hash-to-field, commitments, and the + round-driving protocol iterator every multi-party package uses. + + + AES-GCM and deterministic AES-SIV, Argon2id key derivation, HKDF and X25519, + plus the salt, password, and memory-hygiene helpers. + + + BLS aggregation and threshold keygen, BBS+ selective disclosure, ECDSA + canonicalization and RFC 6979 signing, VRFs, and chain-specific Schnorr schemes. + + + Shamir, Feldman, and Pedersen sharing; FROST and Gennaro DKG; two-party + threshold ECDSA; threshold Ed25519; and the oblivious transfer beneath them. + + + Schnorr proofs of knowledge, pairing-based accumulators for set membership, + Bulletproofs range proofs, and Paillier homomorphic encryption. + + + `did:key` encoding, the MPC enclave, UCAN capability tokens, ECIES payload + encryption, and WebAssembly module signing. + + + +## Choosing a primitive + +| Goal | Reach for | +| --- | --- | +| Sign with a key that never exists in one place | [Threshold ECDSA](/threshold/threshold-ecdsa) or the [MPC enclave](/identity/mpc-enclave) | +| Produce a standard Ed25519 signature from shares | [Threshold Ed25519](/threshold/threshold-ed25519) | +| Aggregate many signatures into one | [BLS](/signatures/bls) | +| Prove attributes without revealing them | [BBS+](/signatures/bbs) | +| Prove a hidden value lies in a range | [Bulletproofs](/zero-knowledge/bulletproof) | +| Prove set membership with a constant-size witness | [Accumulator](/zero-knowledge/accumulator) | +| Add ciphertexts without decrypting | [Paillier](/zero-knowledge/paillier) | +| Split an existing secret among holders | [Secret sharing](/threshold/secret-sharing) | +| Derive a key from a password | [Argon2id](/symmetric/key-derivation) | +| Encrypt a payload to a public key | [ECIES](/identity/ecies) | +| Delegate scoped authority to another party | [UCAN](/identity/ucan) | + +## Where to next + + + + Install the module, pick a curve, and understand the conventions shared across packages. + + + Every importable package mapped to the page that documents it. + + diff --git a/docs/reference/meta.ts b/docs/reference/meta.ts new file mode 100644 index 0000000..815a27d --- /dev/null +++ b/docs/reference/meta.ts @@ -0,0 +1,8 @@ +import { defineMeta } from "blume"; + +export default defineMeta({ + title: "Reference", + icon: "library", + order: 8, + pages: ["packages", "security"], +}); diff --git a/docs/reference/packages.mdx b/docs/reference/packages.mdx new file mode 100644 index 0000000..d360715 --- /dev/null +++ b/docs/reference/packages.mdx @@ -0,0 +1,109 @@ +--- +title: Package index +description: Every importable package in github.com/sonr-io/crypto, what it provides, and the page that documents it. +sidebar: + label: Package index + order: 1 + icon: list +--- + +The module exposes 61 Go packages. This is the complete map from import path to documentation. +Import paths below are relative to `github.com/sonr-io/crypto`. + +## Foundations + +| Package | Provides | Docs | +| --- | --- | --- | +| `core` | Modular arithmetic, hash-to-field, HMAC commitments, safe primes | [Arithmetic](/foundations/arithmetic) | +| `core/curves` | `Curve` / `Point` / `Scalar` abstraction and every named curve | [Curves](/foundations/curves) | +| `core/curves/secp256k1` | Vendored Jacobian secp256k1 `BitCurve` | [Curves](/foundations/curves) | +| `core/protocol` | `Iterator`, `Message`, protocol name and version constants | [Protocol messages](/foundations/protocol) | +| `core/curves/native` | Montgomery field and elliptic-point machinery, hash-to-curve hashers | [Curves](/foundations/curves) | +| `core/curves/native/bls12381` | BLS12-381 `G1`/`G2`/`Gt` and pairing engine | [Curves](/foundations/curves) | +| `core/curves/native/k256` + `k256/fp`, `k256/fq` | secp256k1 base and scalar field arithmetic | [Curves](/foundations/curves) | +| `core/curves/native/p256` + `p256/fp`, `p256/fq` | NIST P-256 base and scalar field arithmetic | [Curves](/foundations/curves) | +| `core/curves/native/pasta` + `pasta/fp`, `pasta/fq` | Pasta (Pallas) field arithmetic | [Curves](/foundations/curves) | + +## Symmetric, KDF, and secret hygiene + +| Package | Provides | Docs | +| --- | --- | --- | +| `aead` | AES-256-GCM with nonce management | [AEAD](/symmetric/aead) | +| `daed` | AES-SIV-CMAC deterministic AEAD (RFC 5297) | [Deterministic AEAD](/symmetric/deterministic-aead) | +| `argon2` | Argon2id password hashing and key derivation | [Key derivation](/symmetric/key-derivation) | +| `subtle` | HKDF, X25519, hash and curve name helpers | [Key derivation](/symmetric/key-derivation) | +| `subtle/random` | Random byte and uint32 generation | [Secrets](/symmetric/secrets) | +| `salt` | Salt value type and in-memory salt store | [Secrets](/symmetric/secrets) | +| `password` | Password policy validation and entropy estimation | [Secrets](/symmetric/secrets) | +| `secure` | Memory zeroing, secure byte/string/buffer wrappers | [Secrets](/symmetric/secrets) | + +## Signatures + +| Package | Provides | Docs | +| --- | --- | --- | +| `signatures/bls/bls_sig` | BLS signatures: Basic, Aug, and PoP ciphersuites, aggregation, threshold keygen | [BLS](/signatures/bls) | +| `signatures/bbs` | BBS+ signatures with selective disclosure and blind signing | [BBS+](/signatures/bbs) | +| `signatures/common` | Shared proof messages, commitment builder, HMAC-DRBG | [Signatures](/signatures) | +| `signatures/schnorr/mina` | Mina-protocol Schnorr over Pallas with Poseidon | [Chain schemes](/signatures/chain-schemes) | +| `signatures/schnorr/nem` | NEM Ed25519-Keccak signatures | [Chain schemes](/signatures/chain-schemes) | +| `ecdsa` | Low-S canonicalization and RFC 6979 deterministic signing | [ECDSA](/signatures/ecdsa) | +| `vrf` | Verifiable random function over Edwards25519 | [VRF](/signatures/vrf) | + +## Threshold cryptography and MPC + +| Package | Provides | Docs | +| --- | --- | --- | +| `sharing` | Shamir, Feldman, and Pedersen secret sharing | [Secret sharing](/threshold/secret-sharing) | +| `sharing/v1` | Legacy field-based sharing over `EcPoint`/`Element` | [Secret sharing](/threshold/secret-sharing) | +| `dkg/frost` | FROST distributed key generation, two rounds | [DKG](/threshold/dkg) | +| `dkg/gennaro` | Gennaro DKG, four rounds | [DKG](/threshold/dkg) | +| `dkg/gennaro2p` | Two-party Gennaro DKG façade | [DKG](/threshold/dkg) | +| `tecdsa/dklsv1` | Two-party threshold ECDSA as protocol iterators | [Threshold ECDSA](/threshold/threshold-ecdsa) | +| `tecdsa/dklsv1/dkg` | The underlying 10-round DKG rounds | [Threshold ECDSA](/threshold/threshold-ecdsa) | +| `tecdsa/dklsv1/sign` | The underlying signing rounds and two-party multiplication | [Threshold ECDSA](/threshold/threshold-ecdsa) | +| `tecdsa/dklsv1/refresh` | Share refresh rounds | [Threshold ECDSA](/threshold/threshold-ecdsa) | +| `tecdsa/dklsv1/dealer` | Trusted-dealer key generation shortcut | [Threshold ECDSA](/threshold/threshold-ecdsa) | +| `ted25519/ted25519` | Threshold Ed25519 producing standard signatures | [Threshold Ed25519](/threshold/threshold-ed25519) | +| `ted25519/frost` | FROST threshold Schnorr signing, three rounds | [Threshold Ed25519](/threshold/threshold-ed25519) | +| `ot/base/simplest` | Verified base (seed) oblivious transfer | [Oblivious transfer](/threshold/oblivious-transfer) | +| `ot/extension/kos` | KOS correlated OT extension | [Oblivious transfer](/threshold/oblivious-transfer) | +| `ot/ottest` | Test harness wiring both OT sides | [Oblivious transfer](/threshold/oblivious-transfer) | + +## Zero-knowledge and homomorphic encryption + +| Package | Provides | Docs | +| --- | --- | --- | +| `zkp/schnorr` | Non-interactive proof of knowledge of a discrete log | [Schnorr proofs](/zero-knowledge/schnorr) | +| `accumulator` | Pairing-based accumulator with membership proofs | [Accumulator](/zero-knowledge/accumulator) | +| `bulletproof` | Inner-product argument and range proofs, batched | [Bulletproofs](/zero-knowledge/bulletproof) | +| `paillier` | Additively homomorphic encryption and the PSF proof | [Paillier](/zero-knowledge/paillier) | + +## Identity and authorization + +| Package | Provides | Docs | +| --- | --- | --- | +| `keys` | `did:key` encoding, multicodec key types, public key verification | [did:key](/identity/did-key) | +| `keys/parsers` | Chain-specific key parsing — **largely unimplemented** | [did:key](/identity/did-key) | +| `mpc` | Threshold ECDSA enclave: keygen, sign, verify, refresh, import/export | [MPC enclave](/identity/mpc-enclave) | +| `mpc/spec` | Duplicate UCAN-over-MPC surface — **prefer `ucan`** | [MPC enclave](/identity/mpc-enclave) | +| `ucan` | UCAN capability tokens, attenuation, verification | [UCAN](/identity/ucan) | +| `ecies` | ECIES payload encryption over secp256k1 | [ECIES](/identity/ecies) | +| `wasm` | Ed25519 module signing and SHA-256 hash pinning | [WASM modules](/identity/wasm-modules) | + +## Not importable or not wired in + +| Package | Status | +| --- | --- | +| `internal`, `internal/ed25519/edwards25519`, `internal/ed25519/extra25519` | Go `internal/` visibility — usable only inside this module. Documented for orientation in [Arithmetic](/foundations/arithmetic). | +| `signatures/bls/tests/bls` | A `main` package used for BLS test-vector generation, not a library. | +| `empty-module` | A separate module containing a `go-bip39` stub whose functions error or panic. It is referenced by neither `go.mod` nor a workspace file. See [security notes](/reference/security). | + +:::tip +`go doc` is the fastest way to check a signature against the version you have vendored: + +```bash +go doc github.com/sonr-io/crypto/sharing +go doc -all github.com/sonr-io/crypto/accumulator +go doc github.com/sonr-io/crypto/core/curves.Point +``` +::: diff --git a/docs/reference/security.mdx b/docs/reference/security.mdx new file mode 100644 index 0000000..f65de18 --- /dev/null +++ b/docs/reference/security.mdx @@ -0,0 +1,390 @@ +--- +title: Security notes +description: Critical defects, stubs, non-constant-time paths, and operational footguns found while documenting this library — including three findings that make packages unsafe or unusable as written. +sidebar: + label: Security notes + order: 2 + icon: shield-alert +--- + +This page records what a source audit turned up while these docs were written. Every claim below was +verified against the code — most by compiling and running the affected path as an external consumer, +a few by running the repository's own tests. Where a finding was proven by execution, the observed +output is quoted. + +This is not a security audit and does not replace one. Re-check anything critical against the +version you have vendored, since a defect may be fixed — or a new one introduced — after this page +was written. + +:::danger[No audit, no warranty] +This library has no public third-party security audit. It bundles vendored and ported code from +several upstream projects, contains packages that are explicitly incomplete, includes arithmetic +documented in its own comments as not constant time, and — as recorded below — ships at least one +signature scheme that is trivially forgeable and one persistence path that cannot round-trip. +::: + +## Critical + +Three findings deserve to be read before anything else. + +### BBS+ signatures are trivially forgeable + +**`signatures/bbs/message_generators.go` — `MessageGenerators.Get`** + +The method copies the internal state array, writes the generator index into the **copy**, then hashes +the **original**: + +```go +state := msgg.state // array copy +state[193] = byte(i >> 24) // index written to the copy +// ... +point, ok := msgg.h0.Hash(msgg.state[:]).(curves.PairingPoint) // hashes the ORIGINAL +``` + +The index never reaches the hash, so every message generator `H_i` for `i >= 1` is the same point. +A BBS+ signature commits to `h_0^s · Π H_i^{m_i}`; with all `H_i` identical, it binds only the +**sum** of the message scalars, not the individual messages or their positions. + +Verified by execution against this repository: + +```text +Get(1..4) == Get(0): true +permuted verifies: true // a signature over [3,4,5,6] is accepted for [6,5,4,3] +same-sum forgery verifies: true // ...and for the unrelated vector [1,2,7,8] +``` + +:::danger +Unforgeability and selective disclosure are both void. Do not use `signatures/bbs` for credentials +or any authorization decision until `Get` hashes the mutated local copy. See [BBS+](/signatures/bbs). +::: + +### A persisted MPC enclave can never be restored + +**`core/protocol/protocol.go` — `Message.UnmarshalJSON`** + +`UnmarshalJSON` decodes into `map[string]any` and then performs unchecked type assertions to +`map[string][]byte` and `map[string]string`. `encoding/json` always produces +`map[string]interface{}`, so the assertion cannot succeed and the call **panics** on any message +with a non-empty `Payloads` or `Metadata` field. + +`mpc.EnclaveData.Marshal` serializes fine, but `Unmarshal` routes through the same decoder. The +repository's own test fails today: + +```text +$ go test ./mpc/ -run TestEnclaveData_MarshalUnmarshal +panic: interface conversion: interface {} is map[string]interface {}, not map[string][]uint8 + github.com/sonr-io/crypto/mpc.(*EnclaveData).Unmarshal + mpc/enclave.go:154 +FAIL github.com/sonr-io/crypto/mpc +``` + +:::danger +An enclave can be written to storage and never read back. The blast radius is anything that +JSON-decodes a `protocol.Message` — `DecodeMessage`, `EnclaveData.Unmarshal`, and any transport that +carries protocol messages as JSON. Persist enclave state through your own encoding until this is +fixed. See [Protocol messages](/foundations/protocol) and [MPC enclave](/identity/mpc-enclave). +::: + +### UCAN caveat and amount attenuation are not enforced + +**`ucan/verifier.go`** + +The two helpers that decide whether a delegated token is *more* restrictive than its parent both +return `true` unconditionally: + +- `areCaveatsMoreRestrictive(childCaveats, parentCaveats []string) bool` — builds a set of the + parent's caveats, then runs a loop whose only branch is `continue`, and returns `true`. +- `isAmountLessOrEqual(childAmount, parentAmount string) bool` — commented + `placeholder implementation`; the body is `return true`. + +`isAmountLessOrEqual` gates the `maxAmount` field on a DEX capability. `areCaveatsMoreRestrictive` is +the final check in vault, DID, and DWN containment validation. Several sibling paths in the same file +also fail open by design — `return true // Basic containment is sufficient for unknown schemes` — +so an unrecognized resource scheme is treated as contained rather than rejected. + +:::danger +A delegated token can carry caveats, or an amount, that its parent never granted and still pass +`VerifyDelegationChain`. Do not treat caveat or amount attenuation as a security boundary; enforce +those constraints in your own application logic. See [UCAN](/identity/ucan). +::: + +## Unusable as written + +APIs that are present and compile, but cannot be used for their stated purpose. + +### Bulletproof range proofs are uncallable from outside the package + +`RangeProofGenerators` has only unexported fields (`g`, `h`, `u`) and the package exports no +constructor, setter, or default. An external package cannot populate it: + +```text +cannot refer to unexported field g in struct literal of type bulletproof.RangeProofGenerators +``` + +A zero-value `RangeProofGenerators{}` does compile, but its points are nil and `RangeProver.Prove` +panics dereferencing `proofGenerators.h`. The commitment helpers a verifier needs are unexported too +(`getcapV`, `getcapVBatched`, and `InnerProductProver.getP`, whose own comment says +*"should only be used for testing"*). + +Net effect: `RangeProver.Prove`, `BatchProve`, `RangeVerifier.Verify`, and `VerifyBatched` are +in-package-only. The inner-product argument is usable; the range proof is not. See +[Bulletproofs](/zero-knowledge/bulletproof). + +### `sharing/v1.Bls12381G2()` returns a G1 curve + +```go +func Bls12381G2() *Bls12381G1Curve { + bls12381g2Initonce.Do(bls12381g2InitAll) + return &bls12381g1 // ← the G1 curve +} +``` + +The return type is `*Bls12381G1Curve` and the value returned is the package-level `bls12381g1`. The +G2 initializer runs and its result is discarded; the singleton's `Name` is even set to +`"Bls12381G1"`. The `Bls12381G2Curve` type does implement real G2 arithmetic, but no exported +constructor returns it. See [Secret sharing](/threshold/secret-sharing). + +### `keys.PubKey.Verify` cannot verify this library's own signatures + +`keys/pubkey.go` requires exactly **66 bytes** laid out as `V || R || S` over a SHA3-256 digest, +while `mpc.SerializeSignature` emits **64 bytes** as `r || s`. Feeding one to the other yields +`malformed signature: not the correct size`. Separately, `getEcdsaPoint` slices `y = bytes[33:]` from +a compressed 33-byte point (`Point.Bytes()` always returns compressed), so `y` decodes as zero. No +test covers `NewPubKey` or `Verify`. See [did:key](/identity/did-key). + +### `mina.Transaction.UnmarshalJSON` always fails + +It type-asserts `Body[1]` from `any` directly to concrete struct types. `encoding/json` decodes an +unconstrained `any` into `map[string]any` / `[]any`, so the assertion can never succeed and every +call returns `unexpected type`. Even if the assertion were fixed, `SourcePk`, `Amount`, `TokenId`, +`Locked`, and `Tag` are never assigned, a computed `sourcePk` local is dropped, a `ParseAddress` +error is swallowed with `return nil`, and the memo is indexed `memo[2 : 2+memo[1]]` with no length +check. There is no `MarshalJSON` counterpart. See [Chain schemes](/signatures/chain-schemes). + +### `keys/parsers` is a skeleton + +Five files contain nothing but a package clause: `btc_parser.go`, `eth_parser.go`, `fil_parser.go`, +`sol_parser.go`, `ton_parser.go`. There is no Bitcoin, Ethereum, Filecoin, Solana, or TON key parsing +in this module. `cosmos_parser.go` holds only `CosmosPrefix` HRP constants, with no functions. + +`keys/parsers/key_parser.go` also duplicates `keys/didkey.go` but with a **different secp256k1 +multicodec** — `0x1206` against the registered `0xe7` used by `keys` — so `parsers.DIDKey` and +`keys.DID` produce mutually unparseable `did:key` strings for the same key. + +:::warning +Use `keys`. Treat `keys/parsers` as dead code. +::: + +### `ucan/stubs.go` + +`TokenBuilder.CreateOriginToken` and `CreateDelegatedToken` assemble a `*Token` with `Raw: ""` — they +never sign or serialize a JWT. `isValidDID` checks only a `did:` prefix and a length, and +`prepareDelegationProofs` merely copies the parent's `Raw` when non-empty. + +For a signed token use `GenerateJWTToken`, `GenerateModuleJWTToken`, or the MPC-backed +`MPCTokenBuilder` — not the bare `TokenBuilder`. + +### `empty-module` + +A separate Go module declaring itself `github.com/tyler-smith/go-bip39`, whose functions all return +an error or panic. Neither `go.mod` nor `go.sum` references it and there is no `go.work`, so nothing +builds against it. There is no BIP-39 mnemonic support in this library. + +### `core/curves/native/pasta/pallas.go` + +Contains only a package clause. Working Pallas support lives in `core/curves/pallas_curve.go` +(`PointPallas`, `ScalarPallas`, `Ep`). + +## Silent wrong answers + +Code that runs, returns no error, and is wrong. + +| Finding | Location | Consequence | +| --- | --- | --- | +| FROST DKG context is discarded | `dkg/frost/participant.go` — `ctxV, _ := strconv.Atoi(ctx)`, stored as `byte(ctxV)` | The error is dropped, so any non-numeric context — including the package's own test string — becomes the byte `0`. Every such session shares one context, and numeric values are truncated mod 256. The replay-protection domain separator does nothing as implemented. Participant ids `>= 256` truncate the same way. Inherited by `ted25519/frost`. | +| `v1.Shamir.Combine` truncates | `sharing/v1/shamir.go` | Only the first `threshold` shares are consumed; extra shares are silently ignored rather than cross-checked. | +| Hard-coded hash-to-field DST | `core/hash.go` — `hashToField` | The domain separation tag is the literal `Coinbase_tECDSA` with no parameter. No separation between protocols, and no interoperability with any standard hash-to-curve suite ID. | +| Fixed Fiat-Shamir info string | `core/hash.go` — `FiatShamir` | `info` is the literal `Coinbase tECDSA 1.0` with a 32-byte zero salt. Values are folded as minimal big-endian `Bytes()`, so lengths are not committed — two different value sequences can produce one transcript. | +| `keys.DID.Address()` is not an address | `keys/didkey.go` | The comment claims an Ethereum-style Keccak-256 truncation; the code is `fmt.Sprintf("sonr1%x", rawPubBytes[:8])` for all key types. No hash, no bech32, no checksum. It leaks 8 bytes of the public key into a 64-bit collision space. Measured: `sonr10304584a69c0f8ac`. Consumed by `ucan` via `MPCTokenBuilder.GetAddress()` and `KeyshareSource.Address()`. | +| Mina threshold challenge is MainNet-only | `signatures/schnorr/mina/challenge_derive.go` | `DeriveChallenge` hard-codes `MainNet` after parsing a `Transaction` that carries a `NetworkId`, then discards it. FROST-signing a TestNet transaction produces a signature that will not verify. No override is exposed. | +| Mina memo length corruption | `signatures/schnorr/mina/txn.go` — `MarshalBinary` | Writes `out[57] = byte(len(txn.Memo))` but copies at most 32 bytes. A 40-byte memo records length 40 with 32 bytes present; a 256-byte memo records length 0. Also dereferences `FeePayerPk`/`SourcePk`/`ReceiverPk` with no nil checks, so a partially filled `Transaction` panics. | +| `NistP256.ScalarMult` is not the native path | `core/curves/p256_curve.go` | The method is spelled `ScalarMul` (missing `t`), so the `elliptic.Curve` interface method resolves to the promoted `*elliptic.CurveParams.ScalarMult` — the generic deprecated `math/big` implementation. `ScalarBaseMult`, `Add`, `Double`, and `IsOnCurve` are native. | +| `BLS12831Name` typo is load-bearing | `core/curves/curve.go` | The constant is spelled `BLS12831` **and** its value is the string `"BLS12831"`. `curves.BLS12381(...)` assigns it, so a BLS12-381 pairing curve reports `Name == "BLS12831"`. Any name-based dispatch must match the typo. | +| `core.Add`/`Mul`/`Exp` accept a nil modulus | `core/mod.go` | A nil modulus means no reduction rather than an error, so a missing parameter silently yields unreduced big integers. | +| `Iterator.Result` returns `(nil, nil)` | `tecdsa/dklsv1/boilerplate.go`, all six `Result` methods | The completion check precedes the `ErrNotInitialized` check, so calling `Result` on an un-cranked iterator returns a nil message *and* a nil error. Every `Decode*` helper then nil-derefs on `m.Payloads`. | +| `Point.SumOfProducts` signals failure with nil | `core/curves/k256_curve.go` and siblings | No error channel. Returns nil on a slice-length mismatch or on any element of a foreign concrete type, turning a length bug into a nil-deref several frames later. | +| `Curve.ToEllipticCurve` covers 2 of 8 curves | `core/curves/curve.go` | Only `K256` and `P256` convert; `ED25519`, `PALLAS`, and all four BLS variants return nil with `can't convert `. | +| `daed.AESSIV` aliases the caller's key | `daed/aes_siv.go` | `K1`/`K2` are **exported** fields that alias `key[:32]` and `key[32:]` rather than copying. `fmt.Printf("%+v")` on an `AESSIV` prints raw key material, and zeroing the input slice silently corrupts the live cipher. | +| `daed` decrypt ignores an error | `daed/aes_siv.go` | `DecryptDeterministically` calls `ctrCrypt` without checking its returned error, unlike the encrypt path. Latent rather than exploitable, since `ctrCrypt` can only fail if `aes.NewCipher(K2)` fails after the constructor's 64-byte check. | +| `mpc/spec` duplicates `ucan` | `mpc/spec/` | A near-verbatim fork of `ucan/source.go` and `ucan/mpc.go` with its own `Token`, `Capability`, and `Attenuation` types. Two copies of authorization logic drift apart. `mpc/spec/source.go` also derives its address from the placeholder `fmt.Sprintf("addr_%x", pubKeyBytes[:8])`. Prefer `ucan`. | + +### Bulletproof range-encoding edge cases + +Beyond being uncallable externally, the range prover has four issues worth recording if it is ever +fixed or used in-package: + +- `getaL` reads bit `i` as `vBytes[i>>3]` with no bounds check, so `n > 256` on these curves indexes + past the slice and panics. `NewRangeProver` accepts `maxVectorLength` above 256 with no gate. +- `getaL` assumes `Scalar.Bytes()` is little-endian. Every bulletproof test uses ED25519 only; on a + big-endian-scalar curve the bit vector is reversed and will not match the commitment. +- `Prove` rejects `v < 0` and `v > 2^n`, so `v == 2^n` passes validation but is not representable in + `n` bits. The unexported `checkRange` used by `BatchProve` has the same comparison despite a + comment claiming `[0, 2^n - 1]`, and additionally omits the negative check. +- `n` must be a power of two, but `RangeProver.Prove` has no gate (unlike + `InnerProductProver.Prove`), so a bad `n` fails late inside the recursion with + `length of scalars must be even`. +- `Verify` and `VerifyBatched` return `(false, nil)` with no diagnostic, so a domain, + `maxVectorLength`, generator, or transcript-label mismatch is indistinguishable from a dishonest + prover. + +## Non-constant-time arithmetic + +The following are documented as not constant time **in their own source comments**: + +| Location | Note | +| --- | --- | +| `core/curves/field.go` | `Field` and `Element` are `math/big`-backed and explicitly documented as not constant time. `NewField` and the element constructor **panic** on a non-prime modulus, an out-of-range value, or mismatched fields. | +| `core/curves/ec_scalar.go` | The `big.Int` Euclidean `Mod` path is flagged as not constant time. Affects `K256Scalar`, `P256Scalar`, `Bls12381Scalar`, and `Ed25519Scalar`. | +| `core` modular helpers | `Add`, `Mul`, `Exp`, `Inv`, `Neg` operate on `*big.Int`. Use `ConstantTimeEq` for comparisons and do not assume the arithmetic itself is constant time. | + +The modern `curves.Point` / `curves.Scalar` implementations backed by `core/curves/native` +(Montgomery-form limb arithmetic) are the better choice for secret-dependent operations. The legacy +`Field` / `Element` / `EcScalar` layer is used by `sharing/v1` and `dkg/gennaro`, which inherit its +timing characteristics. + +## Operational footguns + +Not bugs — the code does what it says — but each has a severe failure mode. + + + + A nonce share from `GenerateSharedNonce` is bound to one message. Signing two different messages + with the same nonce share exposes the secret key through simple algebra. Generate a fresh nonce + per signing session; never persist and replay one. See + [Threshold Ed25519](/threshold/threshold-ed25519). + + + `aead.AESGCMCipher.EncryptWithNonce` exists for test vectors, and its own source comment says + "use only for testing". Repeating a nonce under one key destroys both confidentiality (CTR + keystream reuse) and authenticity (GHASH subkey leakage, enabling forgeries for other messages). + `Encrypt` generates a random 96-bit nonce and prepends it — use that. See [AEAD](/symmetric/aead). + + + `NewEnclave` runs both DKLs18 DKG sides locally, `EnclaveData` stores `ValShare` and `UserShare` + together, `Sign` builds both sign functions from the same struct, and `Marshal` emits both in the + clear. It is a key-management and portability construct; the threshold property only materializes + once the two shares live in separate trust domains. See [MPC enclave](/identity/mpc-enclave). + + + `EnclaveData.Encrypt` derives an AES-256-GCM key with SHA3-256 and reuses the enclave's stored + nonce, which is the AES-GCM failure case above whenever more than one plaintext is encrypted. + + + `tecdsa/dklsv1/dealer.GenerateAndDeal` constructs both parties' shares in one process, so the + full key exists in one place at one time. It is a test and migration convenience. See + [Threshold ECDSA](/threshold/threshold-ecdsa). + + + `zkp/schnorr`, `ot/base/simplest`, and the FROST DKG all take a session id or context that + domain-separates the Fiat-Shamir transcript. Prover and verifier must pass identical bytes, and + reuse across executions weakens the soundness the caller assumes. Note the FROST context defect + above. See [Schnorr proofs](/zero-knowledge/schnorr). + + + Adding or removing an element invalidates every outstanding membership witness. Holders must + refresh via `ApplyDelta` or `BatchUpdate` using the published `Delta`, or their proofs stop + verifying with the bare error `invalid result`. A revoked holder's `BatchUpdate` fails with + `no inverse exists`. See [Accumulator](/zero-knowledge/accumulator). + + + Plain `sharing.Shamir` has no verification step, so a malicious holder can submit a garbage share + and silently corrupt the reconstructed secret. Use Feldman or Pedersen when holders are not + trusted. See [Secret sharing](/threshold/secret-sharing). + + + Only the proof-of-possession ciphersuite (`SigPop`, `SigPopVt`, and the `SigEth2` aliases) + defends against an attacker registering a public key derived from others'. Basic additionally + requires every message in an aggregate to be distinct. See [BLS](/signatures/bls). + + + `daed` produces identical ciphertext for identical plaintext and associated data. That is the + feature, but an observer learns which ciphertexts encrypt the same value, can join across tables, + and can confirm guesses offline. See [Deterministic AEAD](/symmetric/deterministic-aead). + + + `PsfProof.Verify` indexes the proof without a length check: + `index out of range [3] with length 3`. Validate that a deserialized proof has `PsfProofLength` + elements before verifying. See [Paillier](/zero-knowledge/paillier). + + + `aead` output is `nonce || ciphertext || tag` with no version byte, algorithm id, or key id. + There is no key-rotation or migration path short of re-encrypting everything. + + + +## Weaker guarantees than the names suggest + +### `secure` does not lock memory + +`secure/memory.go` overwrites buffers and registers finalizers. It contains no `mlock`, `munlock`, +or `mprotect` call, so secrets remain swappable to disk and readable from a core dump. +`ZeroizeString` cannot work reliably at all: Go strings are immutable and freely copied, so the copy +you zero may not be the only one. Treat these as hygiene, not a guarantee. See +[Secrets](/symmetric/secrets). + +### `subtle/random` panics instead of returning an error + +`GetRandomBytes` and `GetRandomUint32` panic if `crypto/rand` fails rather than surfacing an error — +a process crash originating in library code. + +### `salt.SaltStore` is not goroutine-safe + +An in-memory map with no mutex. Concurrent `Store` and `Retrieve` calls race. Serialize access +yourself. + +### `ecies` has no round-trip test + +A thin alias layer over `github.com/ecies/go/v2`. Its test file covers key generation only — no +encrypt/decrypt round trip is exercised in this repository. See [ECIES](/identity/ecies). + +### `daed` cross-implementation vectors never run + +`TestAESSIV_WycheproofVectors` calls `t.Skip` unless `TEST_SRCDIR` is set, so a normal +`go test ./daed/...` never checks the RFC 5297 vectors. + +### `wasm.Signer.ExportPrivateKey` + +Returns raw Ed25519 private key bytes, so any caller holding a `*Signer` can extract the signing key. +See [WASM modules](/identity/wasm-modules). + +### `keys.DID` error handling + +`MulticodecType()` panics with `unexpected crypto type` on an unguarded key type, and `String()` +calls it unconditionally — so a `DID` built as a struct literal can panic. `String()` also returns +`""` instead of an error when `Raw()` or multibase encoding fails. + +## What the repository does test + +`security_test.go` at the module root is a cross-package suite asserting properties rather than +units. It is a useful statement of intended guarantees: + +- Argon2 timing behavior under configured cost, and concurrent derivation safety +- ECDSA signing determinism and rejection of malleable (high-S) signatures +- Password validator resistance to dictionary inputs +- WASM module hash collision resistance +- Salt uniqueness across generations +- RNG output quality +- Crypto agility across configured algorithms + +```bash +go test ./... -run TestSecurity +``` + +Note that `go test ./mpc/` currently fails on `TestEnclaveData_MarshalUnmarshal` for the reason +recorded above. + +## Reporting + +Found something not listed here? Open an issue at +[github.com/sonr-io/crypto](https://github.com/sonr-io/crypto/issues). For a suspected +vulnerability, prefer a private report over a public issue. diff --git a/docs/signatures/bbs.mdx b/docs/signatures/bbs.mdx new file mode 100644 index 0000000..a0604ca --- /dev/null +++ b/docs/signatures/bbs.mdx @@ -0,0 +1,475 @@ +--- +title: BBS+ Signatures +description: Sign a vector of attributes on BLS12-381, then prove possession of the signature while disclosing only the attributes you choose — plus blind signing so the issuer never sees part of what it signs. +sidebar: + order: 3 + icon: eye-off +--- + +`signatures/bbs` implements the BBS+ signature scheme from +[eprint 2016/663](https://eprint.iacr.org/2016/663.pdf), section 4.3. A BBS+ signature covers an +ordered **vector** of scalar messages rather than one byte string, and that is the entire point: the +holder of a signature can later produce a zero-knowledge proof that says *"an issuer I can name +signed four attributes; here are attributes 3 and 4; I know the other two but I am not telling +you"*. The verifier learns nothing about the hidden attributes beyond the fact that they were signed. + +This is the credential primitive. Reach for it when you are issuing something like a driver's +licence or a KYC attestation and the holder must be able to prove "over 21" to a bar without handing +over a birth date, a licence number, and an address. Do **not** reach for it when you just need to +sign a document — the machinery is heavy, verification runs pairings, and +[BLS](/signatures/bls) or ECDSA does that job far more cheaply. + +:::danger[Do not deploy this package — message generators collide] +`MessageGenerators.Get(i)` returns the **same point for every index**. The method copies the internal +state array, writes the index into the copy, and then hashes the *original* — so the index never +reaches the hash. Every `H_i` for `i >= 1` is the identical point `h_0.Hash(state)`; only `Get(0)` +differs, returning `h_0` itself. + +The consequence is a trivial forgery: because the signature commits to +`h_0^s · Π H_i^{m_i}` and all `H_i` are equal, the signature depends only on the **sum** of the +messages. Any permutation of the signed vector verifies, and so does any different vector with the +same sum. Verified against this repository: + +``` +original verifies: true +permuted verifies: true // [3,4,5,6] signature accepted for [6,5,4,3] +same-sum forgery verifies: true // ...and for [1,2,7,8] +``` + +Everything below describes the API as written. Nothing below is safe to rely on for +unforgeability or for selective disclosure until `signatures/bbs/message_generators.go` hashes the +mutated local copy. See [security notes](/reference/security). +::: + +## Requirements + +BBS+ needs a pairing, so it needs a `*curves.PairingCurve`. In practice that means BLS12-381: + +```go +import ( + "github.com/sonr-io/crypto/core/curves" + "github.com/sonr-io/crypto/signatures/bbs" +) + +curve := curves.BLS12381(&curves.PointBls12381G2{}) +``` + +The argument to `curves.BLS12381` chooses which group holds the **public key**. Passing +`&curves.PointBls12381G2{}` puts the key in G2 and signatures in G1 — the layout every test in the +package uses. See [the curve abstraction](/foundations/curves) for what `PairingCurve` provides. + +Messages are `curves.Scalar`, not bytes. Convert with `curve.Scalar.Hash([]byte("..."))` for +free-form attributes, or `curve.Scalar.New(n)` for small integers. + +## Keys and generators + + + +Generators are **derived from the public key**, not stored with it. That is what lets one key sign +credentials of any width: you re-`Init` with a different `length` and get a different generator set. +It also means the verifier must `Init` with exactly the same `length` the signer used, or every +generator differs and nothing verifies. + +`Get` is one-based for messages: message index `i` in your slice uses generator `Get(i + 1)`, and +`Get(0)` is the blinding generator `h_0`. + +## Signing and verifying a full vector + +Grounded in `TestSignatureWorks`. + +```go sign.go +package main + +import ( + "fmt" + "log" + + "github.com/sonr-io/crypto/core/curves" + "github.com/sonr-io/crypto/signatures/bbs" +) + +func main() { + curve := curves.BLS12381(&curves.PointBls12381G2{}) + + pk, sk, err := bbs.NewKeys(curve) + if err != nil { + log.Fatal(err) + } + + // One generator per attribute. + generators, err := new(bbs.MessageGenerators).Init(pk, 4) + if err != nil { + log.Fatal(err) + } + + msgs := []curves.Scalar{ + curve.Scalar.Hash([]byte("did:key:z6Mk...")), + curve.Scalar.Hash([]byte("Ada")), + curve.Scalar.Hash([]byte("Lovelace")), + curve.Scalar.New(36), + } + + sig, err := sk.Sign(generators, msgs) + if err != nil { + log.Fatal(err) + } + + // Verify returns error, not bool. nil means valid. + if err := pk.Verify(sig, generators, msgs); err != nil { + log.Fatal("invalid signature: ", err) + } + fmt.Println("signature valid") +} +``` + +`Sign` is **deterministic**: the internal `e` and `s` scalars come from a SHAKE256 DRBG seeded with +the secret key, the generators, and the messages. Signing the same vector twice with the same key +produces byte-identical output. There is no `io.Reader` parameter and no nonce to misuse. + +`Sign` errors on an empty message slice, on `generators.length < len(msgs)`, and on a zero secret +key. `Verify` additionally rejects an identity public key and an identity signature point. + +:::note[`Sign` tolerates a short vector; the proof path does not] +`sk.Sign` only requires `generators.length >= len(msgs)`, so you can sign 3 messages against +4 generators. `NewPokSignature` requires `len(msgs) == generators.length` exactly. Size your +generators to the credential, not to a round number. +::: + +## Selective disclosure + +This is the flow that makes BBS+ worth its cost. The holder turns their signature into a +`PokSignature`, derives a Fiat-Shamir challenge from a merlin transcript, and emits a +`PokSignatureProof`. The verifier rebuilds the same transcript from the proof and the messages it +was shown, recomputes the challenge, and checks the two match. + + + + Build a `[]common.ProofMessage` with exactly one entry per generator, in signing order. Use + `common.RevealedMessage{Message: m}` for attributes the verifier will see and + `common.ProofSpecificMessage{Message: m}` for attributes it will not. Use + `common.SharedBlindingMessage{Message: m, Blinding: b}` only when the same hidden value must be + linked to another proof (a range proof over the same age, for example). + + + `NewPokSignature(sig, generators, proofMsgs, reader)` randomises the signature and builds the + Schnorr commitments. The reader supplies the proof's randomness — pass `crand.Reader`. + + + Create a merlin transcript with an application-specific label, feed it + `pok.GetChallengeContribution(transcript)`, append the verifier's nonce, extract 64 bytes and + reduce them with `curve.Scalar.SetBytesWide`. + + + `pok.GenerateProof(challenge)` converts the blinding factors into response scalars and returns + the `*PokSignatureProof`. Send that, the challenge, the revealed messages, and the nonce. + + + The verifier calls `pokSig.Verify(revealedMsgs, pk, generators, nonce, challenge, transcript)` + with a transcript constructed **identically** to the prover's. + + + +Grounded in `TestPokSignatureProofSomeMessagesRevealed`. + +```go disclose.go +package main + +import ( + crand "crypto/rand" + "fmt" + "log" + + "github.com/gtank/merlin" + + "github.com/sonr-io/crypto/core/curves" + "github.com/sonr-io/crypto/signatures/bbs" + "github.com/sonr-io/crypto/signatures/common" +) + +const transcriptLabel = "example.com/credential-presentation/v1" + +func main() { + curve := curves.BLS12381(&curves.PointBls12381G2{}) + pk, sk, err := bbs.NewKeys(curve) + if err != nil { + log.Fatal(err) + } + generators, err := new(bbs.MessageGenerators).Init(pk, 4) + if err != nil { + log.Fatal(err) + } + + msgs := []curves.Scalar{ + curve.Scalar.New(2), // holder id — keep hidden + curve.Scalar.New(3), // date of birth — keep hidden + curve.Scalar.New(4), // issuer — reveal + curve.Scalar.New(5), // credential type — reveal + } + sig, err := sk.Sign(generators, msgs) + if err != nil { + log.Fatal(err) + } + + // ---- holder side ------------------------------------------------------ + // One entry per generator, in signing order. + proofMsgs := []common.ProofMessage{ + &common.ProofSpecificMessage{Message: msgs[0]}, + &common.ProofSpecificMessage{Message: msgs[1]}, + &common.RevealedMessage{Message: msgs[2]}, + &common.RevealedMessage{Message: msgs[3]}, + } + + pok, err := bbs.NewPokSignature(sig, generators, proofMsgs, crand.Reader) + if err != nil { + log.Fatal(err) + } + + nonce := curve.Scalar.Random(crand.Reader) // supplied by the verifier + + transcript := merlin.NewTranscript(transcriptLabel) + pok.GetChallengeContribution(transcript) + transcript.AppendMessage([]byte("nonce"), nonce.Bytes()) + okm := transcript.ExtractBytes([]byte("signature proof of knowledge"), 64) + challenge, err := curve.Scalar.SetBytesWide(okm) + if err != nil { + log.Fatal(err) + } + + proof, err := pok.GenerateProof(challenge) + if err != nil { + log.Fatal(err) + } + + // ---- verifier side ---------------------------------------------------- + revealed := map[int]curves.Scalar{ + 2: msgs[2], + 3: msgs[3], + } + + vTranscript := merlin.NewTranscript(transcriptLabel) // same label, same order + ok := proof.Verify(revealed, pk, generators, nonce, challenge, vTranscript) + fmt.Println("presentation valid:", ok) +} +``` + +`revealed` is keyed by **zero-based message index**, matching the position in the original `msgs` +slice — not by generator index. + +### What `Verify` actually checks, and what `VerifySigPok` does not + +`PokSignatureProof.Verify` does two independent things: + +1. **`VerifySigPok(pk)`** — a pairing check that the randomised signature is a real signature under + `pk`. You can call this on its own. +2. **Challenge equality** — it calls `GetChallengeContribution(generators, revealedMsgs, challenge, + transcript)`, re-extracts 64 bytes from the transcript, and compares the result to the challenge + you passed in. This is what binds the *revealed messages* to the proof. + +Step 2 is why the transcript matters so much. If the verifier reveals a different message set, uses +a different transcript label, or appends the nonce at a different point, the recomputed challenge +differs and `Verify` returns `false`. + +:::warning[A transcript mismatch is indistinguishable from a forgery] +Prover and verifier must construct the merlin transcript with the **same label, the same appended +messages, in the same order, with the same domain-separation byte strings**. Any divergence produces +a different challenge and `Verify` returns `false` — with no error, no diagnostic, and nothing to +distinguish it from an actual attack. Put the transcript construction in one shared function that +both sides call. `PokSignatureProof.Verify` returns a bare `bool`; there is no error channel at all. +::: + +You can also drive the two halves manually — the test does exactly this to show BBS+ composing with +other sigma protocols that share the transcript: + +```go +proof.GetChallengeContribution(generators, revealed, challenge, vTranscript) +// ...other protocols append their contributions to vTranscript here... +vTranscript.AppendMessage([]byte("nonce"), nonce.Bytes()) +okm := vTranscript.ExtractBytes([]byte("signature proof of knowledge"), 64) +vChallenge, _ := curve.Scalar.SetBytesWide(okm) + +valid := proof.VerifySigPok(pk) && challenge.Cmp(vChallenge) == 0 +``` + +## Blind signing + +The dual problem: the *issuer* must sign an attribute it is not allowed to see — a link secret, a +biometric template, a device key. The holder commits to those messages, proves knowledge of the +committed values, and the issuer signs the commitment together with the messages it does know. + + + + `NewBlindSignatureContext(curve, hiddenMsgs, generators, nonce, reader)` returns the context to + send to the issuer **and** a `common.SignatureBlinding` the holder keeps. `hiddenMsgs` is a + `map[int]curves.Scalar` keyed by zero-based message index. + + + `ctx.Verify(knownIndices, generators, nonce)` checks the holder's proof of knowledge of the + hidden values, so the issuer is not signing arbitrary garbage. `knownIndices` is the sorted list + of indices the *issuer* supplies. + + + `ctx.ToBlindSignature(knownMsgs, sk, generators, nonce)` produces a `*BlindSignature`. It calls + `Verify` internally, so a bad commitment fails here too. + + + `blindSig.ToUnblinded(blinding)` adds the retained blinding factor back into the `s` component, + yielding an ordinary `*Signature` that verifies against the complete message vector. + + + +Grounded in `TestBlindSignatureContext`. + +```go blind.go +package main + +import ( + crand "crypto/rand" + "fmt" + "log" + + "github.com/sonr-io/crypto/core/curves" + "github.com/sonr-io/crypto/signatures/bbs" +) + +func main() { + curve := curves.BLS12381(&curves.PointBls12381G2{}) + pk, sk, err := bbs.NewKeys(curve) + if err != nil { + log.Fatal(err) + } + generators, err := new(bbs.MessageGenerators).Init(pk, 4) + if err != nil { + log.Fatal(err) + } + nonce := curve.Scalar.Random(crand.Reader) + + // ---- holder: hide message 0 from the issuer --------------------------- + hidden := map[int]curves.Scalar{ + 0: curve.Scalar.Hash([]byte("link-secret")), + } + ctx, blinding, err := bbs.NewBlindSignatureContext(curve, hidden, generators, nonce, crand.Reader) + if err != nil { + log.Fatal(err) + } + // Send ctx (and nonce) to the issuer. Keep `blinding`. + + // ---- issuer: signs only what it knows --------------------------------- + known := map[int]curves.Scalar{ + 1: curve.Scalar.Hash([]byte("firstname")), + 2: curve.Scalar.Hash([]byte("lastname")), + 3: curve.Scalar.Hash([]byte("age")), + } + blindSig, err := ctx.ToBlindSignature(known, sk, generators, nonce) + if err != nil { + log.Fatal(err) + } + + // ---- holder: unblind and check ---------------------------------------- + sig := blindSig.ToUnblinded(blinding) + + full := []curves.Scalar{hidden[0], known[1], known[2], known[3]} + if err := pk.Verify(sig, generators, full); err != nil { + log.Fatal("unblinded signature invalid: ", err) + } + fmt.Println("blind-signed credential valid") +} +``` + +The issuer never sees `hidden[0]`. It only ever handles `ctx.commitment`, a group element, plus a +Schnorr proof that the holder knows the openings. + +:::danger[Lose the blinding factor and the signature is dead] +`ToUnblinded` is the only way to turn a `*BlindSignature` into a verifiable `*Signature`, and it +requires the exact `common.SignatureBlinding` returned alongside the context. That value is random, +is never transmitted, and cannot be recovered from the signature, the context, or the issuer. +Persist it atomically with the blind signature or the credential is unusable and must be re-issued. +::: + +:::warning[The index sets must partition the vector] +`hidden` and `known` are both keyed by zero-based message index, and between them they must cover +every position `0..length-1` exactly once. Nothing checks this. An index present in neither map +leaves a generator unaccounted for and the unblinded signature simply fails to verify; an index +present in both silently produces a signature over a value the holder did not intend. +::: + +## Serialization + +Every type here is a `BinaryMarshaler`, but the wire format does not carry its curve, so the +unmarshalling side needs `Init(curve)` first: + +```go +data, err := sig.MarshalBinary() + +restored := new(bbs.Signature).Init(curve) +err = restored.UnmarshalBinary(data) +``` + +The same pattern applies to `PublicKey`, `SecretKey`, `BlindSignature`, `BlindSignatureContext`, and +`PokSignatureProof`. Calling `UnmarshalBinary` on a zero-valued struct dereferences nil fields and +panics. + +`BlindSignatureContext.MarshalBinary` writes the commitment point followed by the challenge and one +scalar per proof — `PointSize + (N + 1) * ScalarSize` bytes. + +## Caveats + +:::danger[Broken message binding] +Restated because it invalidates everything above: `MessageGenerators.Get` makes all generators +equal, so a signature binds only the *sum* of the message scalars. +::: + +:::warning[Inconsistent failure signalling] +`PublicKey.Verify` and `BlindSignatureContext.Verify` return `error`. `PokSignatureProof.Verify` and +`VerifySigPok` return `bool`. `MessageGenerators.Get` returns a bare `nil` for an out-of-range index +rather than an error, so a bad index surfaces later as a nil-pointer dereference in whatever point +operation consumes it. Do not assume a uniform idiom across this package. +::: + +:::note[Deterministic signing has a privacy consequence] +Because `Sign` derives its randomness from `(sk, generators, msgs)`, re-issuing the identical +credential yields the identical signature bytes. That is convenient for idempotent issuance and bad +for unlinkability if raw signatures ever leave the holder. Present via `PokSignatureProof`, which +re-randomises, rather than by forwarding the signature. +::: + +## Related + + + + `signatures/common` — `ProofMessage`, `ProofCommittedBuilder`, `HmacDrbg`, and the scalar aliases + this page uses. + + + The standalone sigma protocol, for composing proofs of discrete-log knowledge alongside a + BBS+ presentation. + + + Constant-size set membership on the same pairing curve — the usual companion for revocation. + + + Every defect found while documenting this library, in one place. + + diff --git a/docs/signatures/bls.mdx b/docs/signatures/bls.mdx new file mode 100644 index 0000000..ab9ce01 --- /dev/null +++ b/docs/signatures/bls.mdx @@ -0,0 +1,469 @@ +--- +title: BLS Signatures +description: Pairing-based signatures on BLS12-381 with aggregation, multi-signatures, proofs of possession, and non-interactive threshold key generation. +sidebar: + order: 2 + icon: combine +--- + +`signatures/bls/bls_sig` implements the BLS signature scheme from +[draft-irtf-cfrg-bls-signature-03](https://tools.ietf.org/html/draft-irtf-cfrg-bls-signature-03) on +BLS12-381. Its defining property is **aggregation**: any number of signatures can be combined into a +single group element that verifies against the corresponding set of public keys, and the combined +object is exactly the size of one signature. + +Reach for BLS when you need to compress many signatures (block attestations, multi-party approvals, +certificate chains), or when you want `t`-of-`n` threshold signing **without an interactive +protocol** — BLS partial signatures combine by plain Lagrange interpolation, so signers never talk to +each other. Reach for something else if you need short verification time on constrained hardware +(pairings are expensive), or if your verifier is a chain that only knows secp256k1 or Ed25519 — in +that case see [threshold ECDSA](/threshold/threshold-ecdsa) or +[threshold Ed25519](/threshold/threshold-ed25519). + +```go +import "github.com/sonr-io/crypto/signatures/bls/bls_sig" +``` + +:::note +This package does **not** use the [`core/curves`](/foundations/curves) `Curve` / `Point` / `Scalar` +abstraction. It calls the native `core/curves/native/bls12381` backend directly and is hard-wired to +BLS12-381 — there is no curve parameter anywhere in its API. +::: + +## Two instantiations: `Vt` and non-`Vt` + +BLS12-381 has two source groups, G1 and G2, and the pairing is asymmetric. You must decide which +group carries public keys and which carries signatures; whichever you put in G1 is the small one. +The package exposes both choices as two parallel type families that share a `SecretKey` type. + +| | Non-`Vt` types | `Vt` types | +| --- | --- | --- | +| Public key group | **G1** (`PublicKey`) | **G2** (`PublicKeyVt`) | +| Signature group | **G2** (`Signature`) | **G1** (`SignatureVt`) | +| Compressed public key | 48 bytes (`PublicKeySize`) | 96 bytes (`PublicKeyVtSize`) | +| Compressed signature | 96 bytes (`SignatureSize`) | 48 bytes (`SignatureVtSize`) | +| Compressed PoP | 96 bytes (`ProofOfPossessionSize`) | 48 bytes (`ProofOfPossessionVtSize`) | +| Trade-off | minimal **public key** size | minimal **signature** size | + +Secret keys are shared between the two families: + +| Constant | Value | Meaning | +| --- | --- | --- | +| `SecretKeySize` | `32` | A scalar mod `r`, the subgroup order. Cannot be zero. | +| `SecretKeyShareSize` | `33` | A 32-byte share value followed by a 1-byte identifier at index 32. | + +`SecretKeyShareSize` being 33 rather than 32 is why shares are self-describing: the trailing +identifier is the Shamir x-coordinate, so `CombineSignatures` can reconstruct the Lagrange +coefficients from the partials alone. It also caps you at 255 shares — identifier `0` is invalid. + +Which one do you want? If your verifier stores many public keys and sees few signatures (an on-chain +validator registry), the non-`Vt` family is cheaper. If you publish many signatures against few keys +(per-block attestations), `Vt` is cheaper. Ethereum 2 uses the non-`Vt` layout — 48-byte pubkeys in +G1, 96-byte signatures in G2 — which is what `NewSigEth2()` gives you. + +:::warning[The `Vt` doc comments are wrong in one place] +The source comment above `SigBasicVt` in `tiny_bls.go` says "minimal-pubkey-size"; it is a +copy-paste from the non-`Vt` file. `SigBasicVt` is minimal-*signature*-size, consistent with its +`SignatureVt` being the 48-byte G1 element. Trust the types and the constants, not that comment. +::: + +## Three ciphersuites + +Independently of the group choice, the draft defines three ciphersuites that differ only in what +gets hashed and what the caller must check. Each is a distinct Go type with its own constructor and +its own domain separation tag. + +| Scheme | Constructor | Signature DST | Extra requirement | +| --- | --- | --- | --- | +| `SigBasic` | `NewSigBasic()` | `BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_` | All messages in an aggregate must be distinct | +| `SigAug` | `NewSigAug()` | `BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_AUG_` | Public key is prepended to the message before hashing | +| `SigPop` | `NewSigPop()` | `BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_` | Every key needs a verified proof of possession | +| `SigBasicVt` | `NewSigBasicVt()` | `BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_NUL_` | as above | +| `SigAugVt` | `NewSigAugVt()` | `BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_AUG_` | as above | +| `SigPopVt` | `NewSigPopVt()` | `BLS_SIG_BLS12381G1_XMD:SHA-256_SSWU_RO_POP_` | as above | + +`SigPop` additionally carries a second DST used only for proof-of-possession *proofs*: + +| Constant | Value | +| --- | --- | +| PoP proof DST (non-`Vt`) | `BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_` | +| PoP proof DST (`Vt`) | `BLS_POP_BLS12381G1_XMD:SHA-256_SSWU_RO_POP_` | + +The `G1`/`G2` token inside each DST names the group the *signature* lives in, which is why the `Vt` +tags say `G1`. + +`SigEth2` is a plain Go type alias for `SigPop`, and `SigEth2Vt` for `SigPopVt`: + +```go +type SigEth2 = SigPop +func NewSigEth2() *SigEth2 { return NewSigPop() } +``` + +They are naming conveniences, nothing more — `NewSigEth2()` and `NewSigPop()` return identical +values with identical DSTs. + +### Overriding the DST + +Every scheme has a `WithDst` constructor for interoperating with a system that chose different +domain separation: + +```go +b := bls_sig.NewSigBasicWithDst("MY_APP_BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_") + +// SigPop needs both tags, and rejects equal ones. +p, err := bls_sig.NewSigPopWithDst( + "MY_APP_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_", + "MY_APP_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_", +) +``` + +`NewSigPopWithDst` / `NewSigPopVtWithDst` are the only DST constructors that return an error: they +reject a signature DST equal to the PoP DST. The others accept any string, including an empty one. + +### What each ciphersuite defends against + +The threat is the **rogue-key attack**. Aggregate verification checks a product of pairings. An +attacker who is allowed to publish a public key *after* seeing honest keys can publish +`pk_evil = g^a · (Π pk_honest)^-1` and then produce an "aggregate" signature over a message the +honest parties never signed. The three ciphersuites each break this differently: + + + + Nothing binds a key to its message beyond the message itself, so security rests on the caller + ensuring **every message in an aggregate is distinct**. `AggregateVerify` enforces this: it + rejects the batch if any two message byte strings are equal. Use Basic only when your messages + are naturally unique (they embed a nonce, a height, a hash). + + + `Sign` prepends the signer's own compressed public key to the message before hashing: + `H(pk_bytes || msg)`. That makes each signer's hashed point key-dependent, so rogue keys cannot + cancel. `Verify` and `AggregateVerify` reproduce the same prefix. No caller discipline is + required, and messages may repeat. The cost is that verification needs the exact public key + bytes, and `SigAug.PartialSign` therefore takes an extra `*PublicKey` argument that the other + schemes do not. + + + Each signer publishes a proof of possession — a signature over their own public key under a + separate DST — proving they know the secret behind the key. Once every key in a set has a + verified PoP, rogue keys are impossible by construction, and the fast path opens up: + `FastAggregateVerify` and `VerifyMultiSignature` verify N signatures over the *same* message + with a single pairing check. This is the Eth2 configuration. + + + +:::danger[Pop only defends you if you actually call `PopVerify`] +`FastAggregateVerify`, `AggregatePublicKeys`, and `VerifyMultiSignature` do **not** check proofs of +possession. Nothing in the library forces you to. If you aggregate a public key you have not +`PopVerify`'d, `SigPop` gives you no more rogue-key protection than `SigBasic` with duplicate +messages — which is to say, none. Verify the PoP at key-registration time and refuse to store keys +that fail. +::: + +## Method set + +All six scheme types share this core. Signatures are `(bool, error)` — **check both**, because a +verification that errored also returns `false`, and a nil error does not mean valid. + + + +`SigPop` and `SigPopVt` add: + + + +`AggregateVerify` (many distinct messages) and `FastAggregateVerify` (one shared message) are not +interchangeable. Passing the same message N times to `AggregateVerify` under `SigBasic` or `SigPop` +returns `false` by design. + +## Aggregate verification + +Grounded in `TestBasicAggregateVerifyG2Works` and its `generateBasicAggregateDataG2` helper. + +```go aggregate.go +package main + +import ( + "crypto/rand" + "fmt" + "log" + + "github.com/sonr-io/crypto/signatures/bls/bls_sig" +) + +func main() { + bls := bls_sig.NewSigBasic() + + const n = 10 + pks := make([]*bls_sig.PublicKey, n) + sigs := make([]*bls_sig.Signature, n) + msgs := make([][]byte, n) + + for i := 0; i < n; i++ { + ikm := make([]byte, 32) + if _, err := rand.Read(ikm); err != nil { + log.Fatal(err) + } + pk, sk, err := bls.KeygenWithSeed(ikm) + if err != nil { + log.Fatal(err) + } + + // SigBasic requires every message in the batch to differ. + msg := []byte(fmt.Sprintf("attestation %d", i)) + sig, err := bls.Sign(sk, msg) + if err != nil { + log.Fatal(err) + } + pks[i], sigs[i], msgs[i] = pk, sig, msg + } + + ok, err := bls.AggregateVerify(pks, msgs, sigs) + if err != nil { + log.Fatal(err) + } + fmt.Println("aggregate valid:", ok) +} +``` + +Swap `NewSigBasic()` for `NewSigAug()` and the duplicate-message restriction disappears, at the cost +of `PartialSign` gaining a public-key argument. + +## Threshold signing + +Grounded in `TestBasicPartialSign`. Note there is no DKG here and no interaction between signers: +`ThresholdKeygen` produces the shares centrally, and each holder signs independently. + + + + `ThresholdKeygen(2, 4)` returns one public key plus four `*SecretKeyShare` values. The public + key is the ordinary BLS public key for the reconstructed secret — verifiers never learn that + threshold signing happened. + + + Each holder calls `PartialSign(share, msg)`. No round trips, no shared state, no per-signature + nonce. Partials can be produced years apart. + + + `CombineSignatures(partials...)` Lagrange-interpolates in the exponent. It rejects fewer than + two partials, duplicate share identifiers, and nil entries — but it has no idea what your + threshold was, so short-of-threshold input succeeds and yields a wrong signature. + + + The result is an ordinary `*Signature`. `Verify(pk, msg, sig)` accepts it. + + + +```go threshold.go +package main + +import ( + "fmt" + "log" + + "github.com/sonr-io/crypto/signatures/bls/bls_sig" +) + +func main() { + bls := bls_sig.NewSigBasic() + + // 2-of-4. pk is the ordinary public key for the (never assembled) secret. + pk, shares, err := bls.ThresholdKeygen(2, 4) + if err != nil { + log.Fatal(err) + } + + msg := []byte("release the funds") + + p1, err := bls.PartialSign(shares[0], msg) + if err != nil { + log.Fatal(err) + } + p2, err := bls.PartialSign(shares[2], msg) + if err != nil { + log.Fatal(err) + } + + sig, err := bls.CombineSignatures(p1, p2) + if err != nil { + log.Fatal(err) + } + + ok, err := bls.Verify(pk, msg, sig) + if err != nil { + log.Fatal(err) + } + fmt.Println("threshold signature valid:", ok) // true +} +``` + +`PartialSignature` is the only public-field type in the package: + +```go +type PartialSignature struct { + Identifier byte + Signature bls12381.G2 // bls12381.G1 for PartialSignatureVt +} +``` + +Partials are not `BinaryMarshaler`s — if you need to ship them across a wire, serialize the +identifier and the group element yourself. + +## Serialization + +Every key, signature, PoP, multi-key, multi-signature, and secret-key share implements +`encoding.BinaryMarshaler` and `encoding.BinaryUnmarshaler`, using the standard compressed +[zcash BLS12-381 encoding](https://github.com/zcash/librustzcash/blob/master/pairing/src/bls12_381/README.md#serialization). +The unmarshalers validate length, reject the all-zero encoding, and check subgroup membership. + +```go +raw, err := pk.MarshalBinary() // 48 bytes for PublicKey, 96 for PublicKeyVt + +var restored bls_sig.PublicKey +err = restored.UnmarshalBinary(raw) +``` + +`SecretKey.UnmarshalBinary` requires exactly 32 bytes and rejects all-zero input. +`SecretKeyShare.UnmarshalBinary` requires exactly 33 and likewise rejects all-zero; the identifier +is the final byte. + +## Caveats + +:::danger[Both return values matter] +`Verify`, `AggregateVerify`, `FastAggregateVerify`, `VerifyMultiSignature`, and `PopVerify` all +return `(bool, error)`. Writing `if ok, _ := bls.Verify(...); ok` discards a real error, and writing +`if err == nil` accepts an invalid signature. Check the boolean **and** the error. +::: + +:::warning[`SigBasic` and `SigPop` silently reject duplicate messages] +`AggregateVerify` returns `(false, nil)` — not an error — when two messages in the batch are byte +equal. If you are aggregating attestations that legitimately repeat, you want `SigAug`, or you want +the same-message path (`FastAggregateVerify`) under `SigPop`. +::: + +:::warning[Mixing families does not compile, but mixing ciphersuites does] +`PublicKeyVt` and `PublicKey` are different types, so the compiler catches G1/G2 mistakes. Nothing +catches verifying a `SigAug` signature with `NewSigBasic()` — the DSTs differ, so you simply get +`false`. Store the ciphersuite alongside the key material. +::: + +:::note[Keygen input length] +`KeygenWithSeed` and `ThresholdKeygenWithSeed` require `len(ikm) >= 32`. Shorter input returns an +error rather than stretching. An all-zero 32-byte `ikm` is accepted — the HKDF step still produces a +nonzero scalar — so a zeroed buffer will not fail loudly; it will produce a deterministic, publicly +derivable key. +::: + +:::danger[`CombineSignatures` does not enforce your threshold] +`combineSigs` only checks that it received between 2 and 255 distinct, subgroup-valid partials. It +never learns the `threshold` you passed to `ThresholdKeygen`, so combining 2 partials of a 3-of-5 +key returns a perfectly well-formed `*Signature` with `err == nil` that simply fails verification. +If your application distinguishes "not enough signers yet" from "a signer cheated", count the +partials yourself before combining. +::: + +:::warning[`KeygenWithSeed` mutates the slice you hand it] +Key derivation does `ikm = append(ikm, 0)` before the HKDF call. When your `ikm` slice has spare +capacity — for example a sub-slice of a larger buffer — that append writes a zero byte into the +backing array past `len(ikm)`, clobbering whatever lived there. Pass a slice whose length equals its +capacity, or a fresh copy. +::: + +:::note[Nil versus empty messages] +`SigBasic.Sign` and `SigPop.Sign` accept an empty non-nil slice but reject `nil`. `SigAug.Sign` +rejects both, because it checks `len(msg) == 0`. `PartialSign` rejects both in every scheme, for the +same reason — so a message that a full `Sign` accepts may be refused by the threshold path. +::: + +:::note[Key derivation detail] +`Generate` follows draft-04's KeyGen: `salt = SHA-256("BLS-SIG-KEYGEN-SALT-")`, then +`HKDF-SHA256(ikm || 0x00, salt, info = I2OSP(48, 2))`, read 48 bytes, byte-reversed, reduced mod the +subgroup order. It does not implement the salt-rehashing loop from later drafts, so a zero result +would be returned rather than retried — an outcome with negligible probability, but not one the code +guards against. +::: + +## Related + + + + Shamir, Feldman, and Pedersen sharing — the general machinery behind `ThresholdKeygen`. + + + When no single party may ever hold the whole secret, even at dealing time. + + + The other pairing-based primitive in this library, also on BLS12-381. + + + Known defects and unaudited paths across the library. + + diff --git a/docs/signatures/chain-schemes.mdx b/docs/signatures/chain-schemes.mdx new file mode 100644 index 0000000..74fda94 --- /dev/null +++ b/docs/signatures/chain-schemes.mdx @@ -0,0 +1,476 @@ +--- +title: Chain-Specific Schemes +description: Mina-protocol Schnorr over Pallas with Poseidon, and NEM's Keccak-512 flavoured Ed25519 — interop code for two specific networks, not general-purpose primitives. +sidebar: + order: 6 + icon: link +--- + +Everything under `signatures/schnorr` exists to produce bytes that one particular blockchain will +accept. These are not primitives you choose on cryptographic merit; you use them because you are +talking to Mina or to NEM/Symbol and their consensus rules define the signature format down to the +hash function. Both live under a `schnorr` directory, but only Mina is actually Schnorr — NEM is +Ed25519 with a hash substitution. + +If you are not integrating with those two networks, nothing on this page is for you. For general +signing see [BLS](/signatures/bls), [ECDSA utilities](/signatures/ecdsa), or +[threshold Ed25519](/threshold/threshold-ed25519). + +## Mina: Schnorr over Pallas + +```go +import "github.com/sonr-io/crypto/signatures/schnorr/mina" +``` + +Mina's signature scheme is Schnorr on the **Pallas** curve with the **Poseidon** algebraic hash. Both +choices exist because Mina's recursive SNARKs must verify signatures *inside* a circuit, where +SHA-256 is ruinously expensive and Poseidon is cheap. The package mirrors +[Mina's C reference signer](https://github.com/MinaProtocol/c-reference-signer) — the tests use that +project's key and transaction fixtures. + +Signing computes `k` deterministically from the key, the public key, the network id, and the message +(`msgDerive`), negates `k` when `R` has an odd y-coordinate, and returns `(R.x, s)` where +`s = k + e·sk` and `e` is the Poseidon hash of the public key, `R.x`, the message, and the network +id. There is no randomness at signing time. + +### Keys and addresses + + + +`SetPointPallas(*curves.PointPallas)` and `SetFq(*fq.Fq)` are the escape hatches that let a threshold +signer inject externally-produced key material — see the FROST bridge below. + +### Signing + +`SignTransaction` is the real API; `SignMessage` is a convenience for signing a plain string. + + + +`Signature` is the only struct here with exported fields: + +```go +type Signature struct { + R *fp.Fp // x coordinate of the nonce point, base field + S *fq.Fq // response scalar, scalar field +} +``` + +`MarshalBinary` produces exactly 64 bytes, `R` then `S`; `UnmarshalBinary` requires exactly 64 and +validates both field elements. + +Grounded in `TestSecretKeySignTransaction`. + +```go mina.go +package main + +import ( + "fmt" + "log" + + "github.com/sonr-io/crypto/signatures/schnorr/mina" +) + +func main() { + pk, sk, err := mina.NewKeys() + if err != nil { + log.Fatal(err) + } + fmt.Println("address:", pk.GenerateAddress()) + + feePayer := new(mina.PublicKey) + if err := feePayer.ParseAddress("B62qiy32p8kAKnny8ZFwoMhYpBppM1DWVCqAPBYNcXnsAHhnfAAuXgg"); err != nil { + log.Fatal(err) + } + receiver := new(mina.PublicKey) + if err := receiver.ParseAddress("B62qrcFstkpqXww1EkSGrqMCwCNho86kuqBd4FrAAUsPxNKdiPzAUsy"); err != nil { + log.Fatal(err) + } + + txn := &mina.Transaction{ + Fee: 3, + FeeToken: 1, + Nonce: 200, + ValidUntil: 10000, + Memo: "this is a memo", + FeePayerPk: feePayer, + SourcePk: feePayer, + ReceiverPk: receiver, + TokenId: 1, + Amount: 42, + Locked: false, + Tag: [3]bool{false, false, false}, // all false = payment + NetworkId: mina.MainNet, + } + + sig, err := sk.SignTransaction(txn) + if err != nil { + log.Fatal(err) + } + if err := sk.GetPublicKey().VerifyTransaction(sig, txn); err != nil { + log.Fatal("invalid: ", err) + } + + raw, err := sig.MarshalBinary() + if err != nil { + log.Fatal(err) + } + fmt.Println("signature bytes:", len(raw)) // 64 +} +``` + +Setting `Tag: [3]bool{false, false, true}` makes it a stake delegation instead of a payment, as in +`TestSecretKeySignTransactionStaking`. + +### The `Transaction` type + + + +`MarshalBinary` writes a fixed **175-byte** layout: fee, fee token, fee-payer point, nonce, valid +until, a `0x01` marker, memo length, 32 memo bytes, three tag bytes, source point, receiver point, +token id, amount, locked flag, and finally the network id at offset 174. `UnmarshalBinary` reverses +it and requires that exact length. This encoding is what the FROST bridge parses. + +### Network types + +`NetworkType` selects the Poseidon sponge initialisation vector and is mixed into the nonce +derivation, so a signature made for one network is invalid on another — that is deliberate replay +protection. + +| Constant | Value | Meaning | +| --- | --- | --- | +| `TestNet` | `0` | Mina testnet IV. Also the zero value of `NetworkType`, so a `Transaction` you forgot to fill in is a testnet transaction. | +| `MainNet` | `1` | Mina mainnet IV. | +| `NullNet` | `2` | Zero-initialised sponge state, no IV. Used by the Poseidon unit tests for raw-permutation vectors. | + +### Poseidon internals + +You do not need these to sign, but they are exported and occasionally useful for testing a circuit +against the same hash. + + + +```go +ctx := new(mina.Context).Init(mina.ThreeW, mina.MainNet) +ctx.Update(fields) // []*fp.Fp +digest := ctx.Digest() // *fq.Fq +``` + +:::warning[`Context.Init` reports failure by returning `nil`] +An out-of-range `Permutation` or `NetworkType` makes `Init` return a nil `*Context` rather than an +error. The very next `ctx.Update(...)` panics with a nil dereference. Check the return value. +::: + +The task-facing type `roinput` — the random-oracle input builder that packs a transaction into field +elements and bits — is **unexported**. You cannot construct one, and the only way to reach that +packing logic is through `SignTransaction`, `SignMessage`, or `MinaTSchnorrHandler`. + +### Bridging to threshold signing + +`MinaTSchnorrHandler` adapts Mina's challenge derivation to the library's FROST-style threshold +Schnorr signer, so a Mina key can be split across parties. See +[threshold Ed25519](/threshold/threshold-ed25519) for the signer this plugs into. + +```go +func (m MinaTSchnorrHandler) DeriveChallenge( + msg []byte, + pubKey curves.Point, // must be a *curves.PointPallas + r curves.Point, // must be a *curves.PointPallas +) (curves.Scalar, error) +``` + +`msg` is **not** an arbitrary message: the handler calls `Transaction.UnmarshalBinary(msg)` on it, so +it must be the 175-byte transaction encoding produced by `Transaction.MarshalBinary`. Anything else +returns "invalid byte sequence". + +:::danger[`DeriveChallenge` ignores the transaction's network id] +The handler hardcodes `msgHash(pk, R.X(), input, ThreeW, MainNet)`. It parses `msg` into a +`Transaction`, which carries a `NetworkId` field, and then discards it. Threshold-signing a testnet +transaction through this handler produces a challenge computed with the **mainnet** sponge IV, so +the resulting signature will not verify with `VerifyTransaction` for any `NetworkId` other than +`MainNet`. There is no way to override this from the outside. +::: + +### Mina caveats + +:::danger[`Transaction.UnmarshalJSON` does not work] +The method type-asserts `Body[1]` from `any` directly to its concrete struct types +(`txnBodyPaymentJson`, `[2]any`). `encoding/json` decodes an unconstrained `any` into +`map[string]any` and `[]any`, so those assertions can never succeed. Every call returns +`unexpected type`. Confirmed against a well-formed payload built from the fixtures in +`keys_test.go`: + +``` +UnmarshalJSON err = unexpected type +SourcePk nil: true Amount: 0 TokenId: 0 +``` + +Even if the assertions were fixed, the method never assigns `SourcePk`, `Amount`, `TokenId`, +`Locked`, or `Tag`; it computes a `sourcePk` local and drops it; it swallows a `ParseAddress` error +in the payment branch with a bare `return nil`; and it indexes the decoded memo as +`memo[2 : 2+memo[1]]` without a length check. There is no corresponding `MarshalJSON`. Build +`Transaction` values in Go and use `MarshalBinary` for the wire; do not route Mina transactions +through this JSON path. +::: + +:::warning[Memos longer than 32 bytes corrupt the encoding] +`MarshalBinary` writes `out[57] = byte(len(txn.Memo))` and then `copy(out[58:90], txn.Memo)`. The +copy caps at the 32-byte destination, but the recorded length does not — so a 40-byte memo produces +a transaction claiming length 40 with only 32 bytes present, and a 256-byte memo records length 0. +Neither is rejected. Truncate memos to 32 bytes yourself before signing. +::: + +:::warning[`SignMessage` and `VerifyMessage` are MainNet-only] +Both hardcode `MainNet`; there is no network parameter and no variant that takes one. Only +`SignTransaction` / `VerifyTransaction` honour `NetworkId`. +::: + +:::note[Nil public keys panic] +`Transaction.MarshalBinary` dereferences `FeePayerPk`, `SourcePk`, and `ReceiverPk` without nil +checks. A partially-filled `Transaction` panics rather than returning an error. +::: + +## NEM: Ed25519 with Keccak-512 + +```go +import "github.com/sonr-io/crypto/signatures/schnorr/nem" +``` + +NEM (and its successor Symbol) adopted Ed25519 before the standard settled and substituted +**Keccak-512** for SHA-512 in every hashing step — key expansion, nonce derivation, and challenge +computation. There is one further quirk: the seed is **byte-reversed** before hashing, which the +source comments call a "weird required step to get compatibility with the NEM test vectors". + +Everything else is textbook Ed25519 over Edwards25519, and this package is unusually well grounded: +`ed25519_keccak_test.go` checks derivation and signing against fixtures pulled from +[symbol/test-vectors](https://github.com/symbol/test-vectors), with a comment noting that all 10000 +vectors passed at the time of writing. + +### Constants and API + +| Constant | Value | +| --- | --- | +| `PublicKeySize` | `32` | +| `PrivateKeySize` | `64` | +| `SignatureSize` | `64` | +| `SeedSize` | `32` | + + + +Grounded in `TestPrivToPubkey` and `TestSigs`. + +```go nem.go +package main + +import ( + "encoding/hex" + "fmt" + "log" + + "github.com/sonr-io/crypto/signatures/schnorr/nem" +) + +func main() { + // A NEM test vector: private key and its expected public key. + seed, err := hex.DecodeString( + "575DBB3062267EFF57C970A336EBBC8FBCFE12C5BD3ED7BC11EB0481D7704CED") + if err != nil { + log.Fatal(err) + } + + priv, err := nem.NewKeyFromSeed(seed) + if err != nil { + log.Fatal(err) + } + + pub := priv.Public().(nem.PublicKey) + fmt.Println("public key:", + hex.EncodeToString(pub.Bytes())) + // c5f54ba980fcbb657dbaaa42700539b207873e134d2375efeab5f1ab52f87844 + // — the public key the symbol/test-vectors fixture pairs with that seed. + + msg := []byte("transfer") + sig, err := nem.Sign(priv, msg) + if err != nil { + log.Fatal(err) + } + + ok, err := nem.Verify(pub, msg, sig) + if err != nil { + log.Fatal(err) + } + fmt.Println("valid:", ok, "bytes:", len(sig)) // true 64 +} +``` + +### NEM caveats + +:::danger[These keys and signatures are NOT interchangeable with `crypto/ed25519`] +The hash function differs at every step, and the seed is reversed before expansion. Concretely: + +- `nem.NewKeyFromSeed(seed)` and `ed25519.NewKeyFromSeed(seed)` derive **different public keys** from + the same seed. +- A `nem.PrivateKey` handed to `ed25519.Sign` produces a signature that does not verify under the + public key embedded in that same private key. +- A signature from `nem.Sign` will never verify with `ed25519.Verify`, and vice versa. + +Both types are `[]byte` with identical 32/64-byte layouts, so nothing in the type system stops you +mixing them. Never share a seed, a key, or a signature between the two. If you need standards +Ed25519, use `crypto/ed25519` or [threshold Ed25519](/threshold/threshold-ed25519). +::: + +:::warning[Doc comments promise panics the code does not deliver] +`Sign`, `Verify`, and `NewKeyFromSeed` carry doc comments inherited from the standard library saying +"It will panic if len(...) is not ...". The implementations return an `error` instead. Code written +against the comments — assuming a wrong length is unreachable and therefore ignoring the error — will +silently proceed with a `nil` signature. +::: + +:::note[`Verify` returns `(bool, error)`] +Unlike `crypto/ed25519.Verify`, which returns a bare `bool`. The error path fires when the internal +Keccak write fails or the public key is malformed. Check both values. +::: + +## Related + + + + The FROST signer that `MinaTSchnorrHandler` plugs its challenge derivation into. + + + Pallas, `PointPallas`, and `ScalarPallas` — the types the Mina package builds on. + + + Back to the scheme selection guide. + + + The defects on this page, collected with the rest. + + diff --git a/docs/signatures/ecdsa.mdx b/docs/signatures/ecdsa.mdx new file mode 100644 index 0000000..829c4b8 --- /dev/null +++ b/docs/signatures/ecdsa.mdx @@ -0,0 +1,377 @@ +--- +title: ECDSA Utilities +description: Canonical low-S form, malleability defence, fixed-width signature codecs, and RFC 6979-style deterministic signing on top of the standard library's crypto/ecdsa. +sidebar: + order: 4 + icon: check-check +--- + +The `ecdsa` package is a thin layer of utilities over the standard library. It does not define a key +type, a curve, or a signature struct — it operates on `*ecdsa.PrivateKey`, `*ecdsa.PublicKey`, +`elliptic.Curve`, and raw `*big.Int` pairs from `crypto/ecdsa`. Two problems are solved here that +the standard library leaves to you: + +1. **Malleability.** ECDSA signatures are not unique per message. Two different byte strings verify + equally well, so signature bytes cannot be used as an identifier. +2. **Nonce dependence.** `ecdsa.Sign` needs entropy at signing time, and a bad or repeated nonce + leaks the private key outright. + +Reach for this package when you store, index, deduplicate, or compare ECDSA signatures, or when you +need signing to be reproducible on a device you do not trust to have a good RNG. Do **not** reach +for it for ordinary sign-and-verify: `crypto/ecdsa` already does that, correctly and with a +constant-time implementation. Everything here is `math/big` arithmetic and makes no constant-time +claim. + +```go +import "github.com/sonr-io/crypto/ecdsa" +``` + +:::note +The import path collides with the standard library's `crypto/ecdsa`. In any file that uses both you +must alias one — the examples below alias the standard library as `stdecdsa`. +::: + +## Malleability, and why canonical form matters + +An ECDSA signature is a pair `(r, s)` over a curve of prime order `N`. Verification checks a +relation that is symmetric in the sign of `s`: + +$$ +(r,\; s) \text{ valid} \iff (r,\; N - s) \text{ valid} +$$ + +Anyone who observes a valid signature can therefore produce a *second*, different, equally valid +signature for the same message and the same key — without knowing the private key. The consequences +are practical, not theoretical: + +- **Signature bytes are not an identifier.** Keying a database, a replay-protection cache, or a + transaction ID on raw signature bytes lets an attacker create an unbounded number of distinct + entries for one authorised action. This is the Bitcoin transaction-malleability bug. +- **Byte equality is not signature equality.** `bytes.Equal(sigA, sigB) == false` does not mean two + parties signed different things. + +The fix everybody converged on is a **canonical form**: of the two valid `s` values, always use the +smaller one, `s <= N/2`. This package calls that "canonical" and provides both the coercion and the +strict rejection. + + + +`MakeCanonical` and `IsCanonical` take a bare `*big.Int` order rather than a curve, which makes them +usable with secp256k1 or any other order you have on hand; the rest take an `elliptic.Curve`. + +:::warning[`MakeCanonical` returns `r` by reference] +The internal helper returns the *same* `*big.Int` you passed for `r`, and returns your original `s` +pointer unchanged when it was already canonical. Only the flipped case allocates. Mutating the +result mutates your input. `CanonicalizeSignature` does not have this problem — it copies both +scalars before touching them. +::: + +### Choosing between coerce and reject + + + + `CanonicalizeSignature` accepts a malleated signature and quietly normalises it. Right for a + verifier that must interoperate with signers you do not control, and for anything you are about + to store or hash. + + + `RejectNonCanonical` refuses. Right for a consensus rule or a protocol where you have declared + that only canonical signatures are well-formed — coercion there would let two encodings of the + same intent both be "accepted", which is exactly the ambiguity you set out to remove. + + + +Grounded in `TestCanonicalizeSignature` and `TestIsSignatureCanonical`. + +```go canonical.go +package main + +import ( + stdecdsa "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "fmt" + "log" + "math/big" + + "github.com/sonr-io/crypto/ecdsa" +) + +func main() { + curve := elliptic.P256() + priv, err := stdecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + log.Fatal(err) + } + + digest := sha256.Sum256([]byte("transfer 100 to bob")) + r, s, err := stdecdsa.Sign(rand.Reader, priv, digest[:]) + if err != nil { + log.Fatal(err) + } + + // stdlib Sign does not normalise, so first pin down which of the pair is low-S. + N := curve.Params().N + rLow, sLow, err := ecdsa.CanonicalizeSignature(r, s, curve) + if err != nil { + log.Fatal(err) + } + + // Anyone can produce this second, equally valid, non-canonical signature. + sHigh := new(big.Int).Sub(N, sLow) + fmt.Println("high-S still verifies:", + stdecdsa.Verify(&priv.PublicKey, digest[:], rLow, sHigh)) // true + + // Both collapse to the same canonical pair... + same, err := ecdsa.CompareSignatures(rLow, sLow, rLow, sHigh, curve) + if err != nil { + log.Fatal(err) + } + fmt.Println("same signature:", same) // true + + // ...and to the same fixed-width encoding. + a, err := ecdsa.SignatureBytes(rLow, sLow, curve) + if err != nil { + log.Fatal(err) + } + b, err := ecdsa.SignatureBytes(rLow, sHigh, curve) + if err != nil { + log.Fatal(err) + } + fmt.Println("identical bytes:", string(a) == string(b), len(a)) // true 64 + + // Strict ingress: refuse rather than repair. + fmt.Println("high-S accepted:", ecdsa.IsSignatureCanonical(rLow, sHigh, curve)) // false + if err := ecdsa.RejectNonCanonical(rLow, sHigh, curve); err != nil { + fmt.Println("rejected:", err) // signature is not in canonical form + } +} +``` + +## Fixed-width codecs + +`SignatureBytes` and `SignatureFromBytes` are a canonical, length-prefixed-free alternative to ASN.1 +DER. The layout is the concatenation of two big-endian, zero-padded scalars: + +| Field | Offset | Length | +| --- | --- | --- | +| `r` | `0` | `byteSize` | +| `s` | `byteSize` | `byteSize` | + +where `byteSize = (curve.Params().BitSize + 7) / 8`. For P-256 that is 32, so a signature is exactly +**64 bytes**; P-384 gives 96, P-521 gives 132. + +```go +raw, err := ecdsa.SignatureBytes(r, s, curve) // canonicalizes, then encodes +r2, s2, err := ecdsa.SignatureFromBytes(raw, curve) // decodes, then canonicalizes +``` + +Both directions canonicalize, which is what makes the encoding a stable identifier: `(r, s)` and +`(r, N-s)` produce byte-identical output, and a decode always yields a canonical pair. + +:::note[The size comes from `BitSize`, not from `N`] +`byteSize` is derived from the curve's field bit size, while `r` and `s` are reduced mod `N`. For +the NIST P-curves these agree. For a curve where the group order is meaningfully shorter than the +field, the encoding still uses the field width — so do not assume this format matches another +library's fixed-width convention without checking. +::: + +:::warning[Not DER, not `[R || S]` with a recovery byte] +This is a bare 2×`byteSize` concatenation. It is not ASN.1 DER (what `ecdsa.SignASN1` emits), and it +carries no recovery id, so you cannot recover the public key from it the way Ethereum's 65-byte +format allows. Do not feed these bytes to a verifier expecting either of those. +::: + +## Deterministic signing + +`DeterministicSign` removes the randomness from ECDSA signing. Instead of drawing `k` from an RNG, +it derives `k` from the private key and the message digest through an HMAC-DRBG construction in the +style of [RFC 6979](https://datatracker.ietf.org/doc/html/rfc6979), using **HMAC-SHA-256** as the +fixed underlying primitive. + + + +Why determinism is worth having: + +- **No entropy dependence at signing time.** An embedded device, a freshly-booted VM, or a + deterministic test environment can sign correctly without a seeded CSPRNG. +- **Reproducibility.** The same key and message always yield the same signature, so signatures can + be regenerated, diffed, and used as cache keys. +- **No silent RNG failure.** A subtly broken RNG produces biased nonces, and nonce bias leaks the + private key over enough signatures. Removing the RNG removes that failure mode. + +Grounded in `TestDeterministicSign` and `TestCanonicalSignature`. + +```go deterministic.go +package main + +import ( + stdecdsa "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "fmt" + "log" + + "github.com/sonr-io/crypto/ecdsa" +) + +func main() { + priv, err := stdecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + log.Fatal(err) + } + + digest := sha256.Sum256([]byte("test message for deterministic signing")) + + r1, s1, err := ecdsa.DeterministicSign(priv, digest[:]) + if err != nil { + log.Fatal(err) + } + r2, s2, err := ecdsa.DeterministicSign(priv, digest[:]) + if err != nil { + log.Fatal(err) + } + + fmt.Println("reproducible:", r1.Cmp(r2) == 0 && s1.Cmp(s2) == 0) // true + + // Output is already low-S. + fmt.Println("canonical:", ecdsa.IsCanonical(s1, priv.Curve.Params().N)) // true + + // Verifies with the standard library, and with the strict wrapper. + fmt.Println("stdlib ok:", stdecdsa.Verify(&priv.PublicKey, digest[:], r1, s1)) + fmt.Println("strict ok:", ecdsa.VerifyDeterministic(&priv.PublicKey, digest[:], r1, s1)) +} +``` + +The output is normalised to low-S inside `signWithK` before it is returned, so you never need to +call `MakeCanonical` on a `DeterministicSign` result. + +:::danger[Deterministic does not mean "nonce reuse is now safe"] +Determinism eliminates the *accidental* nonce collision, not the consequence of one. If the same `k` +is ever used for two different messages under the same key, both signatures share an `r`, and +solving the two-equation system recovers the private key immediately: + +$$ +d = \frac{s_1 k - h_1}{r} \quad\text{with}\quad k = \frac{h_1 - h_2}{s_1 - s_2} +$$ + +The derivation binds `k` to both the private key and the message digest — `generateK` seeds the DRBG +with `priv.D` and `hashToInt(hash)` — so two *different* messages under one key can never collide, +which is the whole point. Two residual hazards remain: + +- Signing the **same digest** twice returns byte-identical output. That is correct behaviour, but it + means a signature is a stable fingerprint of `(key, message)`; do not treat repeated signatures as + evidence of repeated intent. +- Deterministic signers are the standard target for **fault injection**: an attacker who can glitch + one of two signings of the same message obtains a correct and a faulted signature sharing `k`, and + the equation above applies. If your threat model includes physical access, pair determinism with + a verify-after-sign check. +::: + +:::danger[Mostly RFC 6979-conformant — and the exception is silent] +The derivation implements RFC 6979 steps (a) through (j), but where the RFC specifies +`bits2octets(H(m))` — a **fixed-width**, mod-`q`-reduced octet string — the code feeds +`bits2int(H(m)).Bytes()` into steps (f) and (h). `big.Int.Bytes()` drops leading zero bytes and does +not reduce mod `q`. + +In the common case that makes no difference, and the implementation reproduces RFC 6979 vectors +exactly. Checked against RFC 6979 A.2.5 (P-256 / SHA-256 / `"sample"`, key +`C9AFA9D8…120F6721`), this package returns +`r = EFD48B2AACB6A8FD1140DD9CD45E81D69D2C877B56AAF991C34D0EA84EAF3716` — the vector's value. + +It diverges whenever the digest, read as an integer, has fewer than `byteSize` significant +bytes — that is, whenever `H(m)` begins with a zero byte, roughly one message in 256 on P-256. +Compared against a reference RFC 6979 derivation on the digest +`00EEECC1EB031E204A211DEC04B6B42B1F446802058873A1A8F36308FE62EC0D`: + +``` +rfc6979 r = 69E8682EEF48289BD67EE185E5756BC416F8D02900249AFC3AA19F9F1B28908F +package r = 5B0E3B459095B47BD231012F545A4300962B6044AC43B3CAC4892C143AA9207B +``` + +The signature is still perfectly valid ECDSA and verifies everywhere; only the *nonce derivation* +disagrees. But an intermittent, digest-dependent disagreement is worse than a consistent one: a +cross-implementation compatibility test will pass 255 times out of 256. `deterministic_test.go` +contains no RFC 6979 vectors at all — it only checks that repeated signing agrees with itself. Do +not build a protocol in which two different libraries must derive the same `k`. +::: + +:::warning[Not constant time] +Every operation here is `math/big` arithmetic: `Div`, `Sub`, `Cmp`, `ModInverse`, `Mul`. `math/big` +makes no constant-time guarantee, and `signWithK` performs the scalar multiplication and the modular +inversion with ordinary variable-time code. On a machine where an attacker can measure your signing, +prefer `crypto/ecdsa.SignASN1`, whose P-256 path is constant time. See +[security notes](/reference/security). +::: + +:::note[`VerifyDeterministic` is stricter than `stdecdsa.Verify`] +It rejects `s > N/2`. A perfectly valid signature produced by a signer that does not normalise will +fail here. That is deliberate — it is the strict-ingress policy applied to verification — but it +means `VerifyDeterministic` is not a drop-in replacement for the standard library verifier. +::: + +## Related + + + + Produce an ECDSA signature from key shares that never combine. The canonicalization helpers here + apply to its output too. + + + The two-party ECDSA wrapper this library ships as its headline API. + + + The library's own curve types — distinct from the `crypto/elliptic` types this package uses. + + + Constant-time gaps and standards deviations across the library. + + diff --git a/docs/signatures/index.mdx b/docs/signatures/index.mdx new file mode 100644 index 0000000..3f8e254 --- /dev/null +++ b/docs/signatures/index.mdx @@ -0,0 +1,179 @@ +--- +title: Signatures +description: Choosing between BLS aggregation, BBS+ selective disclosure, ECDSA canonicalization, verifiable random functions, and the chain-specific Schnorr variants. +sidebar: + order: 1 + icon: pen-tool +--- + +Five very different things live under this heading, and they are not interchangeable. Before you +pick one, decide which property you actually need: **aggregation** (many signatures collapse into +one), **selective disclosure** (a holder proves a subset of signed attributes), **determinism and +canonical encoding** (the same message always yields the same bytes), **verifiable randomness** (an +output nobody can predict but everybody can check), or **wire compatibility with a specific +blockchain**. + +Every package here is a distinct construction with its own key type. There is no shared `Signer` +interface across them, and keys from one scheme are never valid in another. + +## Pick a scheme + +| Goal | Package | Page | +| --- | --- | --- | +| Collapse N signatures over N messages into one 96-byte object | `signatures/bls/bls_sig` | [BLS](/signatures/bls) | +| Multi-signature: N signers, one message, one aggregate check | `signatures/bls/bls_sig` (`SigPop`) | [BLS](/signatures/bls) | +| Split a signing key into `t`-of-`n` shares with no interaction | `signatures/bls/bls_sig` | [BLS](/signatures/bls) | +| Sign a vector of attributes; let the holder reveal only some | `signatures/bbs` | [BBS+](/signatures/bbs) | +| Issue a credential over messages the issuer must not see | `signatures/bbs` | [BBS+](/signatures/bbs) | +| Kill ECDSA signature malleability before storing or comparing | `ecdsa` | [ECDSA utilities](/signatures/ecdsa) | +| Sign with ECDSA without depending on runtime entropy | `ecdsa` | [ECDSA utilities](/signatures/ecdsa) | +| Unpredictable-but-verifiable per-message output (leader election, lotteries) | `vrf` | [VRF](/signatures/vrf) | +| Sign a Mina payment or delegation transaction | `signatures/schnorr/mina` | [Chain schemes](/signatures/chain-schemes) | +| Produce a NEM/Symbol Keccak-flavoured Ed25519 signature | `signatures/schnorr/nem` | [Chain schemes](/signatures/chain-schemes) | + +Some adjacent things are documented elsewhere: + +- The **interactive Schnorr proof of knowledge** (`zkp/schnorr`) is a ZKP, not a signature scheme — + see [zero-knowledge/schnorr](/zero-knowledge/schnorr). +- **Threshold ECDSA** and **threshold Ed25519** (FROST) produce ordinary ECDSA / Ed25519 signatures + from distributed shares — see [threshold ECDSA](/threshold/threshold-ecdsa) and + [threshold Ed25519](/threshold/threshold-ed25519). BLS threshold signing on this page is a + different, much simpler construction: it needs no rounds of interaction. + +## What these packages assume about curves + +`signatures/bbs` and the Mina scheme are written against the +[`core/curves`](/foundations/curves) `Curve` / `Point` / `Scalar` abstraction — BBS+ specifically +requires a `*curves.PairingCurve` (`curves.BLS12381(...)`). `signatures/bls/bls_sig` bypasses the +abstraction entirely and calls the low-level `core/curves/native/bls12381` backend directly, so it +is hard-wired to BLS12-381. The `ecdsa` package operates on stdlib `crypto/ecdsa` and +`crypto/elliptic` types, and `vrf` on a vendored Edwards25519 implementation. + +## The shared proof toolkit: `signatures/common` + +`signatures/common` holds the sigma-protocol plumbing that BBS+ (and code composing proofs with +BBS+) builds on. It is a building-block package — you rarely import it alone, but you will import it +to construct BBS+ proof messages. + +| Symbol | Kind | Purpose | +| --- | --- | --- | +| `Challenge` | `= curves.Scalar` | Fiat-Shamir challenge value | +| `Commitment` | `= curves.Point` | Pedersen commitment to one or more scalars | +| `Nonce` | `= curves.Scalar` | Freshness / replay protection in a proof | +| `SignatureBlinding` | `= curves.PairingScalar` | Blinding factor for blind signing | +| `HmacDrbg` | struct | HMAC deterministic random bit generator, any hash, auto-reseeding | +| `ProofCommittedBuilder` | struct | Accumulates `(point, scalar)` commitments into Schnorr proofs | +| `ProofMessage` | interface | Classifies a signed message as revealed or hidden | + +The four aliases are Go **type aliases**, not defined types: a `common.Nonce` *is* a +`curves.Scalar`, so no conversion is needed and the compiler will not stop you passing a challenge +where a nonce belongs. Treat the names as documentation, not as type safety. + +### `ProofMessage` and its three implementations + +`ProofMessage` is how a BBS+ prover declares, per message, whether it is disclosed: + +```go +type ProofMessage interface { + IsHidden() bool + GetBlinding(reader io.Reader) curves.Scalar + GetMessage() curves.Scalar +} +``` + +