feat: init docs

This commit is contained in:
Prad Nukala
2026-09-02 15:29:51 -04:00
parent d2390a8aad
commit 69425e2b7a
45 changed files with 11790 additions and 18 deletions
+163
View File
@@ -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/<package>`:
```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:
<Tabs>
<Tab title="Named rounds">
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
```
</Tab>
<Tab title="Iterator crank">
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)
```
</Tab>
</Tabs>
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
<CardGroup cols={2}>
<Card title="Foundations" href="/foundations" icon="layers">
The curve, point, and scalar model that the rest of the library is written against.
</Card>
<Card title="Package index" href="/reference/packages" icon="list">
Every importable package and the page that documents it.
</Card>
<Card title="Security notes" href="/reference/security" icon="shield-alert">
Stubs, known defects, and non-constant-time paths found while documenting the code.
</Card>
<Card title="MPC enclave" href="/identity/mpc-enclave" icon="shield">
The highest-level entry point: threshold ECDSA as a single value.
</Card>
</CardGroup>
+285
View File
@@ -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, FiatShamir, 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.
<TypeTable
type={{
"Add(x, y, m)": {
type: "(*big.Int, error)",
description: "z = x + y mod m. If m is nil the result is the unbounded integer sum. Errors only if x or y is nil.",
},
"Mul(x, y, m)": {
type: "(*big.Int, error)",
description: "z = x * y mod m. If m is nil the result is the unbounded product. Errors only if x or y is nil.",
},
"Exp(x, y, m)": {
type: "(*big.Int, error)",
description: "z = x^y mod m. Thin wrapper over big.Int.Exp; a nil m means no reduction. Errors only if x or y is nil.",
},
"Neg(x, m)": {
type: "(*big.Int, error)",
description: "z = -x mod m, reduced into [0, m). m is required — nil m is an error.",
},
"Inv(x, m)": {
type: "(*big.Int, error)",
description: "y such that x*y = 1 mod m. Errors if x is not invertible mod m ('cannot compute the multiplicative inverse').",
},
"Rand(m)": {
type: "(*big.Int, error)",
description: "Cryptographically secure random integer strictly in the range 1 < r < m. Rejection-samples until r > 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 FiatShamir 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: <name>`.
:::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)` MillerRabin 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":"<base64>","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
<CardGroup cols={2}>
<Card title="Protocol iterator" href="/foundations/protocol" icon="arrow-left-right">
How interactive protocols in this library are cranked round by round.
</Card>
<Card title="Paillier" href="/zero-knowledge/paillier" icon="binary">
The main consumer of `GenerateSafePrime` and the modular arithmetic helpers.
</Card>
</CardGroup>
+300
View File
@@ -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
}
```
<TypeTable
type={{
"BLS12381(preferredPoint Point)": {
type: "*PairingCurve",
description: "Builds the BLS12-381 pairing curve. The argument selects which group the curve's scalars prefer to project into — pass BLS12381G1().NewIdentityPoint() or the G2 equivalent.",
},
"GetPairingCurveByName(name string)": {
type: "*PairingCurve",
description: "Accepts BLS12381G1Name, BLS12381G2Name, or BLS12831Name. Returns nil for anything else.",
},
"NewG1GeneratorPoint() / NewG2GeneratorPoint()": {
type: "PairingPoint",
description: "Generators of G1 and G2.",
},
"NewG1IdentityPoint() / NewG2IdentityPoint()": {
type: "PairingPoint",
description: "Identity elements of G1 and G2.",
},
"NewScalar()": {
type: "PairingScalar",
description: "Zero scalar carrying the preferred-point projection.",
},
"ScalarG1BaseMult(sc) / ScalarG2BaseMult(sc)": {
type: "PairingPoint",
description: "Fixed-base multiplication in G1 or G2 respectively.",
},
}}
/>
`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 <name>` — 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`.
+172
View File
@@ -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:
<CardGroup cols={3}>
<Card title="Curves" href="/foundations/curves" icon="circle-dot">
Every named curve constructor, the full `Point` / `Scalar` method sets, pairing curves, and the low-level `native` field arithmetic.
</Card>
<Card title="Arithmetic" href="/foundations/arithmetic" icon="sigma">
The `core` package: modular arithmetic over `big.Int`, hash-to-field, FiatShamir, safe primes, and the HMAC commitment scheme.
</Card>
<Card title="Protocol" href="/foundations/protocol" icon="arrow-left-right">
The `Iterator` / `Message` crank pattern that drives every DKLs18-family interactive protocol.
</Card>
</CardGroup>
## 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.
<TypeTable
type={{
"curve.NewScalar()": {
type: "Scalar",
description: "A fresh scalar set to zero. Equivalent to curve.Scalar.Zero().",
},
"curve.NewGeneratorPoint()": {
type: "Point",
description: "The group generator G. Equivalent to curve.Point.Generator().",
},
"curve.NewIdentityPoint()": {
type: "Point",
description: "The point at infinity. Equivalent to curve.Point.Identity().",
},
"curve.ScalarBaseMult(sc)": {
type: "Point",
description: "Fixed-base multiplication sc·G. Use this instead of NewGeneratorPoint().Mul(sc).",
},
"curve.ToEllipticCurve()": {
type: "(elliptic.Curve, error)",
description: "Bridge to crypto/elliptic. Only K256 and P-256 succeed; every other curve returns an error.",
},
}}
/>
## 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.
<Tabs>
<Tab title="Modern (Point / Scalar)">
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/*`.
</Tab>
<Tab title="Legacy (EcPoint / Field / Element)">
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`.
</Tab>
</Tabs>
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.
+8
View File
@@ -0,0 +1,8 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Foundations",
icon: "layers",
order: 2,
pages: ["index", "curves", "arithmetic", "protocol"],
});
+312
View File
@@ -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"`
}
```
<TypeTable
type={{
Payloads: {
type: "map[string][]byte",
required: true,
description: "The round's actual wire data, keyed by a payload label. The dklsv1 serializers use the single key \"direct\".",
},
Metadata: {
type: "map[string]string",
description: "String side channel. dklsv1 populates it with {\"round\": \"1\"} etc. — the round number as a decimal string. Nothing reads it back; the round sequencing comes from the iterator's own step index.",
},
Protocol: {
type: "string",
required: true,
description: "Which protocol this message belongs to — one of the Dkls18* constants.",
},
Version: {
type: "uint",
required: true,
description: "Serialization version of the payloads. Version0 = 100, Version1 = 200.",
},
}}
/>
### 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`.
<Steps>
<Step title="Construct both participants">
Both sides need the same `*curves.Curve` and the same version. For DKG that is all the input there is.
</Step>
<Step title="Call Next on the first speaker with a nil message">
**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.
</Step>
<Step title="Feed each output into the other party">
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.
</Step>
<Step title="Stop when both report ErrProtocolFinished">
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.
</Step>
<Step title="Pull the output with Result">
`Result(version)` hands back a `*Message` carrying the serialized output. Feed it to the package's `Decode*` helper to get a typed struct.
</Step>
</Steps>
```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
<CardGroup cols={2}>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="users">
`tecdsa/dklsv1` — `AliceDkg`/`BobDkg`, `AliceSign`/`BobSign`, `AliceRefresh`/`BobRefresh` all implement `Iterator`, plus the `Encode*`/`Decode*` result helpers.
</Card>
<Card title="MPC enclave" href="/identity/mpc-enclave" icon="fingerprint">
`mpc` wraps the DKLs18 iterators with `RunProtocol`, `CheckIteratedErrors`, and keyshare encryption.
</Card>
</CardGroup>
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.
+332
View File
@@ -0,0 +1,332 @@
---
title: did:key Identifiers
description: Encode a public key as a self-describing did:key string, parse it back, and derive verification material — plus a frank assessment of the keys/parsers package.
sidebar:
order: 2
icon: id-card
---
`github.com/sonr-io/crypto/keys` turns a public key into a stable, self-describing string and back
again. A `did:key` identifier needs no registry and no network lookup: the key material *is* the
identifier, so resolving one is a pure decode. The package wraps libp2p's
`github.com/libp2p/go-libp2p/core/crypto.PubKey` interface, which gives it RSA, Ed25519, and
secp256k1 support for free, and adds a secp256k1-specific path for public keys that arrive as raw
bytes from an [MPC enclave](/identity/mpc-enclave).
**Reach for this when** you need a canonical identifier for a key you already hold — a UCAN issuer,
a log line, a database column, a delegation audience.
**Do not reach for this when** you need a DID with mutable state (rotation, service endpoints,
multiple verification methods). `did:key` is immutable by construction: change the key, change the
identifier. The `DIDMethod` enum in this package names other methods, but only `did:key` is
implemented here.
## Encoding
`DID.String()` builds the identifier in three steps:
1. `id.Raw()` — the raw public key bytes from libp2p (33 or 65 bytes for secp256k1, 32 for Ed25519,
DER PKIX for RSA).
2. An unsigned-varint multicodec prefix identifying the key type is prepended.
3. The whole buffer is multibase-encoded with base58btc, which yields the leading `z`.
So every identifier this package produces looks like `did:key:z…`. `Parse` reverses exactly those
steps and rejects any multibase encoding other than base58btc.
| Key type | Constant | Multicodec | Accepted raw lengths |
| --- | --- | --- | --- |
| RSA (`rsa-x509-pub`) | `MulticodecKindRSAPubKey` | `0x1205` | DER, parsed via `x509.ParsePKIXPublicKey` |
| Ed25519 (`ed25519-pub`) | `MulticodecKindEd25519PubKey` | `0xed` | 32 |
| secp256k1 (`secp256k1-pub`) | `MulticodecKindSecp256k1PubKey` | `0xe7` | 33 (compressed) or 65 (uncompressed) |
`KeyPrefix` is the string constant `"did:key"`. `GetMulticodecType(keyType int)` maps an
`int(crypto.RSA)` / `int(crypto.Ed25519)` / `int(crypto.Secp256k1)` to the values above and errors on
anything else.
:::note
Canonical `did:key` for secp256k1 uses the **compressed** 33-byte point. `Parse` and `NewFromMPCPubKey`
also accept the 65-byte uncompressed form, which means two distinct `did:key` strings can name the
same key. If you compare identifiers as strings, normalise through `CompressedPubKey()` first.
:::
## Constructors
<TypeTable
type={{
"NewDID": {
type: "func(pub crypto.PubKey) (DID, error)",
description: "Wraps a libp2p public key. Accepts Ed25519, RSA, Secp256k1; errors on any other key type."
},
"NewFromPubKey": {
type: "func(pub PubKey) DID",
description: "Wraps this package's own PubKey (a curves.Point-backed secp256k1 key). Infallible."
},
"NewFromMPCPubKey": {
type: "func(pubKeyBytes []byte) (DID, error)",
description: "Unmarshals 33- or 65-byte secp256k1 public key bytes straight from an MPC enclave. Errors on any other length."
},
"Parse": {
type: "func(keystr string) (DID, error)",
description: "Decodes a did:key string. Requires the did:key prefix, base58btc multibase, and a recognised multicodec."
},
"ValidateFormat": {
type: "func(didString string) error",
description: "Prefix check followed by a full Parse. Use when you only need a yes/no on a string."
},
}}
/>
## The `DID` type
`DID` embeds `crypto.PubKey`, so every libp2p method (`Raw`, `Type`, `Equals`, `Verify`, `Bytes`) is
promoted onto it. On top of that:
<TypeTable
type={{
"String": {
type: "func() string",
description: "The did:key identifier. Returns \"\" — not an error — if Raw() or multibase encoding fails."
},
"PublicKey": {
type: "func() crypto.PubKey",
description: "The embedded libp2p public key."
},
"MulticodecType": {
type: "func() uint64",
description: "The multicodec for this key type. PANICS on an unrecognised key type rather than returning an error."
},
"CompressedPubKey": {
type: "func() ([]byte, error)",
description: "33-byte compressed point for secp256k1 (converting from 65 bytes if needed); raw bytes for every other key type."
},
"VerifyKey": {
type: "func() (any, error)",
description: "*rsa.PublicKey for RSA, ed25519.PublicKey for Ed25519, and the raw []byte for secp256k1."
},
"Address": {
type: "func() (string, error)",
description: "A \"sonr1\"-prefixed string. See the caveat below — it is not a hash and not bech32."
},
}}
/>
:::warning[`MulticodecType` panics]
`String()` calls `MulticodecType()` unconditionally. A `DID` holding a key type outside
`{RSA, Ed25519, secp256k1}` will panic with `"unexpected crypto type"` when stringified. `NewDID`
guards against this, but a `DID` constructed as a struct literal (`keys.DID{PubKey: k}`) does not.
:::
## Round trip
Grounded in `TestDIDStringFormat` and `TestMPCIntegration` in `keys/didkey_test.go`:
```go didkey_roundtrip.go
package main
import (
"crypto/rand"
"fmt"
p2pcrypto "github.com/libp2p/go-libp2p/core/crypto"
"github.com/sonr-io/crypto/keys"
)
func main() {
priv, _, err := p2pcrypto.GenerateSecp256k1Key(rand.Reader)
if err != nil {
panic(err)
}
did, err := keys.NewDID(priv.GetPublic())
if err != nil {
panic(err)
}
s := did.String() // "did:key:z..."
fmt.Println(s)
parsed, err := keys.Parse(s)
if err != nil {
panic(err)
}
// The encoding is canonical for a given input: re-stringifying is identical.
fmt.Println("stable:", parsed.String() == s)
fmt.Println("same type:", parsed.Type() == did.Type())
// Cheap validity check on an untrusted string.
fmt.Println("valid:", keys.ValidateFormat(s) == nil)
compressed, err := parsed.CompressedPubKey()
fmt.Println("compressed len:", len(compressed), err) // 33
}
```
For a key that arrives from an enclave rather than a libp2p keypair, swap the constructor:
```go
did, err := keys.NewFromMPCPubKey(enclave.PubKeyBytes())
```
## `DIDMethod`
A plain string enum, verbatim from `keys/methods.go`. It carries no behaviour beyond `String()`, and
nothing else in the package consumes it — it exists for callers that need to tag which method a DID
string belongs to.
```go
const (
DIDMethodKey DIDMethod = "key"
DIDMethodSonr DIDMethod = "sonr"
DIDMehthodBitcoin DIDMethod = "btcr"
DIDMethodEthereum DIDMethod = "ethr"
DIDMethodCbor DIDMethod = "cbor"
DIDMethodCID DIDMethod = "cid"
DIDMethodIPFS DIDMethod = "ipfs"
)
```
:::note
`DIDMehthodBitcoin` is misspelled in the source. It is exported, so fixing it would be a breaking
change; use it as written.
:::
## The `PubKey` interface
Separate from libp2p's type, `keys.PubKey` adapts a [`curves.Point`](/foundations/curves) into
something `DID` can embed. `NewPubKey(pk curves.Point) PubKey` is the only constructor.
<TypeTable
type={{
"Bytes": { type: "func() []byte", description: "point.ToAffineCompressed() — 33 bytes on secp256k1." },
"Raw": { type: "func() ([]byte, error)", description: "Identical to Bytes; the error is always nil." },
"Hex": { type: "func() string", description: "Hex of the compressed point." },
"Type": { type: "func() p2ppb.KeyType", description: "Hardcoded to KeyType_Secp256k1 regardless of the point's actual curve." },
"Equals": { type: "func(b p2pcrypto.Key) bool", description: "Compares Raw() bytes." },
"Verify": { type: "func(msg, sig []byte) (bool, error)", description: "ECDSA verify over a SHA3-256 digest. Signature layout below." },
}}
/>
### The 66-byte signature layout
`PubKey.Verify` does **not** accept a standard 64-byte `r || s` signature. Reading
`keys/pubkey.go` and `keys/utils.go`, it:
1. Requires the signature to be **exactly 66 bytes**, rejecting anything else with
`"malformed signature: not the correct size"`.
2. Parses it as `V || R || S`, where `V` is a single recovery-id byte at offset 0, `R` is
`sig[1:33]`, and `S` is `sig[33:66]`.
3. Hashes the message with **SHA3-256** (not SHA-256) and calls `ecdsa.Verify` on that digest,
ignoring `V` entirely.
4. Reconstructs the ECDSA public key by slicing the compressed point as `x = bytes[1:33]`,
`y = bytes[33:]` on `curves.K256()`.
:::danger[`keys.PubKey.Verify` cannot verify `mpc.Enclave.Sign` output]
`mpc.SerializeSignature` produces a fixed **64-byte** `r || s` buffer, and `keys.deserializeSignature`
rejects anything that is not 66 bytes. So `keys.NewPubKey(point).Verify(msg, enclaveSig)` always
returns `("malformed signature: not the correct size")`. Verify enclave signatures with
`enclave.Verify(data, sig)` or `mpc.VerifyWithPubKey(enclave.PubKeyBytes(), data, sig)` instead — both
use the 64-byte layout. See [MPC Enclave](/identity/mpc-enclave).
Step 4 above is also wrong for a genuinely compressed point: on a 33-byte compressed encoding,
`bytes[33:]` is empty, so `y` decodes as zero. `Verify` therefore only works if `Bytes()` happens to
return 65 bytes — which it never does, since `ToAffineCompressed()` returns 33. Treat
`keys.PubKey.Verify` as non-functional.
:::
## `Address()` does not do what its comment says
The doc comment promises "a blockchain-compatible address" and an inline comment claims
"first 20 bytes of Keccak-256 hash (Ethereum-style)". The code does neither:
```go
// keys/didkey.go, secp256k1 branch, verbatim:
return fmt.Sprintf("sonr1%x", rawPubBytes[:8]), nil
```
:::danger[`Address()` is a truncated hex prefix, not an address]
For all three key types the function returns `"sonr1"` followed by the hex of the **first 8 bytes of
the raw public key**. There is no hash, no Keccak, and no bech32 encoding despite the bech32-looking
`sonr1` prefix. Consequences:
- It is **not one-way**: the output leaks 8 bytes of the public key verbatim.
- It has **no checksum**, so a typo is undetectable.
- 64 bits of collision space, birthday-bounded at roughly 2<sup>32</sup> keys.
- For secp256k1 it compresses a 65-byte key first, so the compressed and uncompressed forms of the
same key produce the same address — but an Ed25519 and a secp256k1 key sharing a first-8-byte
prefix also collide.
`ucan` uses this value as the address in `MPCTokenBuilder.GetAddress()` and `KeyshareSource.Address()`.
Do not treat it as a chain address on any real network.
:::
## Avoid `keys/parsers`
`keys/parsers` looks like a set of per-chain address parsers. It is not. Verified by reading every
file in the directory:
| File | Lines | Contents |
| --- | --- | --- |
| `btc_parser.go` | 1 | `package parsers` |
| `eth_parser.go` | 1 | `package parsers` |
| `fil_parser.go` | 1 | `package parsers` |
| `sol_parser.go` | 1 | `package parsers` |
| `ton_parser.go` | 1 | `package parsers` |
| `cosmos_parser.go` | 12 | A `CosmosPrefix` string type and six bech32 HRP constants. No functions. |
| `key_parser.go` | 157 | A near-verbatim copy of `keys/didkey.go`, exporting `DIDKey` instead of `DID`. |
:::danger[`keys/parsers` duplicates `keys` with an incompatible multicodec]
`keys/parsers` redeclares the multicodec constants, and one of them disagrees:
```go
// keys/didkey.go
MulticodecKindSecp256k1PubKey = 0xe7 // secp256k1-pub, the registered value
// keys/parsers/key_parser.go
MulticodecKindSecp256k1PubKey = 0x1206 // not secp256k1-pub
```
A secp256k1 `did:key` produced by `parsers.DIDKey.String()` carries a different varint prefix, so
`keys.Parse` rejects it with `"unrecognized key type multicodec prefix"`, and vice versa. The two
packages produce **mutually unparseable identifiers for the same key**. `keys` uses the registered
multicodec table value; `parsers` does not.
The five empty files mean the package name promises chain address parsing that does not exist:
`parsers` exports only `KeyPrefix`, the multicodec constants, `CosmosPrefix` and its six constants,
`DIDKey` with `NewKeyDID`/`MulticodecType`/`String`/`VerifyKey`, and `Parse`.
**Use `github.com/sonr-io/crypto/keys`. Do not import `keys/parsers`.**
:::
## Caveats
:::warning[Silent failure in `String()`]
`String()` returns the empty string on any internal error rather than reporting it. An empty
identifier where you expected `did:key:z…` means `Raw()` or multibase encoding failed; check the key
with `NewDID` first, or call `ValidateFormat` on the result.
:::
:::warning[`Parse` error message reads the wrong byte]
The fallthrough error is `fmt.Errorf("unrecognized key type multicodec prefix: %x", data[0])`, but
the multicodec was decoded as a multi-byte varint into `keyType`. For prefixes above `0x7f` — RSA's
`0x1205`, for example — the reported byte is the first varint byte, not the codec. The error is
cosmetic; the rejection itself is correct.
:::
:::info[What is actually covered by tests]
`keys/didkey_test.go` exercises `NewFromMPCPubKey` length validation, the `0xe7` constant,
`Address`, `CompressedPubKey`, `ValidateFormat`, `GetMulticodecType`, and the string/parse round trip.
There is **no** test for `NewFromPubKey`, `NewPubKey`, or `PubKey.Verify` — which is consistent with
the signature-layout defect above going unnoticed.
:::
## Next
<CardGroup cols={2}>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
Where `NewFromMPCPubKey`'s input comes from, and how to sign with the key behind the identifier.
</Card>
<Card title="UCAN Tokens" href="/identity/ucan" icon="ticket">
Using a `did:key` as a token issuer and delegation audience.
</Card>
</CardGroup>
+196
View File
@@ -0,0 +1,196 @@
---
title: ECIES
description: Encrypt a payload to a secp256k1 public key. A thin wrapper over github.com/ecies/go/v2 with one significant seed hazard.
sidebar:
order: 5
icon: mail
---
`github.com/sonr-io/crypto/ecies` is a **thin wrapper** — three files, 70 lines of code — over
[`github.com/ecies/go/v2`](https://github.com/ecies/go). ECIES (Elliptic Curve Integrated Encryption
Scheme) is hybrid public-key encryption: the sender generates an ephemeral keypair, does ECDH against
the recipient's static public key, derives a symmetric key, and encrypts the payload under an AEAD.
The recipient needs no prior interaction — just their own private key and the ciphertext.
**Reach for this when** you need to encrypt a payload to a public key you already have, with no
handshake and no shared state.
**Do not reach for this when** you need forward secrecy for the recipient, authenticated sender
identity (ECIES gives you confidentiality, not sender authentication — sign separately with
[`mpc`](/identity/mpc-enclave) or an [ECDSA](/signatures/ecdsa) key), or a symmetric key you already
share (use [AEAD](/symmetric/aead) directly).
## The API surface
```go
type PrivateKey = eciesgo.PrivateKey // type ALIAS, not a wrapper struct
type PublicKey = eciesgo.PublicKey // type ALIAS
func GenerateKey() (*PrivateKey, error)
func GenerateKeyFromSeed(seed []byte) (*PrivateKey, error)
func HashSeed(seed []byte) []byte
func Encrypt(pub *PublicKey, plaintext []byte) ([]byte, error)
func Decrypt(priv *PrivateKey, ciphertext []byte) ([]byte, error)
```
That is the entire package. `Encrypt` and `Decrypt` are one-line forwards to `eciesgo.Encrypt` and
`eciesgo.Decrypt`.
:::note[The key types are aliases, so the upstream API is yours]
`PrivateKey` and `PublicKey` are Go **type aliases** (`type PrivateKey = eciesgo.PrivateKey`), not
distinct named types. Everything the upstream library defines on those types is directly available:
`priv.Bytes()`, `priv.Hex()`, `priv.PublicKey`, `priv.ECDH(pub)`, `pub.Bytes(compressed bool)`,
`pub.Hex(compressed bool)`, `eciesgo.NewPrivateKeyFromHex`, `eciesgo.NewPublicKeyFromBytes`, and so
on.
Consult [`github.com/ecies/go/v2`](https://github.com/ecies/go) for:
- **key serialization** — this package exposes no marshal/unmarshal helpers of its own;
- **the ciphertext wire format** — the ephemeral-key encoding, KDF and AEAD choices are entirely
upstream's, and are not restated or pinned here.
:::
## Curve
`GenerateKey` and `GenerateKeyFromSeed` both build their key on `curves.SP256()`, which returns
`ecc.P256k1()` from `github.com/dustinxie/ecc` — i.e. **secp256k1**, the same curve as
[`mpc`](/identity/mpc-enclave) and secp256k1 `did:key` identifiers. See
[Foundations → Curves](/foundations/curves) for the curve abstraction.
Note the constructors bypass `eciesgo.GenerateKey` and assemble the struct by hand from
`ecdsa.GenerateKey(curve, rand.Reader)`:
```go
p, err := ecdsa.GenerateKey(curve, rand.Reader)
return &PrivateKey{
PublicKey: &PublicKey{Curve: curve, X: p.X, Y: p.Y},
D: p.D,
}, nil
```
## Usage
```go ecies_roundtrip.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/ecies"
)
func main() {
// Recipient generates a keypair and publishes the public key.
priv, err := ecies.GenerateKey()
if err != nil {
panic(err)
}
// Sender encrypts to the public key. No prior interaction needed.
ciphertext, err := ecies.Encrypt(priv.PublicKey, []byte("hello"))
if err != nil {
panic(err)
}
// Recipient decrypts with the private key.
plaintext, err := ecies.Decrypt(priv, ciphertext)
if err != nil {
panic(err)
}
fmt.Println(string(plaintext)) // hello
}
```
`GenerateKey` is grounded in `TestGenerateKey` and `GenerateKeyFromSeed` in `TestGenerateFromSeed`
(`ecies/keys_test.go`). The encrypt/decrypt round trip above is **not** covered by any test in the
package — see the caveats.
## `HashSeed` and seeded keys
`HashSeed(seed []byte) []byte` is `blake3.Sum512(seed)` from `lukechampine.com/blake3`, returned as a
64-byte slice. Its purpose is to stretch an arbitrary-length input up to enough bytes for
`GenerateKeyFromSeed`, which reads from the seed as an entropy source:
```go
seed := ecies.HashSeed([]byte("some high-entropy passphrase or master secret"))
priv, err := ecies.GenerateKeyFromSeed(seed)
```
:::note[The seed is key material]
`GenerateKeyFromSeed` treats its argument as the sole entropy input. Whoever holds the seed can
recompute the private key. Store, transmit and destroy a seed exactly as you would a private key —
and note that `HashSeed` is a plain hash, **not** a password KDF: it has no salt, no work factor and
no memory hardness. Do not feed it a human-chosen password. For password-derived keys use a real KDF
from [Key Derivation](/symmetric/key-derivation).
:::
:::danger[`GenerateKeyFromSeed` is not deterministic]
Despite the name, this function does not reliably produce the same key from the same seed on current
Go toolchains. `ecdsa.GenerateKey(curve, bytes.NewReader(seed))` passes the seed reader into
`crypto/ecdsa`, but the standard library does not use it as given:
- On Go 1.26 and later, `crypto/ecdsa` routes a caller-supplied reader through
`crypto/internal/rand.CustomReader`, which **returns the system CSPRNG and discards the supplied
reader** unless the `GODEBUG` setting `cryptocustomrand=1` is active. The `cryptocustomrand`
default became `0` in Go 1.26, so a program whose main module declares `go 1.26` or later gets a
fully random key and the seed is ignored entirely.
- Under the older behaviour (`cryptocustomrand=1`, i.e. a main module declaring an earlier Go
version), `randutil.MaybeReadByte` consumes a byte from the reader with roughly 50% probability
before key generation, which shifts the whole byte stream. Measured against this package: 20
successive calls with an identical seed produced the same private key only **13 times out of 20**.
Both behaviours were confirmed empirically against this package on Go 1.27.
`ecies/keys_test.go`'s `TestGenerateFromSeed` calls `GenerateKeyFromSeed` twice with the same seed
but only asserts that neither call errors — it never compares the two keys, which is why the defect
is not caught.
**Do not use `GenerateKeyFromSeed` for deterministic key derivation.** If you need a key
reproducible from a seed, derive the scalar yourself with a KDF from
[Key Derivation](/symmetric/key-derivation) and construct the key from those bytes via
`eciesgo.NewPrivateKeyFromBytes`.
:::
## Caveats
:::warning[`GenerateKeyFromSeed` errors on a short seed]
The implementation slices `seed[:]` and hands it to `bytes.NewReader`. `randFieldElement` then calls
`io.ReadFull`, which returns `io.ErrUnexpectedEOF` on a seed shorter than 32 bytes — surfaced as
`"cannot generate key pair: unexpected EOF"`. A `nil` seed is worse: `seed[:]` on a nil slice is
legal, so you get the same EOF error rather than a clear "nil seed" message. Always pass
`HashSeed(...)` output (64 bytes) rather than a raw seed. Under the Go 1.26+ behaviour described
above the reader is never consulted, so short seeds succeed there — which makes the failure mode
toolchain-dependent.
:::
:::warning[No round-trip test]
`ecies/keys_test.go` is 24 lines and contains two tests: `TestGenerateKey` and
`TestGenerateFromSeed`. **Neither `Encrypt` nor `Decrypt` is tested at all**, and there is no test
that the hand-assembled `PrivateKey`/`PublicKey` structs are accepted by the upstream library. The
round trip does work — it was verified directly against this package — but the package ships no
regression coverage for its two most important functions.
:::
:::info[No authentication of the sender]
ECIES ciphertext is confidential and integrity-protected against tampering, but **anyone** with the
recipient's public key can produce a valid ciphertext. If the recipient needs to know who sent a
message, sign the plaintext (or the ciphertext) separately and transmit the signature alongside it.
:::
## Next
<CardGroup cols={2}>
<Card title="AEAD" href="/symmetric/aead" icon="lock">
The symmetric layer, for when you already share a key.
</Card>
<Card title="Key Derivation" href="/symmetric/key-derivation" icon="git-branch">
Real KDFs, for deriving keys from seeds or passwords.
</Card>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
Signing, to pair with encryption for sender authentication.
</Card>
<Card title="Curves" href="/foundations/curves" icon="binary">
The secp256k1 curve this package builds on.
</Card>
</CardGroup>
+147
View File
@@ -0,0 +1,147 @@
---
title: Identity & Authorization
description: The application-facing layer — threshold key enclaves, did:key identifiers, UCAN capability tokens, payload encryption, and WebAssembly code signing.
sidebar:
order: 1
icon: fingerprint
---
Everything below this section is code you call directly from an application. The primitives in
[Foundations](/foundations), [Signatures](/signatures), and [Threshold](/threshold) are the machinery;
these five packages are the assembled product: a key that lives in two shares, an identifier derived
from its public point, tokens that delegate narrow slices of authority over that key, and two
supporting utilities for encrypting payloads and pinning executable code.
## How the pieces compose
<Steps>
<Step title="An enclave holds the key">
[`mpc.NewEnclave()`](/identity/mpc-enclave) runs a 2-of-2 DKLs18 threshold ECDSA key generation on
secp256k1 and returns an `Enclave`. The private key never exists as a single scalar: it lives as a
validator share and a user share. Signing is a two-party protocol; refreshing rotates both shares
while leaving the public key fixed.
</Step>
<Step title="Its public point becomes an identifier">
`enclave.PubKeyBytes()` yields the uncompressed public point. `keys.NewFromMPCPubKey` turns those
bytes into a [`keys.DID`](/identity/did-key), whose `String()` is a `did:key:z…` identifier — a
multicodec varint prefix plus multibase base58btc. That string is the stable, resolvable name for
the key.
</Step>
<Step title="The identifier issues capability tokens">
A [UCAN](/identity/ucan) token is a JWT whose issuer is that `did:key`, signed by the enclave.
Its `att` claim is a list of attenuations — `(capability, resource)` pairs. A holder can mint a
delegated token that *narrows* the set, never widens it, and attaches the parent as a proof.
</Step>
<Step title="Payloads and code get their own primitives">
[`ecies`](/identity/ecies) encrypts a payload to a secp256k1 public key without any prior
handshake. [`wasm`](/identity/wasm-modules) signs and hash-pins WebAssembly module bytes so a host
can refuse to load code it does not recognise.
</Step>
</Steps>
## Choosing a package
| You want to… | Use | Notes |
| --- | --- | --- |
| Hold a signing key without a single point of compromise | `mpc` | 2-of-2 only; secp256k1 only |
| Name a public key with a stable string | `keys` | RSA, Ed25519, secp256k1 |
| Grant another party scoped, expiring authority | `ucan` | JWT-based, `ucv` header `0.9.0` |
| Encrypt a message to someone's public key | `ecies` | Thin wrapper over `github.com/ecies/go/v2` |
| Verify that a `.wasm` blob is the one you approved | `wasm` | Ed25519 signing + SHA-256 pinning |
| Parse a chain-specific address | — | `keys/parsers` is unfinished; see [did:key](/identity/did-key) |
## A minimal end-to-end shape
```go
package main
import (
"fmt"
"github.com/sonr-io/crypto/keys"
"github.com/sonr-io/crypto/mpc"
)
func main() {
// 1. Threshold key: both shares generated locally.
enclave, err := mpc.NewEnclave()
if err != nil {
panic(err)
}
// 2. Identifier derived from the enclave's public point.
did, err := keys.NewFromMPCPubKey(enclave.PubKeyBytes())
if err != nil {
panic(err)
}
fmt.Println("issuer:", did.String()) // did:key:z...
// 3. Two-party signature over a message, verified against the public key.
sig, err := enclave.Sign([]byte("hello"))
if err != nil {
panic(err)
}
ok, err := enclave.Verify([]byte("hello"), sig)
fmt.Println("valid:", ok, err)
}
```
Every one of these packages is generic over, or built on, the curve abstraction described in
[Foundations → Curves](/foundations/curves). `Curve`, `Point`, and `Scalar` are not re-explained here.
## Read this before you ship
This section is the least finished part of the repository. The pages below document the rough edges
in place rather than around them, because several of them are the kind that silently weaken a
security property instead of failing loudly.
:::danger[The short version]
- An `mpc.Enclave` value holds **both** keyshares in one process. It is a key-management construct,
not a distributed-trust boundary.
- `mpc.EnclaveData.Unmarshal` **panics** on `Marshal()` output, so a persisted enclave cannot be
restored through the package's own codec.
- `ucan.GenerateJWTToken` / `VerifyJWTToken` sign with **HS256 under a hardcoded secret** compiled
into the package.
- The UCAN verifier's caveat checks are placeholders that always succeed, so caveat restrictions are
**not enforced**.
- `ucan.MPCTokenBuilder.CreateDelegatedToken` will sign a child token that grants **more** than its
parent; only `KeyshareSource.NewAttenuatedToken` enforces attenuation.
- `ucan.MPCVerifier.VerifyMPCToken` fails outright — the `"MPC256"` signing method is never
registered with `golang-jwt`.
- `keys/parsers` contains five empty files and a secp256k1 multicodec constant that disagrees with
`keys`.
- `ecies.GenerateKeyFromSeed` is **not** deterministic on current Go toolchains.
- `wasm.SecurityPolicy.Validate` only checks module size; its other fields are ignored.
- `keys.DID.Address()` is a truncated hex prefix of the public key, not a hashed or checksummed
address, despite its comment claiming Keccak-256.
Each of these was verified against the source and confirmed by running it, and is documented in
detail on the page for its package. They are also aggregated on
[Reference → Security](/reference/security).
:::
## Pages
<CardGroup cols={2}>
<Card title="did:key Identifiers" href="/identity/did-key" icon="id-card">
Multicodec + multibase encoding, the `DID` and `PubKey` types, the non-standard 66-byte signature
layout, and why to avoid `keys/parsers`.
</Card>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
2-of-2 threshold ECDSA lifecycle: keygen, sign, verify, refresh, import/export, and the real
security model.
</Card>
<Card title="UCAN Tokens" href="/identity/ucan" icon="ticket">
Capabilities, attenuation, delegation chains, templates, MPC signing, and which authorization
checks are not actually implemented.
</Card>
<Card title="ECIES" href="/identity/ecies" icon="mail">
Encrypt to a secp256k1 public key. A thin, honest wrapper — plus one seed hazard.
</Card>
<Card title="WASM Module Signing" href="/identity/wasm-modules" icon="package-check">
Ed25519 code signing and SHA-256 hash pinning for WebAssembly supply-chain verification.
</Card>
<Card title="Package Index" href="/reference/packages" icon="list">
Every package in the module with its status at a glance.
</Card>
</CardGroup>
+8
View File
@@ -0,0 +1,8 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Identity & Authorization",
icon: "fingerprint",
order: 7,
pages: ["index", "did-key", "mpc-enclave", "ucan", "ecies", "wasm-modules"],
});
+644
View File
@@ -0,0 +1,644 @@
---
title: MPC Enclave
description: A batteries-included 2-of-2 threshold ECDSA wrapper over tecdsa/dklsv1 — keygen, signing, share refresh, serialization, and the security model it actually provides.
sidebar:
order: 3
icon: shield
---
`github.com/sonr-io/crypto/mpc` is the convenience layer over
[`tecdsa/dklsv1`](/threshold/threshold-ecdsa). Where `dklsv1` hands you two protocol iterators and
makes you drive the message loop yourself, `mpc` hands you a single `Enclave` value with `Sign`,
`Verify`, `Refresh`, `Marshal`, and `Unmarshal`. It is hardwired to a **2-of-2** DKLs18 threshold
ECDSA key on **secp256k1**, signing over a **SHA3-256** digest.
**Reach for this when** you want a signing key that is never materialised as a single scalar in
memory, and you are willing to accept a fixed 2-of-2 shape and a secp256k1 curve.
**Do not reach for this when** you need `t`-of-`n` for any other `t`/`n` (use
[secret sharing](/threshold/secret-sharing) plus [DKG](/threshold/dkg)), a different curve, Ed25519
signatures (see [threshold Ed25519](/threshold/threshold-ed25519)), or a live two-party protocol
across a network — `NewEnclave` runs both sides locally in one process.
## Read this first
:::danger[`Enclave` is key management, not distributed trust]
`mpc.NewEnclave()` runs *both* DKG parties in the calling process (`protocol.go`: it constructs
`dklsv1.NewAliceDkg` and `dklsv1.NewBobDkg` and cranks them against each other with `RunProtocol`),
then stores both results in one struct:
```go
type EnclaveData struct {
PubHex string `json:"pub_hex"`
PubBytes []byte `json:"pub_bytes"`
ValShare Message `json:"val_share"` // validator / Alice share
UserShare Message `json:"user_share"` // user / Bob share
Nonce []byte `json:"nonce"`
Curve CurveName `json:"curve"`
}
```
`Sign` likewise builds both `GetAliceSignFunc(k, data)` and `GetBobSignFunc(k, data)` from the same
`*EnclaveData` and runs them against each other locally. **An `Enclave` that can sign holds the
entire signing capability.** `Marshal()` emits both shares as JSON.
The threshold property — that compromising one party is not enough to forge a signature — only
materialises if you split `ValShare` and `UserShare` across separate trust domains and drive the
protocol with `RunProtocol` across the wire. In its packaged form, `mpc` buys you: a key that never
exists as one scalar, and proactive share rotation via `Refresh()`. It does **not** buy you a
distributed-trust boundary.
:::
## Lifecycle
```go enclave_lifecycle.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/mpc"
)
func main() {
// Keygen: runs both DKG sides locally, returns an Enclave holding both shares.
enclave, err := mpc.NewEnclave()
if err != nil {
panic(err)
}
fmt.Println("valid:", enclave.IsValid())
fmt.Println("pub:", enclave.PubKeyHex())
// Sign: two-party DKLs18 signing over SHA3-256(msg). 64 bytes, r || s.
msg := []byte("test message before refresh")
sig, err := enclave.Sign(msg)
if err != nil {
panic(err)
}
fmt.Println("sig len:", len(sig)) // 64
ok, err := enclave.Verify(msg, sig)
fmt.Println("verified:", ok, err)
// Refresh: rotates both shares. The public key is invariant.
refreshed, err := enclave.Refresh()
if err != nil {
panic(err)
}
fmt.Println("pubkey unchanged:", refreshed.PubKeyHex() == enclave.PubKeyHex())
// Signatures cross-verify in both directions across the refresh boundary.
newSig, err := refreshed.Sign([]byte("test message after refresh"))
if err != nil {
panic(err)
}
preOK, _ := refreshed.Verify(msg, sig)
postOK, _ := enclave.Verify([]byte("test message after refresh"), newSig)
fmt.Println("old sig under new enclave:", preOK)
fmt.Println("new sig under old enclave:", postOK)
// Serialization: Marshal works. Unmarshal PANICS — see the callout below.
blob, err := enclave.GetData().Marshal()
if err != nil {
panic(err)
}
fmt.Println("marshalled bytes:", len(blob))
}
```
Every assertion in that program was verified by running it. `TestEnclaveData_RefreshAndSign` in
`mpc/enclave_test.go` is the source for the invariant public key and the bidirectional
cross-verification.
:::danger[`Unmarshal` panics on `Marshal` output]
A marshalled enclave **cannot be read back**. `EnclaveData.Unmarshal` is `json.Unmarshal` into the
struct, whose `ValShare`/`UserShare` fields are `*protocol.Message` — and
[`protocol.Message`](/foundations/protocol) has a custom `UnmarshalJSON` with unchecked type
assertions that can never hold:
```go
// core/protocol/protocol.go
var obj map[string]any
if err := json.Unmarshal(data, &obj); err != nil {
return err
}
for k, v := range obj {
switch k {
case "payloads":
m.Payloads = v.(map[string][]byte) // <- always the wrong dynamic type
case "metadata":
m.Metadata = v.(map[string]string) // <- likewise
```
Decoding into `map[string]any` yields `map[string]any` for a nested object, never
`map[string][]byte`, so the assertion fails and the program **panics** rather than returning an
error:
```text
panic: interface conversion: interface {} is map[string]interface {}, not map[string][]uint8
core/protocol/protocol.go:92
mpc/enclave.go:154 (EnclaveData.Unmarshal)
```
`TestEnclaveData_MarshalUnmarshal` in `mpc/enclave_test.go` currently **fails** with exactly this
panic — confirmed by running `go test ./mpc/ -run TestEnclaveData_MarshalUnmarshal`. Note that
`MarshalJSON` on `protocol.Message` is fine, so you can persist an enclave but not restore it
through this path.
The blast radius is **anything that JSON-decodes a `protocol.Message`**, not one specific helper.
`mpc.EnclaveData.Unmarshal` reaches the panic through `encoding/json` calling
`Message.UnmarshalJSON` directly, and `protocol.DecodeMessage` panics for the same underlying
reason. `mpc.RestoreEncryptedEnclave` inherits it too, on top of already being broken for the
reasons in the next section.
Workarounds:
1. Keep the live `Enclave` value in memory and avoid the JSON boundary entirely, or hand an
in-memory `*EnclaveData` to `mpc.RestoreEnclaveFromData` — it adopts the pointer and never
touches JSON.
2. If you must persist, write your own codec. `protocol.EncodeMessage` works on the way out, but do
**not** pair it with `protocol.DecodeMessage`; decode into a shadow struct with the same JSON
tags as `protocol.Message` and copy the fields across yourself. See
[Foundations → Protocol](/foundations/protocol) for the full explanation and a worked decode.
3. If you cannot avoid `Unmarshal`, wrap it in a `recover()` — it panics rather than returning an
error, so an error check alone will not save you.
:::
## The `Enclave` interface
`Enclave` is satisfied by `*EnclaveData`, and `GetData()`/`GetEnclave()` are just casts between the
two views of the same pointer.
<TypeTable
type={{
"GetData": { type: "func() *EnclaveData", description: "Returns the receiver. Gives access to GetPubPoint, which is not on the interface." },
"GetEnclave": { type: "func() Enclave", description: "Returns the receiver as an Enclave. Identity function." },
"IsValid": { type: "func() bool", description: "True iff both ValShare and UserShare are non-nil. Does not validate the shares." },
"PubKeyHex": { type: "func() string", description: "Hex of the compressed public point (PubHex)." },
"PubKeyBytes": { type: "func() []byte", description: "The uncompressed 65-byte public point (PubBytes)." },
"Sign": { type: "func(data []byte) ([]byte, error)", description: "Runs 2-party DKLs18 signing. Returns 64 bytes, r || s." },
"Verify": { type: "func(data, sig []byte) (bool, error)", description: "ecdsa.Verify over SHA3-256(data). Errors only on malformed input; an invalid signature returns (false, nil)." },
"Refresh": { type: "func() (Enclave, error)", description: "Rotates both shares, returns a NEW Enclave. Does not mutate the receiver." },
"Encrypt": { type: "func(key []byte) ([]byte, error)", description: "AES-256-GCM over Marshal() output, using the enclave's stored Nonce." },
"Decrypt": { type: "func(key, encryptedData []byte) ([]byte, error)", description: "Inverse of Encrypt. Returns plaintext JSON; does not populate the receiver." },
"Marshal": { type: "func() ([]byte, error)", description: "encoding/json over EnclaveData — both shares included, in the clear." },
"Unmarshal": { type: "func(data []byte) error", description: "encoding/json into the receiver. PANICS on Marshal() output — see the callout above." },
}}
/>
:::note[The interface doc comments are shuffled]
In `mpc/codec.go` the `Unmarshal` line is commented `// Verify returns true if the signature is valid`
and `Marshal` is commented `// Serialize returns the serialized keyEnclave`. The behaviour is what
the method names say; the comments are stale.
:::
`GetPubPoint()` is available on `*EnclaveData` but not on the interface:
```go
point, err := enclave.GetData().GetPubPoint() // curves.Point on k.Curve
```
It reconstructs the point with `curve.NewIdentityPoint().FromAffineUncompressed(k.PubBytes)`, which
is why `PubBytes` must stay uncompressed.
## Roles
```go
const (
RoleVal = "validator"
RoleUser = "user"
)
type Role string
```
The mapping is fixed and worth memorising, because the field names and the protocol names differ:
| Field | Role constant | DKLs18 party | Sign func | Refresh func |
| --- | --- | --- | --- | --- |
| `ValShare` | `RoleVal` | Alice | `GetAliceSignFunc` | `GetAliceRefreshFunc` |
| `UserShare` | `RoleUser` | Bob | `GetBobSignFunc` | `GetBobRefreshFunc` |
`Role` and the two constants are declared but nothing in the package consumes them — they are there
for callers that need to label a share. Note the constants are untyped strings, not `Role` values.
## Import and export
`ImportEnclave` applies a variadic list of options and dispatches on which one was set. `Options`
holds only unexported fields, so `ImportEnclave` (or `Options{}.Apply()`, which sees a zero value) is
the intended entry point.
<TypeTable
type={{
"WithInitialShares": {
type: "func(valKeyshare, userKeyshare Message, curve CurveName) ImportOption",
description: "Build a fresh enclave from two DKG results. Derives PubBytes/PubHex from the validator share and generates a new random 12-byte Nonce."
},
"WithEnclaveData": {
type: "func(data *EnclaveData) ImportOption",
description: "Adopt an existing *EnclaveData verbatim. Errors only if data is nil."
},
"WithEncryptedData": {
type: "func(data, key []byte) ImportOption",
description: "Intended to restore from Encrypt() output. Broken — see the callout below."
},
}}
/>
`Apply()` resolves in a fixed precedence: encrypted data first, then initial shares, then enclave
data. `ImportEnclave` with zero options errors with `"no import options provided"`; with only
`WithEnclaveData(nil)` it errors with `"enclave data cannot be nil"`.
The three lower-level constructors are exported and callable directly:
```go
// Assemble from two protocol results (what NewEnclave does internally).
e, err := mpc.BuildEnclave(valShare, userShare, mpc.Options{})
// Adopt a deserialized struct.
e, err := mpc.RestoreEnclaveFromData(data)
// Decrypt and adopt. Does not work; see below.
e, err := mpc.RestoreEncryptedEnclave(ciphertext, key)
```
:::warning[`BuildEnclave` with a bare `Options{}` records an empty curve]
`BuildEnclave` copies `options.curve` into `EnclaveData.Curve`. A zero `Options` leaves that as the
empty string. `CurveName("").Curve()` falls through to `curves.K256()`, so signing still works on
secp256k1 — but the persisted JSON records `"curve": ""`. Prefer
`mpc.ImportEnclave(mpc.WithInitialShares(val, user, mpc.K256Name))`, which sets it explicitly.
:::
:::danger[The encrypted-import path cannot succeed]
`RestoreEncryptedEnclave` is unreachable-working by construction:
```go
func RestoreEncryptedEnclave(data []byte, key []byte) (Enclave, error) {
keyclave := &EnclaveData{}
err := keyclave.Unmarshal(data) // <- JSON-parses the CIPHERTEXT
if err != nil {
return nil, fmt.Errorf("failed to unmarshal enclave: %w", err)
}
decryptedData, err := keyclave.Decrypt(key, data)
...
}
```
`data` is the AES-256-GCM output of `Encrypt` — indistinguishable from random bytes. `json.Unmarshal`
on it fails, and the function returns before ever decrypting. Even if that line were removed, the
next one could not work either: `Decrypt` reads the nonce from `k.Nonce`, which is a *field of the
still-encrypted struct* and is therefore nil at that point, so `aesgcm.Open` would fail on a
zero-length nonce.
Consequently `mpc.ImportEnclave(mpc.WithEncryptedData(ct, key))` also always fails, since `Apply()`
routes straight to `RestoreEncryptedEnclave`. Nothing in the repository calls either one — grepping
the module, the only references are the definitions themselves and the `Apply()` dispatch, and
`mpc/enclave_test.go` never exercises them.
**There is no working round trip through this package.** You can decrypt — but the plaintext is the
JSON produced by `Marshal()`, and `Unmarshal` panics on it (see the previous section). Decryption on
its own works if you keep the nonce:
```go
data := enclave.GetData()
nonce := data.Nonce // you MUST persist this alongside the ciphertext
ct, err := data.Encrypt(key)
// ... later, in a fresh process ...
shell := &mpc.EnclaveData{Nonce: nonce}
plaintext, err := shell.Decrypt(key, ct) // plaintext == the original Marshal() JSON
if err != nil {
return err
}
// plaintext CANNOT be fed to (*EnclaveData).Unmarshal — it panics.
```
`TestEnclaveData_EncryptDecrypt` passes precisely because it stops here: it compares the decrypted
bytes against `Marshal()` output and never decodes them. To actually restore an enclave, encrypt and
decode with your own codec as described in the panic callout above.
:::
## Encryption at rest
`Encrypt` / `Decrypt` are AES-256-GCM. The key is derived by `GetHashKey`, which is
`sha3.New256(key)` truncated to 32 bytes. The nonce is `EnclaveData.Nonce` — 12 random bytes
generated **once**, at `BuildEnclave` time, and then reused for every call.
:::danger[Fixed per-enclave nonce]
GCM security collapses if a `(key, nonce)` pair is ever reused for two different plaintexts: the
keystream repeats, XOR-ing two ciphertexts reveals the XOR of the plaintexts, and the GHASH
authentication key becomes recoverable, which lets an attacker forge tags.
Because `Nonce` is fixed for the enclave's whole lifetime, calling `Encrypt(key)` twice with the same
`key` on **different** enclave contents — most obviously before and after a `Refresh()`, or after any
field changes — reuses `(key, nonce)`. Encrypting the *same* bytes twice is merely deterministic;
encrypting *different* bytes twice is a break.
Mitigations, in order of preference:
1. Do not use these methods. Marshal the enclave and encrypt with a fresh random nonce per operation
using [`aead`](/symmetric/aead).
2. If you must use them, use a distinct `key` for every encryption, and never reuse a key across a
refresh.
Note also that `Refresh()` returns a new `Enclave` with a new random nonce, while the original value
keeps the old one — so the hazard is per-value, not per-key-lifetime.
:::
`EncryptKeyshare` / `DecryptKeyshare` are the single-share equivalents, and they take the nonce as an
explicit parameter, which is the right shape:
```go
func EncryptKeyshare(msg Message, key []byte, nonce []byte) ([]byte, error)
func DecryptKeyshare(msg []byte, key []byte, nonce []byte) ([]byte, error)
func GetHashKey(key []byte) []byte // SHA3-256(key)[:32]
```
`EncryptKeyshare` runs `protocol.EncodeMessage(msg)` first, so it operates on the wire encoding of a
`*protocol.Message`, not on JSON.
## Refresh
`Refresh()` runs the DKLs18 key-refresh protocol on both sides and returns a fresh `Enclave`:
```go
func (k *EnclaveData) Refresh() (Enclave, error) {
refreshFuncVal, _ := GetAliceRefreshFunc(k)
refreshFuncUser, _ := GetBobRefreshFunc(k)
return ExecuteRefresh(refreshFuncVal, refreshFuncUser, k.Curve)
}
```
Three properties, all asserted in `TestEnclaveData_RefreshAndSign`:
1. **Shares change.** Both `ValShare` and `UserShare` are replaced by the refresh outputs.
2. **The public key does not.** `PubKeyHex()` and `PubKeyBytes()` are byte-identical before and after.
3. **Signatures are interchangeable.** A signature made before the refresh verifies under the
refreshed enclave and vice versa, because verification only touches the public key.
This is proactive security: an attacker who exfiltrated one share before the refresh holds a share
that no longer combines with anything.
:::warning[`Refresh` returns; it does not rotate in place]
The receiver is unchanged. If you keep using the old value you keep using the old shares, and the old
shares still sign valid signatures. Replace your reference and destroy the old serialization.
:::
## Driving the protocol yourself
Everything above is assembled from these exported pieces. Use them when the two shares live in
different processes and you need to shuttle `*protocol.Message` values between them.
<TypeTable
type={{
"RunProtocol": {
type: "func(firstParty, secondParty protocol.Iterator) (error, error)",
description: "Cranks two iterators against each other until both return protocol.ErrProtocolFinished. Returns (aErr, bErr)."
},
"CheckIteratedErrors": {
type: "func(aErr, bErr error) error",
description: "Collapses RunProtocol's pair: nil if both are ErrProtocolFinished, otherwise the first real error."
},
"ExecuteSigning": {
type: "func(signFuncVal, signFuncUser SignFunc) ([]byte, error)",
description: "Runs both sign iterators, takes the USER side's result, decodes it, and serializes to 64 bytes."
},
"ExecuteRefresh": {
type: "func(refreshFuncVal, refreshFuncUser RefreshFunc, curve CurveName) (Enclave, error)",
description: "Runs both refresh iterators and re-imports the two results as a new enclave."
},
"GetAliceSignFunc": { type: "func(k *EnclaveData, bz []byte) (SignFunc, error)", description: "dklsv1.NewAliceSign on k.Curve with sha3.New256 over bz." },
"GetBobSignFunc": { type: "func(k *EnclaveData, bz []byte) (SignFunc, error)", description: "dklsv1.NewBobSign — hardcodes curves.K256(); see caveat." },
"GetAliceRefreshFunc": { type: "func(k *EnclaveData) (RefreshFunc, error)", description: "dklsv1.NewAliceRefresh on k.Curve." },
"GetBobRefreshFunc": { type: "func(k *EnclaveData) (RefreshFunc, error)", description: "dklsv1.NewBobRefresh — hardcodes curves.K256(); see caveat." },
}}
/>
Type aliases, from `mpc/codec.go`:
```go
type (
AliceOut *dkg.AliceOutput
BobOut *dkg.BobOutput
Point curves.Point
Message *protocol.Message
Signature *curves.EcdsaSignature
RefreshFunc interface{ protocol.Iterator }
SignFunc interface{ protocol.Iterator }
)
```
Decoding DKG results:
```go
func GetAliceOut(msg *protocol.Message) (AliceOut, error)
func GetBobOut(msg *protocol.Message) (BobOut, error)
func GetAlicePublicPoint(msg *protocol.Message) (Point, error)
func GetBobPubPoint(msg *protocol.Message) (Point, error)
```
Both parties derive the same public key, so `GetAlicePublicPoint` and `GetBobPubPoint` on the
respective DKG outputs agree; `BuildEnclave` uses the Alice side.
## Signature encoding
```go
func SerializeSignature(sig *curves.EcdsaSignature) ([]byte, error)
func DeserializeSignature(sigBytes []byte) (*curves.EcdsaSignature, error)
func GetECDSAPoint(pubKey []byte) (*curves.EcPoint, error)
func VerifyWithPubKey(pubKeyCompressed, data, sig []byte) (bool, error)
```
`SerializeSignature` emits a **fixed 64-byte** buffer: `r` left-zero-padded to 32 bytes, then `s`
left-zero-padded to 32 bytes. No `V` byte, no DER, no length prefix. `DeserializeSignature` rejects
anything that is not exactly 64 bytes with
`"invalid signature length: expected 64 bytes, got N"`. The `EcdsaSignature.V` field is left zero on
the deserialize path.
:::warning[`VerifyWithPubKey`'s parameter name is wrong]
The parameter is named `pubKeyCompressed`, but it is passed to `GetECDSAPoint`, which slices
`x = pubKey[1:33]` and `y = pubKey[33:]` — that is the **uncompressed** 65-byte layout. Pass
`enclave.PubKeyBytes()` (uncompressed), not `PubKeyHex()`-decoded bytes (compressed). Supplying 33
bytes yields `y = 0` and verification silently returns `false`.
`GetECDSAPoint` also always uses `curves.K256()`, ignoring the enclave's `Curve` field, and does no
length or on-curve check.
:::
Signatures are **not** compatible with [`keys.PubKey.Verify`](/identity/did-key), which requires a
66-byte `V || R || S` layout.
## `CurveName`
```go
type CurveName string
const (
K256Name CurveName = "secp256k1"
BLS12381G1Name CurveName = "BLS12381G1"
BLS12381G2Name CurveName = "BLS12381G2"
BLS12831Name CurveName = "BLS12831"
P256Name CurveName = "P-256"
ED25519Name CurveName = "ed25519"
PallasName CurveName = "pallas"
BLS12377G1Name CurveName = "BLS12377G1"
BLS12377G2Name CurveName = "BLS12377G2"
BLS12377Name CurveName = "BLS12377"
)
```
`Curve()` maps each name to a [`*curves.Curve`](/foundations/curves). `String()` is the underlying
string. The mapping has two quirks worth knowing:
| Name | Maps to | Note |
| --- | --- | --- |
| `BLS12831Name` | `curves.BLS12381G1()` | `"BLS12831"` is a transposition of 12381; aliased to G1 |
| `BLS12377Name` | `curves.BLS12377G1()` | Aggregate name aliased to G1 |
| *anything else* | `curves.K256()` | Silent default — including the empty string |
:::danger[Only secp256k1 actually works]
`CurveName` advertises ten curves, but the package is secp256k1-only in practice:
- `NewEnclave()` hardcodes `K256Name`.
- `GetBobSignFunc` and `GetBobRefreshFunc` ignore `k.Curve` and pass `curves.K256()`, while the Alice
side honours `k.Curve`. Setting `Curve` to anything else therefore puts the two parties on
different curves.
- `GetECDSAPoint`, used by both `Verify` and `VerifyWithPubKey`, always uses `curves.K256()`.
- The `default` branch of `Curve()` returns `curves.K256()` instead of erroring, so a typo in a
persisted `"curve"` field is silently coerced rather than rejected.
Treat every constant other than `K256Name` as unimplemented.
:::
## Signing digests and double hashing
`GetAliceSignFunc`/`GetBobSignFunc` pass `sha3.New256()` and the raw message into `dklsv1`, which
hashes internally; `Verify` independently computes `sha3.New256(data)` and calls `ecdsa.Verify` on
that digest. So `Sign(m)`/`Verify(m, sig)` are consistent, and the digest is SHA3-256 — not SHA-256.
This matters for [UCAN](/identity/ucan): `ucan.MPCSigningMethod` hashes the JWT signing string with
**SHA-256** and then calls `enclave.Sign(digest)`, which hashes that 32-byte digest again with
SHA3-256. The composition is `SHA3-256(SHA-256(signingString))`. It verifies correctly because
`Verify` does the same thing, but any external verifier must replicate both hashes.
## `mpc/spec`
`mpc/spec` is a **near-duplicate fork** of the UCAN types and MPC JWT plumbing that also lives in
`github.com/sonr-io/crypto/ucan`. It redeclares `Token`, `Attenuation`, `Proof`, `Fact`, the
`Capability` and `Resource` interfaces, `SimpleCapability`, `SimpleResource`, `KeyshareSource`, and
`CreateSimpleAttenuation`, and adds:
```go
const (
UCANVersion = "0.9.0"
UCANVersionKey = "ucv"
PrfKey = "prf"
FctKey = "fct"
AttKey = "att"
CapKey = "cap"
)
func NewSource(enclave mpc.Enclave) (KeyshareSource, error)
func NewJWTSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod
func NewMPCSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod // alias
func RegisterMPCMethod(alg string)
func (m *MPCSigningMethod) WithEnclave(enclave mpc.Enclave) *MPCSigningMethod
```
`spec` is the only place in the module that names `UCANVersion` as a constant — `ucan` writes the
literal `"0.9.0"` inline into the `ucv` JWT header.
:::danger[`mpc/spec`'s signing method violates the jwt/v5 contract]
`golang-jwt/jwt/v5` requires `SigningMethod.Sign` to return the **raw** signature bytes (the library
base64url-encodes them) and passes `Verify` the **already-decoded** bytes. `spec`'s implementation
does the encoding itself in both directions:
```go
// Sign
encoded := base64.RawURLEncoding.EncodeToString(sig)
return []byte(encoded), nil
// Verify
sig, err := base64.RawURLEncoding.DecodeString(string(signature))
```
So a token minted through `spec` carries base64-of-base64 in its signature segment, and `Verify`
base64-decodes bytes that jwt/v5 already decoded. `ucan.MPCSigningMethod` gets this right — it
returns and consumes raw bytes.
Worse, `spec`'s `init()` registers this implementation **globally**:
```go
func init() {
jwt.RegisterSigningMethod("MPC256", func() jwt.SigningMethod {
return &MPCSigningMethod{Name: "MPC256"} // enclave is nil
})
}
```
Any program that imports `mpc/spec`, even transitively and even without calling anything in it,
installs a global `"MPC256"` method whose factory produces a method with a nil enclave — so
`jwt.Parse` on an MPC-signed token resolves to it and fails with
`"MPC enclave not available for signature verification"`. `RegisterMPCMethod(alg)` does the same for
an arbitrary algorithm name.
:::
**Use `github.com/sonr-io/crypto/ucan`, not `mpc/spec`.** `spec` is a maintenance hazard: two copies
of the same type set that will drift, one of which is broken. It has no tests. Note that the
duplication also means `ucan.Attenuation` and `spec.Attenuation` are distinct, non-interconvertible
types.
## Caveats
:::warning[`randNonce` ignores its error]
`mpc/codec.go`: `rand.Read(nonce)` is called without checking the return values. On a platform where
`crypto/rand` fails, the nonce would be all zeros. In practice `crypto/rand.Read` on modern Go does
not fail, but the omission is real.
:::
:::warning[`IsValid` is a nil check]
`IsValid()` returns `k.ValShare != nil && k.UserShare != nil`. It does not check that the shares
belong to the same key, that `PubBytes` matches them, or that `Curve` is set. Any `*EnclaveData` with
two non-nil share pointers reports as "valid" and then fails at sign time.
:::
:::warning[`Result` can return `(nil, nil)`]
`dklsv1`'s `Result(version)` returns `(nil, nil)` when the protocol has not finished — its
completion check runs before its initialization check. `NewEnclave`, `ExecuteSigning` and
`ExecuteRefresh` all call `Result` immediately after `CheckIteratedErrors` returns nil, so on the
happy path this does not bite. But if you drive the iterators yourself, an `err == nil` from
`Result` does **not** guarantee a non-nil `*protocol.Message`, and passing nil into
`GetAliceOut`/`GetBobOut`/`GetAlicePublicPoint`/`GetBobPubPoint` or `dklsv1.DecodeSignature`
nil-dereferences. Always nil-check the message as well as the error.
:::
:::warning[`RunProtocol`'s error pair is asymmetric]
`RunProtocol(firstParty, secondParty)` returns `(aErr, bErr)` where `aErr` tracks the *second*
argument and `bErr` the *first*. On an early real error it returns `(nil, bErr)` or `(aErr, nil)` —
so always funnel the pair through `CheckIteratedErrors` rather than inspecting the two values
positionally. Note also that `NewEnclave` calls `RunProtocol(userKs, valKs)`, i.e. the user side is
`firstParty`.
:::
:::info[Marshal is plaintext JSON]
`Marshal()` serializes both keyshares in the clear. If you persist that output, it is the complete
signing key. Protect it accordingly — and given the fixed-nonce hazard above, prefer an independent
AEAD over the built-in `Encrypt`.
:::
## Next
<CardGroup cols={2}>
<Card title="UCAN Tokens" href="/identity/ucan" icon="ticket">
Signing capability tokens with an enclave, and what the verifier does and does not check.
</Card>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="users">
The `tecdsa/dklsv1` protocol underneath, for when you need to run the two parties apart.
</Card>
<Card title="did:key Identifiers" href="/identity/did-key" icon="id-card">
Turning `PubKeyBytes()` into a stable identifier.
</Card>
<Card title="AEAD" href="/symmetric/aead" icon="lock">
Encrypting a marshalled enclave properly, with a fresh nonce per operation.
</Card>
</CardGroup>
+751
View File
@@ -0,0 +1,751 @@
---
title: UCAN Capability Tokens
description: JWT-based User-Controlled Authorization Network tokens signed by an MPC enclave — capabilities, attenuation, delegation chains, templates, and the authorization checks that are not implemented.
sidebar:
order: 4
icon: ticket
---
`github.com/sonr-io/crypto/ucan` implements UCAN — capability tokens where authority flows from a key
rather than from a server-side ACL. A token is a JWT whose issuer (`iss`) is a
[`did:key`](/identity/did-key), whose audience (`aud`) is the recipient's DID, and whose `att` claim
is a list of *attenuations*: `(capability, resource)` pairs. The holder of a token can mint a new
token that grants a **subset** of its own authority to someone else, attaching the parent token as a
proof in `prf`. Verification walks that chain back to a root the verifier trusts.
Tokens carry the UCAN version in a `ucv` JWT header. In this package that value is the literal
`"0.9.0"`, written inline in `ucan/source.go`; the only exported constant naming it is
`spec.UCANVersion` in [`mpc/spec`](/identity/mpc-enclave).
**Reach for this when** you need offline-verifiable, expiring, narrowable authorization derived from
a key you control.
## Read this first
:::danger[Four authorization gaps]
The package's own authorization logic has holes that a reader would not guess from the API surface.
Each was verified by reading the source and confirmed by running it; each is detailed below.
1. **`GenerateJWTToken`, `GenerateModuleJWTToken`, `VerifyJWTToken` and `VerifyModuleJWTToken` sign
and verify with HS256 under the hardcoded secret `"sonr-ucan-secret"`**, which is compiled into
the package and therefore known to anyone with the source. Any party can mint a token these
functions accept.
2. **Caveat validation is a no-op.** Every `validate*Caveat` helper in `verifier.go` returns `nil`
unconditionally, so a caveat such as `"owner"` or `"max-amount"` restricts nothing.
3. **`MPCTokenBuilder.CreateDelegatedToken` does not enforce attenuation** — it will happily sign a
child token that grants *more* than its parent. Only `KeyshareSource.NewAttenuatedToken` checks
the subset property.
4. **`RevokeCapability` effectively does nothing**, because it revokes a *freshly minted* token
string rather than the one you issued.
The MPC-signed path has real cryptography behind it, but two further constraints apply: verification
as written requires possession of the signer's enclave, and `MPCVerifier.VerifyMPCToken` currently
fails outright because `"MPC256"` is never registered with `golang-jwt`. Both are covered under
[MPC signing and verification](#mpc-signing-and-verification).
:::
## The capability model
Two interfaces carry the whole model.
```go
type Capability interface {
GetActions() []string // the actions this capability grants
Grants(abilities []string) bool // does it grant all of these?
Contains(other Capability) bool // does it subsume another capability?
String() string
}
type Resource interface {
GetScheme() string // "ipfs", "did", "dwn", "service", ...
GetValue() string // the path/identifier
GetURI() string // the full "scheme://value"
Matches(other Resource) bool // equivalence, by URI
}
type Attenuation struct {
Capability Capability `json:"can"`
Resource Resource `json:"with"`
}
```
`AttenuationList` is `[]Attenuation` with query helpers:
<TypeTable
type={{
"Contains": { type: "func(resourceURI string) bool", description: "Is there any attenuation whose resource URI matches exactly?" },
"GetCapabilitiesForResource": { type: "func(resourceURI string) []Capability", description: "All capabilities attached to that exact URI." },
"CanPerform": { type: "func(resourceURI string, actions []string) bool", description: "Does any capability on that URI grant every one of these actions?" },
"IsSubsetOf": { type: "func(parent AttenuationList) bool", description: "Every child attenuation must be matched by a parent whose resource Matches and whose capability Contains it." },
}}
/>
### Attenuation
Attenuation is the invariant that makes UCAN safe to hand around: **a delegated token may only
narrow its parent's authority, never widen it.** `IsSubsetOf` is the check:
```go
parent := ucan.AttenuationList{
ucan.CreateMultiAttenuation([]string{"read", "write", "delete"}, "service://api"),
}
child := ucan.AttenuationList{
ucan.CreateSimpleAttenuation("read", "service://api"),
}
child.IsSubsetOf(parent) // true — narrower
parent.IsSubsetOf(child) // false — wider
```
The rule composes: for every attenuation in the child list there must exist a parent attenuation
whose `Resource.Matches` is true *and* whose `Capability.Contains` is true. Resource matching is
plain URI string equality (`SimpleResource.Matches`), so there is no prefix or wildcard matching at
the resource level — only at the action level, via `"*"`.
### Capability types
Every type below implements `Capability`. The module-specific ones exist so that a verifier can pick
the right caveat and serialization path from the resource scheme.
| Type | Shape | Grants semantics |
| --- | --- | --- |
| `SimpleCapability` | `{Action string}` | Grants exactly its one action |
| `MultiCapability` | `{Actions []string}` | Grants every requested action present in the set |
| `VaultCapability` | `Action`, `Actions`, `VaultAddress`, `Caveats`, `EnclaveDataCID`, `Metadata` | Vault operations; JSON tags `can`/`vault`/`cavs` |
| `DIDCapability` | `Action`, `Actions`, `Caveats`, `Metadata` | DID document operations |
| `DWNCapability` | `Action`, `Actions`, `Caveats`, `Metadata` | Decentralized Web Node records |
| `DEXCapability` | plus `MaxAmount string` | Swap/liquidity operations with an amount cap |
| `CrossModuleCapability` | `{Modules map[string]Capability}` | Composes per-module capabilities |
| `GaslessCapability` | embeds `Capability`, plus `AllowGasless bool`, `GasLimit uint64` | Decorator; adds `SupportsGasless()` and `GetGasLimit()` |
`GetActions()` on the module types returns `Actions` when non-empty and `[]string{Action}` otherwise;
`Grants` short-circuits to `true` when `Action == "*"`.
Resources mirror them, each embedding `SimpleResource`: `VaultResource` (`VaultAddress`,
`EnclaveDataCID`), `VaultResourceExt`, `DIDResource` (`DIDMethod`, `DIDSubject`), `DWNResource`
(`RecordType`, `Protocol`, `Owner`), `DEXResource` (`PoolID`, `AssetPair`, `OrderID`), and
`ServiceResource` (`ServiceID`, `Domain`, plus `SupportsDelegate()`).
### Constructors
<TypeTable
type={{
"CreateSimpleAttenuation": { type: "func(action, resourceURI string) Attenuation", description: "SimpleCapability + a SimpleResource parsed from the URI." },
"CreateMultiAttenuation": { type: "func(actions []string, resourceURI string) Attenuation", description: "MultiCapability + SimpleResource." },
"CreateVaultAttenuation": { type: "func(actions []string, enclaveDataCID, vaultAddress string) Attenuation", description: "MultiCapability + VaultResource with scheme \"ipfs\" and URI \"ipfs://<cid>\"." },
"CreateDIDAttenuation": { type: "func(actions []string, didPattern string, caveats []string) Attenuation", description: "DIDCapability + DIDResource with URI \"did:<pattern>\"." },
"CreateDWNAttenuation": { type: "func(actions []string, recordPattern string, caveats []string) Attenuation", description: "DWNCapability + DWNResource." },
"CreateDEXAttenuation": { type: "func(actions []string, poolPattern string, caveats []string, maxAmount string) Attenuation", description: "DEXCapability + DEXResource." },
"CreateServiceAttenuation": { type: "func(actions []string, serviceID, domain string) Attenuation", description: "MultiCapability + ServiceResource with URI \"service://<id>\"." },
"NewCapability": { type: "func(issuer, resource string, abilities []string) (Attenuation, error)", description: "MultiCapability + SimpleResource with scheme \"generic\". The issuer argument is IGNORED and the error is always nil." },
"VaultAttenuationConstructor": { type: "func(m map[string]any) (Attenuation, error)", description: "Builds a vault attenuation from a decoded claim map, running ValidateVaultCapability first." },
}}
/>
:::note
`CreateVaultAttenuation(actions, enclaveDataCID, vaultAddress)` takes the CID **before** the address.
`MPCTokenBuilder.CreateVaultCapabilityToken(aud, vaultAddress, enclaveDataCID, ...)` takes them in
the opposite order. Getting these backwards produces a token whose resource URI is
`ipfs://<vault-address>`, which will pass CID-format validation only if the address happens to look
like a CID — usually it silently fails later.
:::
## The `Token` type
```go
type Token struct {
Raw string `json:"raw"`
Issuer string `json:"iss"`
Audience string `json:"aud"`
ExpiresAt int64 `json:"exp,omitempty"`
NotBefore int64 `json:"nbf,omitempty"`
Attenuations []Attenuation `json:"att"`
Proofs []Proof `json:"prf,omitempty"`
Facts []Fact `json:"fct,omitempty"`
}
type Proof string // a JWT string or a CID
type Fact struct{ Data json.RawMessage `json:"data"` }
```
`Raw` is the encoded JWT when the token came from a verifier or a signing builder, and `""` when it
came from `TokenBuilder`, which does not sign.
### `TokenBuilder`
`TokenBuilder` and `TokenBuilderInterface` (`CreateOriginToken`, `CreateDelegatedToken`) live in
`ucan/stubs.go` and are exactly what the filename says: they assemble a `*Token` struct with
`Raw: ""` and no signature. `CreateDelegatedToken` copies `parentToken.Raw` into `Proofs` if it is
non-empty and sets `Audience: parentToken.Issuer`.
They exist because `NewVaultAdminToken(builder TokenBuilderInterface, vaultOwnerDID, vaultAddress,
enclaveDataCID string, exp time.Time)` takes the interface. Pass an `MPCTokenBuilder`-backed
implementation if you need a signed result; `&TokenBuilder{}` gives you an unsigned struct.
## MPC signing and verification
This is the path with real cryptography. `MPCSigningMethod` plugs an
[`mpc.Enclave`](/identity/mpc-enclave) into `golang-jwt/jwt/v5`:
```go
func NewMPCSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod
func (m *MPCSigningMethod) Alg() string // returns m.Name; "MPC256" everywhere in this package
func (m *MPCSigningMethod) Sign(signingString string, key any) ([]byte, error)
func (m *MPCSigningMethod) Verify(signingString string, signature []byte, key any) error
```
`Sign` computes `sha256.Sum256(signingString)` and passes that digest to `enclave.Sign`, which
hashes again with SHA3-256 internally. `Verify` does the mirror image via `enclave.Verify`.
:::danger[MPC verification requires the signer's enclave]
`MPCSigningMethod.Verify` **ignores its `key` argument entirely** and calls `m.enclave.Verify(...)`.
`MPCVerifier.verifyWithMPC` likewise constructs `NewMPCSigningMethod("MPC256", v.enclave)` and hands
`jwt.Parse` a key func that returns `(nil, nil)`.
So a relying party can only verify an MPC-signed token if it holds an `mpc.Enclave` for the *same
key* — and an enclave holds both keyshares. That inverts the point of public-key verification: the
public key alone is sufficient information to verify (`mpc.VerifyWithPubKey(pubBytes, digest, sig)`
does exactly that), but this method does not take that path.
Compounding it, the `ucan` package never calls `jwt.RegisterSigningMethod("MPC256", ...)`. jwt/v5
resolves a token's `alg` header through its global registry, so `jwt.Parse` inside
`verifyWithMPC` fails with an unavailable-signing-method error unless something else has registered
`"MPC256"`. The only registration in the module is in `mpc/spec`'s `init()`, and that one installs a
*broken* implementation with a nil enclave (see [`mpc/spec`](/identity/mpc-enclave)).
**Practical consequence: `MPCVerifier.VerifyMPCToken` does not currently verify MPC-signed tokens.**
Measured against a token freshly minted by `MPCTokenBuilder.CreateOriginToken`, it returns:
```text
MPC token verification failed: token is unverifiable: signing method (alg) is unavailable
```
To validate a signature yourself, extract the parts and check them directly. The digest chain is
`SHA3-256(SHA-256(signingString))`, so pass the SHA-256 digest as `data` and let
`VerifyWithPubKey` apply the SHA3-256 layer:
```go
unsigned, err := ucan.ExtractUnsignedToken(tokenString) // header.payload
sig, err := ucan.ExtractSignature(tokenString) // decoded bytes
digest := sha256.Sum256([]byte(unsigned))
ok, err := mpc.VerifyWithPubKey(enclave.PubKeyBytes(), digest[:], sig)
```
That path was verified end to end against this package: it returns `(true, nil)` for a real
`MPCTokenBuilder` token and `(false, nil)` when a byte of the signing string is altered.
:::
### Builders and validators
<TypeTable
type={{
"NewMPCTokenBuilder": { type: "func(enclave mpc.Enclave) (*MPCTokenBuilder, error)", description: "Errors if !enclave.IsValid(). Derives the issuer DID and address from enclave.PubKeyBytes()." },
"MPCTokenBuilder.CreateOriginToken": { type: "func(audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)", description: "Root token: no proofs." },
"MPCTokenBuilder.CreateDelegatedToken": { type: "func(parent *Token, audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)", description: "Attaches the parent as a proof. Does NOT check the subset property — see the callout below." },
"MPCTokenBuilder.CreateVaultCapabilityToken": { type: "func(audienceDID, vaultAddress, enclaveDataCID string, actions []string, expiresAt time.Time) (*Token, error)", description: "Convenience origin token carrying a single vault attenuation." },
"MPCTokenBuilder.GetIssuerDID": { type: "func() string", description: "The did:key derived from the enclave public key." },
"MPCTokenBuilder.GetAddress": { type: "func() string", description: "keys.DID.Address() — a truncated hex prefix, not a chain address." },
"NewMPCCapabilityBuilder": { type: "func(enclave mpc.Enclave) (*MPCCapabilityBuilder, error)", description: "Emits vault attenuations: CreateVaultAdminCapability, CreateVaultReadOnlyCapability, CreateVaultSigningCapability, CreateCustomCapability." },
"NewMPCKeyshareSource": { type: "func(enclave mpc.Enclave) (KeyshareSource, error)", description: "The higher-level source interface — see below." },
}}
/>
`KeyshareSource` bundles identity and token minting over one enclave:
```go
type KeyshareSource interface {
Address() string
Issuer() string
ChainCode() ([]byte, error)
OriginToken() (*Token, error)
SignData(data []byte) ([]byte, error)
VerifyData(data []byte, sig []byte) (bool, error)
Enclave() mpc.Enclave
NewOriginToken(audienceDID string, att []Attenuation, fct []Fact, notBefore, expires time.Time) (*Token, error)
NewAttenuatedToken(parent *Token, audienceDID string, att []Attenuation, fct []Fact, nbf, exp time.Time) (*Token, error)
}
```
`ChainCode()` signs the address string with the enclave. Because DKLs18 ECDSA signing is randomized,
**`ChainCode()` returns different 32 bytes on every call** despite the doc comment calling it
deterministic — measured directly: two successive calls on the same source disagree. Treat it as a
fresh signature, not a derivation.
:::danger[Only `KeyshareSource` enforces attenuation at issuance]
There are two delegation APIs and they behave differently. `mpcKeyshareSource.NewAttenuatedToken`
checks the subset property first:
```go
// ucan/source.go
if !isAttenuationSubset(att, parent.Attenuations) {
return nil, fmt.Errorf("scope of ucan attenuations must be less than its parent")
}
```
`MPCTokenBuilder.CreateDelegatedToken` does **not**. Its only pre-step is
`prepareDelegationProofs(parent, attenuations)`, which is the stub in `ucan/stubs.go` that ignores
its `capabilities` argument entirely and returns `[]Proof{parent.Raw}`. Nothing compares the child's
attenuations against the parent's.
Measured against this package, with a parent granting only `read` on `service://api` and a child
asking for `read, delete`:
| API | Result |
| --- | --- |
| `MPCTokenBuilder.CreateDelegatedToken` | `nil` — **widened token issued and signed** |
| `KeyshareSource.NewAttenuatedToken` | `"scope of ucan attenuations must be less than its parent"` |
A widened token from `MPCTokenBuilder` is a validly signed token whose `att` claims more authority
than its proof grants. Whether that is caught depends entirely on the relying party calling
`VerifyDelegationChain` — and nothing in `MPCTokenBuilder` makes that happen.
**Use `ucan.NewMPCKeyshareSource(enclave).NewAttenuatedToken(...)` for delegation.** Note it also
flattens the chain: it appends `parent.Raw` *and* all of `parent.Proofs`, so the child carries the
whole ancestry rather than a single link.
:::
### Verification plumbing
```go
type DIDResolver interface {
ResolveDIDKey(ctx context.Context, did string) (keys.DID, error)
}
```
| Resolver | Behaviour |
| --- | --- |
| `StringDIDResolver{}` | `keys.Parse(didStr)` — pure decode, no network |
| `MPCDIDResolver` (`NewMPCDIDResolver(enclave, fallback)`) | Short-circuits its own enclave-derived DID; otherwise delegates to `fallback`, or `keys.Parse` if `fallback` is nil |
`Verifier` is the general path:
<TypeTable
type={{
"NewVerifier": { type: "func(didResolver DIDResolver) *Verifier", description: "Constructs a verifier over a DID resolver." },
"VerifyToken": { type: "func(ctx, tokenString string) (*Token, error)", description: "jwt.Parse with a resolver-backed key func, then parses att/prf/fct and checks iss, aud, at least one attenuation, nbf and exp." },
"VerifyCapability": { type: "func(ctx, tokenString, resource string, abilities []string) (*Token, error)", description: "VerifyToken plus: some attenuation's resource URI equals `resource` exactly and its capability Grants all `abilities`." },
"VerifyDelegationChain": { type: "func(ctx, tokenString string) error", description: "Verifies the token, then every JWT in Proofs, then the delegation relationship between each pair." },
}}
/>
:::warning[`Verifier` supports only RSA and Ed25519 issuers]
`Verifier.keyFunc` switches on the token's signing method and handles exactly `RS256`, `RS384`,
`RS512` and `EdDSA`; anything else returns `"unsupported signing method"`. Since a `did:key` derived
from an MPC enclave is a **secp256k1** key, `getRSAPublicKey` and `getEd25519PublicKey` both reject
it. `Verifier.VerifyToken` therefore cannot verify tokens issued by an enclave — which is why
`MPCVerifier.VerifyMPCToken` tries `VerifyToken` first and falls through to `verifyWithMPC`.
:::
`MPCVerifier` and `MPCTokenValidator` layer on top:
```go
func NewMPCVerifier(enclave mpc.Enclave) *MPCVerifier
func (v *MPCVerifier) VerifyMPCToken(ctx context.Context, tokenString string) (*Token, error)
func NewMPCTokenValidator(enclave mpc.Enclave, enableEnclaveValidation bool) *MPCTokenValidator
func (v *MPCTokenValidator) ValidateTokenForResource(ctx, tokenString, resourceURI string, requiredAbilities []string) (*Token, error)
func (v *MPCTokenValidator) ValidateTokenForVaultOperation(ctx, tokenString, enclaveDataCID, requiredAction, vaultAddress string) (*Token, error)
```
`ValidateTokenForVaultOperation` is the most complete check in the package, in five ordered steps:
verify the token, `ValidateVaultTokenCapability`, optionally match the enclave-data CID, optionally
match the vault address, and finally `VerifyDelegationChain` if `Proofs` is non-empty. The two
"optionally" steps run only when `enableEnclaveValidation` was true at construction — pass `true`
unless you know why not.
### Signature helpers
<TypeTable
type={{
"SupportedSigningMethods": { type: "func() []jwt.SigningMethod", description: "RS256, RS384, RS512, EdDSA. Note: no ECDSA and no MPC256." },
"ValidateSignature": { type: "func(tokenString string, verifyKey any) error", description: "Parses and validates the signature against a supplied key." },
"ExtractUnsignedToken": { type: "func(tokenString string) (string, error)", description: "The \"header.payload\" prefix — the exact bytes that were signed." },
"ExtractSignature": { type: "func(tokenString string) ([]byte, error)", description: "The decoded third segment." },
"ExtractSignatureInfo": { type: "func(tokenString string, verifyKey any) (*SignatureInfo, error)", description: "Algorithm, key type, signing string, signature, and validity in one struct." },
"GetHashAlgorithmForMethod": { type: "func(method jwt.SigningMethod) (crypto.Hash, error)", description: "The crypto.Hash a signing method expects." },
"CreateHasher": { type: "func(hashAlg crypto.Hash) (hash.Hash, error)", description: "Instantiates that hash." },
"VerifyEd25519Signature": { type: "func(signingString string, signature []byte, publicKey ed25519.PublicKey) error", description: "Raw Ed25519 verification over the signing string." },
"VerifyRSASignature": { type: "func(signingString string, signature []byte, publicKey *rsa.PublicKey, hashAlg crypto.Hash) error", description: "Raw RSA verification." },
"NewSigningValidator": { type: "func() *SigningValidator", description: "Allows every method in SupportedSigningMethods. ValidateSigningMethod and ValidateTokenSignature." },
"NewKeyValidator": { type: "func() *KeyValidator", description: "ValidateEd25519PublicKey and ValidateRSAPublicKey." },
}}
/>
### `SecurityConfig`
<TypeTable
type={{
"AllowedSigningMethods": {
type: "[]jwt.SigningMethod",
required: true,
description: "Permitted JWT algorithms.",
default: "SupportedSigningMethods() — RS256, RS384, RS512, EdDSA"
},
"MinRSAKeySize": {
type: "int",
required: true,
description: "Smallest accepted RSA modulus in bits. ValidateSecurityConfig rejects anything below 1024.",
default: "2048"
},
"MaxRSAKeySize": {
type: "int",
required: true,
description: "Largest accepted RSA modulus. Must be >= MinRSAKeySize and <= 16384.",
default: "8192"
},
"RequireSecureAlgs": {
type: "bool",
required: true,
description: "Marks the config as rejecting weak algorithms.",
default: "true"
},
}}
/>
`RestrictiveSecurityConfig()` narrows those to `{RS256, EdDSA}`, `MinRSAKeySize: 3072`,
`MaxRSAKeySize: 4096`, `RequireSecureAlgs: true`. `ValidateSecurityConfig(config)` enforces the
bounds noted above.
:::warning
`SecurityConfig` is a value object with a validator. Nothing in the package *consumes* it — neither
`Verifier` nor `MPCVerifier` nor `SigningValidator` takes one. Constructing and validating a config
does not change how any verification behaves; wire the allow-list yourself with
`NewSigningValidatorWithMethods(config.AllowedSigningMethods)`.
:::
## Templates and policy
`CapabilityTemplate` is an allow-list of actions per resource scheme, plus lifetime bounds.
<TypeTable
type={{
"AllowedActions": {
type: "map[string][]string",
required: true,
description: "resource scheme -> permitted actions. A scheme that is ABSENT from the map is allowed unconditionally.",
default: "empty map"
},
"DefaultExpiration": {
type: "time.Duration",
required: true,
description: "Used by GetDefaultExpirationTime().",
default: "24h"
},
"MaxExpiration": {
type: "time.Duration",
required: true,
description: "ValidateExpiration rejects an exp further out than this.",
default: "720h (30 days)"
},
}}
/>
```go
tpl := ucan.NewCapabilityTemplate()
tpl.AddAllowedActions("service", []string{"read", "write"})
err := tpl.ValidateAttenuation(ucan.CreateSimpleAttenuation("delete", "service://api"))
// -> "action delete not allowed for resource type service"
err = tpl.ValidateExpiration(tpl.GetDefaultExpirationTime()) // nil
```
`ValidateExpiration` treats `expiresAt == 0` as "no expiration" and returns `nil`; a past timestamp
errors, and one beyond `MaxExpiration` errors. `"*"` in an attenuation is only accepted if `"*"` is
itself in the allow-list for that scheme.
:::warning[Unknown schemes are allowed, not denied]
`ValidateAttenuation` returns `nil` when the resource scheme is missing from `AllowedActions`, with
the comment "Allow unknown resource types for backward compatibility". A template is therefore a
*deny-list of known-bad actions on known schemes*, not an allow-list. `CreateSimpleAttenuation("nuke",
"unknown://everything")` validates cleanly against every template in the package.
:::
Prebuilt templates, each a `NewCapabilityTemplate()` with one or two schemes populated:
| Function | Schemes populated |
| --- | --- |
| `StandardVaultTemplate()` | `ipfs`, `vault` |
| `StandardServiceTemplate()` | `service`, `https`, `http` |
| `StandardDIDTemplate()` | `did` |
| `StandardDWNTemplate()` | `dwn` |
| `StandardDEXTemplate()` | `dex` |
| `EnhancedServiceTemplate()` | `service`, with delegation actions |
`StandardTemplate` is a package-level `var` populated in `ucan/jwt.go`'s `init()` with actions for
`vault`, `service`, `did`, `dwn`, `dex`, `pool` and `svc`. It is the template that
`VerifyJWTToken` and `VerifyModuleJWTToken` validate against.
:::danger[`StandardTemplate` is mutable global state]
It is an exported pointer, and `AddAllowedActions` mutates it in place. Any code — including a test,
as `ucan/ucan_test.go` does — can widen the allow-list that every `VerifyJWTToken` call in the
process then honours. Build your own template with `NewCapabilityTemplate()` for anything that
matters.
:::
## Vault and IPFS integration
Vault capabilities address an enclave backup stored in IPFS, so the resource URI is `ipfs://<CID>`.
<TypeTable
type={{
"VaultCapabilitySchema": { type: "z.Struct", description: "A zog schema requiring `can` from a fixed action set, `with` as a valid ipfs:// URI, a non-empty `vault`, and optional `actions`/`cavs`." },
"ValidateVaultCapability": { type: "func(att map[string]any) error", description: "Runs a decoded attenuation map through VaultCapabilitySchema." },
"ValidateVaultTokenCapability": { type: "func(token *Token, enclaveDataCID, requiredAction string) error", description: "Requires requiredAction in {read, write, sign, export, import, delete} and an attenuation on ipfs://<cid> granting it." },
"GetEnclaveDataCID": { type: "func(token *Token) (string, error)", description: "The first attenuation resource with an ipfs:// prefix, minus the prefix." },
"ValidateIPFSCID": { type: "func(value *string, ctx z.Ctx) bool", description: "zog TestFunc: requires an ipfs:// prefix and a well-formed CID." },
"ValidateEnclaveDataCIDIntegrity": { type: "func(enclaveDataCID string, enclaveData []byte) error", description: "Recomputes the CID over the bytes and compares. Errors on an empty CID, empty data, a malformed CID, or a mismatch." },
"ValidateEnclaveDataIntegrity": { type: "func(enclaveData *mpc.EnclaveData, expectedCID string) error", description: "Structural checks on the EnclaveData (non-nil, non-empty PubBytes) before the CID comparison." },
}}
/>
`VaultAdminAction` is the constant `"vault/admin"`. Note that the vault schema's `can` set uses
slash-prefixed values (`vault/read`, `vault/sign`, …) while `ValidateVaultTokenCapability` and the
templates use bare ones (`read`, `sign`, …); they are different vocabularies applied at different
layers.
`TestValidateEnclaveDataCIDIntegrity` in `ucan/ucan_test.go` is the one genuinely end-to-end test in
the package, covering empty-CID, empty-data, malformed-CID, matching and mismatching cases.
## End-to-end example
Enclave → issuer DID → signed origin token → narrowed delegated token → manual signature check.
This uses `KeyshareSource`, the delegation API that actually enforces attenuation. Every line was
run against this package; the printed values below are the observed output.
```go ucan_delegation.go
package main
import (
"crypto/sha256"
"fmt"
"time"
"github.com/sonr-io/crypto/keys"
"github.com/sonr-io/crypto/mpc"
"github.com/sonr-io/crypto/ucan"
)
func main() {
enclave, err := mpc.NewEnclave()
if err != nil {
panic(err)
}
// KeyshareSource enforces the subset property on delegation.
src, err := ucan.NewMPCKeyshareSource(enclave)
if err != nil {
panic(err)
}
fmt.Println("issuer:", src.Issuer()) // did:key:z...
// The delegate's identity — here just another enclave's DID.
delegateEnclave, err := mpc.NewEnclave()
if err != nil {
panic(err)
}
delegateDID, err := keys.NewFromMPCPubKey(delegateEnclave.PubKeyBytes())
if err != nil {
panic(err)
}
now := time.Now()
// Origin token: broad authority over one service resource.
origin, err := src.NewOriginToken(
delegateDID.String(),
[]ucan.Attenuation{
ucan.CreateMultiAttenuation([]string{"read", "write", "delete"}, "service://api"),
},
nil, now, now.Add(time.Hour),
)
if err != nil {
panic(err)
}
// Widening is rejected at issuance.
_, err = src.NewAttenuatedToken(origin, delegateDID.String(),
[]ucan.Attenuation{
ucan.CreateMultiAttenuation([]string{"read", "write", "delete", "admin"}, "service://api"),
},
nil, now, now.Add(time.Hour))
fmt.Println("widening rejected:", err)
// -> "scope of ucan attenuations must be less than its parent"
// Narrowing is accepted: read only, half the lifetime.
delegated, err := src.NewAttenuatedToken(origin, delegateDID.String(),
[]ucan.Attenuation{ucan.CreateSimpleAttenuation("read", "service://api")},
nil, now, now.Add(30*time.Minute))
if err != nil {
panic(err)
}
fmt.Println("proofs:", len(delegated.Proofs)) // 1 — the origin token
// The attenuation invariant, checked locally.
child := ucan.AttenuationList(delegated.Attenuations)
parent := ucan.AttenuationList(origin.Attenuations)
fmt.Println("narrows:", child.IsSubsetOf(parent)) // true
fmt.Println("widens:", parent.IsSubsetOf(child)) // false
fmt.Println("can read:", child.CanPerform("service://api", []string{"read"})) // true
fmt.Println("can delete:", child.CanPerform("service://api", []string{"delete"})) // false
// Signature verification, done directly against the public key.
unsigned, err := ucan.ExtractUnsignedToken(delegated.Raw)
if err != nil {
panic(err)
}
sig, err := ucan.ExtractSignature(delegated.Raw)
if err != nil {
panic(err)
}
digest := sha256.Sum256([]byte(unsigned))
ok, err := mpc.VerifyWithPubKey(enclave.PubKeyBytes(), digest[:], sig)
fmt.Println("signature valid:", ok, err) // true <nil>
}
```
:::warning
There is **no test in the repository** that mints an MPC-signed token and verifies it back through
the package's own verifier — and per the callout above, `VerifyMPCToken` does not currently work.
The manual check at the end of this program is the path that does, and it was confirmed to return
`(true, nil)` for a genuine token and `(false, nil)` for a tampered signing string.
:::
## Not actually implemented
Each item below was verified by reading the named source file and then confirmed by running it.
Delegation enforcement is covered separately, under
[Only `KeyshareSource` enforces attenuation at issuance](#mpc-signing-and-verification).
:::danger[`GenerateJWTToken` / `VerifyJWTToken` use a hardcoded HS256 secret]
In `ucan/jwt.go`, all four of `GenerateJWTToken`, `GenerateModuleJWTToken`, `VerifyJWTToken` and
`VerifyModuleJWTToken` do this:
```go
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte("sonr-ucan-secret"))
```
```go
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
// Dummy secret verification - replace with proper key validation
return []byte("sonr-ucan-secret"), nil
}, jwt.WithLeeway(5*time.Minute))
```
The secret is a string literal in the package source. Anyone who can read this repository can mint a
token that `VerifyJWTToken` accepts, with any issuer, audience and attenuation set — bounded only by
`StandardTemplate`, which is itself mutable.
`GenerateJWTToken` additionally hardcodes `"iss": "did:sonr:local"` and ignores any notion of an
audience, and it base64-encodes a single `{can, with}` object into a non-standard `can` claim rather
than emitting a UCAN `att` array. `GenerateModuleJWTToken` does use `att` and takes real issuer and
audience arguments — but signs with the same shared secret.
**Treat all four as demo scaffolding.** Use `MPCTokenBuilder` for issuance and verify signatures
explicitly.
:::
:::danger[Caveat restrictions are not enforced]
`Verifier.checkCapabilities` calls `validateCaveats(cap, resource)`, which dispatches by resource
scheme into `validateDIDCaveats`, `validateDWNCaveats`, `validateDEXCaveats`,
`validateServiceCaveats` and `validateVaultCaveats`. Those iterate the capability's `Caveats` slice
and call a per-caveat helper. **Every one of those helpers is a stub that returns `nil`:**
```go
// Caveat validation helper methods (placeholders for actual implementation)
func (v *Verifier) validateOwnerCaveat(resource Resource) error { return nil }
func (v *Verifier) validateControllerCaveat(resource Resource) error { return nil }
func (v *Verifier) validateRecordOwnership(resource Resource) error { return nil }
func (v *Verifier) validateProtocolCaveat(resource Resource) error { return nil }
func (v *Verifier) validateMaxAmountCaveat(maxAmount string) error { return nil }
func (v *Verifier) validatePoolMembershipCaveat(resource Resource) error { return nil }
func (v *Verifier) validateVaultOwnership(vaultAddress string) error { return nil }
func (v *Verifier) validateEnclaveIntegrity(enclaveDataCID string) error { return nil }
```
`validateServiceCaveats` returns `nil` without inspecting anything at all, and `validateCaveats`
returns `nil` for any scheme outside its switch.
The same holds on the delegation path. `areCaveatsMoreRestrictive(childCaveats, parentCaveats)`
builds a set from the parent, then loops over the child caveats with `continue` as the only
statement in the loop body, and returns `true` — it is structurally incapable of returning `false`.
`isAmountLessOrEqual(childAmount, parentAmount)` is commented "placeholder implementation" and
returns `true`. `isModuleCapabilityContained` returns `true` in its `default` branch for any unknown
scheme.
**A caveat in a UCAN token issued or verified by this package restricts nothing.** If you rely on
caveats for authorization — an amount cap, an ownership constraint, pool membership — you must
enforce them in your own code after `VerifyCapability` returns.
:::
:::danger[`RevokeCapability` revokes the wrong token]
`ucan/jwt.go` keeps an unexported `revokedTokens map[string]bool` keyed on the **full JWT string**,
which `VerifyJWTToken` and `VerifyModuleJWTToken` consult first. But the only way to add an entry is:
```go
func RevokeCapability(attenuation Attenuation) error {
token, err := GenerateJWTToken(attenuation, time.Hour)
if err != nil {
return err
}
revokedTokens[token] = true
return nil
}
```
It mints a *brand-new* token from the attenuation and revokes that string. Since the claims include
`iat` and `exp` derived from `time.Now()`, the regenerated string only equals a previously issued one
if that token was created in the same wall-clock second with the identical one-hour duration.
That is exactly the window `TestCapabilityRevocation` happens to hit — it calls
`GenerateJWTToken(att, time.Hour)` and `RevokeCapability(att)` back to back, so the two strings
match and the assertion passes. **The test passes for an incidental reason; the mechanism does not
work.** There is no API to revoke a token you actually hold, and the map is process-local,
unbounded and never persisted.
:::
Measured against this package:
| Sequence | `VerifyJWTToken` after revoking |
| --- | --- |
| Issue, wait 1.5 s, `RevokeCapability` | `nil` — **still accepted** |
| Issue and `RevokeCapability` in the same second | `"token has been revoked"` |
| Issue with a 2 h duration, `RevokeCapability` (which uses 1 h) | `nil` — **still accepted** |
:::warning[`ucan/stubs.go` — what is a stub]
The file declares four things. `TokenBuilderInterface` and `TokenBuilder` are real but do not sign
(they set `Raw: ""`). The two unexported helpers are labelled stubs in the source:
- `isValidDID(did string) bool` — "Basic DID validation stub". Returns
`did != "" && len(did) > 5 && did[:4] == "did:"`. No method check, no multibase check, no key
validation. `"did:xxxxxxxx"` passes. It gates the `audienceDID` argument in
`mpcKeyshareSource.newToken` and in `NewVaultAdminToken`.
- `prepareDelegationProofs(token, capabilities)` — "Minimal stub implementation". Ignores
`capabilities` entirely and returns `[]Proof{token.Raw}` when `Raw` is non-empty.
:::
:::warning[No signature check on `Fact` or proof CIDs]
`Proof` is `string` and may hold either a JWT or a CID. `VerifyDelegationChain` passes every proof to
`VerifyToken`, which calls `jwt.Parse` — so a CID-form proof fails to parse rather than being
resolved. The package has no proof-resolution path; CID proofs are unusable.
:::
## Next
<CardGroup cols={2}>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
The signing key behind the issuer DID, and why `mpc/spec` should be avoided.
</Card>
<Card title="did:key Identifiers" href="/identity/did-key" icon="id-card">
How issuer and audience strings are encoded and parsed.
</Card>
<Card title="Security Notes" href="/reference/security" icon="triangle-alert">
Every stub and defect in the module, in one place.
</Card>
<Card title="ECIES" href="/identity/ecies" icon="mail">
Encrypting a payload to the holder of a key, rather than authorizing them.
</Card>
</CardGroup>
+444
View File
@@ -0,0 +1,444 @@
---
title: WASM Module Signing
description: Ed25519 code signing and SHA-256 hash pinning for WebAssembly module bytes — supply-chain verification, not a JavaScript binding layer.
sidebar:
order: 6
icon: package-check
---
## This is not a js/wasm binding layer
The package name misleads. `github.com/sonr-io/crypto/wasm` contains no `//go:build js,wasm`
constraint, does not import `syscall/js`, and exposes nothing that runs inside a browser. Verified
by reading both source files (`signer.go`, `verifier.go`): the only imports are `crypto/ed25519`,
`crypto/rand`, `crypto/sha256`, `encoding/base64`, `encoding/hex`, `encoding/json`, `fmt`, `sync`,
and `time`.
What it actually is: **Ed25519 code signing and SHA-256 hash pinning over WebAssembly module bytes.**
It answers one question — *is this `.wasm` blob the one I approved?* — before a host embeds and
executes it. That is supply-chain verification, and it is plain Go that compiles and runs on any
target.
**Reach for this when** your program loads WASM plugins or modules from disk, a registry, or the
network and must refuse anything it does not recognise.
**Do not reach for this when** you need sandboxing or capability control over what a module can *do*
once loaded — that is the runtime's job, not this package's. Verification tells you *which* code you
are about to run, never what it will do.
## Trust model
<Steps>
<Step title="Provision trust out of band">
A `SignatureVerifier` starts empty and rejects everything with `"no trusted keys configured"`.
Verification is only as strong as the key set you install with `AddTrustedKey` /
`AddTrustedKeyFromHex`. Those public keys must reach the verifier through a channel you already
trust — baked into the binary, delivered by your config management, pinned in your deployment
manifest. A key learned from the same place as the module buys you nothing.
</Step>
<Step title="Sign at build time">
The publisher holds an Ed25519 private key and calls `SignModule` or `CreateSignatureManifest`
over the exact bytes that will be shipped.
</Step>
<Step title="Verify at load time">
The host recomputes the SHA-256 hash, compares it against the recorded one, and then checks the
Ed25519 signature against a trusted key.
</Step>
<Step title="Pin hashes as an independent check">
`HashVerifier` is deliberately separate from signing. A pinned hash constrains you to one exact
build even if a signing key is later compromised — it is a second, non-overlapping control, not a
weaker substitute for a signature.
</Step>
</Steps>
## Signing
```go
func NewSigner() (*Signer, error)
func NewSignerFromPrivateKey(privateKey ed25519.PrivateKey) (*Signer, error)
func (s *Signer) Sign(wasmBytes []byte) ([]byte, error)
func (s *Signer) GetPublicKey() []byte
func (s *Signer) GetPublicKeyHex() string
func (s *Signer) ExportPrivateKey() []byte
```
`NewSigner` generates a fresh Ed25519 keypair from `crypto/rand`. `NewSignerFromPrivateKey` requires
exactly `ed25519.PrivateKeySize` (64) bytes and derives the public key from it, erroring with
`"invalid private key size: expected 64, got N"` otherwise. `Sign` produces a 64-byte
`ed25519.Sign(priv, wasmBytes)` over the **raw module bytes** — not over the hash, and with no domain
separation prefix.
:::warning[`ExportPrivateKey` hands out the raw signing key]
It returns `s.privateKey` directly — the live 64-byte `ed25519.PrivateKey` slice, not a copy. The
caller can read it, and can also **mutate the signer's key in place** through the returned slice.
Anything that receives this value can forge signatures for every module your key covers. Do not log
it, serialize it, or pass it across a trust boundary; if you must persist a signing key, encrypt it
with [AEAD](/symmetric/aead) and keep the plaintext lifetime as short as possible.
:::
## Signed modules
```go
type SignedModule struct {
Module []byte `json:"-"` // WASM bytecode, EXCLUDED from JSON
Hash string `json:"hash"` // hex SHA-256 of Module
Signature []byte `json:"signature"` // Ed25519, 64 bytes
SignerID string `json:"signer_id"`
Timestamp time.Time `json:"timestamp"`
Version string `json:"version"`
}
func SignModule(signer *Signer, module []byte, signerID, version string) (*SignedModule, error)
func VerifySignedModule(verifier *SignatureVerifier, module *SignedModule) error
```
`VerifySignedModule` runs two checks in order:
1. Recompute the SHA-256 hash over `module.Module` and compare with `module.Hash`; mismatch yields
`"hash mismatch: expected …, got …"`.
2. If `SignerID` is non-empty, `verifier.VerifyWithKey(SignerID, Module, Signature)`; otherwise
`verifier.Verify(Module, Signature)`, which tries every trusted key in turn.
Grounded in `TestSignedModule` (`wasm/signer_test.go`):
```go wasm_sign_verify.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/wasm"
)
func main() {
// Publisher side.
signer, err := wasm.NewSigner()
if err != nil {
panic(err)
}
module := []byte("test wasm module") // in practice, the .wasm file contents
signed, err := wasm.SignModule(signer, module, "test-signer", "v1.0.0")
if err != nil {
panic(err)
}
fmt.Println("hash:", signed.Hash)
publicKeyHex := signer.GetPublicKeyHex() // ship this out of band
// Host side: trust is provisioned from the out-of-band key, not from `signed`.
verifier := wasm.NewSignatureVerifier()
if err := verifier.AddTrustedKeyFromHex("test-signer", publicKeyHex); err != nil {
panic(err)
}
fmt.Println("trusted:", verifier.GetTrustedKeyIDs())
fmt.Println("ok:", wasm.VerifySignedModule(verifier, signed)) // nil
// Tampering is caught at the hash check.
signed.Module = []byte("tampered")
fmt.Println("tampered:", wasm.VerifySignedModule(verifier, signed)) // "hash mismatch"
}
```
:::note[`Module` is not serialized]
`SignedModule.Module` carries `json:"-"`, so marshalling a `SignedModule` drops the bytecode. The
JSON is metadata only; ship the `.wasm` file alongside it and reattach it to `Module` before calling
`VerifySignedModule`, or the hash check compares against an empty module.
:::
## The `SignatureVerifier`
```go
func NewSignatureVerifier() *SignatureVerifier
func (v *SignatureVerifier) AddTrustedKey(keyID string, publicKey ed25519.PublicKey) error
func (v *SignatureVerifier) AddTrustedKeyFromHex(keyID, publicKeyHex string) error
func (v *SignatureVerifier) RemoveTrustedKey(keyID string)
func (v *SignatureVerifier) GetTrustedKeyIDs() []string
func (v *SignatureVerifier) Verify(wasmBytes, signature []byte) error
func (v *SignatureVerifier) VerifyWithKey(keyID string, wasmBytes, signature []byte) error
```
`AddTrustedKey` requires exactly `ed25519.PublicKeySize` (32) bytes. The map is guarded by a
`sync.RWMutex`, so a verifier is safe for concurrent use.
Prefer `VerifyWithKey` over `Verify`. `Verify` iterates the whole trusted set and succeeds if *any*
key validates, so it tells you the module is signed by someone you trust but not by **whom** — and it
does not report which key matched. `VerifyWithKey` binds the check to an expected signer.
## Manifests
A manifest decouples signature metadata from the module file, and supports multiple signatures.
```go
type SignatureManifest struct {
ModuleHash string `json:"module_hash"`
Signatures []SignatureEntry `json:"signatures"`
TrustedKeys []TrustedKeyEntry `json:"trusted_keys"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type SignatureEntry struct {
Signature string `json:"signature"` // base64 std encoding
SignerID string `json:"signer_id"`
Timestamp time.Time `json:"timestamp"`
Algorithm string `json:"algorithm"` // always "Ed25519"
}
type TrustedKeyEntry struct {
KeyID string `json:"key_id"`
PublicKey string `json:"public_key"` // base64 std encoding
AddedAt time.Time `json:"added_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Purpose string `json:"purpose"` // e.g. "code-signing"
}
func CreateSignatureManifest(module []byte, signer *Signer, signerID string) (*SignatureManifest, error)
func ExportManifest(manifest *SignatureManifest) ([]byte, error)
func ImportManifest(data []byte) (*SignatureManifest, error)
func VerifyWithManifest(module []byte, manifest *SignatureManifest) error
```
`CreateSignatureManifest` emits a manifest with exactly one `SignatureEntry` and one
`TrustedKeyEntry` (`Purpose: "code-signing"`). `VerifyWithManifest` checks the module hash, then
`ExpiresAt` on the manifest, then builds a fresh verifier from `manifest.TrustedKeys`, skipping any
entry whose own `ExpiresAt` has passed.
:::danger[`VerifyWithManifest` trusts the keys inside the manifest]
It constructs its verifier from `manifest.TrustedKeys` — keys carried by the very document whose
authenticity is in question. An attacker who can replace both the module and its manifest simply
signs the replacement with their own key, lists that key in `TrustedKeys`, and
`VerifyWithManifest` returns `nil`.
`VerifyWithManifest` therefore establishes only **internal consistency**: this manifest describes
this module. It establishes **no trust**. To get a real decision, verify against a key set you
provisioned yourself:
```go
manifest, err := wasm.ImportManifest(manifestJSON)
if err != nil {
return err
}
// Independent trust anchor — not manifest.TrustedKeys.
verifier := wasm.NewSignatureVerifier()
if err := verifier.AddTrustedKeyFromHex("release-key", pinnedPublicKeyHex); err != nil {
return err
}
// Confirm the manifest describes this module and has not expired.
if err := wasm.VerifyWithManifest(module, manifest); err != nil {
return err
}
// Then check at least one signature against YOUR key.
verified := false
for _, entry := range manifest.Signatures {
sig, err := base64.StdEncoding.DecodeString(entry.Signature)
if err != nil {
continue
}
if verifier.VerifyWithKey("release-key", module, sig) == nil {
verified = true
break
}
}
if !verified {
return errors.New("no signature from a pinned key")
}
```
:::
:::warning[Expiry is optional and unauthenticated]
`ExpiresAt` is a `*time.Time`; a `nil` value means "never expires" and `VerifyWithManifest` accepts
it. Since the manifest is unsigned as a whole, an attacker rewriting the manifest can also clear or
extend `ExpiresAt`. Only the individual `Signature` values are cryptographically protected, and each
covers the module bytes alone — not `ModuleHash`, not `SignerID`, not `Timestamp`, and not any
expiry field.
:::
## Hash pinning
```go
func NewHashVerifier() *HashVerifier
func (v *HashVerifier) ComputeHash(wasmBytes []byte) string // hex SHA-256
func (v *HashVerifier) AddTrustedHash(name, hash string)
func (v *HashVerifier) GetTrustedHash(name string) (string, bool)
func (v *HashVerifier) VerifyHash(name string, wasmBytes []byte) error
func (v *HashVerifier) VerifyHashWithFallback(name string, wasmBytes []byte, fallbackHashes []string) error
func (v *HashVerifier) ClearTrustedHashes()
```
`VerifyHash` errors with `"no trusted hash found for WASM module: <name>"` when the name is unknown —
so an unregistered module is denied by default, which is the right behaviour. The map is
`sync.RWMutex`-guarded.
:::danger[`VerifyHashWithFallback` mutates your pin set]
On a fallback match it calls `AddTrustedHash(name, computedHash)`, **overwriting the pinned hash for
that name**:
```go
for _, fallbackHash := range fallbackHashes {
if computedHash == fallbackHash {
v.AddTrustedHash(name, computedHash) // pin replaced
return nil
}
}
```
Every subsequent `VerifyHash(name, …)` now accepts the fallback build and rejects the original. Two
consequences:
- Pinning becomes trust-on-first-use with silent promotion. If the fallback list is ever wider than
you intended — read from config, a response body, a rollback table — the pin follows it.
- The change is invisible: nothing is returned or logged to say the pin moved.
If you need to accept several builds, keep them in your own set and call `VerifyHash` (or compare
`ComputeHash` output) against each, so the pin set stays under your control.
:::
## Hash chains
```go
type HashEntry struct {
Version string `json:"version"`
Hash string `json:"hash"`
PreviousHash string `json:"previous_hash"`
Timestamp int64 `json:"timestamp"`
}
func NewHashChain() *HashChain
func (hc *HashChain) AddEntry(version, hash string, timestamp int64) error
func (hc *HashChain) GetLatestEntry() (*HashEntry, error)
func (hc *HashChain) VerifyChain() error
```
`AddEntry` appends an entry whose `PreviousHash` is copied from the previous entry's `Hash` (empty
for the first). `VerifyChain` accepts an empty chain, requires the first entry's `PreviousHash` to be
empty, and then checks that each `PreviousHash` equals the preceding `Hash`. `GetLatestEntry` returns
a **copy** of the last entry, or `"hash chain is empty"`.
:::warning[The chain is a linkage check, not a cryptographic commitment]
`AddEntry` always sets `PreviousHash` correctly, so `VerifyChain` **cannot fail** for a chain built
through `AddEntry`. It only becomes meaningful for a chain deserialized from an untrusted source —
which is exactly how `TestHashChain_BrokenChain` exercises it, by assigning the internal slice
directly.
Even then, `PreviousHash` is a plain string field, not a hash *over* the previous entry. Nothing
binds `Version` or `Timestamp` to anything, and no entry is signed. An attacker who can rewrite the
chain can produce a self-consistent chain of their own choosing. Treat it as an audit-trail
convenience for update ordering, and get your integrity from `SignatureVerifier` and `HashVerifier`.
:::
## `SecurityPolicy`
<TypeTable
type={{
"RequireHashVerification": {
type: "bool",
required: true,
description: "Intended to require a hash check. NOT read by Validate.",
default: "true"
},
"RequireSignature": {
type: "bool",
required: true,
description: "Intended to require a signature. NOT read by Validate. Source comment: \"Will be enabled in next phase\".",
default: "false"
},
"AllowedHashes": {
type: "[]string",
required: true,
description: "Intended allow-list of module hashes. NOT read by Validate.",
default: "[] (empty)"
},
"MaxModuleSize": {
type: "int64",
required: true,
description: "Maximum module size in bytes. The ONLY field Validate enforces; skipped entirely when <= 0.",
default: "10485760 (10 MiB)"
},
}}
/>
:::danger[`Validate` only checks the size]
`SecurityPolicy.Validate(wasmBytes []byte) error` is, in full:
```go
func (p *SecurityPolicy) Validate(wasmBytes []byte) error {
if p.MaxModuleSize > 0 && int64(len(wasmBytes)) > p.MaxModuleSize {
return fmt.Errorf("WASM module size %d exceeds maximum allowed size %d",
len(wasmBytes), p.MaxModuleSize)
}
return nil
}
```
`RequireHashVerification`, `RequireSignature` and `AllowedHashes` are never read — not here, and
nowhere else in the package. Setting `RequireSignature: true` and calling `Validate` gives you a
size check and nothing else, while reading like an enforced signature requirement.
`TestSecurityPolicy` asserts exactly this and no more: a 1 KiB module passes, an 11 MiB module fails.
**Do not use `SecurityPolicy` as a gate.** Sequence the checks yourself:
```go
if err := policy.Validate(moduleBytes); err != nil { // size only
return err
}
if err := hashes.VerifyHash(name, moduleBytes); err != nil {
return err
}
if err := signatures.VerifyWithKey(signerID, moduleBytes, sig); err != nil {
return err
}
```
:::
## `VerificationError`
A structured error type for reporting a failed check. It is exported and its `Error()` renders
module, reason, expected and actual hash — but **no function in the package returns it**. Every
failure path uses `fmt.Errorf` instead. Use it in your own verification wrapper if you want typed
errors:
```go
return &wasm.VerificationError{
Module: name,
ExpectedHash: expected,
ActualHash: verifier.ComputeHash(moduleBytes),
Reason: "pinned hash mismatch",
}
```
## Caveats summary
:::info[What the tests cover]
`wasm/signer_test.go` and `wasm/verifier_test.go` are reasonably thorough for this package: signer
construction and key-size validation, signing and tamper detection, trusted-key add/remove/list,
`SignModule`/`VerifySignedModule`, manifest creation, `VerifyWithManifest` including hash mismatch
and expiry, manifest JSON round trip, hash computation and pinning, fallback verification, hash
chains including a broken chain, `SecurityPolicy` size limits, and `VerificationError` formatting.
What they do not cover is the *semantics* of the gaps above: no test asserts that
`RequireSignature: true` is enforced (it is not), or that `VerifyWithManifest` establishes trust (it
does not), or that a fallback match leaves the pin set unchanged (it does not).
:::
## Next
<CardGroup cols={2}>
<Card title="AEAD" href="/symmetric/aead" icon="lock">
Encrypting a signing key at rest.
</Card>
<Card title="Signatures" href="/signatures" icon="fingerprint">
Ed25519's siblings, and when a different signature scheme fits better.
</Card>
<Card title="Security Notes" href="/reference/security" icon="triangle-alert">
Every stub and defect in the module, in one place.
</Card>
<Card title="Identity Overview" href="/identity" icon="fingerprint">
How this fits with enclaves, DIDs and capability tokens.
</Card>
</CardGroup>
+122
View File
@@ -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
<CardGroup cols={2}>
<Card title="Foundations" href="/foundations" icon="layers">
The curve abstraction, modular arithmetic, hash-to-field, commitments, and the
round-driving protocol iterator every multi-party package uses.
</Card>
<Card title="Symmetric & Secrets" href="/symmetric" icon="lock">
AES-GCM and deterministic AES-SIV, Argon2id key derivation, HKDF and X25519,
plus the salt, password, and memory-hygiene helpers.
</Card>
<Card title="Signatures" href="/signatures" icon="pen-tool">
BLS aggregation and threshold keygen, BBS+ selective disclosure, ECDSA
canonicalization and RFC 6979 signing, VRFs, and chain-specific Schnorr schemes.
</Card>
<Card title="Threshold & MPC" href="/threshold" icon="users">
Shamir, Feldman, and Pedersen sharing; FROST and Gennaro DKG; two-party
threshold ECDSA; threshold Ed25519; and the oblivious transfer beneath them.
</Card>
<Card title="Zero-Knowledge" href="/zero-knowledge" icon="eye-off">
Schnorr proofs of knowledge, pairing-based accumulators for set membership,
Bulletproofs range proofs, and Paillier homomorphic encryption.
</Card>
<Card title="Identity & Authorization" href="/identity" icon="fingerprint">
`did:key` encoding, the MPC enclave, UCAN capability tokens, ECIES payload
encryption, and WebAssembly module signing.
</Card>
</CardGroup>
## 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
<CardGroup cols={2}>
<Card title="Getting started" href="/getting-started" icon="rocket">
Install the module, pick a curve, and understand the conventions shared across packages.
</Card>
<Card title="Package index" href="/reference/packages" icon="list">
Every importable package mapped to the page that documents it.
</Card>
</CardGroup>
+8
View File
@@ -0,0 +1,8 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Reference",
icon: "library",
order: 8,
pages: ["packages", "security"],
});
+109
View File
@@ -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
```
:::
+390
View File
@@ -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 <name>`. |
| `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.
<Accordion>
<AccordionItem title="Nonce reuse in threshold Ed25519 reveals the signing key" icon="triangle-alert">
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).
</AccordionItem>
<AccordionItem title="AES-GCM with a caller-supplied nonce" icon="triangle-alert">
`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).
</AccordionItem>
<AccordionItem title="The MPC enclave holds both shares in one process" icon="triangle-alert">
`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).
</AccordionItem>
<AccordionItem title="Enclave encryption uses a fixed per-enclave nonce" icon="triangle-alert">
`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.
</AccordionItem>
<AccordionItem title="The trusted dealer defeats the point of DKG" icon="triangle-alert">
`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).
</AccordionItem>
<AccordionItem title="Session ids must be unique per protocol execution" icon="triangle-alert">
`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).
</AccordionItem>
<AccordionItem title="Accumulator witnesses go stale on every update" icon="triangle-alert">
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).
</AccordionItem>
<AccordionItem title="Shamir sharing does not detect a corrupted share" icon="triangle-alert">
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).
</AccordionItem>
<AccordionItem title="BLS Basic and Aug do not stop rogue-key attacks" icon="triangle-alert">
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).
</AccordionItem>
<AccordionItem title="Deterministic AEAD leaks plaintext equality" icon="triangle-alert">
`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).
</AccordionItem>
<AccordionItem title="A short PsfProof panics instead of erroring" icon="triangle-alert">
`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).
</AccordionItem>
<AccordionItem title="Ciphertexts carry no algorithm or key identifier" icon="triangle-alert">
`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.
</AccordionItem>
</Accordion>
## 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.
+475
View File
@@ -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
<TypeTable
type={{
"NewKeys(curve *curves.PairingCurve)": {
type: "(*PublicKey, *SecretKey, error)",
description: "Generates a fresh keypair. Note the public key comes FIRST in the return order.",
},
"NewSecretKey(curve *curves.PairingCurve)": {
type: "(*SecretKey, error)",
description: "Just the signing key.",
},
"SecretKey.PublicKey()": {
type: "*PublicKey",
description: "Derives the verification key. No error return.",
},
"MessageGenerators.Init(w *PublicKey, length int)": {
type: "(*MessageGenerators, error)",
description: "Derives `length` message generators plus the blinding generator h0, deterministically from the public key. Errors only on negative length.",
},
"MessageGenerators.Get(i int)": {
type: "curves.PairingPoint",
description: "i <= 0 returns h0, the blinding generator. 1..length return message generators. Out of range returns nil (not an error). Currently broken — see the danger callout.",
},
}}
/>
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.
<Steps>
<Step title="Classify every message">
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).
</Step>
<Step title="Commit">
`NewPokSignature(sig, generators, proofMsgs, reader)` randomises the signature and builds the
Schnorr commitments. The reader supplies the proof's randomness — pass `crand.Reader`.
</Step>
<Step title="Derive the challenge">
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`.
</Step>
<Step title="Generate">
`pok.GenerateProof(challenge)` converts the blinding factors into response scalars and returns
the `*PokSignatureProof`. Send that, the challenge, the revealed messages, and the nonce.
</Step>
<Step title="Verify">
The verifier calls `pokSig.Verify(revealedMsgs, pk, generators, nonce, challenge, transcript)`
with a transcript constructed **identically** to the prover's.
</Step>
</Steps>
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.
<Steps>
<Step title="Holder commits">
`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.
</Step>
<Step title="Issuer verifies the commitment">
`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.
</Step>
<Step title="Issuer signs">
`ctx.ToBlindSignature(knownMsgs, sk, generators, nonce)` produces a `*BlindSignature`. It calls
`Verify` internally, so a bad commitment fails here too.
</Step>
<Step title="Holder unblinds">
`blindSig.ToUnblinded(blinding)` adds the retained blinding factor back into the `s` component,
yielding an ordinary `*Signature` that verifies against the complete message vector.
</Step>
</Steps>
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
<CardGroup cols={2}>
<Card title="Proof toolkit" href="/signatures" icon="wrench">
`signatures/common` — `ProofMessage`, `ProofCommittedBuilder`, `HmacDrbg`, and the scalar aliases
this page uses.
</Card>
<Card title="Schnorr proofs" href="/zero-knowledge/schnorr" icon="binary">
The standalone sigma protocol, for composing proofs of discrete-log knowledge alongside a
BBS+ presentation.
</Card>
<Card title="Accumulator" href="/zero-knowledge/accumulator" icon="layers">
Constant-size set membership on the same pairing curve — the usual companion for revocation.
</Card>
<Card title="Security notes" href="/reference/security" icon="shield">
Every defect found while documenting this library, in one place.
</Card>
</CardGroup>
+469
View File
@@ -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:
<Tabs>
<Tab title="Basic">
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).
</Tab>
<Tab title="Aug">
`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.
</Tab>
<Tab title="Pop">
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.
</Tab>
</Tabs>
:::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.
<TypeTable
type={{
"Keygen()": {
type: "(*PublicKey, *SecretKey, error)",
description: "Reads 32 bytes from crypto/rand and derives a keypair.",
},
"KeygenWithSeed(ikm []byte)": {
type: "(*PublicKey, *SecretKey, error)",
description: "Deterministic keygen via HKDF with salt \"BLS-SIG-KEYGEN-SALT-\". ikm MUST be at least 32 bytes; shorter input is an error.",
},
"Sign(sk, msg)": {
type: "(*Signature, error)",
description: "Hashes msg to a point and multiplies by the secret. Deterministic — no nonce, so no nonce-reuse failure mode. Basic and Pop accept an empty (but not nil) message; Aug rejects both.",
},
"Verify(pk, msg, sig)": {
type: "(bool, error)",
description: "Single-signature verification.",
},
"AggregateVerify(pks, msgs, sigs)": {
type: "(bool, error)",
description: "Aggregates sigs internally, then checks the product of pairings against every (pk, msg) pair. Errors on length mismatch. Basic and Pop reject duplicate messages.",
},
"ThresholdKeygen(threshold, total uint)": {
type: "(*PublicKey, []*SecretKeyShare, error)",
description: "Generates one public key and `total` Shamir shares of its secret. Errors when threshold is 0, threshold exceeds total, total is 1 or less, or either exceeds 255.",
},
"ThresholdKeygenWithSeed(ikm, threshold, total)": {
type: "(*PublicKey, []*SecretKeyShare, error)",
description: "Same, seeded deterministically.",
},
"PartialSign(sks, msg)": {
type: "(*PartialSignature, error)",
description: "One share's contribution. Rejects nil and empty messages in every scheme. SigAug and SigAugVt take an extra *PublicKey between the share and the message.",
},
"CombineSignatures(sigs ...*PartialSignature)": {
type: "(*Signature, error)",
description: "Lagrange-interpolates partials into a normal signature. Errors on fewer than 2 partials, more than 255, a nil partial, a duplicate share identifier, or a partial outside the correct subgroup. It does NOT know your threshold — see the caveats.",
},
}}
/>
`SigPop` and `SigPopVt` add:
<TypeTable
type={{
"PopProve(sk)": {
type: "(*ProofOfPossession, error)",
description: "Signs the key's own public key under the PoP DST.",
},
"PopVerify(pk, pop)": {
type: "(bool, error)",
description: "Checks a proof of possession. Run this before trusting a key in any aggregate.",
},
"AggregatePublicKeys(pks ...*PublicKey)": {
type: "(*MultiPublicKey, error)",
description: "Sums public keys into a single group element for same-message verification.",
},
"AggregateSignatures(sigs ...*Signature)": {
type: "(*MultiSignature, error)",
description: "Sums signatures over the same message.",
},
"VerifyMultiSignature(mpk, msg, msig)": {
type: "(bool, error)",
description: "Verifies a pre-aggregated key against a pre-aggregated signature. One pairing check.",
},
"FastAggregateVerify(pks, msg, asig)": {
type: "(bool, error)",
description: "Same-message verification where the signature is already aggregated but the keys are not.",
},
"FastAggregateVerifyConstituent(pks, msg, sigs)": {
type: "(bool, error)",
description: "Same, but takes the individual signatures and aggregates them for you.",
},
}}
/>
`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.
<Steps>
<Step title="Deal the shares">
`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.
</Step>
<Step title="Sign independently">
Each holder calls `PartialSign(share, msg)`. No round trips, no shared state, no per-signature
nonce. Partials can be produced years apart.
</Step>
<Step title="Combine">
`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.
</Step>
<Step title="Verify normally">
The result is an ordinary `*Signature`. `Verify(pk, msg, sig)` accepts it.
</Step>
</Steps>
```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
<CardGroup cols={2}>
<Card title="Secret sharing" href="/threshold/secret-sharing" icon="split">
Shamir, Feldman, and Pedersen sharing — the general machinery behind `ThresholdKeygen`.
</Card>
<Card title="Distributed key generation" href="/threshold/dkg" icon="users">
When no single party may ever hold the whole secret, even at dealing time.
</Card>
<Card title="Accumulator" href="/zero-knowledge/accumulator" icon="layers">
The other pairing-based primitive in this library, also on BLS12-381.
</Card>
<Card title="Security notes" href="/reference/security" icon="shield">
Known defects and unaudited paths across the library.
</Card>
</CardGroup>
+476
View File
@@ -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
<TypeTable
type={{
"NewKeys()": {
type: "(*PublicKey, *SecretKey, error)",
description: "Fresh keypair from crypto/rand. Public key first. Errors on a zero scalar or identity point.",
},
"NewKeysFromReader(reader io.Reader)": {
type: "(*PublicKey, *SecretKey, error)",
description: "Same, from a supplied reader — use for deterministic test fixtures.",
},
"SecretKey.GetPublicKey()": {
type: "*PublicKey",
description: "Scalar multiplication of the Pallas generator. No error return.",
},
"PublicKey.GenerateAddress()": {
type: "string",
description: "Base58 Mina address: 0xcb version byte, 0x01 non-zero-curve-point version, 0x01 compressed flag, the 32-byte x coordinate, a y-parity byte, and a 4-byte double-SHA-256 checksum — 40 bytes encoded. These are the strings beginning \"B62q\".",
},
"PublicKey.ParseAddress(b58 string)": {
type: "error",
description: "Decodes and validates length, all three version bytes, and the checksum (compared in constant time) before recovering the point.",
},
"SecretKey.MarshalBinary()": {
type: "([]byte, error)",
description: "32 bytes, the Fq scalar. UnmarshalBinary requires exactly 32.",
},
"PublicKey.MarshalBinary()": {
type: "([]byte, error)",
description: "Compressed affine Pallas point. Distinct from the address encoding.",
},
}}
/>
`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.
<TypeTable
type={{
"SecretKey.SignTransaction(txn *Transaction)": {
type: "(*Signature, error)",
description: "Builds a random-oracle input with 3 field elements and 75 bytes of packed data, then signs under txn.NetworkId.",
},
"SecretKey.SignMessage(message string)": {
type: "(*Signature, error)",
description: "Signs the raw string bytes. Non-standard — the Mina reference signer does the same thing. Hardcoded to MainNet.",
},
"PublicKey.VerifyTransaction(sig, txn)": {
type: "error",
description: "nil means valid. Uses txn.NetworkId.",
},
"PublicKey.VerifyMessage(sig, message)": {
type: "error",
description: "nil means valid. Also hardcoded to MainNet.",
},
}}
/>
`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
<TypeTable
type={{
Fee: { type: "uint64", description: "Fee in nanomina." },
FeeToken: { type: "uint64", description: "Token id used to pay the fee — 1 for MINA." },
FeePayerPk: { type: "*PublicKey", required: true, description: "Must be non-nil; MarshalBinary dereferences it." },
Nonce: { type: "uint32", description: "Account nonce." },
ValidUntil: { type: "uint32", description: "Expiry slot." },
Memo: { type: "string", description: "At most 32 bytes — longer values are silently truncated. See the caveat below." },
Tag: { type: "[3]bool", description: "Transaction kind. {false,false,false} is a payment; {false,false,true} is a stake delegation." },
SourcePk: { type: "*PublicKey", required: true, description: "Sender. Must be non-nil." },
ReceiverPk: { type: "*PublicKey", required: true, description: "Recipient, or the new delegate. Must be non-nil." },
TokenId: { type: "uint64", description: "Token being moved." },
Amount: { type: "uint64", description: "Amount in nanomina. Zero for a delegation." },
Locked: { type: "bool", description: "Timelock flag." },
NetworkId: { type: "NetworkType", description: "Selects the Poseidon sponge IV and enters the nonce derivation. TestNet is the zero value." },
}}
/>
`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.
<TypeTable
type={{
"Permutation (int)": {
type: "ThreeW | FiveW | Three",
description: "Which Poseidon parameter set to run. Values 0, 1, 2. Every signing path in the package uses ThreeW.",
},
"SBox (int)": {
type: "Cube | Quint | Sept | Inverse",
description: "The exponentiation applied in each round: x^3, x^5, x^7, x^-1. Values 0..3. Selected by the parameter set, not by the caller. SBox.Exp(f *fp.Fp) mutates f in place.",
},
"Context": {
type: "struct",
description: "The Poseidon sponge. Init(pType, networkId) loads round constants, MDS matrix, and IV; Update(fields []*fp.Fp) absorbs, permuting whenever the rate fills; Digest() permutes a final time and returns state[0] reinterpreted as an Fq scalar.",
},
"BitVector": {
type: "struct",
description: "Variable-length bit buffer with Append, Insert, Delete, Set, Element, Length, Bytes. Used to pack transaction fields into field elements. Documented as not thread safe.",
},
"Permutation.Permute(ctx *Context)": {
type: "",
description: "Runs the permutation in place on a Context.",
},
}}
/>
```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` |
<TypeTable
type={{
"GenerateKey(rand io.Reader)": {
type: "(PublicKey, PrivateKey, error)",
description: "Reads a 32-byte seed (crypto/rand when rand is nil) and expands it. Public key first.",
},
"NewKeyFromSeed(seed []byte)": {
type: "(PrivateKey, error)",
description: "Deterministic derivation from a 32-byte seed. Reverses the seed, hashes with Keccak-512, clamps the low 32 bytes into a scalar, and stores seed ‖ publicKey.",
},
"Sign(privateKey PrivateKey, message []byte)": {
type: "([]byte, error)",
description: "64-byte signature. Errors — does not panic — on a wrong-length key, despite what the doc comment says.",
},
"Verify(publicKey PublicKey, message, sig []byte)": {
type: "(bool, error)",
description: "Note the two return values: check both.",
},
"Keccak512(data []byte)": {
type: "([]byte, error)",
description: "Exported because the surrounding NEM protocol hashes with it too — addresses, block hashes.",
},
"PrivateKey.Public()": {
type: "crypto.PublicKey",
description: "Returns a nem.PublicKey as crypto.PublicKey. Type-assert it: priv.Public().(nem.PublicKey).",
},
"PrivateKey.Seed()": {
type: "[]byte",
description: "A copy of the leading 32 bytes.",
},
"PrivateKey.Sign(rand, message, opts)": {
type: "([]byte, error)",
description: "The crypto.Signer interface. opts.HashFunc() must be crypto.Hash(0); rand is ignored because signing is deterministic.",
},
"PublicKey.Bytes()": {
type: "[]byte",
description: "The underlying slice.",
},
}}
/>
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
<CardGroup cols={2}>
<Card title="Threshold Ed25519" href="/threshold/threshold-ed25519" icon="users">
The FROST signer that `MinaTSchnorrHandler` plugs its challenge derivation into.
</Card>
<Card title="Curve abstraction" href="/foundations/curves" icon="git-branch">
Pallas, `PointPallas`, and `ScalarPallas` — the types the Mina package builds on.
</Card>
<Card title="Signature index" href="/signatures" icon="pen-tool">
Back to the scheme selection guide.
</Card>
<Card title="Security notes" href="/reference/security" icon="shield">
The defects on this page, collected with the rest.
</Card>
</CardGroup>
+377
View File
@@ -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.
<TypeTable
type={{
"IsCanonical(s, N *big.Int)": {
type: "bool",
description: "True when s <= N/2. Does not range-check s, and returns false for nil inputs.",
},
"MakeCanonical(r, s, N *big.Int)": {
type: "(*big.Int, *big.Int)",
description: "Returns (r, min(s, N-s)). No validation and no error. Returns its inputs unchanged if any is nil.",
},
"IsSignatureCanonical(r, s *big.Int, curve elliptic.Curve)": {
type: "bool",
description: "Full check: r in [1, N-1] AND s in [1, N/2]. False on any nil argument.",
},
"CanonicalizeSignature(r, s, curve)": {
type: "(*big.Int, *big.Int, error)",
description: "Range-checks both scalars, then returns copies with s reduced to canonical form. Errors on nil arguments or out-of-range r or s.",
},
"NormalizeSignature(r, s, curve)": {
type: "(*big.Int, *big.Int, error)",
description: "Currently a direct pass-through to CanonicalizeSignature. The name suggests more; the body does not do more.",
},
"RejectNonCanonical(r, s, curve)": {
type: "error",
description: "Strict mode: returns an error instead of coercing. Use this on ingress when you want to refuse malleated signatures outright.",
},
"ValidateAndCanonicalizeSignature(pub, hash, r, s)": {
type: "(*big.Int, *big.Int, error)",
description: "Canonicalizes, then verifies against pub and hash. Falls back to verifying the original pair if the canonical one fails. Errors if neither verifies.",
},
"CompareSignatures(r1, s1, r2, s2, curve)": {
type: "(bool, error)",
description: "Canonicalizes both pairs and compares. This is the correct way to ask whether two signatures are the same signature.",
},
}}
/>
`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
<Tabs>
<Tab title="Coerce">
`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.
</Tab>
<Tab title="Reject">
`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.
</Tab>
</Tabs>
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.
<TypeTable
type={{
"DeterministicSign(priv *ecdsa.PrivateKey, hash []byte)": {
type: "(*big.Int, *big.Int, error)",
description: "Derives k deterministically, signs, and returns an already-canonical (low-S) pair. Errors on a nil key, a nil D, or an empty hash.",
},
"VerifyDeterministic(pub *ecdsa.PublicKey, hash []byte, r, s *big.Int)": {
type: "bool",
description: "Range-checks r in [1, N-1] and s in [1, N/2], then delegates to crypto/ecdsa.Verify. Rejects a high-S signature that stdlib Verify would accept.",
},
}}
/>
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
<CardGroup cols={2}>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="users">
Produce an ECDSA signature from key shares that never combine. The canonicalization helpers here
apply to its output too.
</Card>
<Card title="MPC enclave" href="/identity/mpc-enclave" icon="fingerprint">
The two-party ECDSA wrapper this library ships as its headline API.
</Card>
<Card title="Curve abstraction" href="/foundations/curves" icon="git-branch">
The library's own curve types — distinct from the `crypto/elliptic` types this package uses.
</Card>
<Card title="Security notes" href="/reference/security" icon="shield">
Constant-time gaps and standards deviations across the library.
</Card>
</CardGroup>
+179
View File
@@ -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
}
```
<TypeTable
type={{
RevealedMessage: {
type: "struct { Message curves.Scalar }",
description: "IsHidden() == false. The verifier learns this message. GetBlinding returns nil.",
},
ProofSpecificMessage: {
type: "struct { Message curves.Scalar }",
description: "IsHidden() == true. A fresh random blinding factor is drawn from the reader, used only by this proof.",
},
SharedBlindingMessage: {
type: "struct { Message, Blinding curves.Scalar }",
description: "IsHidden() == true, but you supply the blinding factor so the same hidden value can be linked across several proofs (e.g. a BBS+ proof plus a range proof over the same attribute).",
},
}}
/>
### `ProofCommittedBuilder`
A small accumulator for Schnorr-style proofs of knowledge of a linear combination:
```go
import "github.com/sonr-io/crypto/signatures/common"
builder := common.NewProofCommittedBuilder(curve)
_ = builder.CommitRandom(basePoint, crand.Reader) // blinding for a secret you know
_ = builder.Commit(otherPoint, knownScalar) // fixed scalar
bytes := builder.GetChallengeContribution() // feed into your transcript
proofs, err := builder.GenerateProof(challenge, secrets)
```
`GetChallengeContribution` returns the compressed encoding of `SumOfProducts(points, scalars)` — the
aggregate commitment. `GenerateProof` then returns one response scalar per commitment, computed as
`secret*challenge + blinding`, and errors if `len(secrets)` does not match the number of
commitments. `Get(index)` retrieves the `(point, scalar)` pair at a position, returning `(nil, nil)`
out of range. The builder caps out at roughly 65535 commitments.
### `HmacDrbg`
```go
drbg := common.NewHmacDrbg(entropy, nonce, personalization, sha256.New)
buf := make([]byte, 64)
_, _ = drbg.Read(buf)
drbg.Reseed(moreEntropy)
```
It satisfies `io.Reader`, so it can be handed to any API here that takes a `reader` — which is how
you make an otherwise randomised proof reproducible in a test.
:::warning[These are internal building blocks]
`signatures/common` carries no package-level documentation and no tests of its own; it is exercised
only indirectly through `signatures/bbs`. If you use `ProofCommittedBuilder` to build a *new*
protocol rather than to compose with BBS+, you are on your own for soundness — nothing in this
repository validates that usage.
:::
## Caveats that apply across this section
:::danger[No audit, and no uniform error discipline]
None of these packages has a published security audit. They also differ in how they report failure:
BLS returns `(bool, error)` and you must check **both**; BBS+ `Verify` returns a plain `error`;
`PokSignatureProof.Verify` returns a bare `bool`; the VRF `Verify` returns a bare `bool`. Copying an
error-handling idiom from one page to another will silently drop failures. See
[security notes](/reference/security).
:::
:::note
Serialization is `encoding.BinaryMarshaler` / `BinaryUnmarshaler` throughout, but several types
(BBS+ `Signature`, `PokSignatureProof`, `BlindSignature`, `BlindSignatureContext`) need an
`Init(curve)` call before `UnmarshalBinary`, because the wire format does not name its curve.
:::
## Where to next
<CardGroup cols={2}>
<Card title="BLS" href="/signatures/bls" icon="combine">
Two instantiations, three ciphersuites, aggregation, multi-signatures, and non-interactive
threshold keygen on BLS12-381.
</Card>
<Card title="BBS+" href="/signatures/bbs" icon="eye-off">
Sign a vector of attributes, then prove possession while revealing only the ones you choose.
</Card>
<Card title="ECDSA utilities" href="/signatures/ecdsa" icon="check-check">
Malleability, canonical low-S form, fixed-width codecs, and deterministic nonce derivation.
</Card>
<Card title="VRF" href="/signatures/vrf" icon="dice-5">
Verifiable pseudorandom outputs over Edwards25519 with SHAKE256.
</Card>
<Card title="Chain schemes" href="/signatures/chain-schemes" icon="link">
Mina Schnorr over Pallas/Poseidon and NEM's Keccak-512 Ed25519 variant.
</Card>
<Card title="Curve abstraction" href="/foundations/curves" icon="git-branch">
The `Curve` / `Point` / `Scalar` triple that BBS+ and the Mina scheme are generic over.
</Card>
</CardGroup>
+8
View File
@@ -0,0 +1,8 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Signatures",
icon: "pen-tool",
order: 4,
pages: ["index", "bls", "bbs", "ecdsa", "vrf", "chain-schemes"],
});
+244
View File
@@ -0,0 +1,244 @@
---
title: Verifiable Random Function
description: A bespoke VRF over Edwards25519 using SHAKE256 and the Elligator map — unpredictable outputs that anyone holding the public key can verify.
sidebar:
order: 5
icon: dice-5
---
A verifiable random function is a keyed hash with a proof. Given a secret key and an input message,
it produces an output that looks uniformly random to anyone without the key, yet is **uniquely
determined** by the key and message, and comes with a proof that lets anyone holding the public key
confirm the output is the right one. It is the primitive you want whenever a system needs randomness
that participants cannot grind and cannot dispute.
```go
import "github.com/sonr-io/crypto/vrf"
```
## When to use one
- **Leader election.** Each validator computes `VRF_sk(round_seed)`. Whoever's output falls below a
threshold is the leader, and can prove it. Nobody can pre-compute another validator's output, and
nobody can retry with a different key without publishing that key.
- **Verifiable lotteries.** Draw a winner from a beacon value; the operator proves the draw was
honest without revealing the key.
- **Private lookup keys.** In a key-transparency directory (this construction's origin), the map
index for a username is `VRF_sk(username)`, so the directory can prove a name's absence without
its tree structure leaking the set of registered names to an enumerating client.
Do **not** reach for a VRF where a plain signature would do — this package offers no way to sign
arbitrary data, and verification only ever answers "is this the correct output for this message".
And do not treat the output as a commitment: it is a deterministic function of the message, so once
a proof is published anyone holding the public key can confirm a *guess* at the message by
re-verifying against it. A VRF hides the output from people without the key; it does not hide the
input from people who can guess it.
## The construction
The package doc comment states the scheme exactly. `E` is Curve25519 in Edwards coordinates, `h` is
SHA-3 (specifically SHAKE256 throughout the implementation), `f` is the Elligator map, and `8` is the
cofactor:
$$
H(n) = f(h(n))^8, \qquad \mathrm{VRF}_x(n) = h\!\left(n,\, H(n)^x\right)
$$
The proof is a ChaumPedersen style sigma protocol made non-interactive, proving that the same
secret `x` relates `g → g^x` and `H(n) → H(n)^x`:
$$
\mathrm{Prove}_x(n) = \bigl(c,\; t = r - c\cdot x,\; \mathit{ii} = H(n)^x\bigr)
$$
with `r = h(x, n)` supplying the proof's randomness — so proving, like computing, is fully
deterministic. Verification recomputes the challenge from `g^t · P^c` and `H(n)^t · ii^c` and checks
it equals the challenge carried in the proof, and separately checks that the claimed output equals
`h(n, ii)`.
Concretely, in `vrf.go`: `hashToCurve` runs `sha3.ShakeSum256` over the message, maps the digest with
`extra25519.HashToEdwards`, then applies three successive `GeDouble` calls — multiplication by the
cofactor 8 — to land in the prime-order subgroup. The challenge is
`SHAKE256(g ‖ H(n) ‖ pk ‖ H(n)^x ‖ g^r ‖ H(n)^r ‖ n)` reduced mod the group order. In the code the
challenge scalar is named `s`, which is why the proof layout below reads `s ‖ t ‖ ii` rather than
`c ‖ t ‖ ii`.
## Sizes and constants
| Constant | Value | Meaning |
| --- | --- | --- |
| `PublicKeySize` | `32` | Compressed Edwards point |
| `PrivateKeySize` | `64` | 32-byte seed followed by the 32-byte public key |
| `Size` | `32` | The VRF output |
| `ProofSize` | `96` | `s ‖ t ‖ H(n)^x`, three 32-byte values |
`ErrGetPubKey` is the package's only exported error value; it is declared but never returned by any
exported function in `vrf.go` — `Public()` signals failure through its boolean instead.
## API
<TypeTable
type={{
"GenerateKey(rnd io.Reader)": {
type: "(PrivateKey, error)",
description: "Reads 32 bytes of seed from rnd (crypto/rand when nil), expands it, and writes the derived public key into bytes 32..63. Returns a 64-byte PrivateKey.",
},
"PrivateKey.Public()": {
type: "(PublicKey, bool)",
description: "Returns the trailing 32 bytes of the private key. The bool reports whether the internal type assertion succeeded; in practice it is always true for a well-formed key.",
},
"PrivateKey.Compute(m []byte)": {
type: "[]byte",
description: "The 32-byte VRF output alone. One scalar multiplication plus a hash. No error return — a malformed key produces garbage rather than a failure.",
},
"PrivateKey.Prove(m []byte)": {
type: "(vrf, proof []byte)",
description: "The same 32-byte output plus a 96-byte proof. Roughly three scalar multiplications. Deterministic — no reader, no nonce.",
},
"PublicKey.Verify(m, vrfBytes, proof []byte)": {
type: "bool",
description: "Checks the output against the proof under this public key. Returns false on any length mismatch, any bad point encoding, and any check failure. No error channel.",
},
}}
/>
`Compute` and `Prove` return **the same output** for the same key and message — `Prove` just also
gives you the evidence. Use `Compute` when the holder needs the value locally (deciding whether it
even won a leader election, indexing its own directory) and `Prove` only when the value must be
published. That distinction is the main performance lever in the package: skipping the proof avoids
two of the three scalar multiplications.
## Example
Grounded in `TestHonestComplete` and `TestConvertPrivateKeyToPublicKey`.
```go vrf.go
package main
import (
"bytes"
"fmt"
"log"
"github.com/sonr-io/crypto/vrf"
)
func main() {
// nil reader means crypto/rand.
sk, err := vrf.GenerateKey(nil)
if err != nil {
log.Fatal(err)
}
pk, ok := sk.Public()
if !ok {
log.Fatal(vrf.ErrGetPubKey)
}
round := []byte("epoch-4711")
// Cheap path: the holder just wants the value.
out := sk.Compute(round)
// Publishing path: the value plus evidence.
outFromProof, proof := sk.Prove(round)
fmt.Println("Compute == Prove:", bytes.Equal(out, outFromProof)) // true
fmt.Println("output bytes:", len(out), "proof bytes:", len(proof)) // 32 96
// Anyone with pk can check it.
fmt.Println("verified:", pk.Verify(round, outFromProof, proof)) // true
// Any single flipped bit in the proof fails the check.
tampered := append([]byte(nil), proof...)
tampered[0] ^= 0x01
fmt.Println("tampered verified:", pk.Verify(round, outFromProof, tampered)) // false
}
```
`TestFlipBitForgery` in the package flips bits across the proof and asserts every variant fails.
## Properties a caller can rely on
| Property | What it means here |
| --- | --- |
| **Uniqueness** | For a fixed key and message there is exactly one output that will verify. A prover cannot shop for a favourable value. This is what a plain signature cannot give you. |
| **Pseudorandomness** | Without the secret key, the output is indistinguishable from a uniform 32-byte string, so future outputs cannot be predicted from past ones. |
| **Public verifiability** | Anyone with the 32-byte public key can check an output against its proof — no interaction with the prover, no shared secret. |
| **Determinism** | Both `Compute` and `Prove` derive all internal randomness from the key and message, so there is no RNG at evaluation time and nothing to fail open. |
## Caveats
:::danger[This is a bespoke construction with no standards claim]
The package doc names no RFC and no paper. It is **not** RFC 9381 (`draft-irtf-cfrg-vrf`) — that
standard specifies SHA-512 with `try-and-increment` or `hash_to_curve` for `ECVRF-EDWARDS25519-SHA512-*`
ciphersuites, and a differently structured proof and encoding. This package uses SHAKE256 throughout
and the Elligator map, and packs the proof as `s ‖ t ‖ ii`.
The design matches the VRF shipped with the CONIKS key-transparency work, but nothing in this
repository asserts conformance to any published specification, and there are no cross-implementation
test vectors — `vrf_test.go` contains three self-consistency tests and four benchmarks, nothing more.
**Assume zero interoperability** with any other VRF implementation. If your protocol requires a
counterparty running different software to verify these proofs, this package is the wrong choice.
:::
:::danger[The 64-byte key is not an Ed25519 key, despite looking like one]
`PrivateKey` is `[]byte` with the same 64-byte seed-then-public-key layout as
`crypto/ed25519.PrivateKey`, and `Public()` is implemented by converting to
`golang.org/x/crypto/ed25519.PrivateKey` and calling through. But `GenerateKey` derives the scalar by
expanding the seed with **SHAKE256**, where Ed25519 uses SHA-512. The public key written into bytes
32..63 therefore corresponds to a *different* scalar than standard Ed25519 would derive from the same
seed.
Measured against this repository, for one generated key:
```
ed25519.Sign with the vrf key, verified under its own embedded pubkey: false
ed25519.NewKeyFromSeed(sk[:32]) derives the same public key: false
```
The types will not stop you — both are `[]byte` with identical lengths. Never share a seed, a key,
or a signature between `vrf` and `crypto/ed25519`.
:::
:::warning[No error channel anywhere on the hot path]
`Compute` returns only `[]byte`; `Prove` returns only two slices; `Verify` returns only `bool`. A
truncated key, a wrong-length public key, or a corrupted proof all surface as `false` or as silently
wrong bytes. `Compute` in particular does no validation at all — calling it on a short or zero
`PrivateKey` will panic or return meaningless output rather than report anything. Validate lengths
against `PrivateKeySize` and `PublicKeySize` at your trust boundary.
:::
:::warning[Vendored curve code]
The implementation depends on `internal/ed25519/edwards25519` and `internal/ed25519/extra25519`,
vendored copies rather than the maintained `filippo.io/edwards25519`. `extra25519.HashToEdwards` is
the Elligator implementation, and the package doc notes the map "covers half of E" — the cofactor
clearing by three doublings is what brings the result into the prime-order subgroup. None of this
code is constant-time by construction, and none of it receives upstream security fixes. See
[security notes](/reference/security).
:::
:::note[`Verify` does not check that the public key is in the prime-order subgroup]
It calls `FromBytesBaseGroup` on the encoded point, which rejects non-canonical encodings, but the
protocol's security against a maliciously chosen public key rests on the cofactor clearing inside
`hashToCurve` rather than on validating the key. If public keys arrive from untrusted parties in your
protocol, validate them yourself before storing.
:::
## Related
<CardGroup cols={2}>
<Card title="Schnorr proofs" href="/zero-knowledge/schnorr" icon="binary">
The general sigma protocol this VRF's proof is a specialisation of.
</Card>
<Card title="Threshold Ed25519" href="/threshold/threshold-ed25519" icon="users">
Standards-conformant Ed25519 from distributed shares — the interoperable neighbour of this
package's non-standard key handling.
</Card>
<Card title="Curve abstraction" href="/foundations/curves" icon="git-branch">
The library's Ed25519 curve type, which this package deliberately bypasses.
</Card>
<Card title="Security notes" href="/reference/security" icon="shield">
Vendored code, non-standard constructions, and unvalidated inputs across the library.
</Card>
</CardGroup>
+174
View File
@@ -0,0 +1,174 @@
---
title: Randomized AEAD (AES-256-GCM)
description: The aead package — AES-256-GCM with a self-generated nonce prepended to every ciphertext, plus the exact key size, tag size, and wire layout.
sidebar:
order: 2
icon: lock-keyhole
---
`github.com/sonr-io/crypto/aead` is a thin, opinionated wrapper over the standard library's `crypto/cipher.NewGCM`. It exists to remove the two decisions people get wrong with raw AES-GCM: it fixes the key size at AES-256 and it generates and transports the nonce for you. The package doc comment cites NIST SP 800-38D.
There is exactly one type, `AESGCMCipher`, and one constructor. There is no AES-128 mode, no key-unwrapping helper, no streaming interface, and no algorithm identifier on the wire.
## When to use it
Reach for `aead` whenever you have a 32-byte symmetric key and some bytes to protect: session payloads, encrypted records, wrapped blobs, anything where you want confidentiality plus integrity and you can afford a fresh random nonce per message.
Do **not** use it when:
- You need the ciphertext to be a stable function of the plaintext (e.g. an encrypted database column you still have to query by equality). Use [`daed`](/symmetric/deterministic-aead) instead.
- You will encrypt an enormous number of messages under a single key. A random 96-bit nonce is subject to the birthday bound, so nonce collisions become non-negligible after roughly 2^32 messages; rotate keys long before that.
- You need to encrypt a stream too large to hold in memory. `Encrypt` and `Decrypt` are one-shot over full slices.
## Constants
All three constants are plain `int` literals in `aes_gcm.go`.
| Constant | Value | Meaning |
| --- | --- | --- |
| `aead.NonceSize` | `12` | 96-bit GCM nonce — the size GCM is fastest with, and the only size accepted |
| `aead.TagSize` | `16` | 128-bit GCM authentication tag |
| `aead.KeySize` | `32` | AES-256 key size, and the **only** accepted key length |
:::info[32 bytes or nothing]
`NewAESGCM` compares `len(key) != KeySize` and returns `invalid key size: expected 32 bytes, got N` for anything else. A 16-byte AES-128 key and a 24-byte AES-192 key are both rejected, which the package's own table-driven test asserts explicitly. This is stricter than `aes.NewCipher`, which would happily accept both.
:::
## Ciphertext layout
This is the single most important fact about the package:
```text
Encrypt / EncryptWithNonce output:
┌────────────────┬──────────────────────────┬──────────────────┐
│ nonce 12 bytes │ ciphertext len(plaintext)│ GCM tag 16 bytes │
└────────────────┴──────────────────────────┴──────────────────┘
└── produced by gcm.Seal(nil, nonce, pt, aad) ┘
```
`Encrypt` generates the nonce itself from `crypto/rand` and **prepends** it to the sealed output. So:
- `len(output) == NonceSize + len(plaintext) + TagSize` — the test asserts exactly this.
- `Decrypt` expects that same layout. It slices `data[:12]` as the nonce and passes `data[12:]` (ciphertext *and* tag) to `gcm.Open`. You never manage nonces yourself, and you never store them separately.
- The minimum valid ciphertext is `NonceSize + TagSize` = 28 bytes (an empty plaintext). Shorter input returns `invalid ciphertext length: minimum 28 bytes required` before any crypto runs.
The AAD is **not** part of the output. Whatever you pass as `aad` must be reproducible at decrypt time from context you already have — a record ID, a version tag, a tenant name.
## Usage
Grounded in `aead/aes_gcm_test.go` (`TestAESGCMEncryptDecrypt`):
```go encrypt.go
package main
import (
"crypto/rand"
"fmt"
"github.com/sonr-io/crypto/aead"
)
func main() {
// AES-256 key: exactly aead.KeySize bytes.
key := make([]byte, aead.KeySize)
if _, err := rand.Read(key); err != nil {
panic(err)
}
cipher, err := aead.NewAESGCM(key)
if err != nil {
panic(err)
}
plaintext := []byte("secret data")
aad := []byte("additional auth data") // authenticated, not encrypted, not stored
// Encrypt returns nonce || ciphertext || tag.
ct, err := cipher.Encrypt(plaintext, aad)
if err != nil {
panic(err)
}
fmt.Println(len(ct) == aead.NonceSize+len(plaintext)+aead.TagSize) // true
// Decrypt takes that whole blob back, plus the identical AAD.
pt, err := cipher.Decrypt(ct, aad)
if err != nil {
panic(err) // "decryption and authentication failed: ..."
}
fmt.Printf("%s\n", pt) // secret data
}
```
One `AESGCMCipher` value can be reused for many messages — the underlying `cipher.AEAD` is stateless and safe for concurrent use, and each `Encrypt` draws a fresh nonce.
## API reference
<TypeTable
type={{
"NewAESGCM(key []byte)": {
type: "(*AESGCMCipher, error)",
required: true,
description: "Constructor. Errors unless len(key) == 32. Wraps aes.NewCipher then cipher.NewGCM."
},
"Encrypt(plaintext, aad []byte)": {
type: "([]byte, error)",
required: true,
description: "Draws a fresh 12-byte nonce from crypto/rand, seals, and returns nonce || ciphertext || tag. Accepts empty plaintext and nil aad."
},
"Decrypt(data, aad []byte)": {
type: "([]byte, error)",
required: true,
description: "Expects nonce || ciphertext || tag. Errors if len(data) < 28, or if the tag or AAD does not verify."
},
"EncryptWithNonce(plaintext, aad, nonce []byte)": {
type: "([]byte, error)",
description: "Same output layout, but with a caller-supplied nonce. Errors unless len(nonce) == 12. See the danger note below."
},
"GetNonceSize()": {
type: "int",
description: "Always returns the NonceSize constant, 12."
},
"GetTagSize()": {
type: "int",
description: "Always returns the TagSize constant, 16."
}
}}
/>
`AESGCMCipher` has exactly one field, an unexported `gcm cipher.AEAD`. There is nothing to configure and nothing to inspect.
## Caveats
:::danger[EncryptWithNonce: never repeat a nonce under the same key]
`EncryptWithNonce` hands you the nonce. Its own source comment says *"WARNING: Nonce reuse can compromise security. Use only for testing."* Take that literally.
Encrypting two different plaintexts with the same `(key, nonce)` pair in GCM:
- **Destroys confidentiality.** GCM is counter mode. The keystream is a function of the key and nonce alone, so two ciphertexts under a repeated nonce XOR to the XOR of their plaintexts.
- **Destroys authenticity.** A single nonce repetition leaks enough information about the GHASH subkey to let an attacker forge tags for *other* messages under that key. This is not a graceful degradation; the whole key is burned.
The method exists so you can reproduce fixed test vectors and satisfy protocols that specify the nonce externally. If you call it in production, the nonce must come from a source that provably never repeats for a given key — a persisted, atomically incremented counter, not a timestamp and not a hash of the plaintext. If you cannot prove uniqueness, call `Encrypt` and let it draw from `crypto/rand`, or move to [`daed`](/symmetric/deterministic-aead), which is designed to be safe without a nonce.
:::
:::warning[The AAD is your responsibility]
`Decrypt` fails if the AAD differs by a single byte, and the AAD is not carried in the ciphertext. If you encrypt with a record ID as AAD and later change how that ID is serialized, every existing ciphertext becomes undecryptable. Pin the AAD encoding as strictly as you pin the wire format.
:::
:::warning[No algorithm or key identifier on the wire]
The output is `nonce || ciphertext || tag` with no header. There is no version byte, no key ID, and no way to tell an `aead` ciphertext from any other 12-byte-prefixed blob. If you expect to rotate keys or migrate ciphers, add your own framing now — retrofitting it means re-encrypting everything.
:::
:::note[Error strings are wrapped, not typed]
Every failure path returns a `fmt.Errorf` string; there are no sentinel error values to match with `errors.Is`. Authentication failure and malformed-length failure are distinguishable only by their message text. Treat any error from `Decrypt` as "this ciphertext is not authentic" and do not branch on the reason.
:::
## Related
<CardGroup cols={2}>
<Card title="Deterministic AEAD" href="/symmetric/deterministic-aead" icon="repeat">
When you cannot carry a nonce, or need identical ciphertext for identical plaintext.
</Card>
<Card title="Key derivation" href="/symmetric/key-derivation" icon="key-round">
Where the 32-byte key comes from: Argon2id for passwords, HKDF for high-entropy secrets.
</Card>
</CardGroup>
+196
View File
@@ -0,0 +1,196 @@
---
title: Deterministic AEAD (AES-SIV)
description: The daed package — AES-SIV-CMAC per RFC 5297 with a mandatory 64-byte key. Nonce-free and misuse-resistant, at the price of leaking plaintext equality.
sidebar:
order: 3
icon: repeat
---
`github.com/sonr-io/crypto/daed` implements AES-SIV-CMAC as specified in [RFC 5297](https://tools.ietf.org/html/rfc5297). "DAED" is deterministic authenticated encryption with associated data: same key, same plaintext, same associated data, byte-identical ciphertext, every time. There is no nonce parameter and nothing to keep unique.
The implementation is a port of Tink's `subtle` AES-SIV — the test file even aliases the import as `subtle` and loads Wycheproof vectors — and it is restricted to a **single** associated-data component, unlike the general SIV construction which takes a vector of headers.
## The trade
Randomized AEAD like [`aead`](/symmetric/aead) hides everything, but only because a fresh nonce makes every ciphertext unique. That safety is contingent: repeat the nonce once and AES-GCM collapses. AES-SIV removes the nonce entirely by deriving the IV from the message itself (the S2V PRF over the associated data and the plaintext), then running AES-CTR with that IV.
What you get:
- **Nothing to keep unique.** No nonce store, no counter, no rand call on the encrypt path.
- **Misuse resistance.** There is no parameter you can repeat to break it.
- **Stable ciphertext.** You can index it, dedupe it, or use it as a lookup key.
What you pay:
- **Plaintext equality leaks.** Two records that encrypt to the same bytes had the same plaintext and the same associated data. An observer learns that without touching the key.
:::warning[Deterministic means equality is public]
If you deterministically encrypt an email address column, anyone with read access to the ciphertexts can count distinct users, spot duplicate accounts, join across tables, and — with a guessable domain — confirm a guess by encrypting a candidate and comparing. Deterministic encryption is *searchable*, and searchable is the same thing as *leaky*. Vary the associated data per row (a row ID, a tenant ID) when you want equality confined to a scope, and reach for randomized [`aead`](/symmetric/aead) whenever equality itself is sensitive.
:::
## When to use it
Good fits:
- **Wrapping key material.** Encrypting a data key under a key-encryption key, where there is no room in the format for a nonce and no natural place to store one.
- **Deterministic encryption of identifiers.** An opaque token or blind index that you must still be able to look up by equality.
- **Dedupe-able ciphertext.** Content-addressed storage where identical inputs should collapse to one object.
- **Protocols that give you no nonce channel.** Fixed-width fields, legacy record formats, anything where the only bytes you control are the ciphertext.
Bad fits: message payloads, session data, anything user-visible and repeated, and anything where two equal plaintexts appearing twice would be a disclosure.
## Constants and key size
| Constant | Value | Meaning |
| --- | --- | --- |
| `daed.AESSIVKeySize` | `64` | The **only** accepted key length: 512 bits |
The 64-byte key is a double-length key and it is not padding. `NewAESSIV` splits it as `K1 = key[:32]` (the CMAC/S2V key, used to build the AES cipher for the PRF) and `K2 = key[32:]` (the CTR encryption key). RFC 5297 requires the MAC and encryption keys to be the same size, so a 256-bit security level means 2 × 256 bits of key material.
The package's doc comment explains *why* 64 and not 32, and this is the one place the source names a paper, so it is worth repeating verbatim in substance: Chatterjee, Menezes and Sarkar's tightness analysis (Section 5.1) shows AES-SIV is attackable in the multi-user setting — given the encryption of one message under `k` different keys, a MAC key can be recovered in time `2^b / k` for MAC-key size `b`. That makes 128-bit MAC keys insufficient, and since 192-bit AES keys are not supported, the key must be 2 × 256 bits.
`NewAESSIV` rejects every other length with `aes_siv: invalid key size N` — the package's `TestAESSIV_KeySizes` walks every prefix length from 0 to 300+ and asserts that exactly 64 is accepted.
## Ciphertext layout
```text
EncryptDeterministically output:
┌────────────────────┬───────────────────────────┐
│ SIV / tag 16 bytes │ ciphertext len(plaintext) │
└────────────────────┴───────────────────────────┘
= S2V(plaintext, ad) = AES-CTR(K2, masked SIV)
```
The synthetic IV goes **first** and doubles as the authentication tag. Output length is always `len(plaintext) + 16`. An empty plaintext produces a 16-byte ciphertext, and `DecryptDeterministically` rejects anything shorter than 16 bytes with `aes_siv: ciphertext is too short`.
Decryption decrypts first, then recomputes S2V over the recovered plaintext and compares it to the stored SIV byte-by-byte with an accumulating XOR. A mismatch returns `aes_siv: invalid ciphertext`.
## Usage
Grounded in `daed/aes_siv_test.go` (`TestAESSIV_EncryptDecrypt`):
```go deterministic.go
package main
import (
"bytes"
"encoding/hex"
"fmt"
"github.com/sonr-io/crypto/daed"
)
func main() {
// 64 bytes = AESSIVKeySize. Two 32-byte halves: CMAC key, then CTR key.
keyStr := "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" +
"00112233445566778899aabbccddeefff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"
key, err := hex.DecodeString(keyStr)
if err != nil {
panic(err)
}
a, err := daed.NewAESSIV(key)
if err != nil {
panic(err)
}
msg := []byte("Some data to encrypt.")
ad := []byte("Additional data")
ct, err := a.EncryptDeterministically(msg, ad)
if err != nil {
panic(err)
}
fmt.Println(len(ct) == len(msg)+16) // true: SIV || ciphertext
// Determinism: identical inputs, identical output.
again, _ := a.EncryptDeterministically(msg, ad)
fmt.Println(bytes.Equal(ct, again)) // true
// Changing only the associated data changes the whole ciphertext.
other, _ := a.EncryptDeterministically(msg, []byte("Different data"))
fmt.Println(bytes.Equal(ct, other)) // false
pt, err := a.DecryptDeterministically(ct, ad)
if err != nil {
panic(err) // "aes_siv: invalid ciphertext"
}
fmt.Printf("%s\n", pt)
}
```
Note the associated data is a full input to the PRF, not a side channel: it changes the SIV and therefore the CTR keystream, so the entire ciphertext changes. Using a per-row identifier as associated data is the standard way to scope the equality leak.
## API reference
<TypeTable
type={{
"NewAESSIV(key []byte)": {
type: "(*AESSIV, error)",
required: true,
description: "Constructor. Errors unless len(key) == 64. Splits the key, builds the AES cipher over K1, and precomputes the two CMAC subkeys."
},
"EncryptDeterministically(plaintext, associatedData []byte)": {
type: "([]byte, error)",
required: true,
description: "Returns SIV(16) || ciphertext. Accepts nil and empty plaintext and nil associated data. Errors only if the plaintext is within one AES block of max int."
},
"DecryptDeterministically(ciphertext, associatedData []byte)": {
type: "([]byte, error)",
required: true,
description: "Errors on ciphertext shorter than 16 bytes, or when the recomputed SIV does not match the stored one."
}
}}
/>
`AESSIV` exposes its internals as exported struct fields:
<TypeTable
type={{
Cipher: { type: "cipher.Block", description: "AES cipher instance built over K1; used by the CMAC/S2V path." },
K1: { type: "[]byte", description: "First 32 bytes of the key — the CMAC/S2V key." },
K2: { type: "[]byte", description: "Last 32 bytes of the key — the AES-CTR encryption key." },
CmacK1: { type: "[]byte", description: "Precomputed CMAC subkey K1 (one GF(2^128) doubling of E(0))." },
CmacK2: { type: "[]byte", description: "Precomputed CMAC subkey K2 (a second doubling)." }
}}
/>
## Caveats
:::danger[The AESSIV struct publishes raw key material]
`K1`, `K2`, `CmacK1`, and `CmacK2` are exported fields holding the actual key bytes, and they are aliases into the slice you passed to `NewAESSIV` — not copies. Anything that can see an `*AESSIV` can read the key, and anything that mutates the slice you handed in mutates the cipher's key underneath it. Two consequences:
- Never log, marshal, `fmt.Printf("%+v")`, or serialize an `AESSIV` value. `%v` on this struct prints your key.
- Do not reuse or zero the input key slice while the `*AESSIV` is still in use. Conversely, `secure.Zeroize` on that slice *will* silently corrupt the live cipher.
Treat these fields as private even though the compiler will not.
:::
:::warning[Decrypt discards an internal error]
`DecryptDeterministically` calls `asc.ctrCrypt(...)` without checking its returned error — the only other call site, in `EncryptDeterministically`, does check it. In practice `ctrCrypt` can only fail if `aes.NewCipher(K2)` fails, which cannot happen for a key that already passed the 64-byte check in the constructor, so this is latent rather than exploitable. It is still an unchecked error on a decryption path, and the plaintext buffer would be returned uninitialized if it ever did fire. Always verify the returned error *and* validate the plaintext against your own schema.
:::
:::warning[No nonce is not the same as no risk]
AES-SIV is misuse-resistant, not misuse-proof. It removes nonce management, but it does not remove key management: the multi-user attack quoted above is precisely a "one message, many keys" attack, so avoid encrypting a known fixed plaintext under a large fleet of independent keys. And nothing about determinism protects against replay — a stored ciphertext is valid forever, so bind freshness into the associated data if you need it.
:::
:::note[Wycheproof vectors are skipped by default]
`TestAESSIV_WycheproofVectors` calls `t.Skip` unless the `TEST_SRCDIR` environment variable is set, pointing at a Bazel-style test data tree. In an ordinary `go test ./daed/...` run the cross-implementation vectors do not execute; only the round-trip and size tests do. Do not read a passing local test run as confirmation of RFC 5297 conformance.
:::
## Related
<CardGroup cols={2}>
<Card title="Randomized AEAD" href="/symmetric/aead" icon="lock-keyhole">
AES-256-GCM: the default when equality of plaintexts must stay hidden.
</Card>
<Card title="Key derivation" href="/symmetric/key-derivation" icon="key-round">
Producing the 64 bytes AES-SIV needs — ask HKDF for a 64-byte tag.
</Card>
</CardGroup>
To build a 64-byte AES-SIV key from one master secret, ask HKDF for 64 bytes rather than concatenating two independent 32-byte derivations:
```go
kek, err := subtle.ComputeHKDF("SHA256", master, salt, []byte("aes-siv key v1"), daed.AESSIVKeySize)
```
+127
View File
@@ -0,0 +1,127 @@
---
title: Symmetric & Secrets
description: Bulk encryption, password-based key derivation, and the secret-hygiene helpers — how to pick between them and how they compose.
sidebar:
order: 1
icon: lock
---
This is the "boring" half of the library, and the half you will actually touch on every request path. Nothing here is generic over the elliptic-curve abstraction: every package on these pages takes and returns `[]byte`. That makes the layer easy to reason about and easy to misuse, so each page is explicit about the exact key sizes, ciphertext layouts, and constants involved.
The packages split into three jobs:
- **Encrypt bytes.** [`aead`](/symmetric/aead) is randomized AES-256-GCM — the default choice. [`daed`](/symmetric/deterministic-aead) is AES-SIV, a deterministic AEAD for the narrow cases where you need the same plaintext to produce the same ciphertext.
- **Turn a secret into a key.** [`argon2`](/symmetric/key-derivation) stretches a human password into key material and produces PHC-encoded password hashes. [`subtle`](/symmetric/key-derivation#subtle-hkdf-and-x25519) is the HKDF + X25519 layer for turning an already-high-entropy secret (a shared secret, a master key) into per-purpose subkeys.
- **Handle the secret carefully.** [`secure`](/symmetric/secrets), [`salt`](/symmetric/secrets#salt), [`password`](/symmetric/secrets#password), and [`subtle/random`](/symmetric/secrets#subtle-random) are small hygiene helpers: zeroization, constant-time comparison, salt generation and storage, password policy checks, and raw randomness.
## Pick one
| Your goal | Package | Entry point |
| --- | --- | --- |
| Encrypt a payload, blob, or message | `aead` | `NewAESGCM(key)` → `Encrypt(pt, aad)` |
| Encrypt a lookup key or identifier you must still be able to search by | `daed` | `NewAESSIV(key)` → `EncryptDeterministically` |
| Wrap key material with no nonce to manage | `daed` | `NewAESSIV(key)` |
| Derive an encryption key from a user's password | `argon2` | `New(DefaultConfig()).DeriveKey(pw, salt)` |
| Store a verifiable password hash | `argon2` | `HashPassword` / `VerifyPassword` |
| Split one master secret into several purpose-bound subkeys | `subtle` | `ComputeHKDF("SHA256", key, salt, info, 32)` |
| Agree on a key with a remote peer | `subtle` | X25519 trio → `ComputeHKDF` |
| Generate a salt | `salt` or `argon2` | `salt.GenerateDefault()` / `kdf.GenerateSalt()` |
| Generate raw random bytes | `secure` | `SecureRandom(buf)` |
| Compare two secrets without leaking timing | `argon2` | `CompareHashes(a, b)` |
| Zero a key out of memory after use | `secure` | `Zeroize(key)` |
| Enforce a password policy at signup | `password` | `NewValidator(nil).Validate(pw)` |
## How they compose
The realistic pipeline is: get entropy, stretch or expand it into a 32-byte key, encrypt with that key, then zero the key.
```go
package vault
import (
"github.com/sonr-io/crypto/aead"
"github.com/sonr-io/crypto/argon2"
"github.com/sonr-io/crypto/secure"
)
func sealWithPassword(password, plaintext, aad []byte) (key, salt, ct []byte, err error) {
kdf := argon2.New(argon2.DefaultConfig()) // Argon2id, 64 MiB, t=1, p=4
salt, err = kdf.GenerateSalt() // 32 bytes
if err != nil {
return nil, nil, nil, err
}
key = kdf.DeriveKey(password, salt) // 32 bytes == aead.KeySize
defer secure.Zeroize(key)
cipher, err := aead.NewAESGCM(key)
if err != nil {
return nil, nil, nil, err
}
ct, err = cipher.Encrypt(plaintext, aad) // nonce || ciphertext || tag
return key, salt, ct, err
}
```
Two things make that snippet work, and both are worth internalizing:
1. `argon2.DefaultConfig().KeyLength` is `32`, and `aead.KeySize` is `32`. The KDF output plugs straight into the AEAD constructor with no truncation or padding.
2. `aead.Encrypt` generates its own nonce and prepends it, so the only thing you have to persist alongside the ciphertext is the salt.
:::tip[Choose the KDF by input entropy, not by habit]
`argon2` is deliberately slow and memory-hard because its input is a low-entropy human password. If your input is *already* a uniformly random 32-byte secret — an X25519 shared secret, a master key from an HSM, a threshold-signing output — use `subtle.ComputeHKDF` instead. Running Argon2 on high-entropy input buys you nothing but 64 MiB of allocation per call.
:::
## Sizes at a glance
Every one of these is a compile-time constant in the package named, not a default you can override. Getting a size wrong is a constructor error, never a silent truncation.
| Constant | Value | Where |
| --- | --- | --- |
| `aead.KeySize` | `32` | AES-256-GCM key — the only length accepted |
| `aead.NonceSize` | `12` | GCM nonce, generated and prepended by `Encrypt` |
| `aead.TagSize` | `16` | GCM authentication tag |
| `daed.AESSIVKeySize` | `64` | AES-SIV double-length key: 32-byte CMAC key ‖ 32-byte CTR key |
| `salt.DefaultSaltSize` | `32` | Recommended salt size |
| `salt.MinSaltSize` / `salt.MaxSaltSize` | `16` / `1024` | Hard bounds enforced by `salt.Generate` |
An `aead` ciphertext is therefore always `len(plaintext) + 28` bytes; an AES-SIV ciphertext is always `len(plaintext) + 16`. Neither carries a version byte or key identifier, so any framing you need is yours to add.
## What stays your responsibility
These packages cover the primitive, not the protocol. Everything below is out of scope for this layer and has to live in your application:
- **Key lifetime and rotation.** Nothing tracks how many messages a key has protected, and nothing versions a key. `aead` will happily encrypt forever under one key.
- **Salt and hash persistence.** `salt.SaltStore` is an in-memory map with no mutex; `argon2.HashPassword` is the only helper that packages a salt into something durable.
- **Replay and freshness.** AEAD authenticity says "this ciphertext was produced by someone holding the key", never "recently" or "once". Bind timestamps or counters into the AAD.
- **Rate limiting on password paths.** `argon2` makes each guess expensive; it does not make guessing impossible.
- **Unicode normalization** of anything a human types, before both validation and derivation.
## What this layer does not do
:::warning[Read the caveats on each page]
Several packages here are hand-rolled reimplementations of things the standard library already provides — `secure.SecureCompare`, `salt`'s internal `constantTimeCompare`, and `password.SecureCompare` are three separate copies of the same loop, none of which use `crypto/subtle`. `secure` does not lock pages into RAM. `password`'s entropy estimator is a length heuristic, not an entropy measurement. `argon2.EstimateTime` is a formula with no measurement behind it. Each page states exactly what the code does rather than what the name suggests.
:::
There is no key *storage* here — no keystore, no envelope format, no versioned header. `aead.Encrypt` hands you `nonce || ciphertext || tag` and nothing else; if you need an algorithm identifier or a key ID on the wire, you frame it yourself. Likewise there is no ChaCha20-Poly1305, no AES-128, and no streaming/chunked API: `aead` is AES-256-GCM one-shot only.
## Next
<CardGroup cols={2}>
<Card title="Randomized AEAD" href="/symmetric/aead" icon="lock-keyhole">
AES-256-GCM. The default for encrypting anything. Exact key size, nonce handling, and ciphertext layout.
</Card>
<Card title="Deterministic AEAD" href="/symmetric/deterministic-aead" icon="repeat">
AES-SIV with a 64-byte key. Nonce-free and misuse-resistant, at the cost of leaking plaintext equality.
</Card>
<Card title="Key derivation" href="/symmetric/key-derivation" icon="key-round">
Argon2id presets with exact parameters, PHC hash encoding, HKDF, and the X25519 ECDH trio.
</Card>
<Card title="Secret hygiene" href="/symmetric/secrets" icon="eye-off">
Zeroization, salts and the salt store, password policy, randomness — and what each one really guarantees.
</Card>
</CardGroup>
Everything outside this layer — signatures, threshold protocols, zero-knowledge proofs — is generic over the curve abstraction described in [Foundations: curves](/foundations/curves).
+341
View File
@@ -0,0 +1,341 @@
---
title: Key Derivation
description: Argon2id password stretching with exact preset parameters and PHC hash encoding, plus the subtle package's HKDF, hash/curve name mapping, and X25519 ECDH.
sidebar:
order: 4
icon: key-round
---
Two packages, two different jobs, and picking the wrong one is the most common mistake in this layer.
- **`argon2`** takes a *low-entropy* secret — a human password — and spends deliberate time and memory turning it into key material. Use it when a human typed the input.
- **`subtle`** takes a *high-entropy* secret — an X25519 shared secret, a master key, a random 32-byte seed — and expands it cheaply into as many purpose-bound subkeys as you need via HKDF. Use it when a CSPRNG or a Diffie-Hellman produced the input.
Running Argon2 on a random 32-byte key wastes 64 MiB and several hundred milliseconds for no security gain. Running HKDF on a user password produces a key that is exactly as guessable as the password.
## argon2 password stretching
`github.com/sonr-io/crypto/argon2` wraps `golang.org/x/crypto/argon2`. It uses **Argon2id** exclusively — `DeriveKey` calls `argon2.IDKey`, and the encoded hash format hardcodes the `argon2id` label. There is no way to select Argon2i or Argon2d through this API, which is the right default: id is the hybrid variant recommended for password hashing because it resists both side-channel and time-memory-tradeoff attacks.
### Preset parameters
These are the exact literals from `argon2/kdf.go`. `Memory` is in **kibibytes**, matching the underlying `argon2.IDKey` signature.
| Field | `LightConfig()` | `DefaultConfig()` | `HighSecurityConfig()` |
| --- | --- | --- | --- |
| `Time` (iterations) | `1` | `1` | `3` |
| `Memory` (KiB) | `16384` (16 MiB) | `65536` (64 MiB) | `131072` (128 MiB) |
| `Parallelism` (threads) | `2` | `4` | `4` |
| `SaltLength` (bytes) | `16` | `32` | `32` |
| `KeyLength` (bytes) | `32` | `32` | `32` |
All three produce a 32-byte key, which is exactly `aead.KeySize`, so any preset's output plugs directly into [`aead.NewAESGCM`](/symmetric/aead).
`LightConfig` is described in source as "lighter parameters for testing". Use it in tests and CI, not for real credentials.
<TypeTable
type={{
Time: {
type: "uint32",
required: true,
description: "Number of Argon2 passes over memory. Must be >= 1.",
default: "1"
},
Memory: {
type: "uint32",
required: true,
description: "Memory cost in kibibytes. ValidateConfig requires >= 8192 (8 MiB).",
default: "65536"
},
Parallelism: {
type: "uint8",
required: true,
description: "Number of lanes/threads. Must be >= 1.",
default: "4"
},
SaltLength: {
type: "uint32",
required: true,
description: "Size of salts produced by GenerateSalt. Must be >= 8. Not enforced on salts you pass to DeriveKey yourself.",
default: "32"
},
KeyLength: {
type: "uint32",
required: true,
description: "Output key length in bytes. Must be >= 16.",
default: "32"
}
}}
/>
`ValidateConfig` enforces the floors listed above — `Time >= 1`, `Memory >= 8*1024`, `Parallelism >= 1`, `SaltLength >= 8`, `KeyLength >= 16` — and returns a plain error naming the first violated bound.
### Deriving a key
Grounded in `argon2/kdf_test.go` (`TestKDF_DeriveKey`, `TestKDF_GenerateSalt`):
```go derive.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/argon2"
)
func main() {
cfg := argon2.DefaultConfig()
if err := argon2.ValidateConfig(cfg); err != nil { // New() does not validate for you
panic(err)
}
kdf := argon2.New(cfg) // New(nil) falls back to DefaultConfig()
salt, err := kdf.GenerateSalt() // cfg.SaltLength == 32 bytes
if err != nil {
panic(err)
}
key := kdf.DeriveKey([]byte("correct horse battery staple"), salt)
fmt.Println(len(key)) // 32 == cfg.KeyLength
}
```
`DeriveKey` returns no error — every failure mode of Argon2id is a programming error rather than a runtime one — and it is deterministic in `(password, salt, config)`. It is safe to call concurrently from many goroutines on one `*KDF`; the package's `TestConcurrentDerivation` does exactly that. Remember that each concurrent call allocates `Memory` kibibytes, so N parallel derivations with `DefaultConfig` reserve N × 64 MiB.
### Password hashes and the PHC string
`HashPassword` is the "store this in your users table" path. It generates a fresh salt, derives the key, and encodes everything needed to verify later into one self-describing string:
```text
$argon2id$v=19$m=65536,t=1,p=4$<salt>$<hash>
│ │ │ │ └─ derived key, base64.RawStdEncoding
│ │ │ └──────── salt, base64.RawStdEncoding
│ │ └───────────────────────── Memory,Time,Parallelism from the config
│ └────────────────────────────── argon2.Version, always 19
└─────────────────────────────────────── variant label, always "argon2id"
```
The two base64 segments use `base64.RawStdEncoding`: **standard** alphabet (`+` and `/`, not URL-safe) with **no** `=` padding. Splitting the string on `$` yields exactly six parts, the first being empty.
`VerifyPassword` is the inverse and is a package-level function, not a method — it does not need your `*KDF` because it reads the parameters back out of the string:
```go verify.go
kdf := argon2.New(argon2.DefaultConfig())
encoded, err := kdf.HashPassword([]byte("MySecureP@ssw0rd"))
if err != nil {
panic(err)
}
// encoded == "$argon2id$v=19$m=65536,t=1,p=4$...$..."
ok, err := argon2.VerifyPassword([]byte("MySecureP@ssw0rd"), encoded)
if err != nil {
panic(err) // malformed string, wrong variant, or unsupported version
}
fmt.Println(ok) // true
ok, _ = argon2.VerifyPassword([]byte("wrong"), encoded)
fmt.Println(ok) // false, err == nil
```
Note the two-channel result: `err` means *the hash string is unusable*, `ok == false` means *the password is wrong*. Never collapse them — treating a parse error as a failed login masks corruption in your credential store.
The final comparison uses `crypto/subtle.ConstantTimeCompare`. `CompareHashes(a, b)` exposes the same primitive for comparing any two byte slices, and it is the constant-time comparison you should prefer across this whole library — see the [note on duplicated helpers](/symmetric/secrets#duplicated-helpers).
### argon2 caveats
:::danger[VerifyPassword trusts the parameters in the hash string]
`decodeHash` parses `m`, `t`, and `p` out of the encoded hash and `VerifyPassword` derives with those values, not with your configured ones. That is what makes stored hashes upgradeable — but it also means the cost of a verification is chosen by whoever supplied the string.
If an attacker can get an arbitrary encoded hash into the verify path (a "check this hash" endpoint, an imported credential file, a tenant-supplied record), `m=4194304` makes your process try to allocate 4 GiB per call. Only ever call `VerifyPassword` with strings your own `HashPassword` produced and your own storage returned, and if a hash can come from outside, parse and bound `m`/`t`/`p` yourself before verifying.
:::
:::warning[New() and DeriveKey() never validate the config]
`ValidateConfig` exists but nothing in the package calls it. `New(&Config{Time: 0, Memory: 1, Parallelism: 0, KeyLength: 1})` returns a working `*KDF`, and `DeriveKey` hands those values straight to `argon2.IDKey`, which **panics** rather than erroring on out-of-range parameters — `golang.org/x/crypto/argon2` panics with `argon2: number of rounds too small` for `Time < 1` and `argon2: parallelism degree too low` for `Parallelism < 1`. A bad config is therefore a crash on the derivation path, not a returned error. Call `ValidateConfig` yourself on any config you did not get from one of the three preset constructors.
`DeriveKey` also ignores `SaltLength` entirely: it uses whatever slice you pass. A one-byte salt is accepted silently. Get salts from `GenerateSalt` or from the [`salt`](/symmetric/secrets#salt) package.
:::
:::warning[EstimateTime is a formula, not a measurement]
`EstimateTime(config, iterations)` computes `Time * Memory / 65536 * iterations` and formats the result as `"1.00 s"`. There is no benchmark, no calibration, and no hardware input behind it — with `DefaultConfig()` and one iteration it returns exactly `"1.00 s"` by construction, whatever machine you are on. Its own comment calls it "a rough estimate". Do not surface its output to users or use it to pick parameters; measure on your target hardware instead.
:::
:::note[No pepper, no rehash-on-login helper]
There is no application-wide secret ("pepper") input, and no helper that detects a stored hash using outdated parameters and transparently upgrades it. If you tighten your config, you must compare the parsed `m`/`t`/`p` against your current settings after a successful verification and re-hash yourself.
:::
## subtle HKDF and X25519
`github.com/sonr-io/crypto/subtle` is a Tink-derived helper package: HKDF, a hash-function registry keyed by string, an elliptic-curve registry keyed by string, and the three X25519 functions. It is where you go for cheap expansion of an already-random secret.
:::note[This package has no tests and no in-repo callers]
Unlike every other package on this page, `subtle` ships with no `_test.go` file, and nothing else in this module imports it (only its `subtle/random` subpackage is used elsewhere). The code is short and closely follows upstream Tink, but the examples below are derived from the signatures and behaviour in `hkdf.go`, `subtle.go`, and `x25519.go` rather than from an executed test. Validate against your own vectors before depending on it.
:::
### Accepted name strings
`GetHashFunc`, `GetHashDigestSize`, and `ComputeHKDF` all key off a hash **name string**, and they return `nil` / an error for anything unrecognised. The accepted spellings are exact:
| Hash name | Digest size (`GetHashDigestSize`) | Backing function |
| --- | --- | --- |
| `"SHA1"` | `20` | `sha1.New` |
| `"SHA224"` | `28` | `sha256.New224` |
| `"SHA256"` | `32` | `sha256.New` |
| `"SHA384"` | `48` | `sha512.New384` |
| `"SHA512"` | `64` | `sha512.New` |
`ConvertHashName` normalises the hyphenated spellings into the above — `"SHA-1"`→`"SHA1"`, `"SHA-224"`→`"SHA224"`, `"SHA-256"`→`"SHA256"`, `"SHA-384"`→`"SHA384"`, `"SHA-512"`→`"SHA512"` — and returns the **empty string** for anything else.
`ConvertCurveName` and `GetCurve` work the same way for NIST curves:
| Input to `ConvertCurveName` | Canonical name | `GetCurve` returns |
| --- | --- | --- |
| `"secp256r1"`, `"P-256"` | `"NIST_P256"` | `elliptic.P256()` |
| `"secp384r1"`, `"P-384"` | `"NIST_P384"` | `elliptic.P384()` |
| `"secp521r1"`, `"P-521"` | `"NIST_P521"` | `elliptic.P521()` |
:::warning[Unknown names fail silently as zero values]
`ConvertHashName("sha256")` — lowercase — returns `""`, not an error. `GetHashFunc("")` returns a `nil` function, and `GetCurve("P-256")` returns `nil` because it wants the *converted* name `"NIST_P256"`. The source carries an upstream `TODO(ckl)` acknowledging that these should return explicit errors. Always check for `""` / `nil` after a lookup; `ComputeHash(nil, data)` at least fails loudly with `nil hash function`.
:::
### ComputeHKDF
```go
func ComputeHKDF(hashAlg string, key, salt, info []byte, tagSize uint32) ([]byte, error)
```
<TypeTable
type={{
hashAlg: {
type: "string",
required: true,
description: "One of SHA1, SHA224, SHA256, SHA384, SHA512. Anything else errors with 'hkdf: invalid hash algorithm'."
},
key: {
type: "[]byte",
required: true,
description: "Input keying material (IKM). Its length is NOT validated — an empty key is accepted."
},
salt: {
type: "[]byte",
description: "Optional. If empty or nil it is replaced by a zero-filled slice of the hash's digest size, per RFC 5869."
},
info: {
type: "[]byte",
description: "Context/application binding. Use a distinct, versioned label per derived key."
},
tagSize: {
type: "uint32",
required: true,
description: "Output length in bytes. Must be >= 10 ('tag size too small') and <= 255 * digestSize ('tag size too big')."
}
}}
/>
The 10-byte floor is a named constant, `minTagSizeInBytes`, documented in source as providing at least 80-bit security strength. The `255 * digestSize` ceiling is HKDF's structural maximum.
### X25519 ECDH → HKDF → AEAD
This is the pipeline `subtle` exists to serve: agree on a shared secret with a peer, expand it into a purpose-bound AEAD key, encrypt.
```go pipeline.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/aead"
"github.com/sonr-io/crypto/secure"
"github.com/sonr-io/crypto/subtle"
)
func main() {
// 1. Each side generates a 32-byte X25519 private key and publishes the public value.
alicePriv, err := subtle.GeneratePrivateKeyX25519()
if err != nil {
panic(err)
}
alicePub, err := subtle.PublicFromPrivateX25519(alicePriv)
if err != nil {
panic(err)
}
bobPriv, err := subtle.GeneratePrivateKeyX25519()
if err != nil {
panic(err)
}
bobPub, err := subtle.PublicFromPrivateX25519(bobPriv)
if err != nil {
panic(err)
}
// 2. Both sides compute the same 32-byte shared secret. Always check the error.
aliceSecret, err := subtle.ComputeSharedSecretX25519(alicePriv, bobPub)
if err != nil {
panic(err)
}
bobSecret, err := subtle.ComputeSharedSecretX25519(bobPriv, alicePub)
if err != nil {
panic(err)
}
defer secure.ZeroizeMultiple(aliceSecret, bobSecret)
// 3. Never use the raw DH output as a key. Expand it, binding both public
// values and a versioned label into `info`.
info := append(append([]byte("sonr/x25519-aead/v1|"), alicePub...), bobPub...)
key, err := subtle.ComputeHKDF("SHA256", aliceSecret, nil, info, aead.KeySize)
if err != nil {
panic(err)
}
defer secure.Zeroize(key)
// 4. Encrypt.
c, err := aead.NewAESGCM(key)
if err != nil {
panic(err)
}
ct, err := c.Encrypt([]byte("hello"), nil)
if err != nil {
panic(err)
}
fmt.Println(len(ct)) // 12 + 5 + 16
}
```
Deriving several keys from one secret is the same call with a different `info`, which is the entire point of the label:
```go
sendKey, _ := subtle.ComputeHKDF("SHA256", shared, salt, []byte("c2s v1"), 32)
recvKey, _ := subtle.ComputeHKDF("SHA256", shared, salt, []byte("s2c v1"), 32)
sivKey, _ := subtle.ComputeHKDF("SHA256", shared, salt, []byte("wrap v1"), 64) // daed.AESSIVKeySize
```
### subtle caveats
:::danger[Raw Diffie-Hellman output is not a key]
`ComputeSharedSecretX25519` returns the X-coordinate of the scalar multiplication. It is 32 bytes but it is not uniformly distributed, and using it directly as an AES key is a real weakness. Always pass it through `ComputeHKDF` (or another KDF) with an `info` label that binds the protocol, the version, and both parties' public values. The `PublicFromPrivateX25519` function is itself just `ComputeSharedSecretX25519(privKey, curve25519.Basepoint)`, so the same 32-byte shape means "public key" in one place and "shared secret" in another — do not let those slices get mixed up in your code.
:::
:::warning[Always check the X25519 error before touching the result]
All three X25519 functions return `([]byte, error)`, and `GeneratePrivateKeyX25519` in particular returns its buffer *and* the error together — the slice is non-nil even on failure. `ComputeSharedSecretX25519` delegates to `curve25519.X25519`, which reports degenerate inputs as an error rather than handing back a weak secret. Silently ignoring these errors is how you end up encrypting under an all-zero or partially-initialised key.
:::
:::warning[ComputeHKDF does not validate the input key length]
`validateHKDFParams` takes the key size as an ignored `_ uint32` parameter and only checks the hash name and the tag size. `ComputeHKDF("SHA256", nil, nil, info, 32)` succeeds and returns a deterministic 32 bytes derived from nothing. Verify your IKM is non-empty and genuinely high-entropy before calling.
:::
:::note[SHA1 is still reachable]
`"SHA1"` remains in the hash registry, so `ComputeHKDF("SHA1", ...)` works. HMAC-SHA1 is not broken as a PRF, but there is no reason to pick it for new work — use `"SHA256"` unless an existing protocol pins SHA-1.
:::
## Related
<CardGroup cols={2}>
<Card title="Secret hygiene" href="/symmetric/secrets" icon="eye-off">
Salts, zeroization, password policy, and which of the three duplicated SecureCompare helpers to prefer.
</Card>
<Card title="Randomized AEAD" href="/symmetric/aead" icon="lock-keyhole">
Where the 32-byte derived key gets used.
</Card>
</CardGroup>
+8
View File
@@ -0,0 +1,8 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Symmetric & Secrets",
icon: "lock",
order: 3,
pages: ["index", "aead", "deterministic-aead", "key-derivation", "secrets"],
});
+386
View File
@@ -0,0 +1,386 @@
---
title: Secret Hygiene
description: The secure, salt, password, and subtle/random helpers — zeroization, salt management, password policy, and randomness, with an honest account of what each actually guarantees.
sidebar:
order: 5
icon: eye-off
---
Four small packages that surround the cryptography rather than performing it: wiping key material after use, generating and tracking salts, enforcing a password policy at signup, and getting random bytes. None of them is load-bearing for confidentiality — a correct `aead` call with a correctly derived key is secure whether or not you zeroize afterwards — but they are the difference between a key living for microseconds and living until the process exits into a core dump.
They are also the least polished corner of this library. Several helpers are duplicated across packages, one guarantee is weaker than its name suggests, and one type is not safe for concurrent use. Everything below states what the code does.
## secure — zeroization and wrapped secrets
`github.com/sonr-io/crypto/secure` provides free functions for wiping and comparing byte slices, plus three container types that wipe themselves.
### The guarantee, precisely
:::warning[This package overwrites memory. It does not lock it.]
Read `secure/memory.go` and you will find `crypto/rand`, `fmt`, `runtime`, and `sync` — and nothing else. There is **no** `mlock`, no `munlock`, no `madvise`, no `syscall` import, and no build-tagged platform file. Concretely:
- Secrets held by this package **can be paged to swap** or captured in a core dump or hibernation image. If that matters, disable swap for the process, or lock pages yourself outside this library.
- `Zeroize` is a plain `for i := range data { data[i] = 0 }` followed by `runtime.KeepAlive(data)`. The comment claims the loop "prevent[s] compiler optimizations"; in practice a loop writing to a heap slice that is later kept alive is not something the current Go compiler elides, but this is a convention, not a language guarantee. Go has no `explicit_bzero`.
- **The Go runtime may already have copied your secret.** A growing slice, an `append`, a map rehash, or a moving GC leaves stale copies that `Zeroize` cannot reach, because it only sees the slice header you hand it. Zeroize the *original* buffer as early as possible and avoid copying secrets into intermediate values.
Treat zeroization as defence in depth that shortens a secret's lifetime, not as a boundary that guarantees erasure.
:::
:::danger[ZeroizeString is a no-op on the actual bytes]
`ZeroizeString(s *string)` does exactly one thing: `*s = ""`. Its own comment says "(limited effectiveness)". Go strings are immutable and their backing bytes are not writable through the language, so the original characters remain in the heap until the GC collects them — and if the string was interned, is a compile-time constant, or is shared with any other variable, they remain reachable and unchanged. `SecureString.Clear()` calls this function, so `SecureString` inherits the same limitation.
The fix is not a better `ZeroizeString`; it is to never put a secret in a `string`. Read passwords and keys into `[]byte`, pass `[]byte` all the way down (`argon2.DeriveKey`, `password.Validate`, and `aead.Encrypt` all take `[]byte`), and `Zeroize` that.
:::
### Free functions
<TypeTable
type={{
"Zeroize(data []byte)": {
type: "void",
description: "Overwrites every byte with 0, then runtime.KeepAlive. Returns immediately for a nil or empty slice."
},
"ZeroizeMultiple(slices ...[]byte)": {
type: "void",
description: "Calls Zeroize on each argument. Convenient for a deferred wipe of several buffers."
},
"ZeroizeString(s *string)": {
type: "void",
description: "Sets *s = \"\". Does not and cannot overwrite the string's bytes. Nil-safe."
},
"SecureCompare(a, b []byte) bool": {
type: "bool",
description: "Hand-rolled XOR-accumulate comparison. Returns false immediately when lengths differ, so length is not hidden."
},
"SecureRandom(data []byte) error": {
type: "error",
description: "Fills data from crypto/rand.Read. Returns nil for an empty slice. Wraps any read failure as an error rather than panicking."
}
}}
/>
`SecureRandom` is the randomness call to prefer in this library: it reports failure instead of panicking, unlike [`subtle/random`](#subtle-random).
### SecureBytes
A mutex-guarded byte buffer with a `runtime.SetFinalizer` that wipes it if you forget to. Grounded in `secure/memory_test.go` (`TestSecureBytes`):
```go secure_bytes.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/secure"
)
func main() {
// Allocate a zeroed 32-byte secret holder.
sb := secure.NewSecureBytes(32)
defer sb.Clear() // idempotent; also removes the finalizer
// Bytes() hands back a copy, so writes must go through CopyTo.
scratch := make([]byte, sb.Size())
if err := secure.SecureRandom(scratch); err != nil {
panic(err)
}
if err := sb.CopyTo(scratch); err != nil {
panic(err)
}
secure.Zeroize(scratch) // wipe the intermediate immediately
fmt.Println(sb.Size(), sb.IsEmpty()) // 32 false
// Wrapping existing material copies it — the source stays independent.
raw := []byte{1, 2, 3, 4, 5}
wrapped := secure.FromBytes(raw)
secure.Zeroize(raw) // wiping the original does not affect `wrapped`
out := wrapped.Bytes() // a fresh copy: your responsibility now
fmt.Println(out) // [1 2 3 4 5]
secure.Zeroize(out)
wrapped.Clear()
}
```
<TypeTable
type={{
"NewSecureBytes(size int)": {
type: "*SecureBytes",
description: "Allocates a zeroed buffer of `size` bytes and registers a finalizer. size <= 0 yields a nil-data instance with NO finalizer."
},
"FromBytes(data []byte)": {
type: "*SecureBytes",
description: "Copies data into a new instance. Empty input yields a nil-data instance with no finalizer."
},
"Bytes()": {
type: "[]byte",
description: "Returns a fresh COPY of the contents — a new secret you are now responsible for zeroizing. Returns nil once cleared."
},
"CopyTo(data []byte)": {
type: "error",
description: "Zeroizes the buffer then copies data in. Errors if finalized, if the buffer is nil, or if len(data) exceeds the buffer."
},
Size: { type: "int", description: "Length of the held data; 0 after Clear." },
IsEmpty: { type: "bool", description: "True if the data is nil or zero-length." },
Clear: { type: "void", description: "Zeroizes, drops the data, marks finalized, and unregisters the finalizer. Safe to call twice." }
}}
/>
:::warning[Bytes() manufactures new copies of your secret]
Every `Bytes()` call allocates and returns a fresh slice — that is what makes the type safe against external mutation, and it is also what makes it leaky. Each returned slice is an independent copy that `Clear()` will never touch. Call `Bytes()` once, use it, and `Zeroize` the result yourself.
Relying on the finalizer is worse still: `runtime.SetFinalizer` runs at the GC's discretion and is not guaranteed to run at all before the process exits. Always `defer sb.Clear()`.
:::
### SecureString
`NewSecureString(s string)` wraps a string; `String()` returns the value (or `""` once cleared), `IsEmpty()` reports finalized-or-empty, and `Clear()` calls `ZeroizeString` and marks it finalized. Given the `ZeroizeString` limitation above, this type buys you a "cleared" flag and a mutex, not erasure. Prefer `SecureBytes`.
### SecureBuffer
A fixed-capacity append-only buffer for assembling sensitive data.
<TypeTable
type={{
"NewSecureBuffer(capacity int)": {
type: "*SecureBuffer",
description: "Allocates make([]byte, 0, capacity). A capacity <= 0 is silently replaced with 1024."
},
"Write(data []byte)": {
type: "error",
description: "Appends. Returns a 'buffer overflow' error instead of growing when len+len(data) would exceed capacity."
},
"Read()": { type: "[]byte", description: "Returns a copy of the current contents." },
"Reset()": { type: "void", description: "Zeroizes the entire backing array (up to cap) and truncates length to 0, retaining capacity. No-op when length is already 0." },
"Clear()": { type: "void", description: "Zeroizes the entire backing array, sets it to nil, and unregisters the finalizer." },
Size: { type: "int", description: "Current length." },
Capacity: { type: "int", description: "Backing-array capacity; 0 after Clear." }
}}
/>
:::note[SecureBuffer never grows, and Reset skips an empty buffer]
`Write` fails rather than reallocating — deliberate, since a growing slice would leave an un-wipeable copy behind, but it means you must size the buffer up front. Also note `Reset()` is guarded by `if len(sb.buffer) > 0`, so it does nothing when the length is already zero; after a `Reset` the capacity region stays wiped, but do not depend on `Reset` as a general "scrub this" call. After `Clear()`, capacity is 0 and every subsequent `Write` fails with an overflow error.
:::
## salt
`github.com/sonr-io/crypto/salt` wraps salt bytes in a type that redacts itself in logs, compares in constant time, and can wipe itself — plus an in-memory keyed store.
| Constant | Value | Meaning |
| --- | --- | --- |
| `salt.DefaultSaltSize` | `32` | Recommended size, 256 bits — matches `argon2.DefaultConfig().SaltLength` |
| `salt.MinSaltSize` | `16` | Hard floor, 128 bits. Anything smaller is rejected |
| `salt.MaxSaltSize` | `1024` | Hard ceiling, to prevent resource exhaustion |
`Generate(size)` errors outside `[16, 1024]`; `GenerateDefault()` is `Generate(32)`; `FromBytes(data)` applies the same bounds and **copies** the input so later mutation of your slice cannot change the salt.
```go salts.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/argon2"
"github.com/sonr-io/crypto/salt"
)
func main() {
s, err := salt.GenerateDefault() // 32 bytes
if err != nil {
panic(err)
}
defer s.Clear()
fmt.Println(s.Size()) // 32
fmt.Println(s.String()) // Salt{size=32} — value is never printed
fmt.Println(s.IsEmpty()) // false
key := argon2.New(argon2.DefaultConfig()).DeriveKey([]byte("pw"), s.Bytes())
fmt.Println(len(key)) // 32
// Round-tripping a persisted salt.
restored, err := salt.FromBytes(s.Bytes())
if err != nil {
panic(err)
}
fmt.Println(s.Equal(restored)) // true, compared in constant time
}
```
<TypeTable
type={{
"Bytes()": { type: "[]byte", description: "Returns a copy. nil if the Salt is nil or cleared." },
"Size()": { type: "int", description: "Length in bytes; 0 when nil or cleared." },
"String()": { type: "string", description: "Redacted form: \"Salt{size=32}\" or \"Salt{<nil>}\". Never exposes the value." },
"Equal(other *Salt)": { type: "bool", description: "Constant-time comparison over equal-length values; returns false on a length mismatch. Nil-safe (nil equals nil)." },
"Clear()": { type: "void", description: "Zeroizes the value and sets it to nil. Nil-safe." },
"IsEmpty()": { type: "bool", description: "True if the Salt is nil, has nil value, or is zero-length." }
}}
/>
Every `*Salt` method is nil-receiver safe, which is unusual and worth knowing: a `nil` salt reports `Size() == 0` and `IsEmpty() == true` rather than panicking.
### SaltStore
`NewSaltStore()` returns a keyed collection with `Store(id, salt)`, `Retrieve(id)`, `GenerateAndStore(id, size)`, `Remove(id)`, `List()`, `Size()`, and `Clear()`. `Store` and `Retrieve` both copy, so the store never shares a backing array with your code, and `Remove`/`Clear` zeroize before deleting. An empty `id` is an error, as is storing a nil-or-empty salt or retrieving/removing an unknown `id`.
:::danger[SaltStore is in-memory only and NOT concurrency-safe]
Two independent facts, both from `salt.go`:
1. The struct is exactly `struct { salts map[string]*Salt }`. There is **no mutex** — no `sync.Mutex`, no `sync.RWMutex`, no `sync.Map`. Concurrent `Store` and `Retrieve` from different goroutines is a data race on a Go map, and concurrent writes will crash the process with `fatal error: concurrent map writes`. Wrap it in your own lock or confine it to one goroutine.
2. There is no persistence, no encryption at rest, and no export/import. Everything lives in the process heap and is gone on restart. Salts do not need to be secret, but they do need to *survive* — a lost salt means an unverifiable password hash and an underivable key. Persist salts alongside the records they belong to (or use `argon2.HashPassword`, which embeds the salt in the encoded string) and treat `SaltStore` as a request-scoped cache at most.
:::
## password
`github.com/sonr-io/crypto/password` is a policy checker, not a hasher — it never touches Argon2. Feed a candidate password through `Validate` at signup or change-password time, then hand it to [`argon2`](/symmetric/key-derivation#argon2-password-stretching).
<TypeTable
type={{
MinLength: {
type: "int",
required: true,
description: "Minimum length, compared against len(password) in BYTES.",
default: "12"
},
MaxLength: {
type: "int",
required: true,
description: "Maximum length in bytes.",
default: "128"
},
RequireUppercase: {
type: "bool",
description: "Require at least one unicode.IsUpper rune.",
default: "true"
},
RequireLowercase: {
type: "bool",
description: "Require at least one unicode.IsLower rune.",
default: "true"
},
RequireDigits: {
type: "bool",
description: "Require at least one unicode.IsDigit rune.",
default: "true"
},
RequireSpecial: {
type: "bool",
description: "Require at least one unicode.IsPunct or unicode.IsSymbol rune. Whitespace does NOT count as special.",
default: "true"
},
MinEntropy: {
type: "float64",
required: true,
description: "Minimum estimated entropy in bits, from the package's own heuristic (see caveat).",
default: "50.0"
}
}}
/>
Those are the literal values in `DefaultPasswordConfig()`. `NewValidator(nil)` uses them.
```go policy.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/password"
)
func main() {
v := password.NewValidator(nil) // DefaultPasswordConfig()
// Rejected: 7 bytes < MinLength 12.
fmt.Println(v.Validate([]byte("Short1!")))
// password must be at least 12 characters
// Rejected: no uppercase.
fmt.Println(v.Validate([]byte("longenoughpassword123!")))
// password must contain at least one uppercase letter
// Accepted.
fmt.Println(v.Validate([]byte("ValidPassword123!"))) // <nil>
// Loosen the policy explicitly rather than editing the default.
relaxed := password.NewValidator(&password.PasswordConfig{
MinLength: 8,
MaxLength: 64,
RequireUppercase: false,
RequireLowercase: true,
RequireDigits: true,
RequireSpecial: false,
MinEntropy: 30.0,
})
fmt.Println(relaxed.Validate([]byte("simple123"))) // <nil>
}
```
`Validate` returns the **first** violated rule as a `fmt.Errorf` string, checked in order: min length, max length, uppercase, lowercase, digit, special, entropy. There is no aggregated result, so a UI that wants to show every failure must call it repeatedly with narrowed configs.
The three loose helpers are unrelated to validation: `GenerateSalt(size)` returns `size` random bytes and errors below 16; `SecureCompare(a, b)` is the same XOR loop as `secure.SecureCompare`; `ZeroBytes(b)` is the same wipe loop as `secure.Zeroize` minus the `runtime.KeepAlive`.
:::warning[There is no blocklist, dictionary, or breach check]
`Validate` enforces length and character classes only. `Passw0rd123!` passes every default rule — 12 bytes, all four classes, 84 "bits" by the internal estimator. If you care about guessability rather than shape, add a check against a common-password list or a breached-credential API. This package cannot tell you a password is bad, only that it is short or monotonous.
:::
:::warning[MinEntropy is a length heuristic, and with the default MinLength it is nearly vacuous]
`calculateEntropy` detects which of four character classes appear, sums a pool size (26 lower + 26 upper + 10 digit + 32 special = 94 at most), then computes bits-per-character by counting the bits in that integer — `floor(log2(pool)) + 1`, so 7 for the full pool — and returns `len(password) * bitsPerChar`. It is a per-character constant multiplied by a byte count. It does not measure repetition, patterns, or dictionary membership: `aaaaaaaaaaaa` scores 60 "bits". Since 12 characters clears the 50-bit default even in the lowest-scoring case, the entropy gate essentially never fires beyond what `MinLength` already rejected. Do not present its number to users as a strength meter.
:::
:::warning[Length limits are counted in bytes, not characters]
`Validate` compares `len(password)`, the byte length. A 12-character password made of multi-byte runes (accents, CJK, emoji) can be 2448 bytes and may trip `MaxLength`, while `MinLength: 12` is satisfied by as few as 3 emoji. The character-class loop, in contrast, iterates properly over runes via `for _, ch := range string(password)`. If your users type non-ASCII, either raise `MaxLength` or count runes before calling.
:::
:::note[Normalize before validating and before hashing]
There is no Unicode normalization anywhere in this package or in `argon2`. The same typed password can produce different byte sequences (NFC vs NFD) depending on the client's input method, and a hash derived from one will not verify the other. If you accept non-ASCII passwords, normalize to a fixed form (NFKC is the usual choice) at the edge, before both `Validate` and `DeriveKey`.
:::
## subtle random
`github.com/sonr-io/crypto/subtle/random` is nine lines of code with two functions:
| Function | Behaviour |
| --- | --- |
| `GetRandomBytes(n uint32) []byte` | Allocates `n` bytes and fills them from `crypto/rand.Read` |
| `GetRandomUint32() uint32` | `binary.BigEndian.Uint32(GetRandomBytes(4))` |
:::danger[These functions panic on randomness failure]
Neither returns an error. `GetRandomBytes` handles a failed `rand.Read` with `panic(err)`, annotated `// out of randomness, should never happen`. `GetRandomUint32` inherits that panic.
On Linux with a modern kernel, `crypto/rand.Read` failing is genuinely close to impossible, so the assumption usually holds — but "usually" is the operative word: a panic in a library function is an unrecoverable crash of whichever goroutine calls it, and you cannot handle it at the call site. In any long-lived service, prefer `secure.SecureRandom(buf)`, which returns a wrapped error, or call `crypto/rand.Read` directly. Reserve `subtle/random` for tests and for code paths where a crash is an acceptable response to a broken CSPRNG.
:::
Inside this repository, `random` is used by test code (for example the AES-SIV tests) rather than by production paths — which is roughly the right scope for a panicking API.
## Duplicated helpers
The same two primitives are implemented three and four times over. They are not identical, and it matters which you call.
| Primitive | Implementations | Prefer |
| --- | --- | --- |
| Constant-time compare | `secure.SecureCompare`, `password.SecureCompare`, `salt`'s internal `constantTimeCompare` (via `Salt.Equal`), `argon2.CompareHashes` | **`argon2.CompareHashes`** |
| Zero a byte slice | `secure.Zeroize`, `password.ZeroBytes`, `Salt.Clear` | **`secure.Zeroize`** |
| Generate a salt | `salt.Generate`, `argon2.(*KDF).GenerateSalt`, `password.GenerateSalt` | **`salt.Generate`** / **`GenerateDefault`** |
| Random bytes | `secure.SecureRandom`, `random.GetRandomBytes` | **`secure.SecureRandom`** |
:::warning[Prefer the crypto/subtle-backed comparison]
`argon2.CompareHashes` delegates to `crypto/subtle.ConstantTimeCompare`, which the Go team maintains and documents as constant-time. `secure.SecureCompare`, `password.SecureCompare`, and `salt`'s `constantTimeCompare` are three copies of the same hand-written XOR-accumulate loop. The loop is the textbook shape and is very likely constant-time as compiled today, but it carries no guarantee from the compiler and gets no attention from anyone tracking Go's optimizer. There is no reason to prefer a hand-rolled copy over the standard library's.
All four variants short-circuit on a length mismatch, so **none** of them hides the length of the secret. That is fine for fixed-width comparisons (32-byte keys, 16-byte tags) and wrong for variable-length inputs; if length is sensitive, hash both sides to a fixed width first and compare the digests.
:::
For salt generation, `salt.Generate` is the strictest: it enforces the 16-byte floor *and* a 1024-byte ceiling. `password.GenerateSalt` enforces only the 16-byte floor, and `(*KDF).GenerateSalt` enforces nothing beyond using the configured `SaltLength`.
## Related
<CardGroup cols={2}>
<Card title="Key derivation" href="/symmetric/key-derivation" icon="key-round">
Argon2id presets and the HKDF/X25519 layer that consume these salts and randomness.
</Card>
<Card title="Randomized AEAD" href="/symmetric/aead" icon="lock-keyhole">
AES-256-GCM — the consumer of the 32-byte keys you are trying to keep short-lived.
</Card>
</CardGroup>
+440
View File
@@ -0,0 +1,440 @@
---
title: Distributed Key Generation
description: FROST, Gennaro, and 2-party Gennaro DKG — interactive protocols that produce a signing key no single participant ever holds.
sidebar:
order: 3
icon: git-branch
---
DKG replaces the trusted dealer. Instead of one process splitting a key it already has, every
participant samples its own contribution, publishes a verifiable commitment to it, and privately
sends one share to each peer. The joint signing key is the sum of every contribution; each party
ends up holding a Shamir share of that sum plus the joint public key. The key itself is never
assembled — not during generation, and not during signing.
Three protocols live here, and they are not interchangeable.
<CardGroup cols={3}>
<Card title="dkg/frost" icon="git-branch">
2 rounds, t-of-n, modern `curves.Curve` API. Feeds
[`ted25519/frost`](/threshold/threshold-ed25519) Schnorr signing.
</Card>
<Card title="dkg/gennaro" icon="git-branch">
4 rounds, t-of-n, built on legacy [`sharing/v1`](/threshold/secret-sharing). Produces the
public shares tECDSA signing wants.
</Card>
<Card title="dkg/gennaro2p" icon="users">
2-of-2 façade over `dkg/gennaro`. Two rounds plus `Finalize`, one message type per round.
</Card>
</CardGroup>
:::note[These are not the DKG used by tecdsa/dklsv1]
`tecdsa/dklsv1` has its own embedded DKLs18 DKG. Nothing in this page feeds it. See
[Threshold ECDSA](/threshold/threshold-ecdsa).
:::
## FROST DKG — `dkg/frost`
Two rounds, implementing the DKG half of [eprint 2020/852](https://eprint.iacr.org/2020/852.pdf)
(the citation is in the package doc comment). Each participant runs Feldman VSS on its own secret
and attaches a Schnorr proof of knowledge of the constant coefficient, which is what stops a
participant from biasing the joint key by choosing its contribution after seeing everyone else's.
```go
import (
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/dkg/frost"
"github.com/sonr-io/crypto/sharing"
)
func twoPartyFrostDkg() error {
curve := curves.ED25519()
ctx := "1" // see the ctx warning below
// Each participant knows its own id and the ids of all the others.
p1, err := frost.NewDkgParticipant(1, 2, ctx, curve, 2)
if err != nil {
return err
}
p2, err := frost.NewDkgParticipant(2, 2, ctx, curve, 1)
if err != nil {
return err
}
// --- Round 1 --------------------------------------------------------
bcast1, p2pSend1, err := p1.Round1(nil) // nil => sample a fresh secret
if err != nil {
return err
}
bcast2, p2pSend2, err := p2.Round1(nil)
if err != nil {
return err
}
// Broadcasts go to everyone, keyed by SENDER id, and include your own.
bcast := map[uint32]*frost.Round1Bcast{1: bcast1, 2: bcast2}
// P2P inputs are keyed by SENDER id too: p2p1[2] is what participant 2
// sent to participant 1, i.e. p2pSend2[1].
p2p1 := map[uint32]*sharing.ShamirShare{2: p2pSend2[1]}
p2p2 := map[uint32]*sharing.ShamirShare{1: p2pSend1[2]}
// --- Round 2 --------------------------------------------------------
if _, err = p1.Round2(bcast, p2p1); err != nil {
return err
}
if _, err = p2.Round2(bcast, p2p2); err != nil {
return err
}
// p1.SkShare, p1.VkShare, p1.VerificationKey are now populated,
// and p1.VerificationKey == p2.VerificationKey.
_ = p1.SkShare
return nil
}
```
<Steps>
<Step title="Round1(secret []byte) (*Round1Bcast, Round1P2PSend, error)">
Samples (or accepts) a secret `s`, runs Feldman VSS to get `threshold` commitments and `limit`
shares, samples a nonce `k`, and computes the Schnorr-style proof `c = H(i, CTX, a_0·G, k·G)`,
`w = s·c + k`.
**Broadcast** (`*Round1Bcast`): the `*sharing.FeldmanVerifier` and the two scalars `Wi`, `Ci`.
**Point-to-point** (`Round1P2PSend`, a type alias for `map[uint32]*sharing.ShamirShare`): one
private share per peer, keyed by that peer's id. Send `p2pSend[j]` to participant `j` only.
Pass `nil` for `secret` to sample. Passing a secret enables reshare-style flows, but a zero or
out-of-range value is rejected (`internal.ErrZeroValue` or a scalar decode error).
</Step>
<Step title="Round2(bcast, p2psend) (*Round2Bcast, error)">
For every peer: recomputes `c_j` and aborts unless it matches the broadcast `Ci` (this verifies the
proof of knowledge), then runs `FeldmanVerifier.Verify` on the private share that peer sent. Both
maps are keyed by *sender* id; `bcast` must include your own entry, `p2psend` must not.
Then sums the shares into the signing share and sums every peer's `Commitments[0]` into the joint
verification key.
Sets `SkShare` (`curves.Scalar`), `VkShare` (`curves.Point`, `= SkShare · G`), and
`VerificationKey` (`curves.Point`, the joint public key) on the participant, and returns the latter
two as `*Round2Bcast`.
</Step>
</Steps>
### Result fields
<TypeTable
type={{
Id: { type: "uint32", required: true, description: "This participant's identifier." },
Curve: { type: "*curves.Curve", required: true, description: "The curve the DKG ran on." },
SkShare: {
type: "curves.Scalar",
required: true,
description: "Secret signing share. Set by Round2. This is the value to persist and protect.",
},
VkShare: {
type: "curves.Point",
required: true,
description: "SkShare · G. Public; lets peers attribute a partial signature to this id.",
},
VerificationKey: {
type: "curves.Point",
required: true,
description: "The joint public key. Identical across all participants after Round2.",
},
}}
/>
The `SkShare` values are ordinary Shamir shares of the joint key, so
`sharing.NewShamir(t, n, curve).Combine(...)` over `{Id, SkShare.Bytes()}` pairs reconstructs it —
which the package's own test does to prove correctness, and which production code should never do.
### Transport
`Round1Result` bundles the two halves of round 1 for one recipient:
```go
result := &frost.Round1Result{Broadcast: bcast1, P2P: p2pSend1[2]}
wire, err := result.Encode() // gob
// ...
decoded := &frost.Round1Result{}
err = decoded.Decode(wire)
```
`Encode` uses `encoding/gob` and registers the concrete commitment point and `Ci` scalar types on
each call. There is no matching helper for round 2 — serialise `Round2Bcast` yourself.
:::danger[The ctx string is silently reduced to a single byte, usually zero]
`NewDkgParticipant` takes `ctx string` as the fixed context string that binds the Schnorr proofs to
this DKG session. The implementation does:
```go
ctxV, _ := strconv.Atoi(ctx) // error discarded
// ...
ctx: byte(ctxV),
```
Two consequences. First, any non-numeric `ctx` — including the package's own test value
`"string to prevent replay attack"` — fails `Atoi`, the error is thrown away, and the stored context
becomes the byte `0`. Every such session shares an identical context. Second, even a numeric `ctx`
is truncated to one byte, so `"1"`, `"257"`, and `"513"` are indistinguishable.
The context string therefore provides **no meaningful domain separation as implemented**. Do not
rely on it to prevent cross-session replay of round-1 broadcasts; enforce session freshness at your
transport layer. The participant `Id` is hashed as `byte(dp.Id)` and has the same truncation
problem for ids ≥ 256.
:::
:::warning[Round order is enforced; the error is not exported]
Each participant holds an internal round counter. Calling `Round1` twice, or `Round2` before
`Round1`, returns `internal.ErrInvalidRound` — `"invalid round method called"`. Because
`internal` is not importable, you cannot match that sentinel from outside the module; you only get
the message. The same is true of `internal.ErrNilArguments` (`"arguments cannot be nil"`), returned
for a nil curve, an empty `otherParticipants` list, or nil round-2 maps.
:::
## Gennaro DKG — `dkg/gennaro`
Four rounds, implementing the DKG of [eprint 2020/540](https://eprint.iacr.org/2020/540.pdf) (cited
in the package doc). The extra rounds buy a two-phase VSS that FROST's single Feldman pass does not
provide.
```go
import (
"math/big"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/dkg/gennaro"
)
func twoPartyGennaroDkg() error {
// The blinding generator for Pedersen VSS. Must have unknown discrete log
// w.r.t. the base point in real use — this fixed multiple is test-only.
generator, err := curves.NewScalarBaseMult(btcec.S256(), big.NewInt(3333))
if err != nil {
return err
}
p1, err := gennaro.NewParticipant(1, 2, generator, curves.NewK256Scalar(), 2)
if err != nil {
return err
}
p2, err := gennaro.NewParticipant(2, 2, generator, curves.NewK256Scalar(), 1)
if err != nil {
return err
}
// Round 1
bcast1, p2pSend1, err := p1.Round1(nil)
if err != nil {
return err
}
bcast2, p2pSend2, err := p2.Round1(nil)
if err != nil {
return err
}
bcast := map[uint32]gennaro.Round1Bcast{1: bcast1, 2: bcast2}
p2p1 := map[uint32]*gennaro.Round1P2PSendPacket{2: p2pSend2[1]}
p2p2 := map[uint32]*gennaro.Round1P2PSendPacket{1: p2pSend1[2]}
// Round 2
r2out1, err := p1.Round2(bcast, p2p1)
if err != nil {
return err
}
r2out2, err := p2.Round2(bcast, p2p2)
if err != nil {
return err
}
round3Input := map[uint32]gennaro.Round2Bcast{1: r2out1, 2: r2out2}
// Round 3 — yields the joint public key and this party's secret share
pubKey1, share1, err := p1.Round3(round3Input)
if err != nil {
return err
}
if _, _, err = p2.Round3(round3Input); err != nil {
return err
}
// Round 4 — public shares for tECDSA signing (idempotent)
publicShares1, err := p1.Round4()
if err != nil {
return err
}
_, _, _ = pubKey1, share1, publicShares1
return nil
}
```
<Steps>
<Step title="Round1(secret []byte) (Round1Bcast, Round1P2PSend, error)">
Pedersen-committed sharing. The participant runs Pedersen VSS on its secret, producing a secret
polynomial and a blinding polynomial. `Round1Bcast` is a type alias for `[]*v1.ShareVerifier` — the
`threshold` *blinded* commitments `a_j·G + b_j·H`, which reveal nothing about the secret.
`Round1P2PSend` maps each peer id to a `*Round1P2PSendPacket` carrying that peer's `SecretShare`
and its matching `BlindingShare`.
Passing a non-nil `secret` performs proactive secret resharing rather than fresh key generation:
the public key stays the same and only the shares change.
</Step>
<Step title="Round2(bcast, p2p) (Round2Bcast, error)">
Verifies every received `(secretShare, blindingShare)` pair against the sender's blinded
commitments, then de-blinds: broadcasts the *unblinded* Feldman commitments `a_j·G` as
`Round2Bcast` (also `[]*v1.ShareVerifier`). Splitting the commit and reveal across two rounds is
what makes the joint key unbiasable — nobody can see any `a_0·G` until every participant has
already committed.
</Step>
<Step title="Round3(bcast) (*Round3Bcast, *v1.ShamirShare, error)">
Checks each peer's Feldman commitments against the Pedersen commitments it already holds, then
assembles the joint public key. Returns the verification key (`*Round3Bcast`, an alias for
`v1.ShareVerifier`) and this participant's secret share.
</Step>
<Step title="Round4() (map[uint32]*curves.EcPoint, error)">
Computes the per-participant public shares that tECDSA signing needs — `skShare_i · G` for every
`i` — which get converted to additive shares once the signing set is known. Takes no arguments and
is idempotent: calling it repeatedly returns the same map.
</Step>
</Steps>
:::warning[Participant ids must be exactly 1..n]
`NewParticipant` runs `validIds(append(otherParticipants, id))`, which requires the id set to be
precisely the integers `1, 2, …, n`. `NewParticipant(3, 2, gen, scalar, 4)` fails; so does an id of
`0`, and so does any set with a gap. FROST does not impose this — only Gennaro does.
:::
:::note[Built on the legacy sharing layer]
`dkg/gennaro` uses `sharing/v1` throughout: `*curves.EcPoint` instead of `curves.Point`,
`*curves.Element` instead of `curves.Scalar`, `*v1.ShamirShare` instead of `*sharing.ShamirShare`,
and `elliptic.Curve` instead of `*curves.Curve`. Reconstruction of its shares therefore goes through
`v1.NewShamir(t, n, curves.NewField(btcec.S256().N))`, which inherits the
[`v1.Shamir.Combine` truncation defect](/threshold/secret-sharing). The `scalar curves.EcScalar`
argument supplies curve-specific scalar arithmetic — `curves.NewK256Scalar()` for secp256k1.
:::
## 2-party Gennaro — `dkg/gennaro2p`
A façade over `dkg/gennaro` specialised for the 2-of-2 case. Its package doc states the
simplification directly: no distinction between broadcast and peer messages, and only the
counterparty's message is used as round input because self-inputs are always ignored.
```go
import (
"github.com/btcsuite/btcd/btcec/v2"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/dkg/gennaro2p"
)
func twoPartyDkg() (*gennaro2p.DkgResult, *gennaro2p.DkgResult, error) {
curve := btcec.S256()
scalar := curves.NewK256Scalar()
// Passing nil blind makes the client generate a secure blinding generator.
client, err := gennaro2p.NewParticipant(1, 2, nil, scalar, curve)
if err != nil {
return nil, nil, err
}
// Round 1 carries the blind, so the server can adopt the client's.
clientR1, err := client.Round1(nil)
if err != nil {
return nil, nil, err
}
server, err := gennaro2p.NewParticipant(2, 1, clientR1.Blind, scalar, curve)
if err != nil {
return nil, nil, err
}
serverR1, err := server.Round1(nil)
if err != nil {
return nil, nil, err
}
// Round 2 consumes the *counterparty's* round 1 output.
clientR2, err := client.Round2(serverR1)
if err != nil {
return nil, nil, err
}
serverR2, err := server.Round2(clientR1)
if err != nil {
return nil, nil, err
}
// Finalize consumes the counterparty's round 2 output.
clientResult, err := client.Finalize(serverR2)
if err != nil {
return nil, nil, err
}
serverResult, err := server.Finalize(clientR2)
if err != nil {
return nil, nil, err
}
return clientResult, serverResult, nil
}
```
<Steps>
<Step title="Round1(secret []byte) (*Round1Message, error)">
Wraps `gennaro.Round1`. Returns one flat message carrying `Verifiers []*v1.ShareVerifier`,
`SecretShare`, `BlindingShare`, and `Blind *curves.EcPoint`.
</Step>
<Step title="Round2(msg *Round1Message) (*Round2Message, error)">
Wraps `gennaro.Round2` with the counterparty's round-1 message as the sole input. Returns
`Round2Message{Verifiers}`.
</Step>
<Step title="Finalize(msg *Round2Message) (*DkgResult, error)">
Runs `gennaro.Round3` and `gennaro.Round4` back to back and packages the outcome as
`DkgResult{PublicKey *curves.EcPoint, SecretShare *v1.ShamirShare, PublicShares map[uint32]*curves.EcPoint}`.
</Step>
</Steps>
:::tip[Blind synchronisation is the caller's job]
`NewParticipant`'s doc says the blind "must be a generator and must be synchronised between
counterparties. The first participant can set it to `nil` and a secure blinding factor will be
generated." The generated blind is echoed in `Round1Message.Blind`, so the practical ordering is:
party A constructs with `nil` and runs `Round1`, then party B constructs with `Blind` taken from A's
round-1 message. The blind-generation helper itself is unexported, so `nil` is the only way to get
one. Do **not** pass the base point or a known multiple of it — see the
[Pedersen generator warning](/threshold/secret-sharing).
:::
## Caveats
:::warning[No identifiable abort]
None of these protocols tell you *who* misbehaved. Verification failures surface as messages like
`"feldman verify fails for participant with id 2"` (FROST does name the id) or a bare `"not equal"`
(the underlying VSS check). Aborting is correct, but you get no cryptographic evidence to present to
a third party, so a participant can grief the protocol repeatedly without penalty.
:::
:::warning[No transport, no authentication, no replay protection]
These packages produce and consume Go values. Delivering broadcasts to everyone, delivering each
private share to exactly one recipient, authenticating senders, and rejecting replayed round
messages are all your responsibility. Given the `ctx` defect above, replay protection in particular
cannot be delegated to `dkg/frost`.
:::
:::note[Round methods mutate the participant and are not goroutine-safe]
Every round advances an internal counter and stores state on the participant. One participant
value belongs to one goroutine.
:::
## Next
<CardGroup cols={2}>
<Card title="Threshold Ed25519" href="/threshold/threshold-ed25519" icon="key-round">
`ted25519/frost` consumes a `dkg/frost` participant directly.
</Card>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="pen-tool">
DKLs18 2-of-2, with its own embedded DKG.
</Card>
<Card title="Secret Sharing" href="/threshold/secret-sharing" icon="split">
The Shamir/Feldman/Pedersen machinery all three protocols are built on.
</Card>
<Card title="Schnorr Proofs" href="/zero-knowledge/schnorr" icon="fingerprint">
The proof of knowledge that keeps FROST's joint key unbiasable.
</Card>
</CardGroup>
+119
View File
@@ -0,0 +1,119 @@
---
title: Threshold & MPC
description: Splitting keys across parties so no single machine ever holds a signing key — secret sharing, distributed key generation, and threshold signing.
sidebar:
order: 1
icon: users
---
Everything in this section exists to answer one question: **how do you sign without any single
machine ever holding the private key?** The answer is built in four layers, and each layer is a
separate package in this repository. Reading them bottom-up is the fastest way to make sense of the
code.
## The stack
<Steps>
<Step title="Oblivious transfer — ot/base/simplest, ot/extension/kos">
The raw two-party primitive. A sender holds two messages, a receiver picks one, and neither learns
anything about the other's choice. Threshold ECDSA needs it because ECDSA multiplies two secrets
together, and OT is how two parties multiply shares without revealing them. You will almost never
call this directly. See [Oblivious Transfer](/threshold/oblivious-transfer).
</Step>
<Step title="Secret sharing — sharing, sharing/v1">
Shamir, Feldman, and Pedersen. Given a secret that *already exists*, split it into `n` shares so
that any `t` reconstruct it. Purely local: one process does the splitting. See
[Secret Sharing](/threshold/secret-sharing).
</Step>
<Step title="Distributed key generation — dkg/frost, dkg/gennaro, dkg/gennaro2p">
Each party samples its own contribution and the parties run an interactive protocol. The resulting
signing key is never assembled anywhere. See [Distributed Key Generation](/threshold/dkg).
</Step>
<Step title="Threshold signing — tecdsa/dklsv1, ted25519">
Consume a DKG output and produce a signature that verifies under an ordinary ECDSA or Ed25519
verifier. See [Threshold ECDSA](/threshold/threshold-ecdsa) and
[Threshold Ed25519](/threshold/threshold-ed25519).
</Step>
</Steps>
## Sharing a secret is not the same as DKG
This is the distinction people get wrong, and getting it wrong voids the entire security argument.
**Secret sharing with a dealer** (`sharing.Shamir`, `sharing.Feldman`, `sharing.Pedersen`,
`tecdsa/dklsv1/dealer`) starts from a secret that exists in one process's memory. That process runs
a polynomial, emits `n` shares, and hands them out. For the duration of `Split`, one machine knows
the whole key. If that machine is compromised — or if it neglects to zero the secret, or if it is
swapped to disk — the key is gone. Threshold reconstruction after the fact does not undo that.
**Distributed key generation** (`dkg/frost`, `dkg/gennaro`, `dkg/gennaro2p`, and the DKG phase of
`tecdsa/dklsv1`) never forms the key. Each participant `i` samples its own secret `s_i`, shares
`s_i` with everyone, and the joint key is the sum of every contribution. Each party ends up with a
share of `Σ s_i` and the public key `Σ s_i · G`, and no participant — not even a coalition below
threshold — ever sees the key.
:::warning[Dealer setup is a testing and migration tool]
`tecdsa/dklsv1/dealer.GenerateAndDeal` constructs *both* parties' key shares inside a single
process. Its own package doc says so: "Running actual DKG is ALWAYS recommended over a trusted
dealer." Use it for tests and for migrating a key you already hold; never for fresh key creation in
production.
:::
## Protocol comparison
| Package | Threshold model | Curves | Rounds | Notes |
| --- | --- | --- | --- | --- |
| `sharing` (Shamir/Feldman/Pedersen) | t-of-n, `2 ≤ t ≤ n ≤ 255` | any `curves.Curve` | none (local) | Trusted dealer |
| `sharing/v1` | t-of-n | `elliptic.Curve` / `curves.Field` | none (local) | Legacy; `[]byte` secrets |
| `dkg/frost` | t-of-n | any `curves.Curve` | 2 | Feldman VSS + Schnorr PoK |
| `dkg/gennaro` | t-of-n, ids must be exactly `1..n` | k256 and other `elliptic.Curve` | 4 | Pedersen then Feldman |
| `dkg/gennaro2p` | 2-of-2 | `elliptic.Curve` | 2 + `Finalize` | Façade over `dkg/gennaro` |
| `tecdsa/dklsv1` (DKG) | 2-of-2 only | K256, P256 | 10 interleaved half-rounds | DKLs18 |
| `tecdsa/dklsv1` (sign) | 2-of-2 only | K256, P256 | 4 interleaved half-rounds | Bob receives the signature |
| `tecdsa/dklsv1` (refresh) | 2-of-2 only | K256, P256 | 7 interleaved half-rounds | Public key unchanged |
| `ted25519/ted25519` | t-of-n | Ed25519 only | 1 round + aggregation | Output is a plain Ed25519 signature |
| `ted25519/frost` | t-of-n | any `curves.Curve` | 3 | Schnorr, needs a `dkg/frost` result |
| `ot/base/simplest` | 2-party | any `curves.Curve` | 8 interleaved half-rounds | Internal |
| `ot/extension/kos` | 2-party | K256, P256 (tested) | 3 | Internal |
:::note[Curve support in the table means "exercised in this repository's tests"]
Most of these types are generic over the [curve abstraction](/foundations/curves). The curve column
records what the tests actually run, not a claim about what is safe. `tecdsa/dklsv1`, for instance,
is only ever tested on `curves.K256()` and `curves.P256()`.
:::
## Do not drive rounds by hand
For 2-of-2 ECDSA — which is what a Sonr wallet uses — the round-level API is not the intended entry
point. Two layers sit above it:
1. `tecdsa/dklsv1`'s `protocol.Iterator` wrappers (`NewAliceDkg`, `NewBobSign`, …) reduce every
protocol to a `Next(msg)` loop over opaque `*protocol.Message` values you can put on a wire.
2. The `mpc` package wraps *that* into an enclave with key import/export, signing, and
serialization. Application code should start there. See [MPC Enclave](/identity/mpc-enclave).
Reach for the numbered `Round1..Round10` methods only when you are writing your own transport, or
auditing.
## Where to next
<CardGroup cols={2}>
<Card title="Secret Sharing" href="/threshold/secret-sharing" icon="split">
Shamir, Feldman, and Pedersen VSS: which one, and what each fails to protect against.
</Card>
<Card title="Distributed Key Generation" href="/threshold/dkg" icon="git-branch">
FROST, Gennaro, and the 2-party Gennaro façade, with exact round tables.
</Card>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="pen-tool">
DKLs18 2-of-2 ECDSA: the iterator API, serialization, refresh, and the dealer shortcut.
</Card>
<Card title="Threshold Ed25519" href="/threshold/threshold-ed25519" icon="key-round">
t-of-n Ed25519 that verifies under a stock verifier, plus FROST Schnorr.
</Card>
<Card title="Oblivious Transfer" href="/threshold/oblivious-transfer" icon="shuffle">
The layer under tECDSA. Read this to audit, not to call.
</Card>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="lock">
The batteries-included wrapper most application code should use.
</Card>
</CardGroup>
+15
View File
@@ -0,0 +1,15 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Threshold & MPC",
icon: "users",
order: 5,
pages: [
"index",
"secret-sharing",
"dkg",
"threshold-ecdsa",
"threshold-ed25519",
"oblivious-transfer",
],
});
+428
View File
@@ -0,0 +1,428 @@
---
title: Oblivious Transfer
description: The base OT and correlated OT extension underneath threshold ECDSA — simplest (Verified Simplest OT) and kos (KOS15 cOT extension).
sidebar:
order: 6
icon: shuffle
---
:::note[These are internal building blocks, not a user-facing API]
`ot/base/simplest` and `ot/extension/kos` exist to serve
[`tecdsa/dklsv1`](/threshold/threshold-ecdsa). They are exported because the tECDSA packages need
them across package boundaries, not because application code is meant to call them. If you are
building a wallet, use [MPC Enclave](/identity/mpc-enclave); if you are building a signing service,
use the `dklsv1` iterators. This page is here so you can *understand and audit* the layer beneath
tECDSA, and so that a `simplest.SenderOutput` appearing in a DKG result type is not a mystery.
:::
## What oblivious transfer is, and why ECDSA needs it
In 1-out-of-2 OT the sender holds two strings `m_0`, `m_1`; the receiver holds a choice bit `b`.
After the protocol the receiver knows `m_b` and nothing about `m_{1-b}`, and the sender learns
nothing about `b`.
ECDSA needs this because signing requires computing `k^{-1}(H(m) + r·sk)` where `k` and `sk` are
both *split across two parties*. Adding shares is free; multiplying them is not. The standard
two-party trick is to expand one party's secret into bits, have the other party offer a correlated
pair per bit, and let OT select. Sum the selections and you have an additive sharing of the product,
with neither side having learned a factor. That is precisely what `sign.MultiplySender` /
`MultiplyReceiver` do, and `kos` is the OT engine they drive.
## Two layers, one reason
Base OT costs public-key operations — a Schnorr proof, a scalar multiplication per instance. A
single ECDSA signature needs thousands of OTs. Running thousands of base OTs would be intolerably
slow.
**OT extension** fixes this. You run a small fixed number of base OTs once — `kos.Kappa` = 256 of
them, the computational security parameter — and then stretch that seed material into arbitrarily
many OTs using nothing but hashing and binary-field arithmetic. In `kos` each extension produces
`L = 2·Kappa + 2·s = 672` correlated OTs (with `s = 80`, the statistical security parameter) from
that one seed set.
So the pipeline is: **`simplest` once → `kos` many times.**
<Steps>
<Step title="Seed OT — 256 instances of ot/base/simplest">
Run during DKG. Its outputs (`SenderOutput` for Bob, `ReceiverOutput` for Alice) are persisted as
part of the DKG result and reused for every subsequent signature.
</Step>
<Step title="cOT extension — ot/extension/kos, per signature">
Consumes the persisted seed OT results and produces the 672 correlated OTs a signature needs, in
three cheap rounds.
</Step>
</Steps>
:::warning[Roles cross between the layers]
`NewCOtSender` takes a `*simplest.ReceiverOutput`, and `NewCOtReceiver` takes a
`*simplest.SenderOutput`. The constructor docs flag this explicitly — "note the reversal of roles".
Wire them the intuitive way and the protocol fails.
:::
## `ot/base/simplest` — Verified Simplest OT
The package doc names its lineage precisely: "Verified Simplest OT" as defined in "protocol 7" of
[DKLs18](https://eprint.iacr.org/2018/499.pdf), with the original Simplest OT from
[CC15](https://eprint.iacr.org/2015/267.pdf). Multiple choice bits run in parallel, and it is
implemented as a **Random OT** — the sender does not choose its messages; both are random pads
produced by the protocol.
### Security model, from the source
- The "Verified" prefix is the point: rounds 46 are a challenge/response/opening phase that lets
the receiver detect a cheating sender. This is the **maliciously secure** variant of Simplest OT,
not the semi-honest one.
- Ideal functionalities are instantiated concretely, and the package says which: ZKP Schnorr realizes
the `F^{R_{DL}}_{ZK}` zero-knowledge functionality, and *"We have used HMAC for realizing the Random
Oracle Hash function, the key for HMAC is received as input to the protocol."* The HMAC key is the
`uniqueSessionId`.
- Session binding uses a Merlin transcript, initialised with the domain string
`"Coinbase_DKLs_SeedOT"` and immediately absorbing `uniqueSessionId`.
### Construction
```go
import (
"crypto/rand"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/ot/base/simplest"
)
curve := curves.K256()
// Fresh, unpredictable, and identical on both sides. See the danger callout.
uniqueSessionId := [simplest.DigestSize]byte{}
if _, err := rand.Read(uniqueSessionId[:]); err != nil {
return err
}
const batchSize = 256 // must be a multiple of 8
sender, err := simplest.NewSender(curve, batchSize, uniqueSessionId)
if err != nil {
return err
}
receiver, err := simplest.NewReceiver(curve, batchSize, uniqueSessionId)
if err != nil {
return err
}
```
<TypeTable
type={{
curve: {
type: "*curves.Curve",
required: true,
description: "Group for the DiffieHellman-style pad derivation. Tests exercise K256 and P256.",
},
batchSize: {
type: "int",
required: true,
description: "Number of parallel OTs. MUST be a multiple of 8 — the constructors reject anything else with 'batch size should be a multiple of 8', because choice bits are stored packed. tECDSA passes kos.Kappa (256).",
},
uniqueSessionId: {
type: "[simplest.DigestSize]byte",
required: true,
description: "32 bytes. Doubles as the Merlin transcript session binding and the HMAC key for the random oracle. Both parties must supply the identical value, and it must never repeat.",
},
}}
/>
`DigestSize = 32` — the hash length, and also the plaintext/ciphertext size for the optional
encryption steps.
### The eight interleaved rounds
As in tECDSA, the numbers form one global sequence across both parties; the sender owns the odd
rounds and the receiver the even ones. `ot/ottest.RunSimplestOT` wires all of it up:
```go
import "github.com/sonr-io/crypto/ot/ottest"
// Creates both parties, runs rounds 16, and returns their outputs.
senderOutput, receiverOutput, err := ottest.RunSimplestOT(curve, batchSize, uniqueSessionId)
```
Its own doc says it is "a utility function used _only_ during various tests". The sequence it
performs, which is the canonical call order:
<Steps>
<Step title="Round 1 — sender: Round1ComputeAndZkpToPublicKey() (*schnorr.Proof, error)">
Sender computes its key pair `B = b·G` and returns a Schnorr proof of knowledge of `b`. Protocol 7,
step 1.
</Step>
<Step title="Round 2 — receiver: Round2VerifySchnorrAndPadTransfer(proof) ([]ReceiversMaskedChoices, error)">
Receiver verifies the proof (step 2) and performs the Pad Transfer (step 3), returning the masked
choices — the paper's `A` values, in compressed form. Its own random choice bits were generated in
`NewReceiver`.
</Step>
<Step title="Round 3 — sender: Round3PadTransfer(maskedChoices) ([]OtChallenge, error)">
Steps 4 and 5. Sender derives both one-time pads per instance and emits the challenges `xi`.
</Step>
<Step title="Round 4 — receiver: Round4RespondToChallenge(challenge) ([]OtChallengeResponse, error)">
Step 6. Start of the Verify phase: the receiver returns `rho'` for the sender to check.
</Step>
<Step title="Round 5 — sender: Round5Verify(challengeResponses) ([]ChallengeOpening, error)">
Step 7. Aborts if `rho' != H(H(rho^0))`. On success the sender opens its challenges.
</Step>
<Step title="Round 6 — receiver: Round6Verify(challengeOpenings) error">
Step 8, the last verification. Aborts unless `H(rho^w)` matches what the receiver computed itself
*and* `xi == H(opening_0) XOR H(opening_1)`. After this returns nil the random OT is complete and
`Output` is valid on both sides.
</Step>
<Step title="Rounds 7 and 8 — OPTIONAL, only for non-random OT">
`sender.Round7Encrypt(messages)` and `receiver.Round8Decrypt(ciphertext)` bootstrap the random OT
into an actual OT of chosen messages. The package doc states these are optional and that "in the
setting where this OT is used as the seed OT in an OT Extension protocol, the encryption and
decryption steps are not needed" — so tECDSA never calls them.
</Step>
</Steps>
### Outputs
<TypeTable
type={{
"SenderOutput.OneTimePadEncryptionKeys": {
type: "[]OneTimePadEncryptionKeys",
required: true,
description: "Rho^0 and Rho^1 — both pads per instance, as [2][32]byte. One entry per batch slot. Secret.",
},
"ReceiverOutput.OneTimePadDecryptionKey": {
type: "[]OneTimePadDecryptionKey",
required: true,
description: "Rho^w — exactly one pad per instance, as [32]byte: the one matching the receiver's choice bit. Secret.",
},
"ReceiverOutput.PackedRandomChoiceBits": {
type: "[]byte",
required: true,
description: "The choice vector packed one bit per bit, batchSize/8 bytes. Secret.",
},
"ReceiverOutput.RandomChoiceBits": {
type: "[]int",
required: true,
description: "The same choices unpacked, one int per instance. Derived from the packed form at construction.",
},
}}
/>
The correctness invariant, which the tests assert directly:
$$
\texttt{ReceiverOutput.OneTimePadDecryptionKey}[i] = \texttt{SenderOutput.OneTimePadEncryptionKeys}[i][\texttt{RandomChoiceBits}[i]]
$$
The optional message layer is `SenderOutput.Encrypt(plaintexts)` (protocol step 9) and
`ReceiverOutput.Decrypt(ciphertexts)` (step 10); the round wrappers above just call these.
`ExtractBitFromByteVector(vector []byte, index int) byte` reads the `index`-th bit of a packed
vector, little-endian both across and within bytes — needed to interpret `PackedRandomChoiceBits`
by hand.
### Streaming helpers
```go
senderPipe, receiverPipe := simplest.NewPipeWrappers()
errorsChannel := make(chan error, 2)
go func() { errorsChannel <- simplest.SenderStreamOTRun(sender, senderPipe) }()
go func() { errorsChannel <- simplest.ReceiverStreamOTRun(receiver, receiverPipe) }()
for i := 0; i < 2; i++ {
if err := <-errorsChannel; err != nil {
return err
}
}
```
`SenderStreamOTRun(sender *Sender, rw io.ReadWriter) error` and
`ReceiverStreamOTRun(receiver *Receiver, rw io.ReadWriter) error` run the whole six-round process
over one `io.ReadWriter` — a websocket in practice — handling all encoding and decoding. The docs
frame the purpose as "conveniently bundling up the entire seed OT process, for use in tests".
`NewPipeWrappers()` returns a connected in-memory pair for driving both sides in one process.
## `ot/extension/kos` — correlated OT extension
Maliciously secure OT extension, "Protocol 9" of DKLs18, originally
[KOS15](https://eprint.iacr.org/2015/546.pdf) — both cited in the package doc.
This is *correlated* OT: the receiver supplies a choice vector, the sender supplies input scalars
`alpha_j`, and the two outputs add to `alpha_j` where the choice bit is 1 and to zero where it is 0.
That additive-sharing-of-a-selected-value shape is exactly what the multiplication protocol
consumes.
### Constants
| Constant | Value | Meaning |
| --- | --- | --- |
| `Kappa` | 256 | Computational security parameter — and the number of base OTs required |
| `KappaBytes` | 32 | `Kappa >> 3` |
| `L` | 672 | cOT batch size, `2*Kappa + 2*s` with `s = 80` (statistical security parameter) |
| `COtBlockSizeBytes` | 84 | `L >> 3` — size of the packed choice vector |
| `OtWidth` | 2 | Scalars per cOT slot; both parties get `OtWidth` shares per bit |
### Three rounds
```go
import (
"crypto/rand"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/ot/base/simplest"
"github.com/sonr-io/crypto/ot/extension/kos"
"github.com/sonr-io/crypto/ot/ottest"
)
func runCOt(curve *curves.Curve) error {
uniqueSessionId := [simplest.DigestSize]byte{}
if _, err := rand.Read(uniqueSessionId[:]); err != nil {
return err
}
// Seed OT: exactly Kappa base OTs.
baseSenderOutput, baseReceiverOutput, err := ottest.RunSimplestOT(curve, kos.Kappa, uniqueSessionId)
if err != nil {
return err
}
// Note the crossed roles.
sender := kos.NewCOtSender(baseReceiverOutput, curve)
receiver := kos.NewCOtReceiver(baseSenderOutput, curve)
// Receiver's input: the packed choice vector.
choice := [kos.COtBlockSizeBytes]byte{}
if _, err = rand.Read(choice[:]); err != nil {
return err
}
// Sender's input: the correlations alpha_j.
input := [kos.L][kos.OtWidth]curves.Scalar{}
for i := 0; i < kos.L; i++ {
for j := 0; j < kos.OtWidth; j++ {
input[i][j] = curve.Scalar.Random(rand.Reader)
}
}
round1Output, err := receiver.Round1Initialize(uniqueSessionId, choice)
if err != nil {
return err
}
round2Output, err := sender.Round2Transfer(uniqueSessionId, input, round1Output)
if err != nil {
return err
}
if err = receiver.Round3Transfer(round2Output); err != nil {
return err
}
// Invariant: for every slot j and every k < OtWidth,
// sender.OutputAdditiveShares[j][k] + receiver.OutputAdditiveShares[j][k]
// == input[j][k] if choice bit j is 1
// == 0 if choice bit j is 0
return nil
}
```
<Steps>
<Step title="Round 1 — receiver: Round1Initialize(uniqueSessionId, choice) (*Round1Output, error)">
Steps 14 of Protocol 9. The receiver extends its packed `L`-bit choice vector, derives the matrix
`U` from the seed OT pads, and emits `Round1Output{U, WPrime, VPrime}` — `WPrime` and `VPrime` are
the consistency-check values that make the extension maliciously secure rather than merely
semi-honest.
</Step>
<Step title="Round 2 — sender: Round2Transfer(uniqueSessionId, input, round1Output) (*Round2Output, error)">
Steps 2, 5 and 6. The sender checks `WPrime`/`VPrime`, transposes and hashes the matrix, and returns
`Round2Output{Tau}`. Side effect: `sender.OutputAdditiveShares` is populated.
</Step>
<Step title="Round 3 — receiver: Round3Transfer(round2Output) error">
Step 7. The receiver computes its own `OutputAdditiveShares` from `Tau`. No return value beyond the
error.
</Step>
</Steps>
Both parties read their result from the exported field
`OutputAdditiveShares [L][OtWidth]curves.Scalar`.
Streaming equivalents mirror the base layer:
`SenderStreamCOtRun(sender *Sender, hashKeySeed [simplest.DigestSize]byte, input [L][OtWidth]curves.Scalar, rw io.ReadWriter) error`
and
`ReceiverStreamCOtRun(receiver *Receiver, hashKeySeed [simplest.DigestSize]byte, choice [COtBlockSizeBytes]byte, rw io.ReadWriter) error`.
Both take the inputs plus a `ReadWriter` and handle every round and every encode/decode.
## Caveats
:::danger[Never reuse a uniqueSessionId across executions]
The session id is not a label. In `simplest` it is absorbed into the Merlin transcript that binds
the Schnorr proof and every hash in the protocol; the package doc identifies it as *the HMAC key
realizing the random oracle*. In `kos` it is passed to both `Round1Initialize` and `Round2Transfer`
and keys the matrix hashing.
Reusing one across two executions therefore reuses the random-oracle keying. Two runs produce
related pads, the consistency-check values from one run become valid transcripts for another, and
the malicious-security argument — which assumes a fresh independent oracle per session — no longer
holds. Concretely, replaying a recorded round-1 message under a repeated session id is exactly the
attack the transcript binding exists to stop.
The rules:
- 32 bytes from a CSPRNG, per execution. Both parties must hold the identical value, so derive it
from *both* parties' contributions and agree on it before round 1 — that is why
`dklsv1`'s `Round1GenerateRandomSeed` has each side sample 32 bytes and appends both, with the
documented property "secure if either party is honest".
- Never derive it from a counter, a timestamp, a key id, or anything an adversary can predict or
force to repeat.
- Never persist and reuse one across signatures. Each signature runs a fresh cOT extension with a
fresh session id.
- Do not confuse it with the *seed OT output*, which is deliberately long-lived. The seed OT result
is reused for many signatures; the session id of each cOT extension is not.
:::
:::warning[batchSize must be a multiple of 8]
The package doc states the limitation plainly: "currently we only support batch OTs that are
multiples of 8." Choice bits are packed, and both constructors reject a non-multiple with `batch
size should be a multiple of 8`. `kos` always passes `Kappa` (256), which satisfies it.
:::
:::warning[Every output field is key material]
`SenderOutput.OneTimePadEncryptionKeys`, `ReceiverOutput.OneTimePadDecryptionKey`, and
`ReceiverOutput.PackedRandomChoiceBits` / `RandomChoiceBits` are all secret. They are persisted
inside `dkg.AliceOutput.SeedOtResult` and `dkg.BobOutput.SeedOtResult`, and
[serialised in the clear](/threshold/threshold-ecdsa) by the `dklsv1` encoders. Encrypt them at
rest. The one consolation the DKG docs note: unlike a lost `SecretKeyShare`, disclosed seed-OT
material can be replaced by re-running OT — which is what
[key refresh](/threshold/threshold-ecdsa) does.
:::
:::warning[Not constant time]
These packages do byte-level bit manipulation, binary-field multiplication, and matrix transposition
over secret choice vectors, using ordinary indexing and branching. Only the `batchSize & 0x07`
check carries a constant-time comment. Assume nothing here resists timing or cache analysis.
:::
:::note[No audit claim, and correctness only asserted for K256/P256]
This is a port of Coinbase's Kryptology. The live tests run `TestOtOnMultipleCurves`,
`TestOTStreaming`, `TestCOTExtension`, `TestCOTExtensionStreaming`, and `TestBinaryMult` — the cOT
tests over `curves.K256()` and `curves.P256()` only. Nothing here constitutes a security review of
either package. See [Security Notes](/reference/security).
:::
:::note[State is single-use]
A `Sender`/`Receiver` pair, at either layer, serves exactly one protocol execution. Round methods
mutate the value and are not goroutine-safe.
:::
## Next
<CardGroup cols={2}>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="pen-tool">
The consumer: DKG rounds 610 are the seed OT, and every signature runs a cOT extension.
</Card>
<Card title="Schnorr Proofs" href="/zero-knowledge/schnorr" icon="fingerprint">
The proof of knowledge in base OT round 1.
</Card>
<Card title="Curves & Scalars" href="/foundations/curves" icon="binary">
The `Curve`, `Point`, and `Scalar` types both packages are generic over.
</Card>
<Card title="Threshold Overview" href="/threshold" icon="users">
Where this layer sits in the stack.
</Card>
</CardGroup>
+343
View File
@@ -0,0 +1,343 @@
---
title: Secret Sharing
description: Shamir, Feldman, and Pedersen verifiable secret sharing over any supported curve — plus the legacy sharing/v1 layer and its known defects.
sidebar:
order: 2
icon: split
---
The `sharing` package splits a curve scalar into `n` shares such that any `t` of them reconstruct
it, and fewer than `t` reveal nothing. All three schemes share one share type and one
reconstruction routine; they differ only in what a shareholder can *verify* about the share it was
handed.
This is dealer-based sharing: one process holds the secret while `Split` runs. If you need a key
that never exists in one place, you want [DKG](/threshold/dkg) instead.
## Picking a scheme
<CardGroup cols={3}>
<Card title="Shamir" icon="split">
No verification. Fastest, smallest. Use only when every shareholder is trusted, or when a higher
layer verifies for you.
</Card>
<Card title="Feldman" icon="eye">
Adds polynomial commitments `a_j · G`. A holder can check its own share against them. The
commitments leak `a_0 · G` — i.e. the public key of the secret.
</Card>
<Card title="Pedersen" icon="eye-off">
Feldman plus a blinding polynomial under a second generator `H`. The commitments are
information-theoretically hiding, so nothing about the secret leaks before reconstruction.
</Card>
</CardGroup>
## Shamir
```go
import (
crand "crypto/rand"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/sharing"
)
func shamirRoundTrip() error {
curve := curves.ED25519()
scheme, err := sharing.NewShamir(3, 5, curve) // 3-of-5
if err != nil {
return err
}
secret := curve.Scalar.Hash([]byte("test"))
shares, err := scheme.Split(secret, crand.Reader)
if err != nil {
return err
}
// Any 3 of the 5 shares reconstruct the secret.
recovered, err := scheme.Combine(shares[0], shares[2], shares[4])
if err != nil {
return err
}
_ = recovered.Cmp(secret) // == 0
return nil
}
```
`Combine` reconstructs the scalar. `CombinePoints` does the same interpolation in the group,
returning `secret · G` — useful when you want to check that a share set corresponds to a known
public key without materialising the key. `Shamir.LagrangeCoeffs(identities []uint32)` returns the
interpolation coefficients on their own, keyed by identifier, so a caller can compute
`Σ λ_i · share_i` itself; this is exactly what [`ted25519/frost`](/threshold/threshold-ed25519)
needs.
:::info[LagrangeCoeffs has two different signatures]
`Shamir.LagrangeCoeffs` takes `identities []uint32`. `Feldman.LagrangeCoeffs` and
`Pedersen.LagrangeCoeffs` take `shares map[uint32]*ShamirShare` and then delegate to the Shamir
implementation — same computation, different argument shape. Note the subtlety: they read the id
from each map *value* (`share.Id`), never from the map key, so a mismatched key is silently
ignored and a nil value panics. `Combine` and `CombinePoints` likewise delegate, so all three
types behave identically there.
:::
:::danger[Shamir has no integrity check whatsoever]
`Combine` validates that each share's id is nonzero and within `limit`, that ids are not duplicated,
and that the value is a nonzero scalar. It does **not** and cannot check that a share lies on the
dealer's polynomial. One malicious shareholder submitting a well-formed but wrong `Value` silently
produces a wrong secret, with no error. If shareholders are not mutually trusted, use Feldman or
Pedersen and verify every share before combining.
:::
## Feldman
`Feldman.Split` returns a `*FeldmanVerifier` alongside the shares. The verifier holds `Threshold`
points — `a_j · G` for each polynomial coefficient — and `Verify` recomputes
`Σ a_j · id^j` and compares it to `share · G`.
```go
scheme, err := sharing.NewFeldman(3, 5, curves.ED25519())
if err != nil {
return err
}
verifier, shares, err := scheme.Split(secret, crand.Reader)
if err != nil {
return err
}
for _, s := range shares {
if err := verifier.Verify(s); err != nil { // nil == valid
return err
}
}
recovered, err := scheme.Combine(shares[0], shares[1], shares[2])
```
`Verify` returns `fmt.Errorf("not equal")` on a mismatch — there is no typed sentinel error, so
compare against `nil` rather than matching the message.
:::info[The verifier is public data, and it publishes the public key]
`Commitments[0]` *is* `secret · G`. Distributing a `FeldmanVerifier` therefore discloses the public
key of the shared secret. That is normally what you want for a signing key. It is not what you want
if the shared secret must stay hidden even in the exponent — use Pedersen.
:::
## Pedersen
`NewPedersen` takes a **generator point** rather than a curve; the curve is derived from
`generator.CurveName()`. `Split` returns a single struct carrying both verifiers and both share
sets.
```go
curve := curves.ED25519()
// H must have unknown discrete log with respect to G.
h := curve.Point.Generator().Hash([]byte("sonr/pedersen/H/v1"))
scheme, err := sharing.NewPedersen(3, 5, h)
if err != nil {
return err
}
result, err := scheme.Split(secret, crand.Reader)
if err != nil {
return err
}
for i := range result.SecretShares {
// Pedersen verification needs BOTH the secret share and its blinding share.
err = result.PedersenVerifier.Verify(result.SecretShares[i], result.BlindingShares[i])
if err != nil {
return err
}
// The Feldman verifier is also returned and checks the secret share alone.
if err = result.FeldmanVerifier.Verify(result.SecretShares[i]); err != nil {
return err
}
}
recovered, err := scheme.Combine(result.SecretShares[0], result.SecretShares[1], result.SecretShares[2])
```
<TypeTable
type={{
Blinding: {
type: "curves.Scalar",
required: true,
description: "The blinding factor's intercept. Secret — leaking it collapses Pedersen to Feldman.",
},
SecretShares: {
type: "[]*ShamirShare",
required: true,
description: "Shares of the secret. Length == limit.",
},
BlindingShares: {
type: "[]*ShamirShare",
required: true,
description: "Shares of the blinding polynomial, index-aligned with SecretShares.",
},
FeldmanVerifier: {
type: "*FeldmanVerifier",
required: true,
description: "Unblinded commitments a_j · G. Reveals the public key.",
},
PedersenVerifier: {
type: "*PedersenVerifier",
required: true,
description: "Blinded commitments a_j · G + b_j · H, plus the generator H.",
},
}}
/>
:::danger[The generator must have unknown discrete log relative to the base point]
Pedersen's hiding property rests on nobody knowing `x` with `H = x · G`. If the dealer picks
`H = x · G` for a known `x`, it can open the commitment `a_j · G + b_j · H` to any value it likes:
the commitment stops being binding, so the dealer can hand out shares of one secret and later prove
they were shares of another. `NewPedersen` cannot detect this — it only checks that the generator is
on the curve and is not the identity. Derive `H` by hashing to the curve (as above), or use a
published nothing-up-my-sleeve constant. Never derive it as a scalar multiple of `G`.
:::
## ShamirShare
All three schemes emit the same share type.
<TypeTable
type={{
Id: {
type: "uint32",
required: true,
description: "The x-coordinate. 1-indexed; 0 is rejected. Must be ≤ limit.",
},
Value: {
type: "[]byte",
required: true,
description: "The y-coordinate, as the curve's canonical scalar encoding.",
},
}}
/>
`Bytes()` returns the id as 4 big-endian bytes followed by `Value` — a stable wire form.
`Validate(curve)` rejects a zero id, a `Value` that does not decode as a scalar on `curve`, and a
zero scalar. The struct carries `json` tags (`identifier`, `value`) and round-trips through
`encoding/json`.
## Constructor constraints
Identical across `NewShamir`, `NewFeldman`, and `NewPedersen`, checked in this order:
| Check | Error |
| --- | --- |
| `limit >= threshold` | `limit cannot be less than threshold` |
| `threshold >= 2` | `threshold cannot be less than 2` |
| `limit <= 255` | `cannot exceed 255 shares` |
| curve resolvable / non-nil | `invalid curve` |
`Shamir.Split` and `Feldman.Split` additionally reject a zero secret with `invalid secret`.
:::warning[Two rough edges in the constructors]
`Pedersen.Split` reaches the shared polynomial helper directly and **skips the zero-secret check**,
so `NewPedersen(...).Split(curve.Scalar.Zero(), rand)` succeeds and produces shares of zero. Check
`secret.IsZero()` yourself.
`NewPedersen` calls `generator.CurveName()` *before* its `generator == nil` guard, so passing a nil
generator panics with a nil-pointer dereference instead of returning `invalid generator`.
:::
## The Polynomial degree gotcha
`sharing.Polynomial` is exported, and its `Init` signature reads as if it takes a degree:
```go
func (p *Polynomial) Init(intercept curves.Scalar, degree uint32, reader io.Reader) *Polynomial
```
It does not. The implementation allocates `degree` coefficients — `Coefficients[0] = intercept` plus
`degree - 1` random ones — so the resulting polynomial has algebraic degree `degree - 1`. The
parameter is really a *coefficient count*.
Inside the package this is consistent: every scheme calls `Init(secret, threshold, reader)`, which
yields `threshold` coefficients and hence degree `threshold - 1` — precisely what a `t`-of-`n`
scheme requires. But if you call `Init` yourself expecting the named semantics you will get a
polynomial one degree lower than you asked for, and `Init(x, 0, r)` panics on
`Coefficients[0]` before it can return an error.
:::tip
Use `NewShamir`/`NewFeldman`/`NewPedersen`. `Polynomial` is exported incidentally, not as a
supported API.
:::
## Legacy: sharing/v1
<Badge variant="warning">Legacy — do not use for new code</Badge>
`sharing/v1` is the pre-`curves.Curve` generation of the same three schemes. It operates on
`[]byte` secrets over `curves.Field`/`curves.Element` and uses `curves.EcPoint` (aliased locally as
`ShareVerifier`) rather than `curves.Point`. It survives because [`dkg/gennaro`](/threshold/dkg) and
[`ted25519/ted25519`](/threshold/threshold-ed25519) are built on it and have never been ported.
Differences that matter if you must read it:
- `v1.NewShamir(threshold, limit int, field *curves.Field)` takes plain `int`s and a *field*, not a
curve. It enforces only `limit >= threshold` and `threshold >= 2` — **no 255-share ceiling**.
- `v1.NewFeldman(threshold, limit uint32, curve elliptic.Curve)` and
`v1.NewPedersen(threshold, limit uint32, generator *curves.EcPoint)` take standard-library
curves. Tests drive them with `btcec.S256()` and `elliptic.P256()`.
- `Split` takes `[]byte` and reads randomness from an internal source — there is no `io.Reader`
parameter, so you cannot inject a deterministic RNG.
- `Verify` returns `(bool, error)` rather than a bare `error`, and the verifier list is a plain
slice you pass in, not a struct.
- `ShamirShare` here has fields `Identifier uint32` and `Value *curves.Element`, and gains an
`Add` method that panics if the two identifiers differ.
- `ComputeL` is the `LagrangeCoeffs` equivalent, returning an ordered `[]*curves.Element`.
Curve helpers provided by the package: `Ed25519()`, `Bls12381G1()`, `Bls12381G2()`, and
`K256GeneratorFromHashedBytes(bytes []byte) (x, y *big.Int, err error)` — which derives a generator
with unknown discrete log from a byte string, exactly the Pedersen requirement above. There is no
k256 or p256 curve constructor in `v1`; use `btcec.S256()` and `elliptic.P256()` directly, as the
tests do.
:::danger[v1.Bls12381G2 does not return a G2 curve]
Despite its name and its `*Bls12381G1Curve` return type, `Bls12381G2()` initialises a
`Bls12381G2Curve` singleton and then returns `&bls12381g1` — the **G1** curve. Its initialiser also
sets `Name = "Bls12381G1"`, and its `Gy` is a verbatim copy of `B`. The `Bls12381G2Curve` methods
(`Add`, `Double`, `ScalarMult`, `IsOnCurve`, `Hash`) do operate on real G2 points, but there is no
exported constructor that hands you a value of that type. Do not use `Bls12381G2()`.
:::
:::warning[v1.Shamir.Combine ignores shares past the threshold]
`Combine` loops `for i := 0; i < int(s.threshold); i++` over the variadic slice, so passing five
shares to a 3-of-5 scheme interpolates the **first three** and discards the rest. Combined with
`v1`'s lack of Feldman checking inside `Combine`, a corrupt share in one of the leading positions
silently poisons the result even when enough good shares were supplied to notice. The same slicing
applies to `ComputeL`. Note the contrast: the modern `sharing.Shamir.Combine` interpolates over
*all* shares you pass.
:::
## Caveats
:::warning[Not constant time]
Reconstruction goes through `curves.Scalar` arithmetic and, in `v1`, `big.Int`-backed
`curves.Element` arithmetic. Neither is written for constant-time operation on secret data. Treat
these routines as unsafe against local timing adversaries.
:::
:::note[Reconstruction is the dangerous moment]
`Combine` materialises the secret in memory. Any design where `Combine` runs in production has a
window where the key exists in one place — the thing threshold cryptography is meant to eliminate.
Prefer schemes where shares are consumed *as shares* (`LagrangeCoeffs` plus threshold signing) over
schemes that reassemble.
:::
## Next
<CardGroup cols={2}>
<Card title="Distributed Key Generation" href="/threshold/dkg" icon="git-branch">
Same VSS machinery, but nobody ever holds the secret.
</Card>
<Card title="Curves & Scalars" href="/foundations/curves" icon="binary">
The `Curve`, `Point`, and `Scalar` abstractions every scheme here is generic over.
</Card>
</CardGroup>
+516
View File
@@ -0,0 +1,516 @@
---
title: Threshold ECDSA
description: DKLs18 2-of-2 threshold ECDSA — the protocol.Iterator API, serialization, key refresh, the low-level round methods, and the trusted-dealer shortcut.
sidebar:
order: 4
icon: pen-tool
---
`tecdsa/dklsv1` is two-party ECDSA: Alice and Bob each hold a multiplicative share of the private
key, and together they produce a signature that verifies under an ordinary ECDSA verifier. The
package doc names the paper it wraps — [DKLs18](https://eprint.iacr.org/2018/499.pdf) — and the
sub-packages cite specific protocols from it: DKG is "Protocol 2" page 7, signing is "Protocol 4"
page 9, the OT extension is "Protocol 9".
:::warning[2-of-2 only — there is no t-of-n mode]
Every type in this package is named `Alice` or `Bob`. Both parties are required for every
operation; there is no threshold parameter and no way to add a third party or tolerate one being
offline. If you need t-of-n ECDSA, this package cannot provide it. If you need t-of-n Schnorr, see
[Threshold Ed25519](/threshold/threshold-ed25519).
:::
The joint key is *multiplicative*: `pk = (sk_A · sk_B) · G`. That is why the protocol needs
oblivious transfer — multiplying two secret shares without revealing them is the hard part, and
[OT](/threshold/oblivious-transfer) is the machinery that does it.
## Use the iterator API
`tecdsa/dklsv1` exposes six constructors returning types that satisfy `protocol.Iterator`:
```go
type Iterator interface {
Next(input *Message) (*Message, error)
Result(version uint) (*Message, error)
}
```
Each `Next` consumes the counterparty's last message and produces the next one, until it returns
`protocol.ErrProtocolFinished`. Messages are `*protocol.Message` — a JSON-serialisable envelope of
payload bytes, metadata, a protocol name, and a version — so your transport never needs to know
what round it is on.
<TypeTable
type={{
"NewAliceDkg(curve, version)": {
type: "*AliceDkg",
description: "DKG as Alice. Not an error return — construction cannot fail.",
},
"NewBobDkg(curve, version)": {
type: "*BobDkg",
description: "DKG as Bob. Bob moves first in DKG.",
},
"NewAliceSign(curve, hash, message, dkgResultMessage, version)": {
type: "(*AliceSign, error)",
description: "Signing as Alice. Needs Alice's encoded DKG (or refresh) result. Alice moves first in signing.",
},
"NewBobSign(curve, hash, message, dkgResultMessage, version)": {
type: "(*BobSign, error)",
description: "Signing as Bob. Bob is the party that ends up with the signature.",
},
"NewAliceRefresh(curve, dkgResultMessage, version)": {
type: "(*AliceRefresh, error)",
description: "Key refresh as Alice. Alice moves first.",
},
"NewBobRefresh(curve, dkgResultMessage, version)": {
type: "(*BobRefresh, error)",
description: "Key refresh as Bob.",
},
}}
/>
### The crank loop
Both parties advance in lockstep, each `Next` handing its output to the other. This is the harness
the package's own tests use:
```go signing.go
import (
"github.com/sonr-io/crypto/core/protocol"
)
// runIteratedProtocol cranks two parties alternately until both report
// ErrProtocolFinished. firstParty is whichever side moves first.
func runIteratedProtocol(firstParty, secondParty protocol.Iterator) (error, error) {
var (
message *protocol.Message
firstErr error
secondErr error
)
for firstErr != protocol.ErrProtocolFinished || secondErr != protocol.ErrProtocolFinished {
message, firstErr = firstParty.Next(message)
if firstErr != nil && firstErr != protocol.ErrProtocolFinished {
return nil, firstErr
}
message, secondErr = secondParty.Next(message)
if secondErr != nil && secondErr != protocol.ErrProtocolFinished {
return secondErr, nil
}
}
return firstErr, secondErr
}
```
The first `Next` is called with a `nil` message — that is how the mover-first party starts.
:::warning[Who moves first differs per operation]
**DKG: Bob first. Signing: Alice first. Refresh: Alice first.** Getting this backwards does not
produce a clean error; it produces a decode failure on a message the party was not expecting. The
comment in the package's test file states the rule verbatim: *"For DKG bob starts first. For refresh
and sign, Alice starts first."*
:::
### DKG
```go
import (
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/core/protocol"
"github.com/sonr-io/crypto/tecdsa/dklsv1"
)
func runDkg() (*protocol.Message, *protocol.Message, error) {
curve := curves.K256()
alice := dklsv1.NewAliceDkg(curve, protocol.Version1)
bob := dklsv1.NewBobDkg(curve, protocol.Version1)
// Bob moves first in DKG.
aliceErr, bobErr := runIteratedProtocol(bob, alice)
if aliceErr != protocol.ErrProtocolFinished || bobErr != protocol.ErrProtocolFinished {
return nil, nil, fmt.Errorf("dkg did not complete: alice=%v bob=%v", aliceErr, bobErr)
}
// Both sides now agree on the public key:
// alice.Output().PublicKey.Equal(bob.Output().PublicKey) == true
aliceResult, err := alice.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
bobResult, err := bob.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
return aliceResult, bobResult, nil
}
```
`Result` returns the party's *own* state, encoded, ready to be persisted and later fed to
`NewAliceSign` / `NewBobSign`. Alice's result contains her `SecretKeyShare` and her seed-OT
receiver output; Bob's contains his share and his seed-OT sender output. Both contain the shared
`PublicKey`.
<TypeTable
type={{
PublicKey: {
type: "curves.Point",
required: true,
description: "The joint public key. Public; identical for Alice and Bob.",
},
SecretKeyShare: {
type: "curves.Scalar",
required: true,
description: "This party's multiplicative share. Secret. Lose it and the key is unrecoverable.",
},
SeedOtResult: {
type: "*simplest.ReceiverOutput | *simplest.SenderOutput",
required: true,
description: "Seed OT output — ReceiverOutput for Alice, SenderOutput for Bob. Secret, but replaceable by re-running OT (which is what refresh does).",
},
}}
/>
### Signing
```go
import "golang.org/x/crypto/sha3"
func runSign(curve *curves.Curve, aliceDkg, bobDkg *protocol.Message) (*curves.EcdsaSignature, error) {
msg := []byte("As soon as you trust yourself, you will know how to live.")
aliceSign, err := dklsv1.NewAliceSign(curve, sha3.New256(), msg, aliceDkg, protocol.Version1)
if err != nil {
return nil, err
}
bobSign, err := dklsv1.NewBobSign(curve, sha3.New256(), msg, bobDkg, protocol.Version1)
if err != nil {
return nil, err
}
// Alice moves first in signing.
aliceErr, bobErr := runIteratedProtocol(aliceSign, bobSign)
if aliceErr != protocol.ErrProtocolFinished || bobErr != protocol.ErrProtocolFinished {
return nil, fmt.Errorf("sign did not complete")
}
// Only Bob obtains the signature.
resultMessage, err := bobSign.Result(protocol.Version1)
if err != nil {
return nil, err
}
return dklsv1.DecodeSignature(resultMessage)
}
```
:::note[Only Bob gets the signature]
`AliceSign.Result` is documented as *always* returning an error: "Alice does not compute a
signature in the DKLS protocol; only Bob computes the signature." Whichever peer needs the output
must play Bob. Bob also verifies the signature himself before returning it.
:::
The result is a `*curves.EcdsaSignature` and verifies under `curves.VerifyEcdsa` — and under any
standard ECDSA verifier — against the joint public key. The `hash hash.Hash` argument is the digest
function; both parties must pass the same one, and both must pass the same `message`.
### Key refresh
Refresh re-randomises both shares while leaving the public key untouched. The `refresh` package doc
describes the mechanism: Alice draws `k_A`, Bob draws `k_B`, the two are combined through a Merlin
transcript into a single `k`, Bob sets `sk_B *= k` and Alice sets `sk_A *= k^{-1}`. Since
`sk_A · sk_B` is unchanged, so is `pk`. Then the seed OT is redone from scratch.
```go
func runRefresh(curve *curves.Curve, aliceDkg, bobDkg *protocol.Message) (*protocol.Message, *protocol.Message, error) {
aliceRefresh, err := dklsv1.NewAliceRefresh(curve, aliceDkg, protocol.Version1)
if err != nil {
return nil, nil, err
}
bobRefresh, err := dklsv1.NewBobRefresh(curve, bobDkg, protocol.Version1)
if err != nil {
return nil, nil, err
}
// Alice moves first in refresh.
aliceErr, bobErr := runIteratedProtocol(aliceRefresh, bobRefresh)
if aliceErr != protocol.ErrProtocolFinished || bobErr != protocol.ErrProtocolFinished {
return nil, nil, fmt.Errorf("refresh did not complete")
}
aliceOut, err := aliceRefresh.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
bobOut, err := bobRefresh.Result(protocol.Version1)
if err != nil {
return nil, nil, err
}
// These messages substitute for the DKG results in NewAliceSign / NewBobSign.
return aliceOut, bobOut, nil
}
```
The refresh outputs are the same `*dkg.AliceOutput` / `*dkg.BobOutput` shapes as DKG, so they drop
straight into the signing constructors.
:::tip[Why refresh matters]
Refresh defeats a *mobile adversary* — one that compromises Alice this month and Bob next month. If
shares never change, the two stolen halves reconstruct the key. After a refresh, an old share is
useless with a new one. Refresh also replaces the seed OT material, so it recovers from OT state
disclosure. It does **not** rotate the public key, so on-chain addresses and DID documents stay
valid.
:::
:::warning[Refresh is not exercised by this repository's tests]
In `tecdsa/dklsv1/protocol_test.go` the iterator-level refresh coverage — `TestRefreshProto`, the
`refreshV1` helper, `TestSignColdStart`, and `TestEncodeDecode` — is entirely commented out. Only
`TestDkgProto` and `TestDkgSignProto` actually run. The lower-level `tecdsa/dklsv1/refresh` package
does have live tests (`Test_RefreshLeadsToTheSamePublicKeyButDifferentPrivateMaterial`,
`Test_RefreshOTIsCorrect`, `Test_CanSignAfterRefresh`), so the protocol logic is covered; it is the
iterator wrappers, their serializers, and cold-start decoding that are not. Validate the round-trip
in your own environment before relying on it.
:::
## Serialization
Every helper takes or returns a `*protocol.Message`, which marshals to JSON.
| Direction | Alice | Bob |
| --- | --- | --- |
| DKG encode | `EncodeAliceDkgOutput(*dkg.AliceOutput, version)` | `EncodeBobDkgOutput(*dkg.BobOutput, version)` |
| DKG decode | `DecodeAliceDkgResult(*protocol.Message)` | `DecodeBobDkgResult(*protocol.Message)` |
| Refresh encode | `EncodeAliceRefreshOutput(*dkg.AliceOutput, version)` | `EncodeBobRefreshOutput(*dkg.BobOutput, version)` |
| Refresh decode | `DecodeAliceRefreshResult(*protocol.Message)` | `DecodeBobRefreshResult(*protocol.Message)` |
| Signature decode | — | `DecodeSignature(*protocol.Message)` |
Refresh outputs use the *same* `dkg.AliceOutput` / `dkg.BobOutput` structs as DKG; only the
protocol tag on the message differs (`protocol.Dkls18Refresh` versus `protocol.Dkls18Dkg`).
```go
import "encoding/json"
// Persist Alice's DKG state.
msg, err := dklsv1.EncodeAliceDkgOutput(aliceDkg.Output(), protocol.Version1)
if err != nil {
return err
}
blob, err := json.Marshal(msg)
// ... store blob ...
// Restore it later.
restored := &protocol.Message{}
if err := json.Unmarshal(blob, restored); err != nil {
return err
}
aliceOutput, err := dklsv1.DecodeAliceDkgResult(restored)
```
`protocol.EncodeMessage` / `protocol.DecodeMessage` are also available and produce a
base64-of-JSON string if you want a single opaque token instead of a JSON object.
:::danger[The encoded output is the private key share]
`EncodeAliceDkgOutput` and its siblings serialise `SecretKeyShare` and the seed-OT material in the
clear. The resulting bytes are as sensitive as a raw private key half. Encrypt them at rest — see
[AEAD](/symmetric/aead) — and never log or transmit them unprotected.
:::
### The `version` argument
`version uint` selects the serialization format. `core/protocol` defines exactly two constants, and
they are not the numbers you would guess:
```go
// versions will increment in 100 intervals, to leave room for adding other versions in between them if it is
// ever needed in the future.
// Version0 is version 0!
Version0 = 100
// Version1 is version 2!
Version1 = 200
```
Pass `protocol.Version1` (`200`). It is what every live test uses, and the only value the current
serializers are exercised with. The `// Version1 is version 2!` comment is in the source as
written — treat these as opaque tokens and never hardcode the integers.
## The low-level round API
Underneath the iterators sit explicit round methods. Use them only when writing your own transport
or auditing; they are the mechanism, not the interface.
:::note[The numbers interleave the two parties]
`Round1` … `Round10` are a *single* global sequence across Alice and Bob, not per-party sequences.
Alice owns the even-numbered DKG rounds, Bob the odd ones, and neither type has all ten methods.
The names also carry the mapping down into the seed OT — `Round6DkgRound2Ot` means "global round 6,
which is round 2 of the embedded OT".
:::
### `tecdsa/dklsv1/dkg` — 10 rounds
```go
import "github.com/sonr-io/crypto/tecdsa/dklsv1/dkg"
alice := dkg.NewAlice(curve)
bob := dkg.NewBob(curve)
seed, err := bob.Round1GenerateRandomSeed()
round2Output, err := alice.Round2CommitToProof(seed)
proof, err := bob.Round3SchnorrProve(round2Output)
proof, err = alice.Round4VerifyAndReveal(proof)
proof, err = bob.Round5DecommitmentAndStartOt(proof)
compressedReceiversMaskedChoice, err := alice.Round6DkgRound2Ot(proof)
challenge, err := bob.Round7DkgRound3Ot(compressedReceiversMaskedChoice)
challengeResponse, err := alice.Round8DkgRound4Ot(challenge)
challengeOpenings, err := bob.Round9DkgRound5Ot(challengeResponse)
err = alice.Round10DkgRound6Ot(challengeOpenings)
// Only valid after round 10.
aliceOutput := alice.Output()
bobOutput := bob.Output()
```
Rounds 15 establish the joint public key with Schnorr proofs of knowledge of each share. Rounds
610 are the seed OT — `simplest`'s six rounds, driven through thin wrappers. Round 1 exists to
build a session identifier from 32 random bytes contributed by each side; the method's own doc
comment notes this is not in the paper and is "secure if either party is honest".
:::warning[Output before round 10 is undefined behaviour]
Both `Alice.Output()` and `Bob.Output()` are documented as "Must be called after step 9. Calling it
before that step has undefined behaviour." They do not return an error and do not check state.
:::
### `tecdsa/dklsv1/sign` — 4 rounds
```go
import "github.com/sonr-io/crypto/tecdsa/dklsv1/sign"
alice := sign.NewAlice(curve, sha3.New256(), aliceDkgOutput)
bob := sign.NewBob(curve, sha3.New256(), bobDkgOutput)
message := []byte("A message.")
seed, err := alice.Round1GenerateRandomSeed()
round2Output, err := bob.Round2Initialize(seed)
round3Output, err := alice.Round3Sign(message, round2Output)
err = bob.Round4Final(message, round3Output)
signature := bob.Signature // *curves.EcdsaSignature
```
Four rounds, and Bob's `Signature` field is populated by `Round4Final` — which also verifies it.
Note the role reversal versus DKG: Alice contributes the seed here, Bob initialises.
The multiplication sub-protocol ("protocol 5 of the paper") is exposed separately as
`sign.MultiplySender` and `sign.MultiplyReceiver`, constructed with
`NewMultiplySender(seedOtResults *simplest.ReceiverOutput, curve, uniqueSessionId)` and
`NewMultiplyReceiver(seedOtResults *simplest.SenderOutput, curve, uniqueSessionId)`. Note the
crossed roles, which the constructor docs flag explicitly: the multiplication sender consumes the
seed-OT *receiver's* output, and the multiplication receiver consumes the seed-OT *sender's*.
:::note[A copy-pasted doc comment in the source]
`MultiplyReceiver`'s type comment reads "MultiplyReceiver is the party that plays the role of
Sender in the multiplication protocol" — identical to `MultiplySender`'s. It is a stale comment,
not a behavioural claim; the constructor comments are the accurate ones.
:::
### `tecdsa/dklsv1/refresh` — 7 rounds
```go
import "github.com/sonr-io/crypto/tecdsa/dklsv1/refresh"
alice := refresh.NewAlice(curve, aliceDkgOutput)
bob := refresh.NewBob(curve, bobDkgOutput)
round1Output := alice.Round1RefreshGenerateSeed() // no error return
round2Output, err := bob.Round2RefreshProduceSeedAndMultiplyAndStartOT(round1Output)
round3Output, err := alice.Round3RefreshMultiplyRound2Ot(round2Output)
round4Output, err := bob.Round4RefreshRound3Ot(round3Output)
round5Output, err := alice.Round5RefreshRound4Ot(round4Output)
round6Output, err := bob.Round6RefreshRound5Ot(round5Output)
err = alice.Round7DkgRound6Ot(round6Output)
newAliceOutput := alice.Output()
newBobOutput := bob.Output()
```
Rounds 12 do the share re-randomisation; 27 redo the seed OT. `Round1RefreshGenerateSeed` is the
only round method in the whole package with no error return.
## Trusted dealer
`tecdsa/dklsv1/dealer.GenerateAndDeal(curve)` produces `(*dkg.AliceOutput, *dkg.BobOutput, error)`
in one call, with no interaction. The outputs are shape-identical to DKG's and drop straight into
`sign.NewAlice` / `sign.NewBob`.
```go
import "github.com/sonr-io/crypto/tecdsa/dklsv1/dealer"
aliceOutput, bobOutput, err := dealer.GenerateAndDeal(curves.K256())
if err != nil {
return err
}
alice := sign.NewAlice(curves.K256(), sha3.New256(), aliceOutput)
bob := sign.NewBob(curves.K256(), sha3.New256(), bobOutput)
// ... four signing rounds as above ...
```
:::danger[The dealer defeats the entire point of threshold ECDSA]
`GenerateAndDeal` samples `sk_A` and `sk_B` in a single process, multiplies them to build the
public key, and fabricates a matching pair of seed-OT outputs locally. For the duration of that
call, one machine holds material equivalent to the full private key. Anything that reads that
process's memory — a core dump, a swap page, a compromised host, a hypervisor — gets the key.
The package's own doc comments say it twice, in capitals: *"Note that running actual DKG is ALWAYS
recommended over a trusted dealer"*, and *"this function breaks the security guarantees of DKG.
only use this function if you have a very good reason to."*
Legitimate uses: unit tests, and migrating a key you already hold in one place into 2-of-2 shares.
For that second case, follow the deal immediately with a key refresh (see above) so the shares in
long-term storage were never both resident in the dealing process's memory.
:::
## Caveats
:::warning[Curve support is narrow]
Every live test runs on `curves.K256()` and `curves.P256()` only. Other curves in
[`core/curves`](/foundations/curves) are not exercised by this package.
:::
:::warning[Both parties must agree on message and hash out of band]
`NewAliceSign` and `NewBobSign` each take their own `message` and `hash`. Nothing in the protocol
messages forces them to match. If they disagree, signing either fails at Bob's verification step or
— worse — you get a signature over a message one party never approved. Bind the message to your
session at the application layer.
:::
:::note[State is single-use and mutable]
Each `AliceDkg`/`BobSign`/etc. value tracks a step index and mutates on every `Next`. One value
serves one protocol execution in one goroutine. Reusing a completed iterator, or sharing one across
goroutines, is unsupported.
:::
:::note[No audit claim]
This is a port of Coinbase's Kryptology `dklsv1`. Nothing in this repository establishes that the
port, its serializers, or the surrounding wrappers have been reviewed or verified. See
[Security Notes](/reference/security).
:::
## Next
<CardGroup cols={2}>
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="lock">
The wrapper over this package that application code should actually call — key import/export,
signing, and persistence without touching rounds.
</Card>
<Card title="Oblivious Transfer" href="/threshold/oblivious-transfer" icon="shuffle">
The seed OT and cOT extension that rounds 610 are driving.
</Card>
<Card title="ECDSA" href="/signatures/ecdsa" icon="pen-tool">
Single-party ECDSA, and the verifier this package's output satisfies.
</Card>
<Card title="Distributed Key Generation" href="/threshold/dkg" icon="git-branch">
The other DKG protocols in the repository — none of which feed this one.
</Card>
</CardGroup>
+390
View File
@@ -0,0 +1,390 @@
---
title: Threshold Ed25519
description: t-of-n Ed25519 signing whose output verifies under a stock Ed25519 verifier, plus FROST threshold Schnorr on top of a dkg/frost result.
sidebar:
order: 5
icon: key-round
---
Two packages, two different bargains.
`ted25519/ted25519` produces **byte-for-byte standard Ed25519 signatures**. A verifier that has
never heard of threshold cryptography — `crypto/ed25519`, a chain node, a JWT library — accepts
them. That compatibility is the whole reason to use it, and it is what forces the package's
unusual, and dangerous, nonce protocol.
`ted25519/frost` produces FROST threshold Schnorr signatures. Cleaner protocol, three tidy rounds,
works over any curve — but the output is a `(Z, C)` pair, not an Ed25519 signature, and needs
`frost.Verify`.
<CardGroup cols={2}>
<Card title="ted25519/ted25519" icon="key-round">
Pick this when the signature must be accepted by existing Ed25519 verifiers. Ed25519 only. Requires
strict per-message nonce discipline.
</Card>
<Card title="ted25519/frost" icon="fingerprint">
Pick this when you control the verifier. Curve-agnostic, three rounds, consumes a
[`dkg/frost`](/threshold/dkg) result directly.
</Card>
</CardGroup>
## Standard-compatible: `ted25519/ted25519`
The package is a fork of Go's `crypto/ed25519` (itself a port of SUPERCOP `ref10`) with the
threshold pieces added. It keeps the standard sizes:
| Constant | Value |
| --- | --- |
| `PublicKeySize` | 32 |
| `PrivateKeySize` | 64 (seed ‖ public key) |
| `SignatureSize` | 64 (R ‖ s) |
| `SeedSize` | 32 |
Single-party helpers are drop-in: `GenerateKey(rand io.Reader)`, `NewKeyFromSeed(seed []byte)`,
`Sign(priv, msg)`, `Verify(pub, msg, sig)`, plus `PrivateKey.Public()`, `.Seed()`, and a
`crypto.Signer` implementation.
### Why the seed must be expanded before splitting
Standard Ed25519 signing hashes the seed to derive the actual scalar. That hash destroys linearity:
shares of the *seed* are not shares of the *signing scalar*, so partial signatures would not
aggregate. `ExpandSeed(seed []byte) []byte` applies that transform up front, and the split happens
on the expanded value. `ThresholdSign` therefore skips the expansion step that ordinary Ed25519
signing performs — which is exactly why it cannot be replaced with `Sign`.
### t-of-n signing
Every party contributes a nonce, all nonce shares are summed, and each party produces a partial
signature under the summed nonce. `Aggregate` interpolates the `s` components.
```go
import (
"github.com/sonr-io/crypto/ted25519/ted25519"
)
func thresholdSign() error {
config := ted25519.ShareConfiguration{T: 2, N: 3}
// 1. Shared key generation (trusted dealer — see the caveat below).
pub, secretShares, keyCommitments, err := ted25519.GenerateSharedKey(&config)
if err != nil {
return err
}
// Each holder can check its own share against the VSS commitments.
for _, s := range secretShares {
ok, err := s.VerifyVSS(keyCommitments, &config)
if err != nil || !ok {
return fmt.Errorf("bad share")
}
}
message := ted25519.Message("test message")
// 2. Every party generates a nonce FOR THIS MESSAGE and shares it out.
noncePub1, nonceShares1, _, err := ted25519.GenerateSharedNonce(&config, secretShares[0], pub, message)
if err != nil {
return err
}
noncePub2, nonceShares2, _, err := ted25519.GenerateSharedNonce(&config, secretShares[1], pub, message)
if err != nil {
return err
}
noncePub3, nonceShares3, _, err := ted25519.GenerateSharedNonce(&config, secretShares[2], pub, message)
if err != nil {
return err
}
// 3. Sum the nonce shares index-wise, and the nonce pubkeys in the group.
nonceShares := []*ted25519.NonceShare{
nonceShares1[0].Add(nonceShares2[0]).Add(nonceShares3[0]),
nonceShares1[1].Add(nonceShares2[1]).Add(nonceShares3[1]),
nonceShares1[2].Add(nonceShares2[2]).Add(nonceShares3[2]),
}
noncePub := ted25519.GeAdd(ted25519.GeAdd(noncePub1, noncePub2), noncePub3)
// 4. Each party produces a partial signature.
sig1 := ted25519.TSign(message, secretShares[0], pub, nonceShares[0], noncePub)
sig2 := ted25519.TSign(message, secretShares[1], pub, nonceShares[1], noncePub)
// 5. Any T partials aggregate into a complete signature.
sig, err := ted25519.Aggregate([]*ted25519.PartialSignature{sig1, sig2}, &config)
if err != nil {
return err
}
// 6. And it verifies under the ordinary Ed25519 verifier.
ok, err := ted25519.Verify(pub, message, sig)
if err != nil || !ok {
return fmt.Errorf("signature failed verification")
}
return nil
}
```
:::success[This is a plain Ed25519 signature]
`sig` is 64 bytes of `R ‖ s` over the 32-byte public key `pub`. `crypto/ed25519.Verify` accepts it
too. Nothing downstream needs to know a threshold protocol produced it — that is this package's
entire value proposition.
:::
Note that **every** participant must run `GenerateSharedNonce`, not just the `T` who will sign.
The nonce is the sum of all `N` contributions; a missing contribution changes `noncePub` and every
partial signature becomes invalid.
### Types
<TypeTable
type={{
"ShareConfiguration.T": { type: "int", required: true, description: "Threshold — partial signatures needed to aggregate." },
"ShareConfiguration.N": { type: "int", required: true, description: "Total shares issued." },
"KeyShare": { type: "struct{ *v1.ShamirShare }", required: true, description: "A share of the expanded signing scalar. Construct with NewKeyShare(identifier byte, secret []byte); serialise with Bytes(), restore with KeyShareFromBytes." },
"NonceShare": { type: "struct{ *KeyShare }", required: true, description: "A share of a per-message nonce. Add(other) sums two shares with the same identifier. NewNonceShare / NonceShareFromBytes mirror KeyShare." },
"Commitments": { type: "[]curves.Point", required: true, description: "VSS commitments to the polynomial coefficients. CommitmentsToBytes / CommitmentsFromBytes for transport." },
"PartialSignature.ShareIdentifier": { type: "byte", required: true, description: "Which signer produced this partial — the x-coordinate." },
"PartialSignature.Sig": { type: "[]byte", required: true, description: "64 bytes, R ‖ s. R() and S() slice it; Bytes() returns identifier ‖ Sig." },
}}
/>
Supporting functions: `PublicKeyFromBytes(bytes []byte)` (length-checks 32 bytes and returns them),
`GeAdd(a, b PublicKey) PublicKey` (group addition of two public keys, used to sum nonce pubkeys),
`Reconstruct(keyShares []*KeyShare, config *ShareConfiguration) ([]byte, error)`, and
`ThresholdSign(expandedSecretKeyShare []byte, publicKey PublicKey, message []byte, rShare []byte, R PublicKey) []byte` —
the raw form behind `TSign`, taking **little-endian** scalar bytes.
:::danger[Never reuse a nonce share across messages]
Ed25519, like every Schnorr-family scheme, leaks the signing key if two signatures over
*different* messages share the same nonce `R`. Given `s₁ = r + c₁·sk` and `s₂ = r + c₂·sk` with the
same `r`, anyone computes `sk = (s₁ s₂)/(c₁ c₂)`. In a threshold setting this is worse, not
better: the attacker only needs the two aggregate signatures, which are public.
`GenerateSharedNonce` takes the message `m` as an argument for exactly this reason — the nonce is
derived per message. The source is explicit that determinism was deliberately avoided:
> We _must_ introduce randomness to the HKDF to make the output non-deterministic because
> deterministic nonces open up threshold schemes to potential nonce-reuse attacks. We continue to
> use the HKDF that takes in context about what is going to be signed as it adds some protection
> against bad local randomness.
Concretely, the HKDF is keyed on `keyShare ‖ 32 fresh random bytes` with
`info = "ted25519nonce" ‖ publicKey ‖ message`. So the rules are:
- Call `GenerateSharedNonce` **once per message per party**. Never cache the result.
- Never persist a `NonceShare`. If a signing attempt aborts, discard every nonce share and start a
fresh nonce round — do not retry with the old one.
- Never sign two different messages with the same `noncePub`.
- Do not "optimise" by making the nonce deterministic in the message. Two parties disagreeing about
the message set while sharing a nonce is the same catastrophe.
:::
:::danger[Not constant time, on secret values, by the source's own admission]
Two `WARN` comments sit directly on the secret-handling paths:
- In `generateSharableNonce`: *"WARN: This operation is not constant time and we are dealing with a
secret value"* — the rejection-sampling loop that reduces the nonce into the field.
- In `NonceShare.Add`: *"WARN: This is not constant time and deals with secrets"* — the
`big.Int`-backed `curves.Element` addition.
`TSign`, `Aggregate`, and `Reconstruct` go through the same arithmetic. Do not run this where an
attacker can measure your timing.
:::
:::warning[GenerateSharedKey is dealer-based, and there is no DKG for this package]
`GenerateSharedKey` samples a key, expands it, and splits it — all in one process. There is no
distributed alternative that yields standard-Ed25519-compatible shares in this repository.
`dkg/frost` produces shares of a `curves.Scalar` in the modern representation, which is not
interchangeable with the little-endian, field-reduced, expanded-seed representation this package
requires. If dealerless generation is a requirement, use `ted25519/frost` and accept the
non-standard signature format.
:::
:::warning[Built on the legacy sharing layer, with its truncation defect]
`KeyShare` embeds `*v1.ShamirShare` and `Reconstruct` calls `v1.Shamir.Combine`, which
[interpolates only the first `T` shares you pass](/threshold/secret-sharing) and silently ignores
the rest. `VerifyVSS` also requires `len(commitments) >= config.T` and returns
`(false, error)` rather than a typed failure — check both return values.
:::
:::info[Endianness will bite you]
The Ed25519 reference code is little-endian; `curves.Field`/`curves.Element` are big-endian. The
package reverses bytes at nearly every boundary (`ThresholdSign` documents that
`expandedSecretKeyShare` and `rShare` "must be little-endian"). If you hand-roll anything with
these types rather than using `TSign`, expect to get this wrong at least once. Prefer the high-level
helpers.
:::
:::note[The 2-of-2 example in the tests is not a protocol]
`twobytwo_test.go` demonstrates a simpler additive scheme with a file comment saying so plainly:
*"We don't intend to use it and it is not modeled off of any specific known protocol."* Do not copy
it into production.
:::
## FROST Schnorr: `ted25519/frost`
Three rounds implementing the signing half of
[eprint 2020/852](https://eprint.iacr.org/2020/852.pdf), consuming a
[`dkg/frost`](/threshold/dkg) participant directly. Despite the directory name it is not
Ed25519-specific — it is generic over `curves.Curve`, and Ed25519 is one available challenge
derivation.
```go
import (
"github.com/sonr-io/crypto/core/curves"
dkg "github.com/sonr-io/crypto/dkg/frost"
"github.com/sonr-io/crypto/sharing"
"github.com/sonr-io/crypto/ted25519/frost"
)
// participants is the output of a completed dkg/frost run, keyed by id.
func frostSign(curve *curves.Curve, participants map[uint32]*dkg.DkgParticipant) error {
threshold, limit := uint32(2), uint32(3)
// Choose the signing set and precompute its Lagrange coefficients once.
signerIds := []uint32{1, 3}
scheme, err := sharing.NewShamir(threshold, limit, curve)
if err != nil {
return err
}
lCoeffs, err := scheme.LagrangeCoeffs(signerIds)
if err != nil {
return err
}
signers := make(map[uint32]*frost.Signer, len(signerIds))
for _, id := range signerIds {
signers[id], err = frost.NewSigner(
participants[id], id, threshold, lCoeffs, signerIds,
&frost.Ed25519ChallengeDeriver{},
)
if err != nil {
return err
}
}
// --- Round 1: commit to nonces -------------------------------------
round2Input := make(map[uint32]*frost.Round1Bcast, len(signers))
for id := range signers {
out, err := signers[id].SignRound1()
if err != nil {
return err
}
round2Input[id] = out
}
// --- Round 2: partial signatures -----------------------------------
msg := []byte("message")
round3Input := make(map[uint32]*frost.Round2Bcast, len(signers))
for id := range signers {
out, err := signers[id].SignRound2(msg, round2Input)
if err != nil {
return err
}
round3Input[id] = out
}
// --- Round 3: aggregate --------------------------------------------
for id := range signers {
out, err := signers[id].SignRound3(round3Input)
if err != nil {
return err
}
// Every signer derives the identical signature (out.Z, out.C).
_ = out
}
return nil
}
```
<Steps>
<Step title="SignRound1() (*Round1Bcast, error)">
The signer samples two secret nonces `d_i`, `e_i` and **broadcasts their commitments**
`Round1Bcast{Di, Ei curves.Point}` — the two group elements only. Both secret nonces stay local.
Two commitments rather than one is what lets FROST bind the final nonce to the whole signing set
without an extra round.
</Step>
<Step title="SignRound2(msg []byte, round2Input map[uint32]*Round1Bcast) (*Round2Bcast, error)">
Consumes every signer's round-1 broadcast (keyed by signer id, **including your own**), derives the
binding factors and the joint nonce `R`, derives the challenge `c` via the injected
`ChallengeDerive`, and **broadcasts** `Round2Bcast{Zi curves.Scalar, Vki curves.Point}` — this
signer's partial signature `Zi` and its verification-key share `Vki`, which lets peers attribute
and check the partial.
</Step>
<Step title="SignRound3(round3Input map[uint32]*Round2Bcast) (*Round3Bcast, error)">
Consumes every partial, validates each against its `Vki`, and sums them. Returns
`Round3Bcast{R curves.Point, Z, C curves.Scalar}`. Every honest signer produces the identical
`Z` and `C`, so there is no separate coordinator role — whoever needs the signature simply keeps
its own round-3 output.
</Step>
</Steps>
### Verifying
```go
sig := &frost.Signature{Z: out.Z, C: out.C}
ok, err := frost.Verify(curve, &frost.Ed25519ChallengeDeriver{}, vk, msg, sig)
```
`vk` is the joint verification key from DKG (`participant.VerificationKey`). The same
`ChallengeDerive` used for signing must be used for verification.
```go
type ChallengeDerive interface {
DeriveChallenge(msg []byte, pubKey curves.Point, r curves.Point) (curves.Scalar, error)
}
```
`Ed25519ChallengeDeriver` is the implementation shipped in this package. A Mina-flavoured deriver
lives with the [chain signature schemes](/signatures/chain-schemes). Supplying your own is how you
adapt FROST to another verifier's challenge convention — and is also how you break interoperability
if you get it wrong.
`Round1Bcast` and `Round2Bcast` each provide `Encode() ([]byte, error)` and
`Decode(input []byte) error` for transport. `Round3Bcast` does not — it is a local output, not a
message.
### Caveats
:::warning[Lagrange coefficients pin the signing set]
`NewSigner` takes `lcoeffs map[uint32]curves.Scalar` and `cosigners []uint32`. The coefficients are
precomputed for that exact set — the constructor doc cites this as the optimisation from paragraph 3
of section 3 of the FROST draft. **Every signer must be constructed with the same `cosigners` and
the same `lcoeffs`.** Change the set and you must build fresh `Signer` values with fresh
coefficients; reusing stale coefficients yields a signature that fails verification, with no
diagnostic pointing at the cause.
:::
:::danger[One Signer, one signature]
The nonces sampled in `SignRound1` are single-use, and the round counter enforces it: calling
`SignRound1` twice on the same `Signer` returns an error. Construct a new `Signer` per signature.
Never persist a `Signer` between signatures, and never reuse round-1 broadcasts for a second
message — that is nonce reuse and it discloses the signing share.
:::
:::warning[Inherits dkg/frost's context-string defect]
`frost.NewSigner` takes a `*dkg.DkgParticipant` whose `ctx` was collapsed to a single byte at
construction time. That flaw belongs to
[DKG](/threshold/dkg) and is not repaired here — the signing rounds provide no additional
cross-session replay protection.
:::
:::note[No aggregate-only role, no identifiable abort]
Every signer runs all three rounds; there is no lightweight aggregator. And a failed partial-signature
check aborts without producing transferable evidence of which signer cheated.
:::
## Next
<CardGroup cols={2}>
<Card title="Distributed Key Generation" href="/threshold/dkg" icon="git-branch">
`ted25519/frost` needs a completed `dkg/frost` participant as its input.
</Card>
<Card title="Chain Signature Schemes" href="/signatures/chain-schemes" icon="link">
Where the Mina challenge deriver lives.
</Card>
<Card title="Secret Sharing" href="/threshold/secret-sharing" icon="split">
`LagrangeCoeffs`, and the legacy `v1` layer `ted25519/ted25519` is built on.
</Card>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="pen-tool">
The 2-of-2 ECDSA side of the house.
</Card>
</CardGroup>
+452
View File
@@ -0,0 +1,452 @@
---
title: Accumulator
description: Pairing-based ECC accumulator — a constant-size commitment to a set, constant-size membership witnesses, and a zero-knowledge membership proof that hides which element is held.
sidebar:
label: Accumulator
order: 3
icon: list-checks
---
The `accumulator` package implements the pairing-based accumulator of
[eprint 2020/777](https://eprint.iacr.org/2020/777.pdf), together with the zero-knowledge
proof of knowledge from section 7 of that paper. Its own package doc states the scope limit
up front: **only the membership-witness case is implemented**. Non-membership witnesses, and
the accumulator initialisation those would require, are deliberately absent —
`Accumulator.New` simply sets the initial value to the G1 generator.
## The value proposition
Three properties, and they are the entire reason to reach for this instead of a Merkle tree
or a plain list:
- **The accumulator is one curve point** regardless of how many elements it holds. On
BLS12-381 that is a 48-byte compressed G1 point; `Accumulator.MarshalBinary` returns 60
bytes with its BARE framing, at 1 element and at 5000 alike.
- **A membership witness is one curve point plus its element.**
`MembershipWitness.MarshalBinary` is 92 bytes, again independent of set size.
- **The membership proof hides the element.** A verifier learns that the prover holds a valid
witness for *some* accumulated element, not which one.
That last property is what makes this a privacy-preserving revocation mechanism. An issuer
accumulates one element per valid credential and publishes the accumulator. A holder proves
its credential is still accumulated without identifying the credential, and therefore without
being linkable across presentations. Revocation is a `Remove` by the manager.
## When not to use it
Only the holder of the `SecretKey` can mutate the set or issue a witness — `Add`, `Remove`,
`AddElements`, `Update`, and `MembershipWitness.New` all take `*SecretKey`. If you need a
publicly-updatable set, or set membership without a trusted manager, this is the wrong tool.
If you need non-membership proofs, they are not implemented. If you need proofs cheaper than
a multi-pairing per verification, look elsewhere.
Also note the operational cost: every update invalidates every outstanding witness. See the
[witness staleness](#witness-staleness-is-the-hard-part) warning below before committing to
this design.
## Types
Everything here is over a **pairing** curve — in practice `curves.BLS12381(&curves.PointBls12381G1{})`.
The accumulator value lives in G1; the public key lives in G2.
| Type | Definition | Notes |
| --- | --- | --- |
| `Element` | `curves.Scalar` | A set member. Callers hash application data into it, e.g. `curve.Scalar.Hash([]byte("credential-id"))`. |
| `Coefficient` | `curves.Point` | Batch-update polynomial coefficients published by the manager alongside an `Update`. |
| `Accumulator` | struct, unexported `value curves.Point` | The set commitment. |
| `SecretKey` | struct, unexported `value curves.Scalar` | The manager's alpha. |
| `PublicKey` | struct, unexported `value curves.PairingPoint` | `alpha · G2`. |
| `Delta` | struct, unexported `d curves.Scalar`, `p curves.Point` | Witness-update material. See the caveat — you cannot build one. |
| `MembershipWitness` | struct, unexported `c curves.Point`, `y curves.Scalar` | A holder's witness for element `y`. |
Every one of these types implements `MarshalBinary() ([]byte, error)` and
`UnmarshalBinary([]byte) error` — the encoding is BARE (`git.sr.ht/~sircmpwn/go-bare`). Note
that all fields are unexported, so binary marshalling is the *only* way to move these values
across a process boundary; there is no JSON codec and no field access.
## Keys
<TypeTable
type={{
"SecretKey.New": {
type: "func(curve *curves.PairingCurve, seed []byte) (*SecretKey, error)",
required: true,
description: "Derives alpha as curve.Scalar.Hash(seed). Fully deterministic in the seed; performs no validation of seed quality or length.",
},
"SecretKey.GetPublicKey": {
type: "func(curve *curves.PairingCurve) (*PublicKey, error)",
required: true,
description: "Returns alpha times the G2 generator. Errors if the key or curve is nil.",
},
"SecretKey.BatchAdditions": {
type: "func(additions []Element) (Element, error)",
description: "product(y + alpha) over the additions. The multiplier applied to the accumulator on a batch add.",
},
"SecretKey.BatchDeletions": {
type: "func(deletions []Element) (Element, error)",
description: "1/product(y + alpha) over the deletions.",
},
"SecretKey.CreateCoefficients": {
type: "func(additions, deletions []Element) ([]Element, error)",
description: "Batch polynomial coefficients per page 7 of the paper. Update calls this for you; call it directly only if you are reimplementing Update.",
},
}}
/>
:::warning[SecretKey.New is a bare hash of the seed]
`SecretKey.New` is `sk.value = curve.Scalar.Hash(seed)` and always returns a nil error. It
does not check seed length or entropy. A short or predictable seed yields a guessable alpha,
and alpha is total control over the set. Feed it at least 32 bytes from a CSPRNG, or a
properly derived key — see [Key derivation](/symmetric/key-derivation).
:::
## Accumulator operations
<TypeTable
type={{
"Accumulator.New": {
type: "func(curve *curves.PairingCurve) (*Accumulator, error)",
required: true,
description: "Sets the value to the G1 generator. Called on the receiver, so the idiom is new(Accumulator).New(curve).",
},
"Accumulator.WithElements": {
type: "func(curve *curves.PairingCurve, key *SecretKey, m []Element) (*Accumulator, error)",
description: "New plus a batch add: V = product(y + alpha) · V0. The usual way to bootstrap a populated set.",
},
"Accumulator.Add": {
type: "func(key *SecretKey, e Element) (*Accumulator, error)",
description: "V' = (y + alpha) · V. Errors if the accumulator value is nil or the identity.",
},
"Accumulator.AddElements": {
type: "func(key *SecretKey, m []Element) (*Accumulator, error)",
description: "Batch add. Does not emit coefficients, so holders cannot update from it — use Update if witnesses are outstanding.",
},
"Accumulator.Remove": {
type: "func(key *SecretKey, e Element) (*Accumulator, error)",
description: "V' = 1/(y + alpha) · V. Does not verify the element was ever added; removing an absent element silently produces a different accumulator.",
},
"Accumulator.Update": {
type: "func(key *SecretKey, additions, deletions []Element) (*Accumulator, []Coefficient, error)",
description: "Batch add and delete in one step, returning the coefficients holders need for BatchUpdate. This is the update method to use in production.",
},
}}
/>
:::danger[Every mutator mutates the receiver in place]
`Add`, `AddElements`, `Remove`, `Update`, `WithElements`, and `New` all assign to
`acc.value` and then return the same pointer. The returned `*Accumulator` is **not** a new
object — it is the receiver. This idiom compiles cleanly and reads like a functional API:
```go
newAcc, _, err := acc.Update(sk, additions, deletions) // newAcc == acc
```
but `acc` has already changed. If you need the previous state — to serve an older epoch, or
to roll back — call `MarshalBinary()` **before** the mutation and keep the bytes.
`MembershipWitness.New`, `ApplyDelta`, `BatchUpdate`, and `MultiBatchUpdate` behave the same
way on their receivers.
:::
## Witnesses
<TypeTable
type={{
"MembershipWitness.New": {
type: "func(y Element, acc *Accumulator, sk *SecretKey) (*MembershipWitness, error)",
required: true,
description: "Issues a witness for y against the current accumulator: C = 1/(y + alpha) · V. Requires the secret key, so only the manager can issue.",
},
"MembershipWitness.Verify": {
type: "func(pk *PublicKey, acc *Accumulator) error",
required: true,
description: "Multi-pairing check e(C, y·P̃ + Q̃) · e(-V, P̃) == 1. Returns nil on success. Public — any holder or verifier can run it.",
},
"MembershipWitness.BatchUpdate": {
type: "func(additions, deletions []Element, coefficients []Coefficient) (*MembershipWitness, error)",
required: true,
description: "Refreshes a stale witness against one published Update. This is the update path callers should use.",
},
"MembershipWitness.MultiBatchUpdate": {
type: "func(A [][]Element, D [][]Element, C [][]Coefficient) (*MembershipWitness, error)",
description: "Catches up across several epochs at once. All three outer slices must have the same length; index i is the i-th epoch's additions, deletions, and coefficients.",
},
"MembershipWitness.ApplyDelta": {
type: "func(delta *Delta) (*MembershipWitness, error)",
description: "Applies precomputed update material. Effectively unreachable — see caveats.",
},
}}
/>
### Witness staleness is the hard part
:::danger[Updating the set invalidates every outstanding witness]
The witness for element `y` is `1/(y + alpha) · V`, defined *relative to a specific
accumulator value*. The moment the manager adds or removes anything, `V` changes and every
holder's `Verify(pk, acc)` against the new accumulator fails. This is not a bug; it is
inherent to the construction, and it is the dominant operational cost of deploying it.
The manager must therefore publish, for each update, the `[]Coefficient` returned by
`Update` along with the exact `additions` and `deletions` element lists. Holders call
`BatchUpdate(additions, deletions, coefficients)` to move their witness forward. A holder
that misses epochs uses `MultiBatchUpdate` with the per-epoch slices.
Consequences you must design for:
- Coefficients and element lists are **not secret**, but they *do* reveal exactly which
elements were added and removed. Batch your updates if that leakage matters.
- A holder that skips an epoch and cannot obtain that epoch's coefficients can never repair
its witness without the manager reissuing via `MembershipWitness.New`.
- `AddElements` returns no coefficients at all. Use `Update` — even with an empty deletion
slice — whenever witnesses are outstanding.
- A stale witness does not report itself as stale. `Verify` returns the generic
`"invalid result"` from the failed pairing check, which is indistinguishable from a forged
witness. Track the accumulator epoch alongside the witness in your own state.
:::
## Zero-knowledge membership proof
The proof protocol from section 7 of the paper. Unlike the witness check, this hides `y`.
It is a three-move sigma protocol compiled with Fiat-Shamir, and — unusually — verification
is expressed as *recomputing the challenge and comparing it*, not as a `Verify` method.
<TypeTable
type={{
"ProofParams.New": {
type: "func(curve *curves.PairingCurve, pk *PublicKey, entropy []byte) (*ProofParams, error)",
required: true,
description: "Samples the public G1 generators X, Y, Z (and K) from the entropy, the public key, and the curve. Both prover and verifier must use identical params.",
},
"MembershipProofCommitting.New": {
type: "func(witness *MembershipWitness, acc *Accumulator, pp *ProofParams, pk *PublicKey) (*MembershipProofCommitting, error)",
required: true,
description: "Prover's commit phase. Holds all the blinding values; keep it private and short-lived.",
},
"MembershipProofCommitting.GetChallengeBytes": {
type: "func() []byte",
required: true,
description: "The transcript to hash for the challenge: V || Ec || T_sigma || T_rho || R_E || R_sigma || R_rho || R_delta_sigma || R_delta_rho.",
},
"MembershipProofCommitting.GenProof": {
type: "func(c curves.Scalar) *MembershipProof",
required: true,
description: "Computes the s values for the given challenge. Returns the proof to send. No error return.",
},
"MembershipProof.Finalize": {
type: "func(acc *Accumulator, pp *ProofParams, pk *PublicKey, challenge curves.Scalar) (*MembershipProofFinal, error)",
required: true,
description: "Verifier side: recomputes the commitment values from the proof, the accumulator, and the params.",
},
"MembershipProofFinal.GetChallenge": {
type: "func(curve *curves.PairingCurve) curves.Scalar",
required: true,
description: "Recomputes the Fiat-Shamir challenge from the finalized values. Verification succeeds iff this equals the challenge the prover used.",
},
}}
/>
## Full lifecycle
<Steps>
<Step title="Set up the manager">
Derive a `SecretKey` from a strong seed and publish the `PublicKey`.
</Step>
<Step title="Accumulate the initial members">
`new(Accumulator).WithElements(curve, sk, elements)` and publish the accumulator bytes.
</Step>
<Step title="Issue a witness">
For each holder, `new(MembershipWitness).New(element, acc, sk)` and deliver the witness
bytes privately. This step needs the secret key.
</Step>
<Step title="Prove membership">
The holder builds `ProofParams`, runs `MembershipProofCommitting`, hashes
`GetChallengeBytes()` into a challenge, and sends the challenge plus `GenProof(challenge)`.
</Step>
<Step title="Verify">
The verifier calls `Finalize` then `GetChallenge`, and accepts iff the recomputed
challenge equals the one it was given.
</Step>
<Step title="Update the set">
The manager calls `Update(sk, additions, deletions)` and publishes the new accumulator,
the element lists, and the coefficients.
</Step>
<Step title="Refresh witnesses">
Every holder calls `BatchUpdate(additions, deletions, coefficients)`, then can prove again
against the new accumulator.
</Step>
</Steps>
### Membership proof end to end
Grounded in `accumulator/proof_test.go` (`TestMembershipProof`) and
`accumulator/witness_test.go` (`Test_Membership`, `Test_Membership_Batch_Update`).
```go membership.go
package main
import (
"fmt"
"github.com/sonr-io/crypto/accumulator"
"github.com/sonr-io/crypto/core/curves"
)
func main() {
curve := curves.BLS12381(&curves.PointBls12381G1{})
// --- Manager setup -----------------------------------------------------
sk, err := new(accumulator.SecretKey).New(curve, []byte("32-plus-bytes-of-real-entropy..."))
if err != nil {
panic(err)
}
pk, err := sk.GetPublicKey(curve)
if err != nil {
panic(err)
}
// Application data is hashed into set elements.
elements := []accumulator.Element{
curve.Scalar.Hash([]byte("credential-3")),
curve.Scalar.Hash([]byte("credential-4")),
curve.Scalar.Hash([]byte("credential-5")),
curve.Scalar.Hash([]byte("credential-6")),
}
acc, err := new(accumulator.Accumulator).WithElements(curve, sk, elements)
if err != nil {
panic(err)
}
// --- Issue a witness (manager only, needs sk) --------------------------
wit, err := new(accumulator.MembershipWitness).New(elements[3], acc, sk)
if err != nil {
panic(err)
}
// The plain witness check reveals which element is held. Use it for
// self-diagnosis, not as a privacy-preserving presentation.
if err := wit.Verify(pk, acc); err != nil {
panic(err)
}
// --- Zero-knowledge membership proof -----------------------------------
// Both sides must derive identical ProofParams.
params, err := new(accumulator.ProofParams).New(curve, pk, []byte("proof-params/v1"))
if err != nil {
panic(err)
}
mpc, err := new(accumulator.MembershipProofCommitting).New(wit, acc, params, pk)
if err != nil {
panic(err)
}
challenge := curve.Scalar.Hash(mpc.GetChallengeBytes())
proof := mpc.GenProof(challenge)
// Verifier: it has acc, pk, params, the proof, and the challenge.
final, err := proof.Finalize(acc, params, pk, challenge)
if err != nil {
panic(err)
}
if final.GetChallenge(curve).Cmp(challenge) != 0 {
panic("membership proof rejected")
}
fmt.Println("membership proved without revealing which element")
// --- Manager revokes and adds ------------------------------------------
additions := []accumulator.Element{curve.Scalar.Hash([]byte("credential-7"))}
deletions := []accumulator.Element{curve.Scalar.Hash([]byte("credential-5"))}
// Note: this mutates acc in place and also returns it.
_, coefficients, err := acc.Update(sk, additions, deletions)
if err != nil {
panic(err)
}
// --- Holder refreshes its now-stale witness ----------------------------
if _, err := wit.BatchUpdate(additions, deletions, coefficients); err != nil {
panic(err)
}
if err := wit.Verify(pk, acc); err != nil {
panic(err) // would fail without the BatchUpdate above
}
}
```
Note that a fresh `ProofParams` per presentation is fine and is what the test does — proof
params are public and only need to agree between the two parties for that one exchange.
### Catching up across epochs
`MultiBatchUpdate` takes three parallel outer slices, one entry per epoch, and errors with
`"a, d, c should have same length"` if they disagree. From
`Test_Membership_Multi_Batch_Update`:
```go catchup.go
_, coeffs1, _ := acc.Update(sk, adds1, dels1)
_, coeffs2, _ := acc.Update(sk, []accumulator.Element{}, dels2)
_, coeffs3, _ := acc.Update(sk, []accumulator.Element{}, dels3)
a := [][]accumulator.Element{adds1, {}, {}}
d := [][]accumulator.Element{dels1, dels2, dels3}
c := [][]accumulator.Coefficient{coeffs1, coeffs2, coeffs3}
if _, err := wit.MultiBatchUpdate(a, d, c); err != nil {
panic(err)
}
if err := wit.Verify(pk, acc); err != nil {
panic(err)
}
```
## Caveats
:::danger[ApplyDelta is unreachable from outside the package]
`ApplyDelta(delta *Delta)` is exported, but `Delta`'s fields are unexported and the only
constructors — `evaluateDelta` and `evaluateDeltas` — are unexported. No exported function
anywhere in the package returns a `*Delta`. From another package you can therefore obtain one
only by `UnmarshalBinary`-ing bytes that some in-package code produced, and no in-package code
hands them to you. Treat `ApplyDelta` as an internal helper and use `BatchUpdate` /
`MultiBatchUpdate`, which construct the delta for you.
:::
:::warning[Remove does not check membership]
`Remove` applies `1/(y + alpha)` unconditionally. Removing an element that was never added
yields a mathematically valid but semantically meaningless accumulator, and every outstanding
witness silently stops verifying with no diagnostic. The package keeps no member list — the
manager must track set contents itself.
:::
:::warning[No length or duplicate checks on batch inputs]
`BatchAdditions`, `BatchDeletions`, and `Update` accept whatever elements you pass, including
duplicates. Adding the same element twice multiplies the accumulator by `(y + alpha)` twice,
and one `Remove` will not undo both.
:::
:::note[Non-membership is not implemented]
The paper's non-membership witnesses require a different accumulator initialisation
(`V0 = product(y + alpha) · P` over a designated set). This package's `New` sets `V0` to the
plain G1 generator and its own doc comment flags this as the reason non-membership is out of
scope. Do not attempt to derive non-membership proofs from this API.
:::
:::info[There is no MembershipProof.Verify]
Verification is a two-call sequence — `Finalize` then `GetChallenge` — followed by a scalar
comparison you write yourself. Forgetting the comparison, or comparing against a challenge
the *prover* supplied without deriving it from a transcript you control, defeats the proof.
The prover's challenge in the test is `curve.Scalar.Hash(mpc.GetChallengeBytes())`; a verifier
that wants soundness against a chosen-challenge prover should recompute the challenge from the
proof transcript rather than trusting a transmitted scalar.
:::
:::warning[A revoked holder gets a cryptic error, not a clean rejection]
`BatchUpdate` inverts `product(yD_i - y)` over the deletion list. If the holder's own element
`y` appears in `deletions`, that product is zero and the call fails with
`"no inverse exists"`. That is the revoked-credential path, and it surfaces as an internal
arithmetic error rather than a "you were revoked" signal. Handle it explicitly.
:::
On ordering: the `additions` and `deletions` slices a holder passes to `BatchUpdate` enter
only as products, so their internal order is irrelevant — but they must be the same *sets*
the manager passed to `Update`. The `[]Coefficient` slice is different: it is evaluated as a
polynomial by index, so it must be passed exactly as `Update` returned it, unreordered and
untruncated.
+367
View File
@@ -0,0 +1,367 @@
---
title: Bulletproofs
description: Logarithmic-size inner-product argument and the single and batched range proofs built on top of it — plus the exported-API gap that currently makes the range layer callable only from inside the package.
sidebar:
label: Bulletproofs
order: 4
icon: ruler
---
`bulletproof` implements the protocol of [eprint 2017/1066](https://eprint.iacr.org/2017/1066.pdf)
in two layers, and the distinction between them is the most important thing on this page.
The **inner-product argument** (IPP) is the engine. It proves knowledge of two scalar vectors
whose dot product is a claimed value, in proof size logarithmic in the vector length: a
length-256 pair of vectors yields 8 pairs of `L`/`R` points, not 256.
The **range proof** is the application. It encodes a secret value as its bit vector, expresses
"every bit is 0 or 1, and the bits sum to the committed value" as a single inner-product
relation, and then delegates to the IPP. That is why one prover constructor takes *two*
domain separators — the range layer and the IPP layer each need their own generator vectors.
:::danger[The range-proof API cannot be called from another package today]
`RangeProver.Prove`, `BatchProve`, `RangeVerifier.Verify`, and `VerifyBatched` all take a
`RangeProofGenerators` value. That struct's three fields — `g`, `h`, `u` — are unexported,
and the package exports no constructor, setter, or default for it. From outside
`package bulletproof` the compiler rejects any attempt to populate it:
```text
cannot refer to unexported field g in struct literal of type bulletproof.RangeProofGenerators
cannot refer to unexported field h in struct literal of type bulletproof.RangeProofGenerators
cannot refer to unexported field u in struct literal of type bulletproof.RangeProofGenerators
```
A zero-valued `RangeProofGenerators{}` does compile, but its points are `nil`, so `Prove`
panics on the first `proofGenerators.h.Mul(alpha)`. The verifier side has a second gap: it
needs the Pedersen commitment `capV`, and the helper that builds it (`getcapV`) is
unexported too.
The IPP layer has the mirror-image problem: `InnerProductVerifier.Verify` needs `capP`, and
the only thing that computes it is `InnerProductProver.getP`, whose own doc comment says
"This method should only be used for testing" — and which is unexported regardless.
The mathematics in this package is complete and its tests pass. The Go surface is not
finished. Until `RangeProofGenerators` gains an exported constructor and the commitment
helpers are exported, treat `bulletproof` as an in-repo building block rather than a public
API, and read the examples below as descriptions of the in-package tests.
:::
## When to use it
Range proofs are the right tool when a committed number must be shown to be well-formed
without being revealed — confidential amounts that must be non-negative and non-overflowing,
bounded bids, reserve proofs. The batched variant is the right tool when several such values
are proved at once by the same party, because it amortises them into a single proof.
They are the wrong tool for set membership (use the [accumulator](/zero-knowledge/accumulator)),
for proving knowledge of a discrete log (use [Schnorr](/zero-knowledge/schnorr), which is
orders of magnitude smaller and simpler), or for arbitrary statements — there is no
general-purpose circuit layer here.
## Layer 1: the inner-product argument
<TypeTable
type={{
"NewInnerProductProver": {
type: "func(maxVectorLength int, domain []byte, curve curves.Curve) (*InnerProductProver, error)",
required: true,
description: "Derives 2·maxVectorLength generator points by hashing Shake256(domain) to the curve, split into the G and H vectors. Note the curve is passed by value, not pointer.",
},
"InnerProductProver.Prove": {
type: "func(a, b []curves.Scalar, u curves.Point, transcript *merlin.Transcript) (*InnerProductProof, error)",
required: true,
description: "Proves knowledge of a and b with the inner product blinded into P by u. len(a) must equal len(b), be a power of two, and be at most maxVectorLength.",
},
"NewInnerProductVerifier": {
type: "func(maxVectorLength int, domain []byte, curve curves.Curve) (*InnerProductVerifier, error)",
required: true,
description: "Must be constructed with the same maxVectorLength, domain, and curve as the prover, or the generators differ and verification fails.",
},
"InnerProductVerifier.Verify": {
type: "func(capP, u curves.Point, proof *InnerProductProof, transcript *merlin.Transcript) (bool, error)",
required: true,
description: "capP is the commitment ⟨G,a⟩ + ⟨H,b⟩ + ⟨a,b⟩·u. Returns (false, nil) — not an error — when the proof simply does not check out.",
},
"InnerProductVerifier.VerifyFromRangeProof": {
type: "func(proofG, proofH []curves.Point, capPhmuinv, u curves.Point, tHat curves.Scalar, proof *InnerProductProof, transcript *merlin.Transcript) (bool, error)",
description: "The entry point RangeVerifier.Verify uses. It takes explicit generator slices and the range proof's P·h^-mu instead of a plain capP.",
},
"NewInnerProductProof": {
type: "func(curve *curves.Curve) *InnerProductProof",
description: "An empty proof to unmarshal into. Pair it with UnmarshalBinary; do not use it for anything else.",
},
}}
/>
`MarshalBinary() []byte` on both `InnerProductProof` and `RangeProof` returns **no error** —
an unusual signature that does not satisfy `encoding.BinaryMarshaler`. `UnmarshalBinary` does
return an error, and must be called on a proof from the matching `New…Proof(curve)`
constructor so that the curve is set.
```go ipp.go
// From bulletproof/ipp_verifier_test.go (TestIPPVerifyHappyPath).
curve := curves.ED25519()
vecLength := 256
prover, err := bulletproof.NewInnerProductProver(vecLength, []byte("test"), *curve)
if err != nil {
panic(err)
}
a := randScalarVec(vecLength, *curve) // in-package test helper
b := randScalarVec(vecLength, *curve)
u := curve.Point.Random(crand.Reader)
transcriptProver := merlin.NewTranscript("test")
proof, err := prover.Prove(a, b, u, transcriptProver)
if err != nil {
panic(err)
}
// len(proof.capLs) == log2(256) == 8
verifier, err := bulletproof.NewInnerProductVerifier(vecLength, []byte("test"), *curve)
if err != nil {
panic(err)
}
capP, err := prover.getP(a, b, u) // unexported: see the danger callout
if err != nil {
panic(err)
}
transcriptVerifier := merlin.NewTranscript("test")
verified, err := verifier.Verify(capP, u, proof, transcriptVerifier)
// verified == true
```
The Fiat-Shamir transcript is [merlin](https://github.com/gtank/merlin). Both sides construct
it with the *same label* — `merlin.NewTranscript("test")` in the tests — and each side must
start from a fresh transcript in the same state. A transcript is consumed by proving or
verifying; you cannot reuse one.
## Layer 2: range proofs
<TypeTable
type={{
"NewRangeProver": {
type: "func(maxVectorLength int, rangeDomain, ippDomain []byte, curve curves.Curve) (*RangeProver, error)",
required: true,
description: "Two domains: rangeDomain seeds the bit-vector generators, ippDomain seeds the inner-product generators. They must differ from each other, and must match the verifier's exactly.",
},
"RangeProver.Prove": {
type: "func(v, gamma curves.Scalar, n int, proofGenerators RangeProofGenerators, transcript *merlin.Transcript) (*RangeProof, error)",
required: true,
description: "Proves the value v committed as gamma·h + v·g lies in [0, 2^n). gamma is the Pedersen blinding factor and must be kept secret.",
},
"RangeProver.BatchProve": {
type: "func(v, gamma []curves.Scalar, n int, proofGenerators RangeProofGenerators, transcript *merlin.Transcript) (*RangeProof, error)",
required: true,
description: "Aggregated proof for len(v) values, each in [0, 2^n). Requires n·len(v) ≤ maxVectorLength. Output is one RangeProof, not a slice.",
},
"NewRangeVerifier": {
type: "func(maxVectorLength int, rangeDomain, ippDomain []byte, curve curves.Curve) (*RangeVerifier, error)",
required: true,
description: "Same four arguments as the prover, or verification fails.",
},
"RangeVerifier.Verify": {
type: "func(proof *RangeProof, capV curves.Point, proofGenerators RangeProofGenerators, n int, transcript *merlin.Transcript) (bool, error)",
required: true,
description: "capV is the single Pedersen commitment gamma·h + v·g. n must equal the prover's n.",
},
"RangeVerifier.VerifyBatched": {
type: "func(proof *RangeProof, capV []curves.Point, proofGenerators RangeProofGenerators, n int, transcript *merlin.Transcript) (bool, error)",
description: "Takes one commitment per proved value, in the same order the prover passed v.",
},
"NewRangeProof": {
type: "func(curve *curves.Curve) *RangeProof",
description: "An empty proof to unmarshal into.",
},
}}
/>
### What the parameters actually mean
**`n` is a bit width, and it defines the range.** `Prove` decodes `v` into an `n`-element bit
vector via `getaL`, so the provable range is the set of values representable in `n` bits:
`[0, 2^n)`. `n = 256` on ED25519 is the whole scalar field; `n = 64` is a u64-shaped amount.
`n` must be a power of two, because the inner-product recursion halves the vectors at every
step. The range prover does not check this up front — unlike `InnerProductProver.Prove`,
which has an explicit `isPowerOfTwo` gate — so a non-power-of-two `n` surfaces late as
`"length of scalars must be even"` from inside the recursion.
**`maxVectorLength` is a generator budget, not the range.** The constructor precomputes
`2 · maxVectorLength` curve points by hashing the domain. `Prove` requires
`n <= maxVectorLength` and trims the generator vectors to `n`. `BatchProve` requires
`n · len(v) <= maxVectorLength` — proving four 256-bit values needs
`NewRangeProver(256*4, …)`, exactly as `range_batch_prover_test.go` does.
**The two domains seed two independent generator sets.** `rangeDomain` produces the `G`/`H`
vectors that commit to the bit vectors; `ippDomain` produces the generators for the nested
inner-product argument. They must be distinct strings — reusing one string for both would
make the two generator sets identical, which is not a configuration the protocol's security
argument covers. The tests use `[]byte("rangeDomain")` and `[]byte("ippDomain")`.
**`proofGenerators` holds `g`, `h`, `u`, and both sides must use the same three points.**
`g` and `h` are the Pedersen bases for the value commitment; `u` blinds the inner product.
Because the commitment `capV = gamma·h + v·g` is computed from `g` and `h`, a verifier with
different points is verifying a commitment to a different value and gets `false`.
### Single-value range proof
Grounded in `bulletproof/range_prover_test.go` and `range_verifier_test.go`. The unexported
identifiers are marked; they are why this cannot be lifted verbatim into your own package.
```go range.go
curve := curves.ED25519()
n := 256
prover, err := bulletproof.NewRangeProver(n, []byte("rangeDomain"), []byte("ippDomain"), *curve)
if err != nil {
panic(err)
}
v := curve.Scalar.Random(crand.Reader) // the secret value
gamma := curve.Scalar.Random(crand.Reader) // the secret blinding factor
g := curve.Point.Random(crand.Reader)
h := curve.Point.Random(crand.Reader)
u := curve.Point.Random(crand.Reader)
proofGenerators := RangeProofGenerators{g: g, h: h, u: u} // unexported fields
transcript := merlin.NewTranscript("test")
proof, err := prover.Prove(v, gamma, n, proofGenerators, transcript)
if err != nil {
panic(err)
}
// Verifier: same n, same domains, same curve, same g/h/u, fresh transcript
// with the same label.
verifier, err := bulletproof.NewRangeVerifier(n, []byte("rangeDomain"), []byte("ippDomain"), *curve)
if err != nil {
panic(err)
}
transcriptVerifier := merlin.NewTranscript("test")
capV := getcapV(v, gamma, g, h) // unexported: h.Mul(gamma).Add(g.Mul(v))
verified, err := verifier.Verify(proof, capV, proofGenerators, n, transcriptVerifier)
// verified == true
```
In production the verifier receives `capV` over the wire; it never learns `v` or `gamma`.
The commitment is a plain Pedersen commitment, `h·gamma + g·v`, so you can compute it
yourself with `core/curves` point arithmetic without touching this package.
### Batched range proof
`BatchProve` proves `m` values in one proof. From `range_batch_prover_test.go`, four
256-bit values:
```go batch.go
curve := curves.ED25519()
n := 256
// maxVectorLength must cover n * m = 1024.
prover, err := bulletproof.NewRangeProver(n*4, []byte("rangeDomain"), []byte("ippDomain"), *curve)
if err != nil {
panic(err)
}
v := []curves.Scalar{
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
}
gamma := []curves.Scalar{
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
curve.Scalar.Random(crand.Reader),
}
transcript := merlin.NewTranscript("test")
proof, err := prover.BatchProve(v, gamma, n, proofGenerators, transcript)
if err != nil {
panic(err)
}
// One proof, log2(1024) == 10 L/R pairs, covering all four values.
verifier, _ := bulletproof.NewRangeVerifier(n*4, []byte("rangeDomain"), []byte("ippDomain"), *curve)
capV := getcapVBatched(v, gamma, g, h) // unexported; one commitment per value
verified, err := verifier.VerifyBatched(proof, capV, proofGenerators, n, merlin.NewTranscript("test"))
// verified == true
```
Note that the batch call still takes the *per-value* bit width `n`, while the prover was
constructed with `n*4`. Mixing those two up is the easiest way to get a confusing failure.
`VerifyBatched` returns an error (rather than `false`) if a commitment in `capV` is tampered
with in a way that breaks the point arithmetic — `range_batch_verifier_test.go` exercises
that path — but a merely *wrong* proof still comes back as `(false, nil)`.
## Caveats
:::warning[Parameter mismatches fail silently]
`Verify` and `VerifyBatched` return `(false, nil)`. There is no descriptive error and no
distinction between "the prover was dishonest" and "we disagree about the parameters". All of
the following produce an indistinguishable `false`:
- different `rangeDomain` or `ippDomain` between prover and verifier
- different `maxVectorLength` (different generator counts, hence different trimmed vectors)
- different `n`
- different `g`, `h`, or `u`
- a different merlin transcript label, or a transcript that was already consumed
- a `capV` computed from a different `g`/`h` pair
Pin all of these in one shared configuration value. Do not let two sides derive them
independently.
:::
:::danger[n larger than the scalar's byte width panics]
`getaL` reads bit `i` of the value as `vBytes[i>>3] >> (i & 0x07) & 0x01`, where `vBytes` is
`v.Bytes()`. It performs no bounds check against `len(vBytes)`. With a 32-byte scalar
encoding, any `n > 256` indexes past the end of the slice and panics with an index-out-of-range
runtime error rather than returning an error. `NewRangeProver` will happily accept
`maxVectorLength` above 256, so nothing stops you from reaching this. Keep `n <= 256` on the
curves in this module.
:::
:::warning[Bit extraction assumes little-endian scalar bytes]
`getaL` treats byte 0 of `Scalar.Bytes()` as holding the least significant bits. That is
correct for Ed25519 scalars, which is the only curve any bulletproof test exercises. If a
curve's `Scalar.Bytes()` is big-endian, the bit vector is reversed and the proof commits to a
different number than `capV` does — verification fails, or worse, succeeds for the wrong
range. Do not assume this package works on a curve until you have checked that curve's scalar
byte order in [`core/curves`](/foundations/curves).
:::
:::note[Off-by-one in the input range check]
`Prove` rejects `v < 0` and `v > 2^n`, so a value of exactly `2^n` passes the input check —
but `2^n` is not representable in `n` bits, so `getaL` encodes it as `0` and the resulting
proof does not match `capV`. The unexported `checkRange` helper (used by `BatchProve`) has
the same `> 2^n` comparison despite a doc comment claiming it enforces `[0, 2^n - 1]`.
Treat the usable range as `[0, 2^n)` and validate the boundary yourself. `checkRange` also
does not reject negative values, unlike the inline check in `Prove`.
:::
:::note[Stale generator documentation]
`getGeneratorPoints`' comment claims it returns `2·lenVector + 1` points "split between a
single u generator and G and H lists". It returns `2·lenVector` points split evenly into `G`
and `H`; there is no `u` generator in the output. `u` is always caller-supplied. Similarly
`RangeProver.Prove`'s comment says the range is `[0, 2^n]`; the encoding makes it `[0, 2^n)`.
:::
:::info[isPowerOfTwo accepts zero]
The helper is `i&(i-1) == 0`, which is true for `i == 0`. A zero-length vector therefore
passes the power-of-two gate in `InnerProductProver.Prove` and fails later, or recurses
oddly. Validate non-empty inputs before calling.
:::
## Related
<CardGroup cols={2}>
<Card title="Curves" href="/foundations/curves" icon="git-branch">
The `Curve`, `Point`, and `Scalar` types every signature here is generic over, plus the
scalar byte-order details the bit extraction depends on.
</Card>
<Card title="Schnorr proofs" href="/zero-knowledge/schnorr" icon="badge-check">
Far simpler and fully usable from outside its package. Prefer it whenever the statement is
just discrete-log knowledge.
</Card>
</CardGroup>
+80
View File
@@ -0,0 +1,80 @@
---
title: Zero-Knowledge
description: Four unrelated tools that all let a verifier learn a fact without learning the secret behind it — discrete-log proofs, set-membership accumulators, range proofs, and additively homomorphic encryption.
sidebar:
label: Overview
order: 1
icon: eye-off
---
This section covers four packages that have almost nothing in common structurally, but which
solve the same shape of problem: a party holds a secret and needs a counterparty to accept a
statement about it without seeing it.
They are not interchangeable, and picking the wrong one is expensive. `zkp/schnorr` proves
you know a discrete log and nothing else. `accumulator` commits to a *set* and proves
membership. `bulletproof` proves a committed number lies in a range. `paillier` is not a
proof system at all — it is an encryption scheme that lets a third party compute on
ciphertexts, and it ships with one proof (`PsfProof`) about the shape of its own public key,
which is why it lives on this page rather than under symmetric or signature primitives.
## Which one do I need
| Goal | Package | What the verifier learns | What stays hidden |
| --- | --- | --- | --- |
| "I know the private key behind this public point" | `zkp/schnorr` | that some `x` with `Statement = x·B` exists and the prover knows it | `x` itself |
| "My credential is in the issuer's current set" | `accumulator` | that the holder possesses a valid witness for *some* accumulated element | which element, and the rest of the set |
| "This committed amount is between 0 and 2^n" | `bulletproof` | that the value behind a Pedersen commitment is in range | the value and the blinding factor |
| "I know two vectors whose dot product is c" | `bulletproof` (inner-product layer) | the claimed inner product | both vectors |
| "Compute on my data without seeing it" | `paillier` | nothing about the plaintexts | every plaintext |
| "Your Paillier modulus is not malformed" | `paillier` (`PsfProof`) | that `N` is square-free | the factorization of `N` |
## Maturity is uneven
These four packages are at very different levels of usability, and this matters more than the
cryptography when you are choosing between them.
`zkp/schnorr` is the most mature: small, exercised across eight curves, and load-bearing
inside this repo's own threshold ECDSA and oblivious-transfer stacks. `accumulator` is
complete and well tested, with one dead branch in its API. `paillier` is complete for
encryption but its proof layer has a missing length check. `bulletproof` implements the full
protocol correctly but does not export enough of its own types to be callable from another
package.
:::warning[Read the caveats sections]
Every page below ends with a caveats section that names concrete defects found in the source
— unconstructible parameter structs, panics on malformed input, off-by-one range checks, and
endianness assumptions. None of these are theoretical. Check them before you build on a
package. Nothing in this module carries a security audit.
:::
## Shared foundation
Every package here except `paillier` is generic over the curve abstraction in `core/curves`.
`accumulator` additionally requires a *pairing* curve (`*curves.PairingCurve`, in practice
BLS12-381), because its witness check is a pairing equation. `paillier` is the odd one out:
it works over `math/big` integers modulo a composite, and its PSF proof takes a
`crypto/elliptic` curve rather than a `core/curves` one.
See [Curves](/foundations/curves) for the `Curve` / `Point` / `Scalar` types that appear in
nearly every signature on these pages, and [Arithmetic](/foundations/arithmetic) for the
`core` modular-arithmetic helpers that `paillier` is built on.
<CardGroup cols={2}>
<Card title="Schnorr proofs" href="/zero-knowledge/schnorr" icon="badge-check">
Non-interactive proof of knowledge of a discrete log, with an optional commit-then-reveal
variant. The building block used by this module's own MPC protocols.
</Card>
<Card title="Accumulator" href="/zero-knowledge/accumulator" icon="list-checks">
Constant-size commitment to a set, constant-size membership witnesses, and a
zero-knowledge membership proof. Built for revocation lists.
</Card>
<Card title="Bulletproofs" href="/zero-knowledge/bulletproof" icon="ruler">
Logarithmic-size inner-product argument, and the range proof built on top of it.
Single and batched.
</Card>
<Card title="Paillier" href="/zero-knowledge/paillier" icon="calculator">
Additively homomorphic encryption over a composite modulus, plus the square-free proof
that keeps a malicious key from breaking protocols above it.
</Card>
</CardGroup>
+8
View File
@@ -0,0 +1,8 @@
import { defineMeta } from "blume";
export default defineMeta({
title: "Zero-Knowledge",
icon: "eye-off",
order: 6,
pages: ["index", "schnorr", "accumulator", "bulletproof", "paillier"],
});
+431
View File
@@ -0,0 +1,431 @@
---
title: Paillier
description: Additively homomorphic public-key encryption over a composite modulus, plus the PSF proof that a Paillier modulus is square-free — the check that keeps a malformed key from breaking protocols above it.
sidebar:
label: Paillier
order: 5
icon: calculator
---
`paillier` implements Paillier's 1999 cryptosystem. Its package doc cites the original paper —
*Public-Key Cryptosystems Based on Composite Degree Residuosity Class* — and states that all
routines follow the pseudocode of §2.5, Fig. 1. Unlike everything else in this section it is
not built on `core/curves`: plaintexts, ciphertexts, and keys are all `*math/big.Int` values
modulo a composite `N = PQ`.
It is here rather than under encryption because of what it is *used for*. Paillier's value in
this module is that a party can compute on data it cannot read, which is the multiplication
primitive underpinning several MPC protocols — and the accompanying `PsfProof` is a
zero-knowledge proof about the public key itself.
## Additively homomorphic, and only additively
Two operations work on ciphertexts:
| Operation | Call | Plaintext effect | Implementation |
| --- | --- | --- | --- |
| Ciphertext + ciphertext | `pk.Add(c, d)` | `Dec(result) = a + b` | `c · d mod N²` |
| Known scalar × ciphertext | `pk.Mul(a, c)` | `Dec(result) = a · b` | `c^a mod N²` |
That is the whole homomorphic surface, and the boundary is hard:
:::danger[You cannot multiply two ciphertexts' plaintexts]
There is no operation, and no combination of the available operations, that takes `Enc(a)`
and `Enc(b)` and produces `Enc(a·b)`. Paillier is additively homomorphic — a group
homomorphism from addition mod `N` to multiplication mod `N²`, nothing more. `pk.Mul` takes a
*plaintext* `*big.Int` as its first argument, not a second ciphertext; its name refers to
multiplying the plaintext by a known constant.
If you need ciphertext-ciphertext multiplication you need a different primitive — a
fully-homomorphic scheme, or an interactive multiplication protocol such as the
oblivious-transfer-based multiplier in this repo. See
[Oblivious transfer](/threshold/oblivious-transfer).
:::
Note also that both operands must be in range. `Add` requires `c, d ∈ Z_N²`; `Mul` requires
`a ∈ Z_N` and `c ∈ Z_N²`. In particular `a` must be non-negative and less than `N` — you
cannot pass a negative scalar to subtract. To subtract, add the modular negation
`new(big.Int).Sub(pk.N, x)`.
## Keys
<TypeTable
type={{
"NewKeys": {
type: "func() (*PublicKey, *SecretKey, error)",
required: true,
description: "Generates a fresh keypair with two PaillierPrimeBits-sized safe primes. Slow: see the note below.",
},
"NewSecretKey": {
type: "func(p, q *big.Int) (*SecretKey, error)",
required: true,
description: "Derives lambda, totient, and U from primes you supply. Performs no primality or safety check on p and q.",
},
"NewPubkey": {
type: "func(n *big.Int) (*PublicKey, error)",
required: true,
description: "Wraps a modulus received from a counterparty and caches N².",
},
"PaillierPrimeBits": {
type: "int constant = 1024",
required: true,
description: "Bit size of each safe prime, so N is 2048 bits. Not configurable through the exported API.",
},
}}
/>
`PublicKey` exposes `N` (the modulus) and `N2` (`N²`, cached to avoid recomputation).
`SecretKey` embeds `PublicKey` and adds:
<TypeTable
type={{
Lambda: {
type: "*big.Int",
required: true,
description: "lcm(P-1, Q-1), the decryption exponent.",
},
Totient: {
type: "*big.Int",
required: true,
description: "Euler's totient (P-1)(Q-1). Used by the PSF proof, not by decryption.",
},
U: {
type: "*big.Int",
required: true,
description: "L((N+1)^lambda mod N²)^-1 mod N, the precomputed decryption multiplier.",
},
}}
/>
Both key types implement `MarshalJSON`/`UnmarshalJSON`. `PublicKeyJson` and `SecretKeyJson`
are the exported-but-internal wire shapes. `P` and `Q` are **not** retained on the secret key
and are not serialized — only the derived values are, so you cannot recover the factors from a
marshalled `SecretKey`.
`Ciphertext` is a defined type over `*big.Int`, so it serializes as an integer with no
wrapper.
:::note[NewKeys is slow, by construction]
`NewKeys` calls `core.GenerateSafePrime` twice at 1024 bits. A safe prime `p` requires
`(p-1)/2` to also be prime, which makes them rare — safe-prime search is orders of magnitude
slower than ordinary prime generation, and the cost is highly variable run to run. Generate
keys once at setup and persist them; never generate inside a request path. `NewKeys` is the
only function in the package that performs a search — everything else is a bounded number of
modular operations.
:::
:::warning[NewSecretKey trusts its inputs completely]
`NewSecretKey(p, q)` computes `lcm(p-1, q-1)`, `(p-1)(q-1)`, `N`, `N²`, and `U` and returns.
It does not test whether `p` and `q` are prime, whether they are safe primes, whether they are
distinct, or whether they are large enough. Passing composites yields a key whose `Decrypt`
returns garbage without error. It exists so that callers with pre-generated primes (and the
package's own tests) can skip the expensive search — not as a general constructor.
:::
## Encryption
`Encrypt` returns **three** values, and the middle one is easy to discard by accident:
```go
func (pk *PublicKey) Encrypt(msg *big.Int) (Ciphertext, *big.Int, error)
```
The second return is `r`, the randomness the ciphertext was built with. Internally
`c = (N+1)^msg · r^N mod N²`, where `r` is drawn uniformly from `Z_N` and rejected if zero.
You need to keep `r` whenever a later step must prove something about *this* ciphertext.
`r` and `msg` together are the witness for essentially every zero-knowledge statement about a
Paillier ciphertext ("this encrypts a value in range", "these two ciphertexts encrypt the same
value", "this encrypts the plaintext behind that commitment"). Without `r` you cannot produce
such a proof and cannot recompute the ciphertext deterministically. If you are only encrypting
and never proving, discard it with `_`.
```go paillier.go
package main
import (
"fmt"
"math/big"
"github.com/sonr-io/crypto/paillier"
)
func main() {
// One-time, slow: two 1024-bit safe primes.
pk, sk, err := paillier.NewKeys()
if err != nil {
panic(err)
}
a := big.NewInt(1234)
b := big.NewInt(5678)
// The second return is the encryption randomness r. Keep it if you will
// later need to prove a statement about this ciphertext.
ca, ra, err := pk.Encrypt(a)
if err != nil {
panic(err)
}
_ = ra
cb, _, err := pk.Encrypt(b)
if err != nil {
panic(err)
}
// Add plaintexts by multiplying ciphertexts.
csum, err := pk.Add(ca, cb)
if err != nil {
panic(err)
}
sum, err := sk.Decrypt(csum)
if err != nil {
panic(err)
}
fmt.Println(sum) // 6912
// Scale a plaintext by a known constant.
cscaled, err := pk.Mul(big.NewInt(3), ca)
if err != nil {
panic(err)
}
scaled, err := sk.Decrypt(cscaled)
if err != nil {
panic(err)
}
fmt.Println(scaled) // 3702
// A counterparty that only received N can encrypt but not decrypt.
remote, err := paillier.NewPubkey(pk.N)
if err != nil {
panic(err)
}
_, _, _ = remote.Encrypt(big.NewInt(1))
}
```
Plaintexts must satisfy `msg ∈ Z_N`. Arithmetic wraps modulo `N`, so a sum of two large
plaintexts that exceeds `N` decrypts to the reduced value, not the integer sum. If you are
encoding signed or fixed-point quantities, choose a representation with headroom and check it
yourself — the package does not.
## The PSF proof: proving a modulus is square-free
A protocol that accepts a Paillier public key from an untrusted party accepts an arbitrary
integer `N`. If `N` is malformed — not square-free, or sharing a factor with the ambient group
order — the homomorphic structure the protocol relies on breaks down, and a malicious key
holder can extract information or force a decryption to a value of its choosing. The PSF
(Paillier square-free) proof forces `N` to be well-formed before anything is encrypted under it.
The implementation cites its spec as `[spec] §10.2` and `fig. 15` (`ProvePSF`, `VerifyPSF`),
and the source carries explicit notes where it deviates from that pseudocode to fix errors in
it — the modulus for the exponentiation in step 5, and the inclusion of `N` in the challenge
commitment.
### What it actually asserts
The prover, who knows the factorization, computes `M = N⁻¹ mod φ(N)` and returns
`y_i = x_i^M mod N` for 13 deterministically derived challenges `x_i`. The verifier checks
`y_i^N ≡ x_i mod N` for every `i`.
That check passes for all `i` only if raising to the `N`-th power is a bijection on `Z_N`,
which holds exactly when `gcd(N, φ(N)) = 1` — that is, when `N` is **square-free**. The
verifier additionally rejects `N` if the curve subgroup order `q` divides `N`.
Be precise about the limits of this:
- It proves `N` is square-free.
- It does **not** prove `N` is a product of exactly two primes.
- It does **not** prove the factors are safe primes, or of equal size, or large.
- It does **not** prove the prover knows the factorization beyond what square-freeness needs.
If your protocol needs a biprime or safe-prime guarantee, PSF alone is insufficient.
<TypeTable
type={{
"PsfProofParams.Prove": {
type: "func() (PsfProof, error)",
required: true,
description: "Returns 13 big.Ints. Errors with ErrNilArguments if Curve, SecretKey, or Y is nil, or if Pi is zero.",
},
"PsfProof.Verify": {
type: "func(psf *PsfVerifyParams) error",
required: true,
description: "Returns nil on success. Same nil/zero argument validation as Prove.",
},
"PsfProofLength": {
type: "int constant = 13",
required: true,
description: "The number of challenges, and therefore the exact length of a valid PsfProof.",
},
}}
/>
`PsfProof` is `[]*big.Int`, so `encoding/json` round-trips it directly with no custom codec —
`psf_test.go` marshals and unmarshals it that way.
### Parameters
The prover's and verifier's parameter structs are deliberately near-identical: the only
difference is that the prover holds the `*SecretKey` while the verifier holds only the
`*PublicKey`. Every other field is a public value both sides must agree on, because all three
are hashed into the challenge derivation.
<TypeTable
type={{
Curve: {
type: "elliptic.Curve",
required: true,
description: "A crypto/elliptic curve — not a core/curves one. Its Params() supply the generator and subgroup order for challenge derivation. Tests use btcec.S256() and elliptic.P256().",
},
SecretKey: {
type: "*SecretKey",
required: true,
description: "Prover only (PsfProofParams). Supplies N and Totient for computing M.",
},
PublicKey: {
type: "*PublicKey",
required: true,
description: "Verifier only (PsfVerifyParams). Supplies N.",
},
Pi: {
type: "uint32",
required: true,
description: "Party index bound into the challenges. Must be non-zero — zero is rejected as a nil argument on both sides.",
},
Y: {
type: "*curves.EcPoint",
required: true,
description: "A public point bound into the challenges, tying the proof to a protocol-specific value.",
},
}}
/>
### Prove and verify
Grounded in `paillier/psf_test.go` (`TestPsfProofWorks`).
```go psf.go
package main
import (
"crypto/elliptic"
"fmt"
"math/big"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/paillier"
)
// Y is an ordinary EC point. In psf_test.go it is built with
// curves.NewScalarBaseMult over the same crypto/elliptic curve.
func bindingPoint(k *big.Int) (*curves.EcPoint, error) {
return curves.NewScalarBaseMult(elliptic.P256(), k)
}
func provePSF(sk *paillier.SecretKey, pi uint32, y *curves.EcPoint) (paillier.PsfProof, error) {
return (&paillier.PsfProofParams{
Curve: elliptic.P256(),
SecretKey: sk,
Pi: pi, // must be non-zero
Y: y,
}).Prove()
}
func verifyPSF(
proof paillier.PsfProof,
pk *paillier.PublicKey,
pi uint32,
y *curves.EcPoint,
) error {
// Do this length check yourself: Verify does not, and indexes 13 elements.
if len(proof) != paillier.PsfProofLength {
return fmt.Errorf(
"malformed psf proof: want %d elements, got %d",
paillier.PsfProofLength, len(proof),
)
}
return proof.Verify(&paillier.PsfVerifyParams{
Curve: elliptic.P256(),
PublicKey: pk,
Pi: pi,
Y: y,
})
}
```
Wiring it into a key exchange:
<Steps>
<Step title="Generate once">
Each party runs `paillier.NewKeys()` at setup and persists the keypair.
</Step>
<Step title="Agree on the binding values">
Both sides fix the `elliptic.Curve`, the non-zero party index `Pi`, and the public point
`Y` from protocol state. A mismatch in any of the three produces different challenges and
a failed verification.
</Step>
<Step title="Publish key plus proof">
Send `pk.N` (via `MarshalJSON`) together with the 13-element `PsfProof`.
</Step>
<Step title="Verify before use">
The receiver rebuilds the public key with `paillier.NewPubkey(n)`, checks the proof length,
calls `proof.Verify(...)`, and only then encrypts anything under that key.
</Step>
</Steps>
## Caveats
:::danger[Verify panics on a short proof]
`PsfProof.Verify` validates that the *challenge* array has length `PsfProofLength`, but never
checks `len(p)` — the proof itself. It then indexes `p[j]` for `j` in `0..12`. A proof shorter
than 13 elements — for instance one decoded from attacker-supplied JSON — panics inside
`Verify` instead of returning an error. A three-element proof produces
`runtime error: index out of range [3] with length 3`.
Check `len(proof) == paillier.PsfProofLength` yourself immediately after deserialization,
before calling `Verify`. This is unconditional: any code path where the proof arrives from
outside your process needs the guard.
:::
:::warning[The proof does not authenticate the sender]
`Pi` and `Y` bind the proof to a protocol position and a public point, but the PSF proof is not
a signature over `N`. A relayed valid `(N, proof)` pair from an honest party remains valid.
If you need to know *who* sent a modulus, authenticate the transport or sign the key material
separately.
:::
:::warning[Constant-time behaviour is partial]
Some paths use constant-time helpers — `Add` and `Mul` accumulate their two range-check errors
before branching, and `encrypt` uses `core.ConstantTimeEq` to reject a zero nonce. Others are
plain `math/big` operations, and `math/big` is not constant-time. Do not treat this package as
side-channel hardened. No timing analysis of this code exists in the repository.
:::
:::note[No key-size configurability, and no key validation on import]
`NewKeys` is hardcoded to `PaillierPrimeBits = 1024` per prime; the parameterised generator is
unexported. `NewPubkey(n)` accepts any modulus you hand it — it caches `N²` and returns. It
performs no size check, no square-free check, and no primality-related check. Validating an
imported modulus is exactly what the PSF proof is for, and it is your responsibility to run it.
:::
:::info[Decryption does not authenticate]
Paillier is not an authenticated encryption scheme. `Decrypt` will happily return a plaintext
for any `c ∈ Z_N²`, including one an adversary derived homomorphically from a ciphertext you
sent. There is no integrity tag. If you need to know that a ciphertext is the one you expected,
you need an additional proof or a MAC over a separate channel. For authenticated symmetric
encryption see [AEAD](/symmetric/aead).
:::
## Related
<CardGroup cols={2}>
<Card title="Arithmetic" href="/foundations/arithmetic" icon="binary">
The `core` modular-arithmetic layer this package is built on — `Inv`, `Exp`, `Mul`, `In`,
`Rand`, and safe-prime generation.
</Card>
<Card title="Oblivious transfer" href="/threshold/oblivious-transfer" icon="shuffle">
The interactive multiplication primitive this module actually uses where Paillier's
additive-only homomorphism is not enough.
</Card>
</CardGroup>
+275
View File
@@ -0,0 +1,275 @@
---
title: Schnorr proofs
description: Non-interactive proof of knowledge of a discrete log over any curve in core/curves, with an optional commit-then-reveal variant used by this module's DKG and OT protocols.
sidebar:
label: Schnorr
order: 2
icon: badge-check
---
`zkp/schnorr` implements a single, small, well-scoped thing: a Fiat-Shamir-compiled proof
that you know the scalar behind a curve point. Its package doc names its source — Doerner et
al., [eprint 2018/499](https://eprint.iacr.org/2018/499.pdf) — and implements Functionality 6
(the plain proof) and Functionality 7 (the committed variant) from that paper.
This is the most heavily used primitive in the repository. It is the proof that Alice and Bob
exchange in DKLs threshold-ECDSA key generation, and the proof the sender uses to convince the
receiver it knows its own base-OT secret key.
## What is actually proved
Given a base point `B` and a witness scalar `x`, the prover publishes the statement
`X = x·B` together with a challenge/response pair `(C, S)`. Writing `k` for a fresh random
nonce and `sid` for `uniqueSessionId`:
$$
C = H(\text{sid} \parallel B \parallel X \parallel k \cdot B), \qquad
S = C \cdot x + k
$$
The verifier never sees `k`. It recovers the nonce point from the response and re-derives the
challenge:
$$
C' = H(\text{sid} \parallel B \parallel X \parallel (S \cdot B - C \cdot X))
$$
and accepts only if `C'` equals `C`, compared with `crypto/subtle.ConstantTimeCompare`. The
hash is SHA3-256; the digest is widened to a scalar with `Scalar.SetBytesWide`. A verifier
learns that *some* `x` satisfying `X = x·B` is known to the prover, and learns nothing else
about it.
## When to use it
Reach for this when a protocol participant must demonstrate honest generation of a public
value derived from a secret it keeps — a key share, an OT secret key, a nonce commitment.
It is the standard defence against a party contributing a public point whose discrete log it
does not know.
Do **not** reach for it as a signature scheme. The statement is not bound to a message, only
to `uniqueSessionId` and the base point, so it authenticates nothing about payload data. For
signing use [ECDSA](/signatures/ecdsa) or [BLS](/signatures/bls). Do not reach for it to prove
anything other than discrete-log knowledge: there is no range, no set membership, and no
relation between multiple statements here.
## API
<TypeTable
type={{
"NewProver": {
type: "func(curve *curves.Curve, basepoint curves.Point, uniqueSessionId []byte) *Prover",
required: true,
description: "Binds a curve, a base point, and a domain separator. Never returns an error.",
},
"Prover.Prove": {
type: "func(x curves.Scalar) (*Proof, error)",
required: true,
description: "Computes Statement = x·basepoint and the (c, s) pair. One curve multiplication for the statement plus one for the nonce point.",
},
"Prover.ProveCommit": {
type: "func(x curves.Scalar) (*Proof, Commitment, error)",
description: "Same proof, plus SHA3-256(c || s) as a commitment to open later.",
},
"Verify": {
type: "func(proof *Proof, curve *curves.Curve, basepoint curves.Point, uniqueSessionId []byte) error",
required: true,
description: "Returns nil on success, an error on failure. There is no boolean return.",
},
"DecommitVerify": {
type: "func(proof *Proof, commitment Commitment, curve *curves.Curve, basepoint curves.Point, uniqueSessionId []byte) error",
description: "Checks the proof opens the commitment, then verifies the proof.",
},
}}
/>
### Types
`Commitment` is a plain type alias for `[]byte` — no wrapper, no methods.
<TypeTable
type={{
Statement: {
type: "curves.Point",
required: true,
description: "The point whose discrete log is proved: x · basepoint. Constructed by Prove, not supplied by the caller.",
},
C: {
type: "curves.Scalar",
required: true,
description: "The Fiat-Shamir challenge scalar.",
},
S: {
type: "curves.Scalar",
required: true,
description: "The response scalar, c·x + k.",
},
}}
/>
All three `Proof` fields are exported, so the struct serializes directly. `tecdsa/dklsv1`
transmits it with `encoding/gob` in `dkgserializers.go`.
### The `basepoint == nil` shorthand
Both `NewProver` and `Verify` accept `basepoint == nil` and substitute
`curve.NewGeneratorPoint()`. Passing `nil` on one side and an explicit generator on the other
is safe because it resolves to the same point. Passing a *different* point on the two sides is
not: the base point is hashed into the challenge, so verification simply fails.
Proving with respect to a non-generator base point is a real use case, not a curiosity.
`tecdsa/dklsv1/sign` proves knowledge of Alice's nonce `kA` with respect to Bob's point `DB`,
so that the statement is exactly `R = kA · DB`:
```go
rSchnorrProver := schnorr.NewProver(alice.curve, round2Output.DB, uniqueSessionId[:])
round3Output.RSchnorrProof, err = rSchnorrProver.Prove(kA)
```
## Basic proof and verification
Grounded in `zkp/schnorr/schnorr_test.go`, which runs this exact flow over K256, P256,
PALLAS, BLS12-377 G1/G2, BLS12-381 G1/G2, and ED25519.
```go proof.go
package main
import (
"crypto/rand"
"fmt"
"golang.org/x/crypto/sha3"
"github.com/sonr-io/crypto/core/curves"
"github.com/sonr-io/crypto/zkp/schnorr"
)
func main() {
curve := curves.K256()
// Both sides must agree on these bytes, byte for byte.
uniqueSessionId := sha3.New256().Sum([]byte("my-protocol/dkg/round-3"))
// Prover side: nil basepoint means the curve's default generator.
prover := schnorr.NewProver(curve, nil, uniqueSessionId)
secret := curve.Scalar.Random(rand.Reader)
proof, err := prover.Prove(secret)
if err != nil {
panic(err)
}
// proof.Statement == secret * G, and is what the verifier will treat
// as the public key.
fmt.Println("statement:", proof.Statement.ToAffineCompressed())
// Verifier side: same curve, same basepoint convention, same session id.
if err := schnorr.Verify(proof, curve, nil, uniqueSessionId); err != nil {
panic(err) // "schnorr verification failed"
}
}
```
## The committed variant
`ProveCommit` returns the proof *and* `SHA3-256(C.Bytes() || S.Bytes())`. A protocol sends
the commitment first, waits for the counterparty to commit to its own contribution, and only
then reveals the proof, which `DecommitVerify` checks against the earlier commitment before
verifying it.
The reason is ordering, not secrecy. Without it, whichever party speaks second can choose its
key share *after* seeing the first party's public point, and bias the combined public key.
Committing first removes that freedom.
This is precisely how DKLs 2-of-2 DKG is wired in `tecdsa/dklsv1/dkg`:
<Steps>
<Step title="Alice commits">
Alice builds a prover over her session id and calls `ProveCommit(alice.secretKeyShare)`.
She keeps the `*schnorr.Proof` in memory and sends only the `schnorr.Commitment`.
</Step>
<Step title="Bob proves in the clear">
Bob stores `round2Output.Commitment`, builds his own prover, and calls
`Prove(bob.secretKeyShare)`, sending the full proof.
</Step>
<Step title="Alice verifies and reveals">
`Round4VerifyAndReveal` calls `schnorr.Verify` on Bob's proof, then returns Alice's
previously withheld proof.
</Step>
<Step title="Bob decommits and verifies">
`Round5DecommitmentAndStartOt` calls
`schnorr.DecommitVerify(proof, bob.aliceCommitment, bob.curve, nil, bob.aliceSalt[:])`.
Only after this does Bob derive `bob.publicKey = proof.Statement.Mul(bob.secretKeyShare)`.
</Step>
</Steps>
```go committed.go
prover := schnorr.NewProver(curve, nil, uniqueSessionId)
proof, commitment, err := prover.ProveCommit(secret)
if err != nil {
panic(err)
}
// ... round trip: send `commitment`, receive the peer's contribution ...
// ... then send `proof` ...
if err := schnorr.DecommitVerify(proof, commitment, curve, nil, uniqueSessionId); err != nil {
panic(err) // "initial hash decommitment failed" or "schnorr verification failed"
}
```
## Caveats
:::warning[uniqueSessionId is load-bearing]
`uniqueSessionId` is the first thing hashed into the challenge. It is the domain separator
that binds a proof to one execution of one protocol, and prover and verifier **must** pass
byte-identical values or verification fails with a generic
`"schnorr verification failed"` — you get no hint that the session ids diverged.
Two failure modes matter:
- **Reuse across contexts.** A proof made under session id `S` verifies under session id `S`
anywhere. If you use a constant, a proof captured from one sub-protocol replays into
another. Derive it from a live transcript. The repo's own callers do: `dklsv1` and
`simplest` build it from a hash of protocol-specific salts and seeds.
- **Attacker-chosen ids.** If a remote party picks the session id you verify under, it picks
the domain the proof is bound to. Derive it from data both sides contributed, never from
one side's unilateral input.
:::
:::note[The commitment covers only (C, S)]
`ProveCommit` hashes `C.Bytes()` and `S.Bytes()` — it does **not** hash `Statement`. The
statement is still bound, but indirectly: `Verify` recomputes the challenge from the statement,
so an opened proof only verifies against the statement it was made for. Do not, however,
treat the `Commitment` as a standalone commitment to the public point; it is not one, and it
carries no information about which statement will be revealed.
:::
:::warning[No message binding]
The challenge covers `uniqueSessionId`, the base point, the statement, and the nonce point.
It does not cover any application message. This is a proof of knowledge, not a signature. If
you need to bind a payload, fold that payload into `uniqueSessionId` before constructing the
prover.
:::
:::info[Error shape]
`Verify` and `DecommitVerify` return `error`, not `(bool, error)`. A `nil` return is the only
success signal. Do not ignore the error value; there is no other output to inspect.
:::
The `Prover` struct itself is stateless with respect to the witness — it holds only the curve,
base point, and session id, so a single prover can produce proofs for many different witnesses
under the same domain. Each `Prove` call draws a fresh nonce `k` from `crypto/rand`.
## Where this is used in the module
<CardGroup cols={2}>
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="users">
`tecdsa/dklsv1` uses the committed variant in DKG rounds 35, and the plain variant with a
custom base point during signing.
</Card>
<Card title="Oblivious transfer" href="/threshold/oblivious-transfer" icon="shuffle">
`ot/base/simplest` has the sender prove knowledge of its base-OT secret key in round 1,
which the receiver verifies before any transfer.
</Card>
</CardGroup>