mirror of
https://github.com/sonr-io/crypto.git
synced 2026-09-17 01:26:23 +00:00
feat: init docs
This commit is contained in:
@@ -8,3 +8,5 @@ CLAUDE.md
|
|||||||
|
|
||||||
.opencode
|
.opencode
|
||||||
|
|
||||||
|
node_modules/
|
||||||
|
.blume/
|
||||||
|
|||||||
@@ -1,26 +1,81 @@
|
|||||||
# Sonr Crypto
|
# Sonr Crypto
|
||||||
|
|
||||||
Sonr Crypto is a collection of cryptographic primitives that are used by Sonr.
|
Cryptographic primitives used by Sonr: elliptic-curve arithmetic, threshold signatures,
|
||||||
|
multi-party computation, zero-knowledge proofs, and the identity layer built on top of them.
|
||||||
|
|
||||||
|
**Documentation: https://sonr-io.github.io/crypto**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/sonr-io/crypto
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Go 1.24.7 or newer.
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This library has no public security audit, and several packages contain stubs or known defects.
|
||||||
|
> Read the [security notes](https://sonr-io.github.io/crypto/reference/security) before depending on
|
||||||
|
> any of it.
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
- [Accumulator](./accumulator): Accumulator is a cryptographic accumulator that allows for efficient verification of large sets of data.
|
### Foundations
|
||||||
- [Bulletproof](./bulletproof): Bulletproof is a zero-knowledge proof system that allows for efficient verification of large sets of data.
|
|
||||||
- [Core](./core): Core is a collection of cryptographic primitives that are used by Sonr.
|
- [`core/curves`](./core/curves): the `Curve` / `Point` / `Scalar` abstraction every other package is
|
||||||
- [Daed](./daed): Daed is a distributed key generation protocol that allows for efficient verification of large sets of data.
|
written against, with secp256k1, NIST P-256, Ed25519, Pallas, BLS12-381, and BLS12-377.
|
||||||
- [Dkg](./dkg): Dkg is a distributed key generation protocol that allows for efficient verification of large sets of data.
|
- [`core`](./core): modular arithmetic, hash-to-field, HMAC commitments, and safe-prime generation.
|
||||||
- [Ecies](./ecies): Ecies is a symmetric encryption algorithm that allows for efficient verification of large sets of data.
|
- [`core/protocol`](./core/protocol): the message and iterator types that drive multi-round protocols.
|
||||||
- [Keys](./keys): Keys is a collection of cryptographic primitives that are used by Sonr.
|
|
||||||
- [Mpc](./mpc): Mpc is a collection of cryptographic primitives that are used by Sonr.
|
### Symmetric and key derivation
|
||||||
- [Ot](./ot): Ot is a collection of cryptographic primitives that are used by Sonr.
|
|
||||||
- [Paillier](./paillier): Paillier is a cryptographic algorithm that allows for efficient verification of large sets of data.
|
- [`aead`](./aead): AES-256-GCM with generated, prepended nonces.
|
||||||
- [Sharing](./sharing): Sharing is a collection of cryptographic primitives that are used by Sonr.
|
- [`daed`](./daed): AES-SIV-CMAC deterministic AEAD (RFC 5297).
|
||||||
- [Signatures](./signatures): Signatures is a collection of cryptographic primitives that are used by Sonr.
|
- [`argon2`](./argon2): Argon2id password hashing and key derivation.
|
||||||
- [Subtle](./subtle): Subtle is a collection of cryptographic primitives that are used by Sonr.
|
- [`subtle`](./subtle): HKDF, X25519, and hash/curve name helpers.
|
||||||
- [Tecdsa](./tecdsa): Tecdsa is a collection of cryptographic primitives that are used by Sonr.
|
- [`salt`](./salt), [`password`](./password), [`secure`](./secure): salt handling, password policy,
|
||||||
- [Ted25519](./ted25519): Ted25519 is a collection of cryptographic primitives that are used by Sonr.
|
and memory-hygiene helpers.
|
||||||
- [Ucan](./ucan): Ucan is a collection of cryptographic primitives that are used by Sonr.
|
|
||||||
- [Zkp](./zkp): Zkp is a collection of cryptographic primitives that are used by Sonr.
|
### Signatures
|
||||||
|
|
||||||
|
- [`signatures/bls`](./signatures/bls): BLS signatures with aggregation, proof of possession, and
|
||||||
|
threshold key generation.
|
||||||
|
- [`signatures/bbs`](./signatures/bbs): BBS+ signatures for selective disclosure and blind signing.
|
||||||
|
- [`signatures/schnorr`](./signatures/schnorr): Mina (Pallas/Poseidon) and NEM (Ed25519-Keccak) schemes.
|
||||||
|
- [`ecdsa`](./ecdsa): low-S canonicalization and RFC 6979 deterministic signing.
|
||||||
|
- [`vrf`](./vrf): verifiable random function over Edwards25519.
|
||||||
|
|
||||||
|
### Threshold cryptography and MPC
|
||||||
|
|
||||||
|
- [`sharing`](./sharing): Shamir, Feldman, and Pedersen secret sharing.
|
||||||
|
- [`dkg`](./dkg): FROST and Gennaro distributed key generation.
|
||||||
|
- [`tecdsa`](./tecdsa): two-party threshold ECDSA (DKLs18) with key refresh.
|
||||||
|
- [`ted25519`](./ted25519): threshold Ed25519 and FROST threshold Schnorr signing.
|
||||||
|
- [`ot`](./ot): base oblivious transfer and correlated OT extension — building blocks for the above.
|
||||||
|
- [`mpc`](./mpc): threshold ECDSA packaged as a single enclave value: keygen, sign, verify, refresh.
|
||||||
|
|
||||||
|
### Zero-knowledge
|
||||||
|
|
||||||
|
- [`zkp/schnorr`](./zkp/schnorr): non-interactive proof of knowledge of a discrete log.
|
||||||
|
- [`accumulator`](./accumulator): pairing-based accumulator with constant-size membership proofs.
|
||||||
|
- [`bulletproof`](./bulletproof): inner-product argument and range proofs.
|
||||||
|
- [`paillier`](./paillier): additively homomorphic encryption with a square-free modulus proof.
|
||||||
|
|
||||||
|
### Identity
|
||||||
|
|
||||||
|
- [`keys`](./keys): `did:key` encoding and decoding over libp2p public keys.
|
||||||
|
- [`ucan`](./ucan): UCAN capability tokens, attenuation, and verification.
|
||||||
|
- [`ecies`](./ecies): ECIES payload encryption over secp256k1.
|
||||||
|
- [`wasm`](./wasm): Ed25519 signing and hash pinning for WebAssembly modules.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
The site under `docs/` is built with [Blume](https://useblume.dev) and deployed to GitHub Pages by
|
||||||
|
`.github/workflows/docs.yml`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run dev # local preview with hot reload
|
||||||
|
npm run build # static output to dist/
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
---
|
||||||
|
title: Arithmetic & Commitments
|
||||||
|
description: The core package — modular arithmetic over big.Int with explicit moduli, constant-time comparison, hash-to-field, Fiat–Shamir, safe primes, and the HMAC commitment scheme.
|
||||||
|
sidebar:
|
||||||
|
order: 3
|
||||||
|
icon: sigma
|
||||||
|
---
|
||||||
|
|
||||||
|
`core` is the one package in this library that does **not** use the [curve abstraction](/foundations/curves). It works directly on `math/big` integers with an explicit modulus, and it exists because a handful of constructions — Paillier, the legacy `sharing/v1` and `dkg/gennaro` layers, the older threshold ECDSA code — need integer arithmetic in a group whose order is not a curve order.
|
||||||
|
|
||||||
|
**Reach for `core` when** you are implementing something over the integers mod `m` for an `m` you chose yourself, need a byte-level HMAC commitment, or need RFC-shaped hash-to-field. **Do not reach for it** to do scalar arithmetic on a curve — `curve.Scalar` is faster, constant-time-oriented, and cannot silently escape its field.
|
||||||
|
|
||||||
|
## Modular arithmetic
|
||||||
|
|
||||||
|
Every helper takes the modulus as its *last* argument and returns `(*big.Int, error)`. The error is not decoration: it is how the package refuses nil inputs instead of panicking.
|
||||||
|
|
||||||
|
<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 Fiat–Shamir combination, and a 0 collapses the result. This is the right default for blinding factors, but it means `core.Rand` is **not** a uniform sample over the whole of `Z_m` — if a protocol's soundness argument needs uniformity over the full range, this is the wrong function.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Constant-time comparison
|
||||||
|
|
||||||
|
```go
|
||||||
|
func ConstantTimeEqByte(a, b *big.Int) byte // 0x1 if equal, 0x0 otherwise
|
||||||
|
func ConstantTimeEq(a, b *big.Int) bool // ConstantTimeEqByte(a, b) == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Both compare `a.Bytes()` against `b.Bytes()` via `crypto/subtle.ConstantTimeCompare` **and** compare `Sign()`. Two nil arguments compare equal; one nil compares unequal.
|
||||||
|
|
||||||
|
:::warning[Constant time in the byte comparison only]
|
||||||
|
`big.Int.Bytes()` returns the minimal big-endian encoding, so its *length* leaks the magnitude of the value. `subtle.ConstantTimeCompare` also returns 0 immediately when the two lengths differ. So these functions are constant-time with respect to the *contents* of equal-length values, not with respect to bit length. For comparing secrets of unknown width, pad to a fixed width first.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Hashing and hash-to-field
|
||||||
|
|
||||||
|
| Function | Signature | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Hash` | `Hash(msg []byte, curve elliptic.Curve) (*big.Int, error)` | Hash-to-field: one field element for the given curve. |
|
||||||
|
| `ExpandMessageXmd` | `ExpandMessageXmd(f func() hash.Hash, msg, DST []byte, lenInBytes int) ([]byte, error)` | `expand_message_xmd` from the CFRG hash-to-curve draft, §5.4.1. |
|
||||||
|
| `I2OSP` | `I2OSP(b, n int) []byte` | Integer-to-octet-string, `n` bytes, big-endian. |
|
||||||
|
| `OS2IP` | `OS2IP(os []byte) *big.Int` | Octet-string-to-integer. |
|
||||||
|
| `FiatShamir` | `FiatShamir(values ...*big.Int) ([]byte, error)` | Iterated HKDF challenge derivation; 32-byte output. |
|
||||||
|
| `ComputeHMAC` | `ComputeHMAC(f func() hash.Hash, msg, k []byte) ([]byte, error)` | HMAC with an explicit hash constructor. |
|
||||||
|
| `Size` | `const Size = sha256.Size` | 32 — the width of commitments and nonces in this package. |
|
||||||
|
| `HashField` | `struct{ Order, Characteristic, ExtensionDegree *big.Int }` | Describes the field `F_p^k` for the curve being hashed to. |
|
||||||
|
| `Params` | `struct{ F *HashField; SecurityParameter int; Hash func() hash.Hash; L int }` | Per-curve hash-to-field parameters. |
|
||||||
|
|
||||||
|
### `Hash` — curve support and its fixed DST
|
||||||
|
|
||||||
|
`Hash` looks up a `Params` for the curve, then runs `expand_message_xmd` and reduces to one field element. Supported curves and their parameters, read from `getParams`:
|
||||||
|
|
||||||
|
| Curve (`Params().Name`) | Security parameter | Hash | `L` (bytes) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `secp256k1` (btcec) | 128 | SHA-256 | 48 |
|
||||||
|
| `P-256` | 128 | SHA-256 | 48 |
|
||||||
|
| `P-384` / `secp384r1` | 192 | SHA3-384 | 72 |
|
||||||
|
| `P-521` / `secp521r1` | 256 | SHA-512 | 98 |
|
||||||
|
| `Bls12381G1` | 128 | SHA-256 | 48 |
|
||||||
|
| `ed25519` | 128 | SHA-256 | 48 |
|
||||||
|
|
||||||
|
Any other curve returns `unsupported curve: <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)` Miller–Rabin rounds.
|
||||||
|
|
||||||
|
:::warning[Expensive by construction]
|
||||||
|
This is a rejection loop over `rand.Prime`, and the density of safe primes makes it dramatically slower than generating an ordinary prime of the same size. It is the dominant cost of [Paillier](/zero-knowledge/paillier) key generation, which needs two of them. Generate keys ahead of time, off the request path; never call this inside a handler.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Commitments
|
||||||
|
|
||||||
|
`core` ships one commitment scheme, and it is a hash commitment — not Pedersen, not polynomial. It commits to *bytes*, not to a group element.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Commitment []byte // 32 bytes: HMAC-SHA256(key = nonce, msg)
|
||||||
|
|
||||||
|
type Witness struct {
|
||||||
|
Msg []byte
|
||||||
|
// unexported: r [32]byte, the random nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
func Commit(msg []byte) (Commitment, *Witness, error)
|
||||||
|
func Open(c Commitment, d Witness) (bool, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Commit` draws a 32-byte nonce from `crypto/rand` and returns `HMAC-SHA256(msg, key = nonce)` as the commitment, with the nonce hidden inside the `Witness`. `Open` recomputes the HMAC from `d.Msg` and the witness nonce and compares against `c` with `subtle.ConstantTimeCompare`.
|
||||||
|
|
||||||
|
```go title="commit.go"
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sonr-io/crypto/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Committer: publish c, keep w secret until the reveal phase.
|
||||||
|
c, w, err := core.Commit([]byte("bid: 42"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println(len(c) == core.Size) // true, 32 bytes
|
||||||
|
|
||||||
|
// Witness marshals to JSON (msg + nonce) so it can be sent on reveal.
|
||||||
|
wire, _ := json.Marshal(w)
|
||||||
|
|
||||||
|
// Verifier: after receiving the witness.
|
||||||
|
var got core.Witness
|
||||||
|
if err := json.Unmarshal(wire, &got); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
ok, err := core.Open(c, got)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println(ok) // true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Properties as implemented.** Hiding rests on HMAC-SHA256 being a PRF under the fresh 32-byte random key — the commitment is a PRF evaluation keyed by a secret nonce, so it reveals nothing about `msg` to anyone without the nonce. Binding rests on collision resistance: to open the same 32-byte commitment to a different message you would need `HMAC(msg', k') == HMAC(msg, k)`.
|
||||||
|
|
||||||
|
:::warning[Length is not committed independently, and `Open` only length-checks the commitment]
|
||||||
|
`Open` rejects a commitment whose length is not exactly `core.Size` (32), then does a constant-time compare. It performs no validation on the witness beyond that. In particular:
|
||||||
|
|
||||||
|
- The nonce is unexported and has no accessor — the only way to move a `Witness` between processes is its JSON marshalling. The wire shape has **no `json` tags**, so the field names are the Go defaults: `{"Msg":"<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>
|
||||||
@@ -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`.
|
||||||
@@ -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, Fiat–Shamir, 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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({
|
||||||
|
title: "Foundations",
|
||||||
|
icon: "layers",
|
||||||
|
order: 2,
|
||||||
|
pages: ["index", "curves", "arithmetic", "protocol"],
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
---
|
||||||
|
title: did:key Identifiers
|
||||||
|
description: Encode a public key as a self-describing did:key string, parse it back, and derive verification material — plus a frank assessment of the keys/parsers package.
|
||||||
|
sidebar:
|
||||||
|
order: 2
|
||||||
|
icon: id-card
|
||||||
|
---
|
||||||
|
|
||||||
|
`github.com/sonr-io/crypto/keys` turns a public key into a stable, self-describing string and back
|
||||||
|
again. A `did:key` identifier needs no registry and no network lookup: the key material *is* the
|
||||||
|
identifier, so resolving one is a pure decode. The package wraps libp2p's
|
||||||
|
`github.com/libp2p/go-libp2p/core/crypto.PubKey` interface, which gives it RSA, Ed25519, and
|
||||||
|
secp256k1 support for free, and adds a secp256k1-specific path for public keys that arrive as raw
|
||||||
|
bytes from an [MPC enclave](/identity/mpc-enclave).
|
||||||
|
|
||||||
|
**Reach for this when** you need a canonical identifier for a key you already hold — a UCAN issuer,
|
||||||
|
a log line, a database column, a delegation audience.
|
||||||
|
|
||||||
|
**Do not reach for this when** you need a DID with mutable state (rotation, service endpoints,
|
||||||
|
multiple verification methods). `did:key` is immutable by construction: change the key, change the
|
||||||
|
identifier. The `DIDMethod` enum in this package names other methods, but only `did:key` is
|
||||||
|
implemented here.
|
||||||
|
|
||||||
|
## Encoding
|
||||||
|
|
||||||
|
`DID.String()` builds the identifier in three steps:
|
||||||
|
|
||||||
|
1. `id.Raw()` — the raw public key bytes from libp2p (33 or 65 bytes for secp256k1, 32 for Ed25519,
|
||||||
|
DER PKIX for RSA).
|
||||||
|
2. An unsigned-varint multicodec prefix identifying the key type is prepended.
|
||||||
|
3. The whole buffer is multibase-encoded with base58btc, which yields the leading `z`.
|
||||||
|
|
||||||
|
So every identifier this package produces looks like `did:key:z…`. `Parse` reverses exactly those
|
||||||
|
steps and rejects any multibase encoding other than base58btc.
|
||||||
|
|
||||||
|
| Key type | Constant | Multicodec | Accepted raw lengths |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| RSA (`rsa-x509-pub`) | `MulticodecKindRSAPubKey` | `0x1205` | DER, parsed via `x509.ParsePKIXPublicKey` |
|
||||||
|
| Ed25519 (`ed25519-pub`) | `MulticodecKindEd25519PubKey` | `0xed` | 32 |
|
||||||
|
| secp256k1 (`secp256k1-pub`) | `MulticodecKindSecp256k1PubKey` | `0xe7` | 33 (compressed) or 65 (uncompressed) |
|
||||||
|
|
||||||
|
`KeyPrefix` is the string constant `"did:key"`. `GetMulticodecType(keyType int)` maps an
|
||||||
|
`int(crypto.RSA)` / `int(crypto.Ed25519)` / `int(crypto.Secp256k1)` to the values above and errors on
|
||||||
|
anything else.
|
||||||
|
|
||||||
|
:::note
|
||||||
|
Canonical `did:key` for secp256k1 uses the **compressed** 33-byte point. `Parse` and `NewFromMPCPubKey`
|
||||||
|
also accept the 65-byte uncompressed form, which means two distinct `did:key` strings can name the
|
||||||
|
same key. If you compare identifiers as strings, normalise through `CompressedPubKey()` first.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Constructors
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"NewDID": {
|
||||||
|
type: "func(pub crypto.PubKey) (DID, error)",
|
||||||
|
description: "Wraps a libp2p public key. Accepts Ed25519, RSA, Secp256k1; errors on any other key type."
|
||||||
|
},
|
||||||
|
"NewFromPubKey": {
|
||||||
|
type: "func(pub PubKey) DID",
|
||||||
|
description: "Wraps this package's own PubKey (a curves.Point-backed secp256k1 key). Infallible."
|
||||||
|
},
|
||||||
|
"NewFromMPCPubKey": {
|
||||||
|
type: "func(pubKeyBytes []byte) (DID, error)",
|
||||||
|
description: "Unmarshals 33- or 65-byte secp256k1 public key bytes straight from an MPC enclave. Errors on any other length."
|
||||||
|
},
|
||||||
|
"Parse": {
|
||||||
|
type: "func(keystr string) (DID, error)",
|
||||||
|
description: "Decodes a did:key string. Requires the did:key prefix, base58btc multibase, and a recognised multicodec."
|
||||||
|
},
|
||||||
|
"ValidateFormat": {
|
||||||
|
type: "func(didString string) error",
|
||||||
|
description: "Prefix check followed by a full Parse. Use when you only need a yes/no on a string."
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
## The `DID` type
|
||||||
|
|
||||||
|
`DID` embeds `crypto.PubKey`, so every libp2p method (`Raw`, `Type`, `Equals`, `Verify`, `Bytes`) is
|
||||||
|
promoted onto it. On top of that:
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"String": {
|
||||||
|
type: "func() string",
|
||||||
|
description: "The did:key identifier. Returns \"\" — not an error — if Raw() or multibase encoding fails."
|
||||||
|
},
|
||||||
|
"PublicKey": {
|
||||||
|
type: "func() crypto.PubKey",
|
||||||
|
description: "The embedded libp2p public key."
|
||||||
|
},
|
||||||
|
"MulticodecType": {
|
||||||
|
type: "func() uint64",
|
||||||
|
description: "The multicodec for this key type. PANICS on an unrecognised key type rather than returning an error."
|
||||||
|
},
|
||||||
|
"CompressedPubKey": {
|
||||||
|
type: "func() ([]byte, error)",
|
||||||
|
description: "33-byte compressed point for secp256k1 (converting from 65 bytes if needed); raw bytes for every other key type."
|
||||||
|
},
|
||||||
|
"VerifyKey": {
|
||||||
|
type: "func() (any, error)",
|
||||||
|
description: "*rsa.PublicKey for RSA, ed25519.PublicKey for Ed25519, and the raw []byte for secp256k1."
|
||||||
|
},
|
||||||
|
"Address": {
|
||||||
|
type: "func() (string, error)",
|
||||||
|
description: "A \"sonr1\"-prefixed string. See the caveat below — it is not a hash and not bech32."
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
:::warning[`MulticodecType` panics]
|
||||||
|
`String()` calls `MulticodecType()` unconditionally. A `DID` holding a key type outside
|
||||||
|
`{RSA, Ed25519, secp256k1}` will panic with `"unexpected crypto type"` when stringified. `NewDID`
|
||||||
|
guards against this, but a `DID` constructed as a struct literal (`keys.DID{PubKey: k}`) does not.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Round trip
|
||||||
|
|
||||||
|
Grounded in `TestDIDStringFormat` and `TestMPCIntegration` in `keys/didkey_test.go`:
|
||||||
|
|
||||||
|
```go didkey_roundtrip.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
p2pcrypto "github.com/libp2p/go-libp2p/core/crypto"
|
||||||
|
"github.com/sonr-io/crypto/keys"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
priv, _, err := p2pcrypto.GenerateSecp256k1Key(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
did, err := keys.NewDID(priv.GetPublic())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := did.String() // "did:key:z..."
|
||||||
|
fmt.Println(s)
|
||||||
|
|
||||||
|
parsed, err := keys.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The encoding is canonical for a given input: re-stringifying is identical.
|
||||||
|
fmt.Println("stable:", parsed.String() == s)
|
||||||
|
fmt.Println("same type:", parsed.Type() == did.Type())
|
||||||
|
|
||||||
|
// Cheap validity check on an untrusted string.
|
||||||
|
fmt.Println("valid:", keys.ValidateFormat(s) == nil)
|
||||||
|
|
||||||
|
compressed, err := parsed.CompressedPubKey()
|
||||||
|
fmt.Println("compressed len:", len(compressed), err) // 33
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For a key that arrives from an enclave rather than a libp2p keypair, swap the constructor:
|
||||||
|
|
||||||
|
```go
|
||||||
|
did, err := keys.NewFromMPCPubKey(enclave.PubKeyBytes())
|
||||||
|
```
|
||||||
|
|
||||||
|
## `DIDMethod`
|
||||||
|
|
||||||
|
A plain string enum, verbatim from `keys/methods.go`. It carries no behaviour beyond `String()`, and
|
||||||
|
nothing else in the package consumes it — it exists for callers that need to tag which method a DID
|
||||||
|
string belongs to.
|
||||||
|
|
||||||
|
```go
|
||||||
|
const (
|
||||||
|
DIDMethodKey DIDMethod = "key"
|
||||||
|
DIDMethodSonr DIDMethod = "sonr"
|
||||||
|
DIDMehthodBitcoin DIDMethod = "btcr"
|
||||||
|
DIDMethodEthereum DIDMethod = "ethr"
|
||||||
|
DIDMethodCbor DIDMethod = "cbor"
|
||||||
|
DIDMethodCID DIDMethod = "cid"
|
||||||
|
DIDMethodIPFS DIDMethod = "ipfs"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
:::note
|
||||||
|
`DIDMehthodBitcoin` is misspelled in the source. It is exported, so fixing it would be a breaking
|
||||||
|
change; use it as written.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## The `PubKey` interface
|
||||||
|
|
||||||
|
Separate from libp2p's type, `keys.PubKey` adapts a [`curves.Point`](/foundations/curves) into
|
||||||
|
something `DID` can embed. `NewPubKey(pk curves.Point) PubKey` is the only constructor.
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"Bytes": { type: "func() []byte", description: "point.ToAffineCompressed() — 33 bytes on secp256k1." },
|
||||||
|
"Raw": { type: "func() ([]byte, error)", description: "Identical to Bytes; the error is always nil." },
|
||||||
|
"Hex": { type: "func() string", description: "Hex of the compressed point." },
|
||||||
|
"Type": { type: "func() p2ppb.KeyType", description: "Hardcoded to KeyType_Secp256k1 regardless of the point's actual curve." },
|
||||||
|
"Equals": { type: "func(b p2pcrypto.Key) bool", description: "Compares Raw() bytes." },
|
||||||
|
"Verify": { type: "func(msg, sig []byte) (bool, error)", description: "ECDSA verify over a SHA3-256 digest. Signature layout below." },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
### The 66-byte signature layout
|
||||||
|
|
||||||
|
`PubKey.Verify` does **not** accept a standard 64-byte `r || s` signature. Reading
|
||||||
|
`keys/pubkey.go` and `keys/utils.go`, it:
|
||||||
|
|
||||||
|
1. Requires the signature to be **exactly 66 bytes**, rejecting anything else with
|
||||||
|
`"malformed signature: not the correct size"`.
|
||||||
|
2. Parses it as `V || R || S`, where `V` is a single recovery-id byte at offset 0, `R` is
|
||||||
|
`sig[1:33]`, and `S` is `sig[33:66]`.
|
||||||
|
3. Hashes the message with **SHA3-256** (not SHA-256) and calls `ecdsa.Verify` on that digest,
|
||||||
|
ignoring `V` entirely.
|
||||||
|
4. Reconstructs the ECDSA public key by slicing the compressed point as `x = bytes[1:33]`,
|
||||||
|
`y = bytes[33:]` on `curves.K256()`.
|
||||||
|
|
||||||
|
:::danger[`keys.PubKey.Verify` cannot verify `mpc.Enclave.Sign` output]
|
||||||
|
`mpc.SerializeSignature` produces a fixed **64-byte** `r || s` buffer, and `keys.deserializeSignature`
|
||||||
|
rejects anything that is not 66 bytes. So `keys.NewPubKey(point).Verify(msg, enclaveSig)` always
|
||||||
|
returns `("malformed signature: not the correct size")`. Verify enclave signatures with
|
||||||
|
`enclave.Verify(data, sig)` or `mpc.VerifyWithPubKey(enclave.PubKeyBytes(), data, sig)` instead — both
|
||||||
|
use the 64-byte layout. See [MPC Enclave](/identity/mpc-enclave).
|
||||||
|
|
||||||
|
Step 4 above is also wrong for a genuinely compressed point: on a 33-byte compressed encoding,
|
||||||
|
`bytes[33:]` is empty, so `y` decodes as zero. `Verify` therefore only works if `Bytes()` happens to
|
||||||
|
return 65 bytes — which it never does, since `ToAffineCompressed()` returns 33. Treat
|
||||||
|
`keys.PubKey.Verify` as non-functional.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## `Address()` does not do what its comment says
|
||||||
|
|
||||||
|
The doc comment promises "a blockchain-compatible address" and an inline comment claims
|
||||||
|
"first 20 bytes of Keccak-256 hash (Ethereum-style)". The code does neither:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// keys/didkey.go, secp256k1 branch, verbatim:
|
||||||
|
return fmt.Sprintf("sonr1%x", rawPubBytes[:8]), nil
|
||||||
|
```
|
||||||
|
|
||||||
|
:::danger[`Address()` is a truncated hex prefix, not an address]
|
||||||
|
For all three key types the function returns `"sonr1"` followed by the hex of the **first 8 bytes of
|
||||||
|
the raw public key**. There is no hash, no Keccak, and no bech32 encoding despite the bech32-looking
|
||||||
|
`sonr1` prefix. Consequences:
|
||||||
|
|
||||||
|
- It is **not one-way**: the output leaks 8 bytes of the public key verbatim.
|
||||||
|
- It has **no checksum**, so a typo is undetectable.
|
||||||
|
- 64 bits of collision space, birthday-bounded at roughly 2<sup>32</sup> keys.
|
||||||
|
- For secp256k1 it compresses a 65-byte key first, so the compressed and uncompressed forms of the
|
||||||
|
same key produce the same address — but an Ed25519 and a secp256k1 key sharing a first-8-byte
|
||||||
|
prefix also collide.
|
||||||
|
|
||||||
|
`ucan` uses this value as the address in `MPCTokenBuilder.GetAddress()` and `KeyshareSource.Address()`.
|
||||||
|
Do not treat it as a chain address on any real network.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Avoid `keys/parsers`
|
||||||
|
|
||||||
|
`keys/parsers` looks like a set of per-chain address parsers. It is not. Verified by reading every
|
||||||
|
file in the directory:
|
||||||
|
|
||||||
|
| File | Lines | Contents |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `btc_parser.go` | 1 | `package parsers` |
|
||||||
|
| `eth_parser.go` | 1 | `package parsers` |
|
||||||
|
| `fil_parser.go` | 1 | `package parsers` |
|
||||||
|
| `sol_parser.go` | 1 | `package parsers` |
|
||||||
|
| `ton_parser.go` | 1 | `package parsers` |
|
||||||
|
| `cosmos_parser.go` | 12 | A `CosmosPrefix` string type and six bech32 HRP constants. No functions. |
|
||||||
|
| `key_parser.go` | 157 | A near-verbatim copy of `keys/didkey.go`, exporting `DIDKey` instead of `DID`. |
|
||||||
|
|
||||||
|
:::danger[`keys/parsers` duplicates `keys` with an incompatible multicodec]
|
||||||
|
`keys/parsers` redeclares the multicodec constants, and one of them disagrees:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// keys/didkey.go
|
||||||
|
MulticodecKindSecp256k1PubKey = 0xe7 // secp256k1-pub, the registered value
|
||||||
|
|
||||||
|
// keys/parsers/key_parser.go
|
||||||
|
MulticodecKindSecp256k1PubKey = 0x1206 // not secp256k1-pub
|
||||||
|
```
|
||||||
|
|
||||||
|
A secp256k1 `did:key` produced by `parsers.DIDKey.String()` carries a different varint prefix, so
|
||||||
|
`keys.Parse` rejects it with `"unrecognized key type multicodec prefix"`, and vice versa. The two
|
||||||
|
packages produce **mutually unparseable identifiers for the same key**. `keys` uses the registered
|
||||||
|
multicodec table value; `parsers` does not.
|
||||||
|
|
||||||
|
The five empty files mean the package name promises chain address parsing that does not exist:
|
||||||
|
`parsers` exports only `KeyPrefix`, the multicodec constants, `CosmosPrefix` and its six constants,
|
||||||
|
`DIDKey` with `NewKeyDID`/`MulticodecType`/`String`/`VerifyKey`, and `Parse`.
|
||||||
|
|
||||||
|
**Use `github.com/sonr-io/crypto/keys`. Do not import `keys/parsers`.**
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
:::warning[Silent failure in `String()`]
|
||||||
|
`String()` returns the empty string on any internal error rather than reporting it. An empty
|
||||||
|
identifier where you expected `did:key:z…` means `Raw()` or multibase encoding failed; check the key
|
||||||
|
with `NewDID` first, or call `ValidateFormat` on the result.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::warning[`Parse` error message reads the wrong byte]
|
||||||
|
The fallthrough error is `fmt.Errorf("unrecognized key type multicodec prefix: %x", data[0])`, but
|
||||||
|
the multicodec was decoded as a multi-byte varint into `keyType`. For prefixes above `0x7f` — RSA's
|
||||||
|
`0x1205`, for example — the reported byte is the first varint byte, not the codec. The error is
|
||||||
|
cosmetic; the rejection itself is correct.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::info[What is actually covered by tests]
|
||||||
|
`keys/didkey_test.go` exercises `NewFromMPCPubKey` length validation, the `0xe7` constant,
|
||||||
|
`Address`, `CompressedPubKey`, `ValidateFormat`, `GetMulticodecType`, and the string/parse round trip.
|
||||||
|
There is **no** test for `NewFromPubKey`, `NewPubKey`, or `PubKey.Verify` — which is consistent with
|
||||||
|
the signature-layout defect above going unnoticed.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
<CardGroup cols={2}>
|
||||||
|
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
|
||||||
|
Where `NewFromMPCPubKey`'s input comes from, and how to sign with the key behind the identifier.
|
||||||
|
</Card>
|
||||||
|
<Card title="UCAN Tokens" href="/identity/ucan" icon="ticket">
|
||||||
|
Using a `did:key` as a token issuer and delegation audience.
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
---
|
||||||
|
title: ECIES
|
||||||
|
description: Encrypt a payload to a secp256k1 public key. A thin wrapper over github.com/ecies/go/v2 with one significant seed hazard.
|
||||||
|
sidebar:
|
||||||
|
order: 5
|
||||||
|
icon: mail
|
||||||
|
---
|
||||||
|
|
||||||
|
`github.com/sonr-io/crypto/ecies` is a **thin wrapper** — three files, 70 lines of code — over
|
||||||
|
[`github.com/ecies/go/v2`](https://github.com/ecies/go). ECIES (Elliptic Curve Integrated Encryption
|
||||||
|
Scheme) is hybrid public-key encryption: the sender generates an ephemeral keypair, does ECDH against
|
||||||
|
the recipient's static public key, derives a symmetric key, and encrypts the payload under an AEAD.
|
||||||
|
The recipient needs no prior interaction — just their own private key and the ciphertext.
|
||||||
|
|
||||||
|
**Reach for this when** you need to encrypt a payload to a public key you already have, with no
|
||||||
|
handshake and no shared state.
|
||||||
|
|
||||||
|
**Do not reach for this when** you need forward secrecy for the recipient, authenticated sender
|
||||||
|
identity (ECIES gives you confidentiality, not sender authentication — sign separately with
|
||||||
|
[`mpc`](/identity/mpc-enclave) or an [ECDSA](/signatures/ecdsa) key), or a symmetric key you already
|
||||||
|
share (use [AEAD](/symmetric/aead) directly).
|
||||||
|
|
||||||
|
## The API surface
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PrivateKey = eciesgo.PrivateKey // type ALIAS, not a wrapper struct
|
||||||
|
type PublicKey = eciesgo.PublicKey // type ALIAS
|
||||||
|
|
||||||
|
func GenerateKey() (*PrivateKey, error)
|
||||||
|
func GenerateKeyFromSeed(seed []byte) (*PrivateKey, error)
|
||||||
|
func HashSeed(seed []byte) []byte
|
||||||
|
|
||||||
|
func Encrypt(pub *PublicKey, plaintext []byte) ([]byte, error)
|
||||||
|
func Decrypt(priv *PrivateKey, ciphertext []byte) ([]byte, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the entire package. `Encrypt` and `Decrypt` are one-line forwards to `eciesgo.Encrypt` and
|
||||||
|
`eciesgo.Decrypt`.
|
||||||
|
|
||||||
|
:::note[The key types are aliases, so the upstream API is yours]
|
||||||
|
`PrivateKey` and `PublicKey` are Go **type aliases** (`type PrivateKey = eciesgo.PrivateKey`), not
|
||||||
|
distinct named types. Everything the upstream library defines on those types is directly available:
|
||||||
|
`priv.Bytes()`, `priv.Hex()`, `priv.PublicKey`, `priv.ECDH(pub)`, `pub.Bytes(compressed bool)`,
|
||||||
|
`pub.Hex(compressed bool)`, `eciesgo.NewPrivateKeyFromHex`, `eciesgo.NewPublicKeyFromBytes`, and so
|
||||||
|
on.
|
||||||
|
|
||||||
|
Consult [`github.com/ecies/go/v2`](https://github.com/ecies/go) for:
|
||||||
|
|
||||||
|
- **key serialization** — this package exposes no marshal/unmarshal helpers of its own;
|
||||||
|
- **the ciphertext wire format** — the ephemeral-key encoding, KDF and AEAD choices are entirely
|
||||||
|
upstream's, and are not restated or pinned here.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Curve
|
||||||
|
|
||||||
|
`GenerateKey` and `GenerateKeyFromSeed` both build their key on `curves.SP256()`, which returns
|
||||||
|
`ecc.P256k1()` from `github.com/dustinxie/ecc` — i.e. **secp256k1**, the same curve as
|
||||||
|
[`mpc`](/identity/mpc-enclave) and secp256k1 `did:key` identifiers. See
|
||||||
|
[Foundations → Curves](/foundations/curves) for the curve abstraction.
|
||||||
|
|
||||||
|
Note the constructors bypass `eciesgo.GenerateKey` and assemble the struct by hand from
|
||||||
|
`ecdsa.GenerateKey(curve, rand.Reader)`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
p, err := ecdsa.GenerateKey(curve, rand.Reader)
|
||||||
|
return &PrivateKey{
|
||||||
|
PublicKey: &PublicKey{Curve: curve, X: p.X, Y: p.Y},
|
||||||
|
D: p.D,
|
||||||
|
}, nil
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```go ecies_roundtrip.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sonr-io/crypto/ecies"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Recipient generates a keypair and publishes the public key.
|
||||||
|
priv, err := ecies.GenerateKey()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender encrypts to the public key. No prior interaction needed.
|
||||||
|
ciphertext, err := ecies.Encrypt(priv.PublicKey, []byte("hello"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recipient decrypts with the private key.
|
||||||
|
plaintext, err := ecies.Decrypt(priv, ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println(string(plaintext)) // hello
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`GenerateKey` is grounded in `TestGenerateKey` and `GenerateKeyFromSeed` in `TestGenerateFromSeed`
|
||||||
|
(`ecies/keys_test.go`). The encrypt/decrypt round trip above is **not** covered by any test in the
|
||||||
|
package — see the caveats.
|
||||||
|
|
||||||
|
## `HashSeed` and seeded keys
|
||||||
|
|
||||||
|
`HashSeed(seed []byte) []byte` is `blake3.Sum512(seed)` from `lukechampine.com/blake3`, returned as a
|
||||||
|
64-byte slice. Its purpose is to stretch an arbitrary-length input up to enough bytes for
|
||||||
|
`GenerateKeyFromSeed`, which reads from the seed as an entropy source:
|
||||||
|
|
||||||
|
```go
|
||||||
|
seed := ecies.HashSeed([]byte("some high-entropy passphrase or master secret"))
|
||||||
|
priv, err := ecies.GenerateKeyFromSeed(seed)
|
||||||
|
```
|
||||||
|
|
||||||
|
:::note[The seed is key material]
|
||||||
|
`GenerateKeyFromSeed` treats its argument as the sole entropy input. Whoever holds the seed can
|
||||||
|
recompute the private key. Store, transmit and destroy a seed exactly as you would a private key —
|
||||||
|
and note that `HashSeed` is a plain hash, **not** a password KDF: it has no salt, no work factor and
|
||||||
|
no memory hardness. Do not feed it a human-chosen password. For password-derived keys use a real KDF
|
||||||
|
from [Key Derivation](/symmetric/key-derivation).
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::danger[`GenerateKeyFromSeed` is not deterministic]
|
||||||
|
Despite the name, this function does not reliably produce the same key from the same seed on current
|
||||||
|
Go toolchains. `ecdsa.GenerateKey(curve, bytes.NewReader(seed))` passes the seed reader into
|
||||||
|
`crypto/ecdsa`, but the standard library does not use it as given:
|
||||||
|
|
||||||
|
- On Go 1.26 and later, `crypto/ecdsa` routes a caller-supplied reader through
|
||||||
|
`crypto/internal/rand.CustomReader`, which **returns the system CSPRNG and discards the supplied
|
||||||
|
reader** unless the `GODEBUG` setting `cryptocustomrand=1` is active. The `cryptocustomrand`
|
||||||
|
default became `0` in Go 1.26, so a program whose main module declares `go 1.26` or later gets a
|
||||||
|
fully random key and the seed is ignored entirely.
|
||||||
|
- Under the older behaviour (`cryptocustomrand=1`, i.e. a main module declaring an earlier Go
|
||||||
|
version), `randutil.MaybeReadByte` consumes a byte from the reader with roughly 50% probability
|
||||||
|
before key generation, which shifts the whole byte stream. Measured against this package: 20
|
||||||
|
successive calls with an identical seed produced the same private key only **13 times out of 20**.
|
||||||
|
|
||||||
|
Both behaviours were confirmed empirically against this package on Go 1.27.
|
||||||
|
|
||||||
|
`ecies/keys_test.go`'s `TestGenerateFromSeed` calls `GenerateKeyFromSeed` twice with the same seed
|
||||||
|
but only asserts that neither call errors — it never compares the two keys, which is why the defect
|
||||||
|
is not caught.
|
||||||
|
|
||||||
|
**Do not use `GenerateKeyFromSeed` for deterministic key derivation.** If you need a key
|
||||||
|
reproducible from a seed, derive the scalar yourself with a KDF from
|
||||||
|
[Key Derivation](/symmetric/key-derivation) and construct the key from those bytes via
|
||||||
|
`eciesgo.NewPrivateKeyFromBytes`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
:::warning[`GenerateKeyFromSeed` errors on a short seed]
|
||||||
|
The implementation slices `seed[:]` and hands it to `bytes.NewReader`. `randFieldElement` then calls
|
||||||
|
`io.ReadFull`, which returns `io.ErrUnexpectedEOF` on a seed shorter than 32 bytes — surfaced as
|
||||||
|
`"cannot generate key pair: unexpected EOF"`. A `nil` seed is worse: `seed[:]` on a nil slice is
|
||||||
|
legal, so you get the same EOF error rather than a clear "nil seed" message. Always pass
|
||||||
|
`HashSeed(...)` output (64 bytes) rather than a raw seed. Under the Go 1.26+ behaviour described
|
||||||
|
above the reader is never consulted, so short seeds succeed there — which makes the failure mode
|
||||||
|
toolchain-dependent.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::warning[No round-trip test]
|
||||||
|
`ecies/keys_test.go` is 24 lines and contains two tests: `TestGenerateKey` and
|
||||||
|
`TestGenerateFromSeed`. **Neither `Encrypt` nor `Decrypt` is tested at all**, and there is no test
|
||||||
|
that the hand-assembled `PrivateKey`/`PublicKey` structs are accepted by the upstream library. The
|
||||||
|
round trip does work — it was verified directly against this package — but the package ships no
|
||||||
|
regression coverage for its two most important functions.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::info[No authentication of the sender]
|
||||||
|
ECIES ciphertext is confidential and integrity-protected against tampering, but **anyone** with the
|
||||||
|
recipient's public key can produce a valid ciphertext. If the recipient needs to know who sent a
|
||||||
|
message, sign the plaintext (or the ciphertext) separately and transmit the signature alongside it.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
<CardGroup cols={2}>
|
||||||
|
<Card title="AEAD" href="/symmetric/aead" icon="lock">
|
||||||
|
The symmetric layer, for when you already share a key.
|
||||||
|
</Card>
|
||||||
|
<Card title="Key Derivation" href="/symmetric/key-derivation" icon="git-branch">
|
||||||
|
Real KDFs, for deriving keys from seeds or passwords.
|
||||||
|
</Card>
|
||||||
|
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
|
||||||
|
Signing, to pair with encryption for sender authentication.
|
||||||
|
</Card>
|
||||||
|
<Card title="Curves" href="/foundations/curves" icon="binary">
|
||||||
|
The secp256k1 curve this package builds on.
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
title: Identity & Authorization
|
||||||
|
description: The application-facing layer — threshold key enclaves, did:key identifiers, UCAN capability tokens, payload encryption, and WebAssembly code signing.
|
||||||
|
sidebar:
|
||||||
|
order: 1
|
||||||
|
icon: fingerprint
|
||||||
|
---
|
||||||
|
|
||||||
|
Everything below this section is code you call directly from an application. The primitives in
|
||||||
|
[Foundations](/foundations), [Signatures](/signatures), and [Threshold](/threshold) are the machinery;
|
||||||
|
these five packages are the assembled product: a key that lives in two shares, an identifier derived
|
||||||
|
from its public point, tokens that delegate narrow slices of authority over that key, and two
|
||||||
|
supporting utilities for encrypting payloads and pinning executable code.
|
||||||
|
|
||||||
|
## How the pieces compose
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="An enclave holds the key">
|
||||||
|
[`mpc.NewEnclave()`](/identity/mpc-enclave) runs a 2-of-2 DKLs18 threshold ECDSA key generation on
|
||||||
|
secp256k1 and returns an `Enclave`. The private key never exists as a single scalar: it lives as a
|
||||||
|
validator share and a user share. Signing is a two-party protocol; refreshing rotates both shares
|
||||||
|
while leaving the public key fixed.
|
||||||
|
</Step>
|
||||||
|
<Step title="Its public point becomes an identifier">
|
||||||
|
`enclave.PubKeyBytes()` yields the uncompressed public point. `keys.NewFromMPCPubKey` turns those
|
||||||
|
bytes into a [`keys.DID`](/identity/did-key), whose `String()` is a `did:key:z…` identifier — a
|
||||||
|
multicodec varint prefix plus multibase base58btc. That string is the stable, resolvable name for
|
||||||
|
the key.
|
||||||
|
</Step>
|
||||||
|
<Step title="The identifier issues capability tokens">
|
||||||
|
A [UCAN](/identity/ucan) token is a JWT whose issuer is that `did:key`, signed by the enclave.
|
||||||
|
Its `att` claim is a list of attenuations — `(capability, resource)` pairs. A holder can mint a
|
||||||
|
delegated token that *narrows* the set, never widens it, and attaches the parent as a proof.
|
||||||
|
</Step>
|
||||||
|
<Step title="Payloads and code get their own primitives">
|
||||||
|
[`ecies`](/identity/ecies) encrypts a payload to a secp256k1 public key without any prior
|
||||||
|
handshake. [`wasm`](/identity/wasm-modules) signs and hash-pins WebAssembly module bytes so a host
|
||||||
|
can refuse to load code it does not recognise.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Choosing a package
|
||||||
|
|
||||||
|
| You want to… | Use | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Hold a signing key without a single point of compromise | `mpc` | 2-of-2 only; secp256k1 only |
|
||||||
|
| Name a public key with a stable string | `keys` | RSA, Ed25519, secp256k1 |
|
||||||
|
| Grant another party scoped, expiring authority | `ucan` | JWT-based, `ucv` header `0.9.0` |
|
||||||
|
| Encrypt a message to someone's public key | `ecies` | Thin wrapper over `github.com/ecies/go/v2` |
|
||||||
|
| Verify that a `.wasm` blob is the one you approved | `wasm` | Ed25519 signing + SHA-256 pinning |
|
||||||
|
| Parse a chain-specific address | — | `keys/parsers` is unfinished; see [did:key](/identity/did-key) |
|
||||||
|
|
||||||
|
## A minimal end-to-end shape
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sonr-io/crypto/keys"
|
||||||
|
"github.com/sonr-io/crypto/mpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 1. Threshold key: both shares generated locally.
|
||||||
|
enclave, err := mpc.NewEnclave()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Identifier derived from the enclave's public point.
|
||||||
|
did, err := keys.NewFromMPCPubKey(enclave.PubKeyBytes())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("issuer:", did.String()) // did:key:z...
|
||||||
|
|
||||||
|
// 3. Two-party signature over a message, verified against the public key.
|
||||||
|
sig, err := enclave.Sign([]byte("hello"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
ok, err := enclave.Verify([]byte("hello"), sig)
|
||||||
|
fmt.Println("valid:", ok, err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every one of these packages is generic over, or built on, the curve abstraction described in
|
||||||
|
[Foundations → Curves](/foundations/curves). `Curve`, `Point`, and `Scalar` are not re-explained here.
|
||||||
|
|
||||||
|
## Read this before you ship
|
||||||
|
|
||||||
|
This section is the least finished part of the repository. The pages below document the rough edges
|
||||||
|
in place rather than around them, because several of them are the kind that silently weaken a
|
||||||
|
security property instead of failing loudly.
|
||||||
|
|
||||||
|
:::danger[The short version]
|
||||||
|
- An `mpc.Enclave` value holds **both** keyshares in one process. It is a key-management construct,
|
||||||
|
not a distributed-trust boundary.
|
||||||
|
- `mpc.EnclaveData.Unmarshal` **panics** on `Marshal()` output, so a persisted enclave cannot be
|
||||||
|
restored through the package's own codec.
|
||||||
|
- `ucan.GenerateJWTToken` / `VerifyJWTToken` sign with **HS256 under a hardcoded secret** compiled
|
||||||
|
into the package.
|
||||||
|
- The UCAN verifier's caveat checks are placeholders that always succeed, so caveat restrictions are
|
||||||
|
**not enforced**.
|
||||||
|
- `ucan.MPCTokenBuilder.CreateDelegatedToken` will sign a child token that grants **more** than its
|
||||||
|
parent; only `KeyshareSource.NewAttenuatedToken` enforces attenuation.
|
||||||
|
- `ucan.MPCVerifier.VerifyMPCToken` fails outright — the `"MPC256"` signing method is never
|
||||||
|
registered with `golang-jwt`.
|
||||||
|
- `keys/parsers` contains five empty files and a secp256k1 multicodec constant that disagrees with
|
||||||
|
`keys`.
|
||||||
|
- `ecies.GenerateKeyFromSeed` is **not** deterministic on current Go toolchains.
|
||||||
|
- `wasm.SecurityPolicy.Validate` only checks module size; its other fields are ignored.
|
||||||
|
- `keys.DID.Address()` is a truncated hex prefix of the public key, not a hashed or checksummed
|
||||||
|
address, despite its comment claiming Keccak-256.
|
||||||
|
|
||||||
|
Each of these was verified against the source and confirmed by running it, and is documented in
|
||||||
|
detail on the page for its package. They are also aggregated on
|
||||||
|
[Reference → Security](/reference/security).
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
<CardGroup cols={2}>
|
||||||
|
<Card title="did:key Identifiers" href="/identity/did-key" icon="id-card">
|
||||||
|
Multicodec + multibase encoding, the `DID` and `PubKey` types, the non-standard 66-byte signature
|
||||||
|
layout, and why to avoid `keys/parsers`.
|
||||||
|
</Card>
|
||||||
|
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
|
||||||
|
2-of-2 threshold ECDSA lifecycle: keygen, sign, verify, refresh, import/export, and the real
|
||||||
|
security model.
|
||||||
|
</Card>
|
||||||
|
<Card title="UCAN Tokens" href="/identity/ucan" icon="ticket">
|
||||||
|
Capabilities, attenuation, delegation chains, templates, MPC signing, and which authorization
|
||||||
|
checks are not actually implemented.
|
||||||
|
</Card>
|
||||||
|
<Card title="ECIES" href="/identity/ecies" icon="mail">
|
||||||
|
Encrypt to a secp256k1 public key. A thin, honest wrapper — plus one seed hazard.
|
||||||
|
</Card>
|
||||||
|
<Card title="WASM Module Signing" href="/identity/wasm-modules" icon="package-check">
|
||||||
|
Ed25519 code signing and SHA-256 hash pinning for WebAssembly supply-chain verification.
|
||||||
|
</Card>
|
||||||
|
<Card title="Package Index" href="/reference/packages" icon="list">
|
||||||
|
Every package in the module with its status at a glance.
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({
|
||||||
|
title: "Identity & Authorization",
|
||||||
|
icon: "fingerprint",
|
||||||
|
order: 7,
|
||||||
|
pages: ["index", "did-key", "mpc-enclave", "ucan", "ecies", "wasm-modules"],
|
||||||
|
});
|
||||||
@@ -0,0 +1,644 @@
|
|||||||
|
---
|
||||||
|
title: MPC Enclave
|
||||||
|
description: A batteries-included 2-of-2 threshold ECDSA wrapper over tecdsa/dklsv1 — keygen, signing, share refresh, serialization, and the security model it actually provides.
|
||||||
|
sidebar:
|
||||||
|
order: 3
|
||||||
|
icon: shield
|
||||||
|
---
|
||||||
|
|
||||||
|
`github.com/sonr-io/crypto/mpc` is the convenience layer over
|
||||||
|
[`tecdsa/dklsv1`](/threshold/threshold-ecdsa). Where `dklsv1` hands you two protocol iterators and
|
||||||
|
makes you drive the message loop yourself, `mpc` hands you a single `Enclave` value with `Sign`,
|
||||||
|
`Verify`, `Refresh`, `Marshal`, and `Unmarshal`. It is hardwired to a **2-of-2** DKLs18 threshold
|
||||||
|
ECDSA key on **secp256k1**, signing over a **SHA3-256** digest.
|
||||||
|
|
||||||
|
**Reach for this when** you want a signing key that is never materialised as a single scalar in
|
||||||
|
memory, and you are willing to accept a fixed 2-of-2 shape and a secp256k1 curve.
|
||||||
|
|
||||||
|
**Do not reach for this when** you need `t`-of-`n` for any other `t`/`n` (use
|
||||||
|
[secret sharing](/threshold/secret-sharing) plus [DKG](/threshold/dkg)), a different curve, Ed25519
|
||||||
|
signatures (see [threshold Ed25519](/threshold/threshold-ed25519)), or a live two-party protocol
|
||||||
|
across a network — `NewEnclave` runs both sides locally in one process.
|
||||||
|
|
||||||
|
## Read this first
|
||||||
|
|
||||||
|
:::danger[`Enclave` is key management, not distributed trust]
|
||||||
|
`mpc.NewEnclave()` runs *both* DKG parties in the calling process (`protocol.go`: it constructs
|
||||||
|
`dklsv1.NewAliceDkg` and `dklsv1.NewBobDkg` and cranks them against each other with `RunProtocol`),
|
||||||
|
then stores both results in one struct:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type EnclaveData struct {
|
||||||
|
PubHex string `json:"pub_hex"`
|
||||||
|
PubBytes []byte `json:"pub_bytes"`
|
||||||
|
ValShare Message `json:"val_share"` // validator / Alice share
|
||||||
|
UserShare Message `json:"user_share"` // user / Bob share
|
||||||
|
Nonce []byte `json:"nonce"`
|
||||||
|
Curve CurveName `json:"curve"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Sign` likewise builds both `GetAliceSignFunc(k, data)` and `GetBobSignFunc(k, data)` from the same
|
||||||
|
`*EnclaveData` and runs them against each other locally. **An `Enclave` that can sign holds the
|
||||||
|
entire signing capability.** `Marshal()` emits both shares as JSON.
|
||||||
|
|
||||||
|
The threshold property — that compromising one party is not enough to forge a signature — only
|
||||||
|
materialises if you split `ValShare` and `UserShare` across separate trust domains and drive the
|
||||||
|
protocol with `RunProtocol` across the wire. In its packaged form, `mpc` buys you: a key that never
|
||||||
|
exists as one scalar, and proactive share rotation via `Refresh()`. It does **not** buy you a
|
||||||
|
distributed-trust boundary.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```go enclave_lifecycle.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sonr-io/crypto/mpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Keygen: runs both DKG sides locally, returns an Enclave holding both shares.
|
||||||
|
enclave, err := mpc.NewEnclave()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("valid:", enclave.IsValid())
|
||||||
|
fmt.Println("pub:", enclave.PubKeyHex())
|
||||||
|
|
||||||
|
// Sign: two-party DKLs18 signing over SHA3-256(msg). 64 bytes, r || s.
|
||||||
|
msg := []byte("test message before refresh")
|
||||||
|
sig, err := enclave.Sign(msg)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("sig len:", len(sig)) // 64
|
||||||
|
|
||||||
|
ok, err := enclave.Verify(msg, sig)
|
||||||
|
fmt.Println("verified:", ok, err)
|
||||||
|
|
||||||
|
// Refresh: rotates both shares. The public key is invariant.
|
||||||
|
refreshed, err := enclave.Refresh()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("pubkey unchanged:", refreshed.PubKeyHex() == enclave.PubKeyHex())
|
||||||
|
|
||||||
|
// Signatures cross-verify in both directions across the refresh boundary.
|
||||||
|
newSig, err := refreshed.Sign([]byte("test message after refresh"))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
preOK, _ := refreshed.Verify(msg, sig)
|
||||||
|
postOK, _ := enclave.Verify([]byte("test message after refresh"), newSig)
|
||||||
|
fmt.Println("old sig under new enclave:", preOK)
|
||||||
|
fmt.Println("new sig under old enclave:", postOK)
|
||||||
|
|
||||||
|
// Serialization: Marshal works. Unmarshal PANICS — see the callout below.
|
||||||
|
blob, err := enclave.GetData().Marshal()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("marshalled bytes:", len(blob))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every assertion in that program was verified by running it. `TestEnclaveData_RefreshAndSign` in
|
||||||
|
`mpc/enclave_test.go` is the source for the invariant public key and the bidirectional
|
||||||
|
cross-verification.
|
||||||
|
|
||||||
|
:::danger[`Unmarshal` panics on `Marshal` output]
|
||||||
|
A marshalled enclave **cannot be read back**. `EnclaveData.Unmarshal` is `json.Unmarshal` into the
|
||||||
|
struct, whose `ValShare`/`UserShare` fields are `*protocol.Message` — and
|
||||||
|
[`protocol.Message`](/foundations/protocol) has a custom `UnmarshalJSON` with unchecked type
|
||||||
|
assertions that can never hold:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// core/protocol/protocol.go
|
||||||
|
var obj map[string]any
|
||||||
|
if err := json.Unmarshal(data, &obj); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for k, v := range obj {
|
||||||
|
switch k {
|
||||||
|
case "payloads":
|
||||||
|
m.Payloads = v.(map[string][]byte) // <- always the wrong dynamic type
|
||||||
|
case "metadata":
|
||||||
|
m.Metadata = v.(map[string]string) // <- likewise
|
||||||
|
```
|
||||||
|
|
||||||
|
Decoding into `map[string]any` yields `map[string]any` for a nested object, never
|
||||||
|
`map[string][]byte`, so the assertion fails and the program **panics** rather than returning an
|
||||||
|
error:
|
||||||
|
|
||||||
|
```text
|
||||||
|
panic: interface conversion: interface {} is map[string]interface {}, not map[string][]uint8
|
||||||
|
core/protocol/protocol.go:92
|
||||||
|
mpc/enclave.go:154 (EnclaveData.Unmarshal)
|
||||||
|
```
|
||||||
|
|
||||||
|
`TestEnclaveData_MarshalUnmarshal` in `mpc/enclave_test.go` currently **fails** with exactly this
|
||||||
|
panic — confirmed by running `go test ./mpc/ -run TestEnclaveData_MarshalUnmarshal`. Note that
|
||||||
|
`MarshalJSON` on `protocol.Message` is fine, so you can persist an enclave but not restore it
|
||||||
|
through this path.
|
||||||
|
|
||||||
|
The blast radius is **anything that JSON-decodes a `protocol.Message`**, not one specific helper.
|
||||||
|
`mpc.EnclaveData.Unmarshal` reaches the panic through `encoding/json` calling
|
||||||
|
`Message.UnmarshalJSON` directly, and `protocol.DecodeMessage` panics for the same underlying
|
||||||
|
reason. `mpc.RestoreEncryptedEnclave` inherits it too, on top of already being broken for the
|
||||||
|
reasons in the next section.
|
||||||
|
|
||||||
|
Workarounds:
|
||||||
|
|
||||||
|
1. Keep the live `Enclave` value in memory and avoid the JSON boundary entirely, or hand an
|
||||||
|
in-memory `*EnclaveData` to `mpc.RestoreEnclaveFromData` — it adopts the pointer and never
|
||||||
|
touches JSON.
|
||||||
|
2. If you must persist, write your own codec. `protocol.EncodeMessage` works on the way out, but do
|
||||||
|
**not** pair it with `protocol.DecodeMessage`; decode into a shadow struct with the same JSON
|
||||||
|
tags as `protocol.Message` and copy the fields across yourself. See
|
||||||
|
[Foundations → Protocol](/foundations/protocol) for the full explanation and a worked decode.
|
||||||
|
3. If you cannot avoid `Unmarshal`, wrap it in a `recover()` — it panics rather than returning an
|
||||||
|
error, so an error check alone will not save you.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## The `Enclave` interface
|
||||||
|
|
||||||
|
`Enclave` is satisfied by `*EnclaveData`, and `GetData()`/`GetEnclave()` are just casts between the
|
||||||
|
two views of the same pointer.
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"GetData": { type: "func() *EnclaveData", description: "Returns the receiver. Gives access to GetPubPoint, which is not on the interface." },
|
||||||
|
"GetEnclave": { type: "func() Enclave", description: "Returns the receiver as an Enclave. Identity function." },
|
||||||
|
"IsValid": { type: "func() bool", description: "True iff both ValShare and UserShare are non-nil. Does not validate the shares." },
|
||||||
|
"PubKeyHex": { type: "func() string", description: "Hex of the compressed public point (PubHex)." },
|
||||||
|
"PubKeyBytes": { type: "func() []byte", description: "The uncompressed 65-byte public point (PubBytes)." },
|
||||||
|
"Sign": { type: "func(data []byte) ([]byte, error)", description: "Runs 2-party DKLs18 signing. Returns 64 bytes, r || s." },
|
||||||
|
"Verify": { type: "func(data, sig []byte) (bool, error)", description: "ecdsa.Verify over SHA3-256(data). Errors only on malformed input; an invalid signature returns (false, nil)." },
|
||||||
|
"Refresh": { type: "func() (Enclave, error)", description: "Rotates both shares, returns a NEW Enclave. Does not mutate the receiver." },
|
||||||
|
"Encrypt": { type: "func(key []byte) ([]byte, error)", description: "AES-256-GCM over Marshal() output, using the enclave's stored Nonce." },
|
||||||
|
"Decrypt": { type: "func(key, encryptedData []byte) ([]byte, error)", description: "Inverse of Encrypt. Returns plaintext JSON; does not populate the receiver." },
|
||||||
|
"Marshal": { type: "func() ([]byte, error)", description: "encoding/json over EnclaveData — both shares included, in the clear." },
|
||||||
|
"Unmarshal": { type: "func(data []byte) error", description: "encoding/json into the receiver. PANICS on Marshal() output — see the callout above." },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
:::note[The interface doc comments are shuffled]
|
||||||
|
In `mpc/codec.go` the `Unmarshal` line is commented `// Verify returns true if the signature is valid`
|
||||||
|
and `Marshal` is commented `// Serialize returns the serialized keyEnclave`. The behaviour is what
|
||||||
|
the method names say; the comments are stale.
|
||||||
|
:::
|
||||||
|
|
||||||
|
`GetPubPoint()` is available on `*EnclaveData` but not on the interface:
|
||||||
|
|
||||||
|
```go
|
||||||
|
point, err := enclave.GetData().GetPubPoint() // curves.Point on k.Curve
|
||||||
|
```
|
||||||
|
|
||||||
|
It reconstructs the point with `curve.NewIdentityPoint().FromAffineUncompressed(k.PubBytes)`, which
|
||||||
|
is why `PubBytes` must stay uncompressed.
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
```go
|
||||||
|
const (
|
||||||
|
RoleVal = "validator"
|
||||||
|
RoleUser = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Role string
|
||||||
|
```
|
||||||
|
|
||||||
|
The mapping is fixed and worth memorising, because the field names and the protocol names differ:
|
||||||
|
|
||||||
|
| Field | Role constant | DKLs18 party | Sign func | Refresh func |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `ValShare` | `RoleVal` | Alice | `GetAliceSignFunc` | `GetAliceRefreshFunc` |
|
||||||
|
| `UserShare` | `RoleUser` | Bob | `GetBobSignFunc` | `GetBobRefreshFunc` |
|
||||||
|
|
||||||
|
`Role` and the two constants are declared but nothing in the package consumes them — they are there
|
||||||
|
for callers that need to label a share. Note the constants are untyped strings, not `Role` values.
|
||||||
|
|
||||||
|
## Import and export
|
||||||
|
|
||||||
|
`ImportEnclave` applies a variadic list of options and dispatches on which one was set. `Options`
|
||||||
|
holds only unexported fields, so `ImportEnclave` (or `Options{}.Apply()`, which sees a zero value) is
|
||||||
|
the intended entry point.
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"WithInitialShares": {
|
||||||
|
type: "func(valKeyshare, userKeyshare Message, curve CurveName) ImportOption",
|
||||||
|
description: "Build a fresh enclave from two DKG results. Derives PubBytes/PubHex from the validator share and generates a new random 12-byte Nonce."
|
||||||
|
},
|
||||||
|
"WithEnclaveData": {
|
||||||
|
type: "func(data *EnclaveData) ImportOption",
|
||||||
|
description: "Adopt an existing *EnclaveData verbatim. Errors only if data is nil."
|
||||||
|
},
|
||||||
|
"WithEncryptedData": {
|
||||||
|
type: "func(data, key []byte) ImportOption",
|
||||||
|
description: "Intended to restore from Encrypt() output. Broken — see the callout below."
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
`Apply()` resolves in a fixed precedence: encrypted data first, then initial shares, then enclave
|
||||||
|
data. `ImportEnclave` with zero options errors with `"no import options provided"`; with only
|
||||||
|
`WithEnclaveData(nil)` it errors with `"enclave data cannot be nil"`.
|
||||||
|
|
||||||
|
The three lower-level constructors are exported and callable directly:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Assemble from two protocol results (what NewEnclave does internally).
|
||||||
|
e, err := mpc.BuildEnclave(valShare, userShare, mpc.Options{})
|
||||||
|
|
||||||
|
// Adopt a deserialized struct.
|
||||||
|
e, err := mpc.RestoreEnclaveFromData(data)
|
||||||
|
|
||||||
|
// Decrypt and adopt. Does not work; see below.
|
||||||
|
e, err := mpc.RestoreEncryptedEnclave(ciphertext, key)
|
||||||
|
```
|
||||||
|
|
||||||
|
:::warning[`BuildEnclave` with a bare `Options{}` records an empty curve]
|
||||||
|
`BuildEnclave` copies `options.curve` into `EnclaveData.Curve`. A zero `Options` leaves that as the
|
||||||
|
empty string. `CurveName("").Curve()` falls through to `curves.K256()`, so signing still works on
|
||||||
|
secp256k1 — but the persisted JSON records `"curve": ""`. Prefer
|
||||||
|
`mpc.ImportEnclave(mpc.WithInitialShares(val, user, mpc.K256Name))`, which sets it explicitly.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::danger[The encrypted-import path cannot succeed]
|
||||||
|
`RestoreEncryptedEnclave` is unreachable-working by construction:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func RestoreEncryptedEnclave(data []byte, key []byte) (Enclave, error) {
|
||||||
|
keyclave := &EnclaveData{}
|
||||||
|
err := keyclave.Unmarshal(data) // <- JSON-parses the CIPHERTEXT
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal enclave: %w", err)
|
||||||
|
}
|
||||||
|
decryptedData, err := keyclave.Decrypt(key, data)
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`data` is the AES-256-GCM output of `Encrypt` — indistinguishable from random bytes. `json.Unmarshal`
|
||||||
|
on it fails, and the function returns before ever decrypting. Even if that line were removed, the
|
||||||
|
next one could not work either: `Decrypt` reads the nonce from `k.Nonce`, which is a *field of the
|
||||||
|
still-encrypted struct* and is therefore nil at that point, so `aesgcm.Open` would fail on a
|
||||||
|
zero-length nonce.
|
||||||
|
|
||||||
|
Consequently `mpc.ImportEnclave(mpc.WithEncryptedData(ct, key))` also always fails, since `Apply()`
|
||||||
|
routes straight to `RestoreEncryptedEnclave`. Nothing in the repository calls either one — grepping
|
||||||
|
the module, the only references are the definitions themselves and the `Apply()` dispatch, and
|
||||||
|
`mpc/enclave_test.go` never exercises them.
|
||||||
|
|
||||||
|
**There is no working round trip through this package.** You can decrypt — but the plaintext is the
|
||||||
|
JSON produced by `Marshal()`, and `Unmarshal` panics on it (see the previous section). Decryption on
|
||||||
|
its own works if you keep the nonce:
|
||||||
|
|
||||||
|
```go
|
||||||
|
data := enclave.GetData()
|
||||||
|
nonce := data.Nonce // you MUST persist this alongside the ciphertext
|
||||||
|
|
||||||
|
ct, err := data.Encrypt(key)
|
||||||
|
// ... later, in a fresh process ...
|
||||||
|
shell := &mpc.EnclaveData{Nonce: nonce}
|
||||||
|
plaintext, err := shell.Decrypt(key, ct) // plaintext == the original Marshal() JSON
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// plaintext CANNOT be fed to (*EnclaveData).Unmarshal — it panics.
|
||||||
|
```
|
||||||
|
|
||||||
|
`TestEnclaveData_EncryptDecrypt` passes precisely because it stops here: it compares the decrypted
|
||||||
|
bytes against `Marshal()` output and never decodes them. To actually restore an enclave, encrypt and
|
||||||
|
decode with your own codec as described in the panic callout above.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Encryption at rest
|
||||||
|
|
||||||
|
`Encrypt` / `Decrypt` are AES-256-GCM. The key is derived by `GetHashKey`, which is
|
||||||
|
`sha3.New256(key)` truncated to 32 bytes. The nonce is `EnclaveData.Nonce` — 12 random bytes
|
||||||
|
generated **once**, at `BuildEnclave` time, and then reused for every call.
|
||||||
|
|
||||||
|
:::danger[Fixed per-enclave nonce]
|
||||||
|
GCM security collapses if a `(key, nonce)` pair is ever reused for two different plaintexts: the
|
||||||
|
keystream repeats, XOR-ing two ciphertexts reveals the XOR of the plaintexts, and the GHASH
|
||||||
|
authentication key becomes recoverable, which lets an attacker forge tags.
|
||||||
|
|
||||||
|
Because `Nonce` is fixed for the enclave's whole lifetime, calling `Encrypt(key)` twice with the same
|
||||||
|
`key` on **different** enclave contents — most obviously before and after a `Refresh()`, or after any
|
||||||
|
field changes — reuses `(key, nonce)`. Encrypting the *same* bytes twice is merely deterministic;
|
||||||
|
encrypting *different* bytes twice is a break.
|
||||||
|
|
||||||
|
Mitigations, in order of preference:
|
||||||
|
|
||||||
|
1. Do not use these methods. Marshal the enclave and encrypt with a fresh random nonce per operation
|
||||||
|
using [`aead`](/symmetric/aead).
|
||||||
|
2. If you must use them, use a distinct `key` for every encryption, and never reuse a key across a
|
||||||
|
refresh.
|
||||||
|
|
||||||
|
Note also that `Refresh()` returns a new `Enclave` with a new random nonce, while the original value
|
||||||
|
keeps the old one — so the hazard is per-value, not per-key-lifetime.
|
||||||
|
:::
|
||||||
|
|
||||||
|
`EncryptKeyshare` / `DecryptKeyshare` are the single-share equivalents, and they take the nonce as an
|
||||||
|
explicit parameter, which is the right shape:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func EncryptKeyshare(msg Message, key []byte, nonce []byte) ([]byte, error)
|
||||||
|
func DecryptKeyshare(msg []byte, key []byte, nonce []byte) ([]byte, error)
|
||||||
|
func GetHashKey(key []byte) []byte // SHA3-256(key)[:32]
|
||||||
|
```
|
||||||
|
|
||||||
|
`EncryptKeyshare` runs `protocol.EncodeMessage(msg)` first, so it operates on the wire encoding of a
|
||||||
|
`*protocol.Message`, not on JSON.
|
||||||
|
|
||||||
|
## Refresh
|
||||||
|
|
||||||
|
`Refresh()` runs the DKLs18 key-refresh protocol on both sides and returns a fresh `Enclave`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (k *EnclaveData) Refresh() (Enclave, error) {
|
||||||
|
refreshFuncVal, _ := GetAliceRefreshFunc(k)
|
||||||
|
refreshFuncUser, _ := GetBobRefreshFunc(k)
|
||||||
|
return ExecuteRefresh(refreshFuncVal, refreshFuncUser, k.Curve)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Three properties, all asserted in `TestEnclaveData_RefreshAndSign`:
|
||||||
|
|
||||||
|
1. **Shares change.** Both `ValShare` and `UserShare` are replaced by the refresh outputs.
|
||||||
|
2. **The public key does not.** `PubKeyHex()` and `PubKeyBytes()` are byte-identical before and after.
|
||||||
|
3. **Signatures are interchangeable.** A signature made before the refresh verifies under the
|
||||||
|
refreshed enclave and vice versa, because verification only touches the public key.
|
||||||
|
|
||||||
|
This is proactive security: an attacker who exfiltrated one share before the refresh holds a share
|
||||||
|
that no longer combines with anything.
|
||||||
|
|
||||||
|
:::warning[`Refresh` returns; it does not rotate in place]
|
||||||
|
The receiver is unchanged. If you keep using the old value you keep using the old shares, and the old
|
||||||
|
shares still sign valid signatures. Replace your reference and destroy the old serialization.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Driving the protocol yourself
|
||||||
|
|
||||||
|
Everything above is assembled from these exported pieces. Use them when the two shares live in
|
||||||
|
different processes and you need to shuttle `*protocol.Message` values between them.
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"RunProtocol": {
|
||||||
|
type: "func(firstParty, secondParty protocol.Iterator) (error, error)",
|
||||||
|
description: "Cranks two iterators against each other until both return protocol.ErrProtocolFinished. Returns (aErr, bErr)."
|
||||||
|
},
|
||||||
|
"CheckIteratedErrors": {
|
||||||
|
type: "func(aErr, bErr error) error",
|
||||||
|
description: "Collapses RunProtocol's pair: nil if both are ErrProtocolFinished, otherwise the first real error."
|
||||||
|
},
|
||||||
|
"ExecuteSigning": {
|
||||||
|
type: "func(signFuncVal, signFuncUser SignFunc) ([]byte, error)",
|
||||||
|
description: "Runs both sign iterators, takes the USER side's result, decodes it, and serializes to 64 bytes."
|
||||||
|
},
|
||||||
|
"ExecuteRefresh": {
|
||||||
|
type: "func(refreshFuncVal, refreshFuncUser RefreshFunc, curve CurveName) (Enclave, error)",
|
||||||
|
description: "Runs both refresh iterators and re-imports the two results as a new enclave."
|
||||||
|
},
|
||||||
|
"GetAliceSignFunc": { type: "func(k *EnclaveData, bz []byte) (SignFunc, error)", description: "dklsv1.NewAliceSign on k.Curve with sha3.New256 over bz." },
|
||||||
|
"GetBobSignFunc": { type: "func(k *EnclaveData, bz []byte) (SignFunc, error)", description: "dklsv1.NewBobSign — hardcodes curves.K256(); see caveat." },
|
||||||
|
"GetAliceRefreshFunc": { type: "func(k *EnclaveData) (RefreshFunc, error)", description: "dklsv1.NewAliceRefresh on k.Curve." },
|
||||||
|
"GetBobRefreshFunc": { type: "func(k *EnclaveData) (RefreshFunc, error)", description: "dklsv1.NewBobRefresh — hardcodes curves.K256(); see caveat." },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
Type aliases, from `mpc/codec.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type (
|
||||||
|
AliceOut *dkg.AliceOutput
|
||||||
|
BobOut *dkg.BobOutput
|
||||||
|
Point curves.Point
|
||||||
|
Message *protocol.Message
|
||||||
|
Signature *curves.EcdsaSignature
|
||||||
|
RefreshFunc interface{ protocol.Iterator }
|
||||||
|
SignFunc interface{ protocol.Iterator }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Decoding DKG results:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func GetAliceOut(msg *protocol.Message) (AliceOut, error)
|
||||||
|
func GetBobOut(msg *protocol.Message) (BobOut, error)
|
||||||
|
func GetAlicePublicPoint(msg *protocol.Message) (Point, error)
|
||||||
|
func GetBobPubPoint(msg *protocol.Message) (Point, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both parties derive the same public key, so `GetAlicePublicPoint` and `GetBobPubPoint` on the
|
||||||
|
respective DKG outputs agree; `BuildEnclave` uses the Alice side.
|
||||||
|
|
||||||
|
## Signature encoding
|
||||||
|
|
||||||
|
```go
|
||||||
|
func SerializeSignature(sig *curves.EcdsaSignature) ([]byte, error)
|
||||||
|
func DeserializeSignature(sigBytes []byte) (*curves.EcdsaSignature, error)
|
||||||
|
func GetECDSAPoint(pubKey []byte) (*curves.EcPoint, error)
|
||||||
|
func VerifyWithPubKey(pubKeyCompressed, data, sig []byte) (bool, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
`SerializeSignature` emits a **fixed 64-byte** buffer: `r` left-zero-padded to 32 bytes, then `s`
|
||||||
|
left-zero-padded to 32 bytes. No `V` byte, no DER, no length prefix. `DeserializeSignature` rejects
|
||||||
|
anything that is not exactly 64 bytes with
|
||||||
|
`"invalid signature length: expected 64 bytes, got N"`. The `EcdsaSignature.V` field is left zero on
|
||||||
|
the deserialize path.
|
||||||
|
|
||||||
|
:::warning[`VerifyWithPubKey`'s parameter name is wrong]
|
||||||
|
The parameter is named `pubKeyCompressed`, but it is passed to `GetECDSAPoint`, which slices
|
||||||
|
`x = pubKey[1:33]` and `y = pubKey[33:]` — that is the **uncompressed** 65-byte layout. Pass
|
||||||
|
`enclave.PubKeyBytes()` (uncompressed), not `PubKeyHex()`-decoded bytes (compressed). Supplying 33
|
||||||
|
bytes yields `y = 0` and verification silently returns `false`.
|
||||||
|
|
||||||
|
`GetECDSAPoint` also always uses `curves.K256()`, ignoring the enclave's `Curve` field, and does no
|
||||||
|
length or on-curve check.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Signatures are **not** compatible with [`keys.PubKey.Verify`](/identity/did-key), which requires a
|
||||||
|
66-byte `V || R || S` layout.
|
||||||
|
|
||||||
|
## `CurveName`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type CurveName string
|
||||||
|
|
||||||
|
const (
|
||||||
|
K256Name CurveName = "secp256k1"
|
||||||
|
BLS12381G1Name CurveName = "BLS12381G1"
|
||||||
|
BLS12381G2Name CurveName = "BLS12381G2"
|
||||||
|
BLS12831Name CurveName = "BLS12831"
|
||||||
|
P256Name CurveName = "P-256"
|
||||||
|
ED25519Name CurveName = "ed25519"
|
||||||
|
PallasName CurveName = "pallas"
|
||||||
|
BLS12377G1Name CurveName = "BLS12377G1"
|
||||||
|
BLS12377G2Name CurveName = "BLS12377G2"
|
||||||
|
BLS12377Name CurveName = "BLS12377"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Curve()` maps each name to a [`*curves.Curve`](/foundations/curves). `String()` is the underlying
|
||||||
|
string. The mapping has two quirks worth knowing:
|
||||||
|
|
||||||
|
| Name | Maps to | Note |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BLS12831Name` | `curves.BLS12381G1()` | `"BLS12831"` is a transposition of 12381; aliased to G1 |
|
||||||
|
| `BLS12377Name` | `curves.BLS12377G1()` | Aggregate name aliased to G1 |
|
||||||
|
| *anything else* | `curves.K256()` | Silent default — including the empty string |
|
||||||
|
|
||||||
|
:::danger[Only secp256k1 actually works]
|
||||||
|
`CurveName` advertises ten curves, but the package is secp256k1-only in practice:
|
||||||
|
|
||||||
|
- `NewEnclave()` hardcodes `K256Name`.
|
||||||
|
- `GetBobSignFunc` and `GetBobRefreshFunc` ignore `k.Curve` and pass `curves.K256()`, while the Alice
|
||||||
|
side honours `k.Curve`. Setting `Curve` to anything else therefore puts the two parties on
|
||||||
|
different curves.
|
||||||
|
- `GetECDSAPoint`, used by both `Verify` and `VerifyWithPubKey`, always uses `curves.K256()`.
|
||||||
|
- The `default` branch of `Curve()` returns `curves.K256()` instead of erroring, so a typo in a
|
||||||
|
persisted `"curve"` field is silently coerced rather than rejected.
|
||||||
|
|
||||||
|
Treat every constant other than `K256Name` as unimplemented.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Signing digests and double hashing
|
||||||
|
|
||||||
|
`GetAliceSignFunc`/`GetBobSignFunc` pass `sha3.New256()` and the raw message into `dklsv1`, which
|
||||||
|
hashes internally; `Verify` independently computes `sha3.New256(data)` and calls `ecdsa.Verify` on
|
||||||
|
that digest. So `Sign(m)`/`Verify(m, sig)` are consistent, and the digest is SHA3-256 — not SHA-256.
|
||||||
|
|
||||||
|
This matters for [UCAN](/identity/ucan): `ucan.MPCSigningMethod` hashes the JWT signing string with
|
||||||
|
**SHA-256** and then calls `enclave.Sign(digest)`, which hashes that 32-byte digest again with
|
||||||
|
SHA3-256. The composition is `SHA3-256(SHA-256(signingString))`. It verifies correctly because
|
||||||
|
`Verify` does the same thing, but any external verifier must replicate both hashes.
|
||||||
|
|
||||||
|
## `mpc/spec`
|
||||||
|
|
||||||
|
`mpc/spec` is a **near-duplicate fork** of the UCAN types and MPC JWT plumbing that also lives in
|
||||||
|
`github.com/sonr-io/crypto/ucan`. It redeclares `Token`, `Attenuation`, `Proof`, `Fact`, the
|
||||||
|
`Capability` and `Resource` interfaces, `SimpleCapability`, `SimpleResource`, `KeyshareSource`, and
|
||||||
|
`CreateSimpleAttenuation`, and adds:
|
||||||
|
|
||||||
|
```go
|
||||||
|
const (
|
||||||
|
UCANVersion = "0.9.0"
|
||||||
|
UCANVersionKey = "ucv"
|
||||||
|
PrfKey = "prf"
|
||||||
|
FctKey = "fct"
|
||||||
|
AttKey = "att"
|
||||||
|
CapKey = "cap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewSource(enclave mpc.Enclave) (KeyshareSource, error)
|
||||||
|
func NewJWTSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod
|
||||||
|
func NewMPCSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod // alias
|
||||||
|
func RegisterMPCMethod(alg string)
|
||||||
|
func (m *MPCSigningMethod) WithEnclave(enclave mpc.Enclave) *MPCSigningMethod
|
||||||
|
```
|
||||||
|
|
||||||
|
`spec` is the only place in the module that names `UCANVersion` as a constant — `ucan` writes the
|
||||||
|
literal `"0.9.0"` inline into the `ucv` JWT header.
|
||||||
|
|
||||||
|
:::danger[`mpc/spec`'s signing method violates the jwt/v5 contract]
|
||||||
|
`golang-jwt/jwt/v5` requires `SigningMethod.Sign` to return the **raw** signature bytes (the library
|
||||||
|
base64url-encodes them) and passes `Verify` the **already-decoded** bytes. `spec`'s implementation
|
||||||
|
does the encoding itself in both directions:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Sign
|
||||||
|
encoded := base64.RawURLEncoding.EncodeToString(sig)
|
||||||
|
return []byte(encoded), nil
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(string(signature))
|
||||||
|
```
|
||||||
|
|
||||||
|
So a token minted through `spec` carries base64-of-base64 in its signature segment, and `Verify`
|
||||||
|
base64-decodes bytes that jwt/v5 already decoded. `ucan.MPCSigningMethod` gets this right — it
|
||||||
|
returns and consumes raw bytes.
|
||||||
|
|
||||||
|
Worse, `spec`'s `init()` registers this implementation **globally**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func init() {
|
||||||
|
jwt.RegisterSigningMethod("MPC256", func() jwt.SigningMethod {
|
||||||
|
return &MPCSigningMethod{Name: "MPC256"} // enclave is nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Any program that imports `mpc/spec`, even transitively and even without calling anything in it,
|
||||||
|
installs a global `"MPC256"` method whose factory produces a method with a nil enclave — so
|
||||||
|
`jwt.Parse` on an MPC-signed token resolves to it and fails with
|
||||||
|
`"MPC enclave not available for signature verification"`. `RegisterMPCMethod(alg)` does the same for
|
||||||
|
an arbitrary algorithm name.
|
||||||
|
:::
|
||||||
|
|
||||||
|
**Use `github.com/sonr-io/crypto/ucan`, not `mpc/spec`.** `spec` is a maintenance hazard: two copies
|
||||||
|
of the same type set that will drift, one of which is broken. It has no tests. Note that the
|
||||||
|
duplication also means `ucan.Attenuation` and `spec.Attenuation` are distinct, non-interconvertible
|
||||||
|
types.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
:::warning[`randNonce` ignores its error]
|
||||||
|
`mpc/codec.go`: `rand.Read(nonce)` is called without checking the return values. On a platform where
|
||||||
|
`crypto/rand` fails, the nonce would be all zeros. In practice `crypto/rand.Read` on modern Go does
|
||||||
|
not fail, but the omission is real.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::warning[`IsValid` is a nil check]
|
||||||
|
`IsValid()` returns `k.ValShare != nil && k.UserShare != nil`. It does not check that the shares
|
||||||
|
belong to the same key, that `PubBytes` matches them, or that `Curve` is set. Any `*EnclaveData` with
|
||||||
|
two non-nil share pointers reports as "valid" and then fails at sign time.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::warning[`Result` can return `(nil, nil)`]
|
||||||
|
`dklsv1`'s `Result(version)` returns `(nil, nil)` when the protocol has not finished — its
|
||||||
|
completion check runs before its initialization check. `NewEnclave`, `ExecuteSigning` and
|
||||||
|
`ExecuteRefresh` all call `Result` immediately after `CheckIteratedErrors` returns nil, so on the
|
||||||
|
happy path this does not bite. But if you drive the iterators yourself, an `err == nil` from
|
||||||
|
`Result` does **not** guarantee a non-nil `*protocol.Message`, and passing nil into
|
||||||
|
`GetAliceOut`/`GetBobOut`/`GetAlicePublicPoint`/`GetBobPubPoint` or `dklsv1.DecodeSignature`
|
||||||
|
nil-dereferences. Always nil-check the message as well as the error.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::warning[`RunProtocol`'s error pair is asymmetric]
|
||||||
|
`RunProtocol(firstParty, secondParty)` returns `(aErr, bErr)` where `aErr` tracks the *second*
|
||||||
|
argument and `bErr` the *first*. On an early real error it returns `(nil, bErr)` or `(aErr, nil)` —
|
||||||
|
so always funnel the pair through `CheckIteratedErrors` rather than inspecting the two values
|
||||||
|
positionally. Note also that `NewEnclave` calls `RunProtocol(userKs, valKs)`, i.e. the user side is
|
||||||
|
`firstParty`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::info[Marshal is plaintext JSON]
|
||||||
|
`Marshal()` serializes both keyshares in the clear. If you persist that output, it is the complete
|
||||||
|
signing key. Protect it accordingly — and given the fixed-nonce hazard above, prefer an independent
|
||||||
|
AEAD over the built-in `Encrypt`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
<CardGroup cols={2}>
|
||||||
|
<Card title="UCAN Tokens" href="/identity/ucan" icon="ticket">
|
||||||
|
Signing capability tokens with an enclave, and what the verifier does and does not check.
|
||||||
|
</Card>
|
||||||
|
<Card title="Threshold ECDSA" href="/threshold/threshold-ecdsa" icon="users">
|
||||||
|
The `tecdsa/dklsv1` protocol underneath, for when you need to run the two parties apart.
|
||||||
|
</Card>
|
||||||
|
<Card title="did:key Identifiers" href="/identity/did-key" icon="id-card">
|
||||||
|
Turning `PubKeyBytes()` into a stable identifier.
|
||||||
|
</Card>
|
||||||
|
<Card title="AEAD" href="/symmetric/aead" icon="lock">
|
||||||
|
Encrypting a marshalled enclave properly, with a fresh nonce per operation.
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
@@ -0,0 +1,751 @@
|
|||||||
|
---
|
||||||
|
title: UCAN Capability Tokens
|
||||||
|
description: JWT-based User-Controlled Authorization Network tokens signed by an MPC enclave — capabilities, attenuation, delegation chains, templates, and the authorization checks that are not implemented.
|
||||||
|
sidebar:
|
||||||
|
order: 4
|
||||||
|
icon: ticket
|
||||||
|
---
|
||||||
|
|
||||||
|
`github.com/sonr-io/crypto/ucan` implements UCAN — capability tokens where authority flows from a key
|
||||||
|
rather than from a server-side ACL. A token is a JWT whose issuer (`iss`) is a
|
||||||
|
[`did:key`](/identity/did-key), whose audience (`aud`) is the recipient's DID, and whose `att` claim
|
||||||
|
is a list of *attenuations*: `(capability, resource)` pairs. The holder of a token can mint a new
|
||||||
|
token that grants a **subset** of its own authority to someone else, attaching the parent token as a
|
||||||
|
proof in `prf`. Verification walks that chain back to a root the verifier trusts.
|
||||||
|
|
||||||
|
Tokens carry the UCAN version in a `ucv` JWT header. In this package that value is the literal
|
||||||
|
`"0.9.0"`, written inline in `ucan/source.go`; the only exported constant naming it is
|
||||||
|
`spec.UCANVersion` in [`mpc/spec`](/identity/mpc-enclave).
|
||||||
|
|
||||||
|
**Reach for this when** you need offline-verifiable, expiring, narrowable authorization derived from
|
||||||
|
a key you control.
|
||||||
|
|
||||||
|
## Read this first
|
||||||
|
|
||||||
|
:::danger[Four authorization gaps]
|
||||||
|
The package's own authorization logic has holes that a reader would not guess from the API surface.
|
||||||
|
Each was verified by reading the source and confirmed by running it; each is detailed below.
|
||||||
|
|
||||||
|
1. **`GenerateJWTToken`, `GenerateModuleJWTToken`, `VerifyJWTToken` and `VerifyModuleJWTToken` sign
|
||||||
|
and verify with HS256 under the hardcoded secret `"sonr-ucan-secret"`**, which is compiled into
|
||||||
|
the package and therefore known to anyone with the source. Any party can mint a token these
|
||||||
|
functions accept.
|
||||||
|
2. **Caveat validation is a no-op.** Every `validate*Caveat` helper in `verifier.go` returns `nil`
|
||||||
|
unconditionally, so a caveat such as `"owner"` or `"max-amount"` restricts nothing.
|
||||||
|
3. **`MPCTokenBuilder.CreateDelegatedToken` does not enforce attenuation** — it will happily sign a
|
||||||
|
child token that grants *more* than its parent. Only `KeyshareSource.NewAttenuatedToken` checks
|
||||||
|
the subset property.
|
||||||
|
4. **`RevokeCapability` effectively does nothing**, because it revokes a *freshly minted* token
|
||||||
|
string rather than the one you issued.
|
||||||
|
|
||||||
|
The MPC-signed path has real cryptography behind it, but two further constraints apply: verification
|
||||||
|
as written requires possession of the signer's enclave, and `MPCVerifier.VerifyMPCToken` currently
|
||||||
|
fails outright because `"MPC256"` is never registered with `golang-jwt`. Both are covered under
|
||||||
|
[MPC signing and verification](#mpc-signing-and-verification).
|
||||||
|
:::
|
||||||
|
|
||||||
|
## The capability model
|
||||||
|
|
||||||
|
Two interfaces carry the whole model.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Capability interface {
|
||||||
|
GetActions() []string // the actions this capability grants
|
||||||
|
Grants(abilities []string) bool // does it grant all of these?
|
||||||
|
Contains(other Capability) bool // does it subsume another capability?
|
||||||
|
String() string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resource interface {
|
||||||
|
GetScheme() string // "ipfs", "did", "dwn", "service", ...
|
||||||
|
GetValue() string // the path/identifier
|
||||||
|
GetURI() string // the full "scheme://value"
|
||||||
|
Matches(other Resource) bool // equivalence, by URI
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attenuation struct {
|
||||||
|
Capability Capability `json:"can"`
|
||||||
|
Resource Resource `json:"with"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AttenuationList` is `[]Attenuation` with query helpers:
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"Contains": { type: "func(resourceURI string) bool", description: "Is there any attenuation whose resource URI matches exactly?" },
|
||||||
|
"GetCapabilitiesForResource": { type: "func(resourceURI string) []Capability", description: "All capabilities attached to that exact URI." },
|
||||||
|
"CanPerform": { type: "func(resourceURI string, actions []string) bool", description: "Does any capability on that URI grant every one of these actions?" },
|
||||||
|
"IsSubsetOf": { type: "func(parent AttenuationList) bool", description: "Every child attenuation must be matched by a parent whose resource Matches and whose capability Contains it." },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
### Attenuation
|
||||||
|
|
||||||
|
Attenuation is the invariant that makes UCAN safe to hand around: **a delegated token may only
|
||||||
|
narrow its parent's authority, never widen it.** `IsSubsetOf` is the check:
|
||||||
|
|
||||||
|
```go
|
||||||
|
parent := ucan.AttenuationList{
|
||||||
|
ucan.CreateMultiAttenuation([]string{"read", "write", "delete"}, "service://api"),
|
||||||
|
}
|
||||||
|
child := ucan.AttenuationList{
|
||||||
|
ucan.CreateSimpleAttenuation("read", "service://api"),
|
||||||
|
}
|
||||||
|
|
||||||
|
child.IsSubsetOf(parent) // true — narrower
|
||||||
|
parent.IsSubsetOf(child) // false — wider
|
||||||
|
```
|
||||||
|
|
||||||
|
The rule composes: for every attenuation in the child list there must exist a parent attenuation
|
||||||
|
whose `Resource.Matches` is true *and* whose `Capability.Contains` is true. Resource matching is
|
||||||
|
plain URI string equality (`SimpleResource.Matches`), so there is no prefix or wildcard matching at
|
||||||
|
the resource level — only at the action level, via `"*"`.
|
||||||
|
|
||||||
|
### Capability types
|
||||||
|
|
||||||
|
Every type below implements `Capability`. The module-specific ones exist so that a verifier can pick
|
||||||
|
the right caveat and serialization path from the resource scheme.
|
||||||
|
|
||||||
|
| Type | Shape | Grants semantics |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SimpleCapability` | `{Action string}` | Grants exactly its one action |
|
||||||
|
| `MultiCapability` | `{Actions []string}` | Grants every requested action present in the set |
|
||||||
|
| `VaultCapability` | `Action`, `Actions`, `VaultAddress`, `Caveats`, `EnclaveDataCID`, `Metadata` | Vault operations; JSON tags `can`/`vault`/`cavs` |
|
||||||
|
| `DIDCapability` | `Action`, `Actions`, `Caveats`, `Metadata` | DID document operations |
|
||||||
|
| `DWNCapability` | `Action`, `Actions`, `Caveats`, `Metadata` | Decentralized Web Node records |
|
||||||
|
| `DEXCapability` | plus `MaxAmount string` | Swap/liquidity operations with an amount cap |
|
||||||
|
| `CrossModuleCapability` | `{Modules map[string]Capability}` | Composes per-module capabilities |
|
||||||
|
| `GaslessCapability` | embeds `Capability`, plus `AllowGasless bool`, `GasLimit uint64` | Decorator; adds `SupportsGasless()` and `GetGasLimit()` |
|
||||||
|
|
||||||
|
`GetActions()` on the module types returns `Actions` when non-empty and `[]string{Action}` otherwise;
|
||||||
|
`Grants` short-circuits to `true` when `Action == "*"`.
|
||||||
|
|
||||||
|
Resources mirror them, each embedding `SimpleResource`: `VaultResource` (`VaultAddress`,
|
||||||
|
`EnclaveDataCID`), `VaultResourceExt`, `DIDResource` (`DIDMethod`, `DIDSubject`), `DWNResource`
|
||||||
|
(`RecordType`, `Protocol`, `Owner`), `DEXResource` (`PoolID`, `AssetPair`, `OrderID`), and
|
||||||
|
`ServiceResource` (`ServiceID`, `Domain`, plus `SupportsDelegate()`).
|
||||||
|
|
||||||
|
### Constructors
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"CreateSimpleAttenuation": { type: "func(action, resourceURI string) Attenuation", description: "SimpleCapability + a SimpleResource parsed from the URI." },
|
||||||
|
"CreateMultiAttenuation": { type: "func(actions []string, resourceURI string) Attenuation", description: "MultiCapability + SimpleResource." },
|
||||||
|
"CreateVaultAttenuation": { type: "func(actions []string, enclaveDataCID, vaultAddress string) Attenuation", description: "MultiCapability + VaultResource with scheme \"ipfs\" and URI \"ipfs://<cid>\"." },
|
||||||
|
"CreateDIDAttenuation": { type: "func(actions []string, didPattern string, caveats []string) Attenuation", description: "DIDCapability + DIDResource with URI \"did:<pattern>\"." },
|
||||||
|
"CreateDWNAttenuation": { type: "func(actions []string, recordPattern string, caveats []string) Attenuation", description: "DWNCapability + DWNResource." },
|
||||||
|
"CreateDEXAttenuation": { type: "func(actions []string, poolPattern string, caveats []string, maxAmount string) Attenuation", description: "DEXCapability + DEXResource." },
|
||||||
|
"CreateServiceAttenuation": { type: "func(actions []string, serviceID, domain string) Attenuation", description: "MultiCapability + ServiceResource with URI \"service://<id>\"." },
|
||||||
|
"NewCapability": { type: "func(issuer, resource string, abilities []string) (Attenuation, error)", description: "MultiCapability + SimpleResource with scheme \"generic\". The issuer argument is IGNORED and the error is always nil." },
|
||||||
|
"VaultAttenuationConstructor": { type: "func(m map[string]any) (Attenuation, error)", description: "Builds a vault attenuation from a decoded claim map, running ValidateVaultCapability first." },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
:::note
|
||||||
|
`CreateVaultAttenuation(actions, enclaveDataCID, vaultAddress)` takes the CID **before** the address.
|
||||||
|
`MPCTokenBuilder.CreateVaultCapabilityToken(aud, vaultAddress, enclaveDataCID, ...)` takes them in
|
||||||
|
the opposite order. Getting these backwards produces a token whose resource URI is
|
||||||
|
`ipfs://<vault-address>`, which will pass CID-format validation only if the address happens to look
|
||||||
|
like a CID — usually it silently fails later.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## The `Token` type
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Token struct {
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Issuer string `json:"iss"`
|
||||||
|
Audience string `json:"aud"`
|
||||||
|
ExpiresAt int64 `json:"exp,omitempty"`
|
||||||
|
NotBefore int64 `json:"nbf,omitempty"`
|
||||||
|
Attenuations []Attenuation `json:"att"`
|
||||||
|
Proofs []Proof `json:"prf,omitempty"`
|
||||||
|
Facts []Fact `json:"fct,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Proof string // a JWT string or a CID
|
||||||
|
type Fact struct{ Data json.RawMessage `json:"data"` }
|
||||||
|
```
|
||||||
|
|
||||||
|
`Raw` is the encoded JWT when the token came from a verifier or a signing builder, and `""` when it
|
||||||
|
came from `TokenBuilder`, which does not sign.
|
||||||
|
|
||||||
|
### `TokenBuilder`
|
||||||
|
|
||||||
|
`TokenBuilder` and `TokenBuilderInterface` (`CreateOriginToken`, `CreateDelegatedToken`) live in
|
||||||
|
`ucan/stubs.go` and are exactly what the filename says: they assemble a `*Token` struct with
|
||||||
|
`Raw: ""` and no signature. `CreateDelegatedToken` copies `parentToken.Raw` into `Proofs` if it is
|
||||||
|
non-empty and sets `Audience: parentToken.Issuer`.
|
||||||
|
|
||||||
|
They exist because `NewVaultAdminToken(builder TokenBuilderInterface, vaultOwnerDID, vaultAddress,
|
||||||
|
enclaveDataCID string, exp time.Time)` takes the interface. Pass an `MPCTokenBuilder`-backed
|
||||||
|
implementation if you need a signed result; `&TokenBuilder{}` gives you an unsigned struct.
|
||||||
|
|
||||||
|
## MPC signing and verification
|
||||||
|
|
||||||
|
This is the path with real cryptography. `MPCSigningMethod` plugs an
|
||||||
|
[`mpc.Enclave`](/identity/mpc-enclave) into `golang-jwt/jwt/v5`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewMPCSigningMethod(name string, enclave mpc.Enclave) *MPCSigningMethod
|
||||||
|
func (m *MPCSigningMethod) Alg() string // returns m.Name; "MPC256" everywhere in this package
|
||||||
|
func (m *MPCSigningMethod) Sign(signingString string, key any) ([]byte, error)
|
||||||
|
func (m *MPCSigningMethod) Verify(signingString string, signature []byte, key any) error
|
||||||
|
```
|
||||||
|
|
||||||
|
`Sign` computes `sha256.Sum256(signingString)` and passes that digest to `enclave.Sign`, which
|
||||||
|
hashes again with SHA3-256 internally. `Verify` does the mirror image via `enclave.Verify`.
|
||||||
|
|
||||||
|
:::danger[MPC verification requires the signer's enclave]
|
||||||
|
`MPCSigningMethod.Verify` **ignores its `key` argument entirely** and calls `m.enclave.Verify(...)`.
|
||||||
|
`MPCVerifier.verifyWithMPC` likewise constructs `NewMPCSigningMethod("MPC256", v.enclave)` and hands
|
||||||
|
`jwt.Parse` a key func that returns `(nil, nil)`.
|
||||||
|
|
||||||
|
So a relying party can only verify an MPC-signed token if it holds an `mpc.Enclave` for the *same
|
||||||
|
key* — and an enclave holds both keyshares. That inverts the point of public-key verification: the
|
||||||
|
public key alone is sufficient information to verify (`mpc.VerifyWithPubKey(pubBytes, digest, sig)`
|
||||||
|
does exactly that), but this method does not take that path.
|
||||||
|
|
||||||
|
Compounding it, the `ucan` package never calls `jwt.RegisterSigningMethod("MPC256", ...)`. jwt/v5
|
||||||
|
resolves a token's `alg` header through its global registry, so `jwt.Parse` inside
|
||||||
|
`verifyWithMPC` fails with an unavailable-signing-method error unless something else has registered
|
||||||
|
`"MPC256"`. The only registration in the module is in `mpc/spec`'s `init()`, and that one installs a
|
||||||
|
*broken* implementation with a nil enclave (see [`mpc/spec`](/identity/mpc-enclave)).
|
||||||
|
|
||||||
|
**Practical consequence: `MPCVerifier.VerifyMPCToken` does not currently verify MPC-signed tokens.**
|
||||||
|
Measured against a token freshly minted by `MPCTokenBuilder.CreateOriginToken`, it returns:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MPC token verification failed: token is unverifiable: signing method (alg) is unavailable
|
||||||
|
```
|
||||||
|
|
||||||
|
To validate a signature yourself, extract the parts and check them directly. The digest chain is
|
||||||
|
`SHA3-256(SHA-256(signingString))`, so pass the SHA-256 digest as `data` and let
|
||||||
|
`VerifyWithPubKey` apply the SHA3-256 layer:
|
||||||
|
|
||||||
|
```go
|
||||||
|
unsigned, err := ucan.ExtractUnsignedToken(tokenString) // header.payload
|
||||||
|
sig, err := ucan.ExtractSignature(tokenString) // decoded bytes
|
||||||
|
digest := sha256.Sum256([]byte(unsigned))
|
||||||
|
ok, err := mpc.VerifyWithPubKey(enclave.PubKeyBytes(), digest[:], sig)
|
||||||
|
```
|
||||||
|
|
||||||
|
That path was verified end to end against this package: it returns `(true, nil)` for a real
|
||||||
|
`MPCTokenBuilder` token and `(false, nil)` when a byte of the signing string is altered.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Builders and validators
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"NewMPCTokenBuilder": { type: "func(enclave mpc.Enclave) (*MPCTokenBuilder, error)", description: "Errors if !enclave.IsValid(). Derives the issuer DID and address from enclave.PubKeyBytes()." },
|
||||||
|
"MPCTokenBuilder.CreateOriginToken": { type: "func(audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)", description: "Root token: no proofs." },
|
||||||
|
"MPCTokenBuilder.CreateDelegatedToken": { type: "func(parent *Token, audienceDID string, att []Attenuation, facts []Fact, notBefore, expiresAt time.Time) (*Token, error)", description: "Attaches the parent as a proof. Does NOT check the subset property — see the callout below." },
|
||||||
|
"MPCTokenBuilder.CreateVaultCapabilityToken": { type: "func(audienceDID, vaultAddress, enclaveDataCID string, actions []string, expiresAt time.Time) (*Token, error)", description: "Convenience origin token carrying a single vault attenuation." },
|
||||||
|
"MPCTokenBuilder.GetIssuerDID": { type: "func() string", description: "The did:key derived from the enclave public key." },
|
||||||
|
"MPCTokenBuilder.GetAddress": { type: "func() string", description: "keys.DID.Address() — a truncated hex prefix, not a chain address." },
|
||||||
|
"NewMPCCapabilityBuilder": { type: "func(enclave mpc.Enclave) (*MPCCapabilityBuilder, error)", description: "Emits vault attenuations: CreateVaultAdminCapability, CreateVaultReadOnlyCapability, CreateVaultSigningCapability, CreateCustomCapability." },
|
||||||
|
"NewMPCKeyshareSource": { type: "func(enclave mpc.Enclave) (KeyshareSource, error)", description: "The higher-level source interface — see below." },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
`KeyshareSource` bundles identity and token minting over one enclave:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type KeyshareSource interface {
|
||||||
|
Address() string
|
||||||
|
Issuer() string
|
||||||
|
ChainCode() ([]byte, error)
|
||||||
|
OriginToken() (*Token, error)
|
||||||
|
SignData(data []byte) ([]byte, error)
|
||||||
|
VerifyData(data []byte, sig []byte) (bool, error)
|
||||||
|
Enclave() mpc.Enclave
|
||||||
|
|
||||||
|
NewOriginToken(audienceDID string, att []Attenuation, fct []Fact, notBefore, expires time.Time) (*Token, error)
|
||||||
|
NewAttenuatedToken(parent *Token, audienceDID string, att []Attenuation, fct []Fact, nbf, exp time.Time) (*Token, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ChainCode()` signs the address string with the enclave. Because DKLs18 ECDSA signing is randomized,
|
||||||
|
**`ChainCode()` returns different 32 bytes on every call** despite the doc comment calling it
|
||||||
|
deterministic — measured directly: two successive calls on the same source disagree. Treat it as a
|
||||||
|
fresh signature, not a derivation.
|
||||||
|
|
||||||
|
:::danger[Only `KeyshareSource` enforces attenuation at issuance]
|
||||||
|
There are two delegation APIs and they behave differently. `mpcKeyshareSource.NewAttenuatedToken`
|
||||||
|
checks the subset property first:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ucan/source.go
|
||||||
|
if !isAttenuationSubset(att, parent.Attenuations) {
|
||||||
|
return nil, fmt.Errorf("scope of ucan attenuations must be less than its parent")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`MPCTokenBuilder.CreateDelegatedToken` does **not**. Its only pre-step is
|
||||||
|
`prepareDelegationProofs(parent, attenuations)`, which is the stub in `ucan/stubs.go` that ignores
|
||||||
|
its `capabilities` argument entirely and returns `[]Proof{parent.Raw}`. Nothing compares the child's
|
||||||
|
attenuations against the parent's.
|
||||||
|
|
||||||
|
Measured against this package, with a parent granting only `read` on `service://api` and a child
|
||||||
|
asking for `read, delete`:
|
||||||
|
|
||||||
|
| API | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| `MPCTokenBuilder.CreateDelegatedToken` | `nil` — **widened token issued and signed** |
|
||||||
|
| `KeyshareSource.NewAttenuatedToken` | `"scope of ucan attenuations must be less than its parent"` |
|
||||||
|
|
||||||
|
A widened token from `MPCTokenBuilder` is a validly signed token whose `att` claims more authority
|
||||||
|
than its proof grants. Whether that is caught depends entirely on the relying party calling
|
||||||
|
`VerifyDelegationChain` — and nothing in `MPCTokenBuilder` makes that happen.
|
||||||
|
|
||||||
|
**Use `ucan.NewMPCKeyshareSource(enclave).NewAttenuatedToken(...)` for delegation.** Note it also
|
||||||
|
flattens the chain: it appends `parent.Raw` *and* all of `parent.Proofs`, so the child carries the
|
||||||
|
whole ancestry rather than a single link.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Verification plumbing
|
||||||
|
|
||||||
|
```go
|
||||||
|
type DIDResolver interface {
|
||||||
|
ResolveDIDKey(ctx context.Context, did string) (keys.DID, error)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Resolver | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| `StringDIDResolver{}` | `keys.Parse(didStr)` — pure decode, no network |
|
||||||
|
| `MPCDIDResolver` (`NewMPCDIDResolver(enclave, fallback)`) | Short-circuits its own enclave-derived DID; otherwise delegates to `fallback`, or `keys.Parse` if `fallback` is nil |
|
||||||
|
|
||||||
|
`Verifier` is the general path:
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"NewVerifier": { type: "func(didResolver DIDResolver) *Verifier", description: "Constructs a verifier over a DID resolver." },
|
||||||
|
"VerifyToken": { type: "func(ctx, tokenString string) (*Token, error)", description: "jwt.Parse with a resolver-backed key func, then parses att/prf/fct and checks iss, aud, at least one attenuation, nbf and exp." },
|
||||||
|
"VerifyCapability": { type: "func(ctx, tokenString, resource string, abilities []string) (*Token, error)", description: "VerifyToken plus: some attenuation's resource URI equals `resource` exactly and its capability Grants all `abilities`." },
|
||||||
|
"VerifyDelegationChain": { type: "func(ctx, tokenString string) error", description: "Verifies the token, then every JWT in Proofs, then the delegation relationship between each pair." },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
:::warning[`Verifier` supports only RSA and Ed25519 issuers]
|
||||||
|
`Verifier.keyFunc` switches on the token's signing method and handles exactly `RS256`, `RS384`,
|
||||||
|
`RS512` and `EdDSA`; anything else returns `"unsupported signing method"`. Since a `did:key` derived
|
||||||
|
from an MPC enclave is a **secp256k1** key, `getRSAPublicKey` and `getEd25519PublicKey` both reject
|
||||||
|
it. `Verifier.VerifyToken` therefore cannot verify tokens issued by an enclave — which is why
|
||||||
|
`MPCVerifier.VerifyMPCToken` tries `VerifyToken` first and falls through to `verifyWithMPC`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
`MPCVerifier` and `MPCTokenValidator` layer on top:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewMPCVerifier(enclave mpc.Enclave) *MPCVerifier
|
||||||
|
func (v *MPCVerifier) VerifyMPCToken(ctx context.Context, tokenString string) (*Token, error)
|
||||||
|
|
||||||
|
func NewMPCTokenValidator(enclave mpc.Enclave, enableEnclaveValidation bool) *MPCTokenValidator
|
||||||
|
func (v *MPCTokenValidator) ValidateTokenForResource(ctx, tokenString, resourceURI string, requiredAbilities []string) (*Token, error)
|
||||||
|
func (v *MPCTokenValidator) ValidateTokenForVaultOperation(ctx, tokenString, enclaveDataCID, requiredAction, vaultAddress string) (*Token, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ValidateTokenForVaultOperation` is the most complete check in the package, in five ordered steps:
|
||||||
|
verify the token, `ValidateVaultTokenCapability`, optionally match the enclave-data CID, optionally
|
||||||
|
match the vault address, and finally `VerifyDelegationChain` if `Proofs` is non-empty. The two
|
||||||
|
"optionally" steps run only when `enableEnclaveValidation` was true at construction — pass `true`
|
||||||
|
unless you know why not.
|
||||||
|
|
||||||
|
### Signature helpers
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"SupportedSigningMethods": { type: "func() []jwt.SigningMethod", description: "RS256, RS384, RS512, EdDSA. Note: no ECDSA and no MPC256." },
|
||||||
|
"ValidateSignature": { type: "func(tokenString string, verifyKey any) error", description: "Parses and validates the signature against a supplied key." },
|
||||||
|
"ExtractUnsignedToken": { type: "func(tokenString string) (string, error)", description: "The \"header.payload\" prefix — the exact bytes that were signed." },
|
||||||
|
"ExtractSignature": { type: "func(tokenString string) ([]byte, error)", description: "The decoded third segment." },
|
||||||
|
"ExtractSignatureInfo": { type: "func(tokenString string, verifyKey any) (*SignatureInfo, error)", description: "Algorithm, key type, signing string, signature, and validity in one struct." },
|
||||||
|
"GetHashAlgorithmForMethod": { type: "func(method jwt.SigningMethod) (crypto.Hash, error)", description: "The crypto.Hash a signing method expects." },
|
||||||
|
"CreateHasher": { type: "func(hashAlg crypto.Hash) (hash.Hash, error)", description: "Instantiates that hash." },
|
||||||
|
"VerifyEd25519Signature": { type: "func(signingString string, signature []byte, publicKey ed25519.PublicKey) error", description: "Raw Ed25519 verification over the signing string." },
|
||||||
|
"VerifyRSASignature": { type: "func(signingString string, signature []byte, publicKey *rsa.PublicKey, hashAlg crypto.Hash) error", description: "Raw RSA verification." },
|
||||||
|
"NewSigningValidator": { type: "func() *SigningValidator", description: "Allows every method in SupportedSigningMethods. ValidateSigningMethod and ValidateTokenSignature." },
|
||||||
|
"NewKeyValidator": { type: "func() *KeyValidator", description: "ValidateEd25519PublicKey and ValidateRSAPublicKey." },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
### `SecurityConfig`
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"AllowedSigningMethods": {
|
||||||
|
type: "[]jwt.SigningMethod",
|
||||||
|
required: true,
|
||||||
|
description: "Permitted JWT algorithms.",
|
||||||
|
default: "SupportedSigningMethods() — RS256, RS384, RS512, EdDSA"
|
||||||
|
},
|
||||||
|
"MinRSAKeySize": {
|
||||||
|
type: "int",
|
||||||
|
required: true,
|
||||||
|
description: "Smallest accepted RSA modulus in bits. ValidateSecurityConfig rejects anything below 1024.",
|
||||||
|
default: "2048"
|
||||||
|
},
|
||||||
|
"MaxRSAKeySize": {
|
||||||
|
type: "int",
|
||||||
|
required: true,
|
||||||
|
description: "Largest accepted RSA modulus. Must be >= MinRSAKeySize and <= 16384.",
|
||||||
|
default: "8192"
|
||||||
|
},
|
||||||
|
"RequireSecureAlgs": {
|
||||||
|
type: "bool",
|
||||||
|
required: true,
|
||||||
|
description: "Marks the config as rejecting weak algorithms.",
|
||||||
|
default: "true"
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
`RestrictiveSecurityConfig()` narrows those to `{RS256, EdDSA}`, `MinRSAKeySize: 3072`,
|
||||||
|
`MaxRSAKeySize: 4096`, `RequireSecureAlgs: true`. `ValidateSecurityConfig(config)` enforces the
|
||||||
|
bounds noted above.
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
`SecurityConfig` is a value object with a validator. Nothing in the package *consumes* it — neither
|
||||||
|
`Verifier` nor `MPCVerifier` nor `SigningValidator` takes one. Constructing and validating a config
|
||||||
|
does not change how any verification behaves; wire the allow-list yourself with
|
||||||
|
`NewSigningValidatorWithMethods(config.AllowedSigningMethods)`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Templates and policy
|
||||||
|
|
||||||
|
`CapabilityTemplate` is an allow-list of actions per resource scheme, plus lifetime bounds.
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"AllowedActions": {
|
||||||
|
type: "map[string][]string",
|
||||||
|
required: true,
|
||||||
|
description: "resource scheme -> permitted actions. A scheme that is ABSENT from the map is allowed unconditionally.",
|
||||||
|
default: "empty map"
|
||||||
|
},
|
||||||
|
"DefaultExpiration": {
|
||||||
|
type: "time.Duration",
|
||||||
|
required: true,
|
||||||
|
description: "Used by GetDefaultExpirationTime().",
|
||||||
|
default: "24h"
|
||||||
|
},
|
||||||
|
"MaxExpiration": {
|
||||||
|
type: "time.Duration",
|
||||||
|
required: true,
|
||||||
|
description: "ValidateExpiration rejects an exp further out than this.",
|
||||||
|
default: "720h (30 days)"
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
```go
|
||||||
|
tpl := ucan.NewCapabilityTemplate()
|
||||||
|
tpl.AddAllowedActions("service", []string{"read", "write"})
|
||||||
|
|
||||||
|
err := tpl.ValidateAttenuation(ucan.CreateSimpleAttenuation("delete", "service://api"))
|
||||||
|
// -> "action delete not allowed for resource type service"
|
||||||
|
|
||||||
|
err = tpl.ValidateExpiration(tpl.GetDefaultExpirationTime()) // nil
|
||||||
|
```
|
||||||
|
|
||||||
|
`ValidateExpiration` treats `expiresAt == 0` as "no expiration" and returns `nil`; a past timestamp
|
||||||
|
errors, and one beyond `MaxExpiration` errors. `"*"` in an attenuation is only accepted if `"*"` is
|
||||||
|
itself in the allow-list for that scheme.
|
||||||
|
|
||||||
|
:::warning[Unknown schemes are allowed, not denied]
|
||||||
|
`ValidateAttenuation` returns `nil` when the resource scheme is missing from `AllowedActions`, with
|
||||||
|
the comment "Allow unknown resource types for backward compatibility". A template is therefore a
|
||||||
|
*deny-list of known-bad actions on known schemes*, not an allow-list. `CreateSimpleAttenuation("nuke",
|
||||||
|
"unknown://everything")` validates cleanly against every template in the package.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Prebuilt templates, each a `NewCapabilityTemplate()` with one or two schemes populated:
|
||||||
|
|
||||||
|
| Function | Schemes populated |
|
||||||
|
| --- | --- |
|
||||||
|
| `StandardVaultTemplate()` | `ipfs`, `vault` |
|
||||||
|
| `StandardServiceTemplate()` | `service`, `https`, `http` |
|
||||||
|
| `StandardDIDTemplate()` | `did` |
|
||||||
|
| `StandardDWNTemplate()` | `dwn` |
|
||||||
|
| `StandardDEXTemplate()` | `dex` |
|
||||||
|
| `EnhancedServiceTemplate()` | `service`, with delegation actions |
|
||||||
|
|
||||||
|
`StandardTemplate` is a package-level `var` populated in `ucan/jwt.go`'s `init()` with actions for
|
||||||
|
`vault`, `service`, `did`, `dwn`, `dex`, `pool` and `svc`. It is the template that
|
||||||
|
`VerifyJWTToken` and `VerifyModuleJWTToken` validate against.
|
||||||
|
|
||||||
|
:::danger[`StandardTemplate` is mutable global state]
|
||||||
|
It is an exported pointer, and `AddAllowedActions` mutates it in place. Any code — including a test,
|
||||||
|
as `ucan/ucan_test.go` does — can widen the allow-list that every `VerifyJWTToken` call in the
|
||||||
|
process then honours. Build your own template with `NewCapabilityTemplate()` for anything that
|
||||||
|
matters.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Vault and IPFS integration
|
||||||
|
|
||||||
|
Vault capabilities address an enclave backup stored in IPFS, so the resource URI is `ipfs://<CID>`.
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"VaultCapabilitySchema": { type: "z.Struct", description: "A zog schema requiring `can` from a fixed action set, `with` as a valid ipfs:// URI, a non-empty `vault`, and optional `actions`/`cavs`." },
|
||||||
|
"ValidateVaultCapability": { type: "func(att map[string]any) error", description: "Runs a decoded attenuation map through VaultCapabilitySchema." },
|
||||||
|
"ValidateVaultTokenCapability": { type: "func(token *Token, enclaveDataCID, requiredAction string) error", description: "Requires requiredAction in {read, write, sign, export, import, delete} and an attenuation on ipfs://<cid> granting it." },
|
||||||
|
"GetEnclaveDataCID": { type: "func(token *Token) (string, error)", description: "The first attenuation resource with an ipfs:// prefix, minus the prefix." },
|
||||||
|
"ValidateIPFSCID": { type: "func(value *string, ctx z.Ctx) bool", description: "zog TestFunc: requires an ipfs:// prefix and a well-formed CID." },
|
||||||
|
"ValidateEnclaveDataCIDIntegrity": { type: "func(enclaveDataCID string, enclaveData []byte) error", description: "Recomputes the CID over the bytes and compares. Errors on an empty CID, empty data, a malformed CID, or a mismatch." },
|
||||||
|
"ValidateEnclaveDataIntegrity": { type: "func(enclaveData *mpc.EnclaveData, expectedCID string) error", description: "Structural checks on the EnclaveData (non-nil, non-empty PubBytes) before the CID comparison." },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
`VaultAdminAction` is the constant `"vault/admin"`. Note that the vault schema's `can` set uses
|
||||||
|
slash-prefixed values (`vault/read`, `vault/sign`, …) while `ValidateVaultTokenCapability` and the
|
||||||
|
templates use bare ones (`read`, `sign`, …); they are different vocabularies applied at different
|
||||||
|
layers.
|
||||||
|
|
||||||
|
`TestValidateEnclaveDataCIDIntegrity` in `ucan/ucan_test.go` is the one genuinely end-to-end test in
|
||||||
|
the package, covering empty-CID, empty-data, malformed-CID, matching and mismatching cases.
|
||||||
|
|
||||||
|
## End-to-end example
|
||||||
|
|
||||||
|
Enclave → issuer DID → signed origin token → narrowed delegated token → manual signature check.
|
||||||
|
This uses `KeyshareSource`, the delegation API that actually enforces attenuation. Every line was
|
||||||
|
run against this package; the printed values below are the observed output.
|
||||||
|
|
||||||
|
```go ucan_delegation.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sonr-io/crypto/keys"
|
||||||
|
"github.com/sonr-io/crypto/mpc"
|
||||||
|
"github.com/sonr-io/crypto/ucan"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
enclave, err := mpc.NewEnclave()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyshareSource enforces the subset property on delegation.
|
||||||
|
src, err := ucan.NewMPCKeyshareSource(enclave)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("issuer:", src.Issuer()) // did:key:z...
|
||||||
|
|
||||||
|
// The delegate's identity — here just another enclave's DID.
|
||||||
|
delegateEnclave, err := mpc.NewEnclave()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
delegateDID, err := keys.NewFromMPCPubKey(delegateEnclave.PubKeyBytes())
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Origin token: broad authority over one service resource.
|
||||||
|
origin, err := src.NewOriginToken(
|
||||||
|
delegateDID.String(),
|
||||||
|
[]ucan.Attenuation{
|
||||||
|
ucan.CreateMultiAttenuation([]string{"read", "write", "delete"}, "service://api"),
|
||||||
|
},
|
||||||
|
nil, now, now.Add(time.Hour),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Widening is rejected at issuance.
|
||||||
|
_, err = src.NewAttenuatedToken(origin, delegateDID.String(),
|
||||||
|
[]ucan.Attenuation{
|
||||||
|
ucan.CreateMultiAttenuation([]string{"read", "write", "delete", "admin"}, "service://api"),
|
||||||
|
},
|
||||||
|
nil, now, now.Add(time.Hour))
|
||||||
|
fmt.Println("widening rejected:", err)
|
||||||
|
// -> "scope of ucan attenuations must be less than its parent"
|
||||||
|
|
||||||
|
// Narrowing is accepted: read only, half the lifetime.
|
||||||
|
delegated, err := src.NewAttenuatedToken(origin, delegateDID.String(),
|
||||||
|
[]ucan.Attenuation{ucan.CreateSimpleAttenuation("read", "service://api")},
|
||||||
|
nil, now, now.Add(30*time.Minute))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("proofs:", len(delegated.Proofs)) // 1 — the origin token
|
||||||
|
|
||||||
|
// The attenuation invariant, checked locally.
|
||||||
|
child := ucan.AttenuationList(delegated.Attenuations)
|
||||||
|
parent := ucan.AttenuationList(origin.Attenuations)
|
||||||
|
fmt.Println("narrows:", child.IsSubsetOf(parent)) // true
|
||||||
|
fmt.Println("widens:", parent.IsSubsetOf(child)) // false
|
||||||
|
fmt.Println("can read:", child.CanPerform("service://api", []string{"read"})) // true
|
||||||
|
fmt.Println("can delete:", child.CanPerform("service://api", []string{"delete"})) // false
|
||||||
|
|
||||||
|
// Signature verification, done directly against the public key.
|
||||||
|
unsigned, err := ucan.ExtractUnsignedToken(delegated.Raw)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
sig, err := ucan.ExtractSignature(delegated.Raw)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
digest := sha256.Sum256([]byte(unsigned))
|
||||||
|
ok, err := mpc.VerifyWithPubKey(enclave.PubKeyBytes(), digest[:], sig)
|
||||||
|
fmt.Println("signature valid:", ok, err) // true <nil>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
:::warning
|
||||||
|
There is **no test in the repository** that mints an MPC-signed token and verifies it back through
|
||||||
|
the package's own verifier — and per the callout above, `VerifyMPCToken` does not currently work.
|
||||||
|
The manual check at the end of this program is the path that does, and it was confirmed to return
|
||||||
|
`(true, nil)` for a genuine token and `(false, nil)` for a tampered signing string.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Not actually implemented
|
||||||
|
|
||||||
|
Each item below was verified by reading the named source file and then confirmed by running it.
|
||||||
|
Delegation enforcement is covered separately, under
|
||||||
|
[Only `KeyshareSource` enforces attenuation at issuance](#mpc-signing-and-verification).
|
||||||
|
|
||||||
|
:::danger[`GenerateJWTToken` / `VerifyJWTToken` use a hardcoded HS256 secret]
|
||||||
|
In `ucan/jwt.go`, all four of `GenerateJWTToken`, `GenerateModuleJWTToken`, `VerifyJWTToken` and
|
||||||
|
`VerifyModuleJWTToken` do this:
|
||||||
|
|
||||||
|
```go
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
tokenString, err := token.SignedString([]byte("sonr-ucan-secret"))
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
|
||||||
|
// Dummy secret verification - replace with proper key validation
|
||||||
|
return []byte("sonr-ucan-secret"), nil
|
||||||
|
}, jwt.WithLeeway(5*time.Minute))
|
||||||
|
```
|
||||||
|
|
||||||
|
The secret is a string literal in the package source. Anyone who can read this repository can mint a
|
||||||
|
token that `VerifyJWTToken` accepts, with any issuer, audience and attenuation set — bounded only by
|
||||||
|
`StandardTemplate`, which is itself mutable.
|
||||||
|
|
||||||
|
`GenerateJWTToken` additionally hardcodes `"iss": "did:sonr:local"` and ignores any notion of an
|
||||||
|
audience, and it base64-encodes a single `{can, with}` object into a non-standard `can` claim rather
|
||||||
|
than emitting a UCAN `att` array. `GenerateModuleJWTToken` does use `att` and takes real issuer and
|
||||||
|
audience arguments — but signs with the same shared secret.
|
||||||
|
|
||||||
|
**Treat all four as demo scaffolding.** Use `MPCTokenBuilder` for issuance and verify signatures
|
||||||
|
explicitly.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::danger[Caveat restrictions are not enforced]
|
||||||
|
`Verifier.checkCapabilities` calls `validateCaveats(cap, resource)`, which dispatches by resource
|
||||||
|
scheme into `validateDIDCaveats`, `validateDWNCaveats`, `validateDEXCaveats`,
|
||||||
|
`validateServiceCaveats` and `validateVaultCaveats`. Those iterate the capability's `Caveats` slice
|
||||||
|
and call a per-caveat helper. **Every one of those helpers is a stub that returns `nil`:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Caveat validation helper methods (placeholders for actual implementation)
|
||||||
|
|
||||||
|
func (v *Verifier) validateOwnerCaveat(resource Resource) error { return nil }
|
||||||
|
func (v *Verifier) validateControllerCaveat(resource Resource) error { return nil }
|
||||||
|
func (v *Verifier) validateRecordOwnership(resource Resource) error { return nil }
|
||||||
|
func (v *Verifier) validateProtocolCaveat(resource Resource) error { return nil }
|
||||||
|
func (v *Verifier) validateMaxAmountCaveat(maxAmount string) error { return nil }
|
||||||
|
func (v *Verifier) validatePoolMembershipCaveat(resource Resource) error { return nil }
|
||||||
|
func (v *Verifier) validateVaultOwnership(vaultAddress string) error { return nil }
|
||||||
|
func (v *Verifier) validateEnclaveIntegrity(enclaveDataCID string) error { return nil }
|
||||||
|
```
|
||||||
|
|
||||||
|
`validateServiceCaveats` returns `nil` without inspecting anything at all, and `validateCaveats`
|
||||||
|
returns `nil` for any scheme outside its switch.
|
||||||
|
|
||||||
|
The same holds on the delegation path. `areCaveatsMoreRestrictive(childCaveats, parentCaveats)`
|
||||||
|
builds a set from the parent, then loops over the child caveats with `continue` as the only
|
||||||
|
statement in the loop body, and returns `true` — it is structurally incapable of returning `false`.
|
||||||
|
`isAmountLessOrEqual(childAmount, parentAmount)` is commented "placeholder implementation" and
|
||||||
|
returns `true`. `isModuleCapabilityContained` returns `true` in its `default` branch for any unknown
|
||||||
|
scheme.
|
||||||
|
|
||||||
|
**A caveat in a UCAN token issued or verified by this package restricts nothing.** If you rely on
|
||||||
|
caveats for authorization — an amount cap, an ownership constraint, pool membership — you must
|
||||||
|
enforce them in your own code after `VerifyCapability` returns.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::danger[`RevokeCapability` revokes the wrong token]
|
||||||
|
`ucan/jwt.go` keeps an unexported `revokedTokens map[string]bool` keyed on the **full JWT string**,
|
||||||
|
which `VerifyJWTToken` and `VerifyModuleJWTToken` consult first. But the only way to add an entry is:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func RevokeCapability(attenuation Attenuation) error {
|
||||||
|
token, err := GenerateJWTToken(attenuation, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
revokedTokens[token] = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It mints a *brand-new* token from the attenuation and revokes that string. Since the claims include
|
||||||
|
`iat` and `exp` derived from `time.Now()`, the regenerated string only equals a previously issued one
|
||||||
|
if that token was created in the same wall-clock second with the identical one-hour duration.
|
||||||
|
|
||||||
|
That is exactly the window `TestCapabilityRevocation` happens to hit — it calls
|
||||||
|
`GenerateJWTToken(att, time.Hour)` and `RevokeCapability(att)` back to back, so the two strings
|
||||||
|
match and the assertion passes. **The test passes for an incidental reason; the mechanism does not
|
||||||
|
work.** There is no API to revoke a token you actually hold, and the map is process-local,
|
||||||
|
unbounded and never persisted.
|
||||||
|
:::
|
||||||
|
|
||||||
|
Measured against this package:
|
||||||
|
|
||||||
|
| Sequence | `VerifyJWTToken` after revoking |
|
||||||
|
| --- | --- |
|
||||||
|
| Issue, wait 1.5 s, `RevokeCapability` | `nil` — **still accepted** |
|
||||||
|
| Issue and `RevokeCapability` in the same second | `"token has been revoked"` |
|
||||||
|
| Issue with a 2 h duration, `RevokeCapability` (which uses 1 h) | `nil` — **still accepted** |
|
||||||
|
|
||||||
|
:::warning[`ucan/stubs.go` — what is a stub]
|
||||||
|
The file declares four things. `TokenBuilderInterface` and `TokenBuilder` are real but do not sign
|
||||||
|
(they set `Raw: ""`). The two unexported helpers are labelled stubs in the source:
|
||||||
|
|
||||||
|
- `isValidDID(did string) bool` — "Basic DID validation stub". Returns
|
||||||
|
`did != "" && len(did) > 5 && did[:4] == "did:"`. No method check, no multibase check, no key
|
||||||
|
validation. `"did:xxxxxxxx"` passes. It gates the `audienceDID` argument in
|
||||||
|
`mpcKeyshareSource.newToken` and in `NewVaultAdminToken`.
|
||||||
|
- `prepareDelegationProofs(token, capabilities)` — "Minimal stub implementation". Ignores
|
||||||
|
`capabilities` entirely and returns `[]Proof{token.Raw}` when `Raw` is non-empty.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::warning[No signature check on `Fact` or proof CIDs]
|
||||||
|
`Proof` is `string` and may hold either a JWT or a CID. `VerifyDelegationChain` passes every proof to
|
||||||
|
`VerifyToken`, which calls `jwt.Parse` — so a CID-form proof fails to parse rather than being
|
||||||
|
resolved. The package has no proof-resolution path; CID proofs are unusable.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
<CardGroup cols={2}>
|
||||||
|
<Card title="MPC Enclave" href="/identity/mpc-enclave" icon="shield">
|
||||||
|
The signing key behind the issuer DID, and why `mpc/spec` should be avoided.
|
||||||
|
</Card>
|
||||||
|
<Card title="did:key Identifiers" href="/identity/did-key" icon="id-card">
|
||||||
|
How issuer and audience strings are encoded and parsed.
|
||||||
|
</Card>
|
||||||
|
<Card title="Security Notes" href="/reference/security" icon="triangle-alert">
|
||||||
|
Every stub and defect in the module, in one place.
|
||||||
|
</Card>
|
||||||
|
<Card title="ECIES" href="/identity/ecies" icon="mail">
|
||||||
|
Encrypting a payload to the holder of a key, rather than authorizing them.
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
---
|
||||||
|
title: WASM Module Signing
|
||||||
|
description: Ed25519 code signing and SHA-256 hash pinning for WebAssembly module bytes — supply-chain verification, not a JavaScript binding layer.
|
||||||
|
sidebar:
|
||||||
|
order: 6
|
||||||
|
icon: package-check
|
||||||
|
---
|
||||||
|
|
||||||
|
## This is not a js/wasm binding layer
|
||||||
|
|
||||||
|
The package name misleads. `github.com/sonr-io/crypto/wasm` contains no `//go:build js,wasm`
|
||||||
|
constraint, does not import `syscall/js`, and exposes nothing that runs inside a browser. Verified
|
||||||
|
by reading both source files (`signer.go`, `verifier.go`): the only imports are `crypto/ed25519`,
|
||||||
|
`crypto/rand`, `crypto/sha256`, `encoding/base64`, `encoding/hex`, `encoding/json`, `fmt`, `sync`,
|
||||||
|
and `time`.
|
||||||
|
|
||||||
|
What it actually is: **Ed25519 code signing and SHA-256 hash pinning over WebAssembly module bytes.**
|
||||||
|
It answers one question — *is this `.wasm` blob the one I approved?* — before a host embeds and
|
||||||
|
executes it. That is supply-chain verification, and it is plain Go that compiles and runs on any
|
||||||
|
target.
|
||||||
|
|
||||||
|
**Reach for this when** your program loads WASM plugins or modules from disk, a registry, or the
|
||||||
|
network and must refuse anything it does not recognise.
|
||||||
|
|
||||||
|
**Do not reach for this when** you need sandboxing or capability control over what a module can *do*
|
||||||
|
once loaded — that is the runtime's job, not this package's. Verification tells you *which* code you
|
||||||
|
are about to run, never what it will do.
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Provision trust out of band">
|
||||||
|
A `SignatureVerifier` starts empty and rejects everything with `"no trusted keys configured"`.
|
||||||
|
Verification is only as strong as the key set you install with `AddTrustedKey` /
|
||||||
|
`AddTrustedKeyFromHex`. Those public keys must reach the verifier through a channel you already
|
||||||
|
trust — baked into the binary, delivered by your config management, pinned in your deployment
|
||||||
|
manifest. A key learned from the same place as the module buys you nothing.
|
||||||
|
</Step>
|
||||||
|
<Step title="Sign at build time">
|
||||||
|
The publisher holds an Ed25519 private key and calls `SignModule` or `CreateSignatureManifest`
|
||||||
|
over the exact bytes that will be shipped.
|
||||||
|
</Step>
|
||||||
|
<Step title="Verify at load time">
|
||||||
|
The host recomputes the SHA-256 hash, compares it against the recorded one, and then checks the
|
||||||
|
Ed25519 signature against a trusted key.
|
||||||
|
</Step>
|
||||||
|
<Step title="Pin hashes as an independent check">
|
||||||
|
`HashVerifier` is deliberately separate from signing. A pinned hash constrains you to one exact
|
||||||
|
build even if a signing key is later compromised — it is a second, non-overlapping control, not a
|
||||||
|
weaker substitute for a signature.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Signing
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewSigner() (*Signer, error)
|
||||||
|
func NewSignerFromPrivateKey(privateKey ed25519.PrivateKey) (*Signer, error)
|
||||||
|
|
||||||
|
func (s *Signer) Sign(wasmBytes []byte) ([]byte, error)
|
||||||
|
func (s *Signer) GetPublicKey() []byte
|
||||||
|
func (s *Signer) GetPublicKeyHex() string
|
||||||
|
func (s *Signer) ExportPrivateKey() []byte
|
||||||
|
```
|
||||||
|
|
||||||
|
`NewSigner` generates a fresh Ed25519 keypair from `crypto/rand`. `NewSignerFromPrivateKey` requires
|
||||||
|
exactly `ed25519.PrivateKeySize` (64) bytes and derives the public key from it, erroring with
|
||||||
|
`"invalid private key size: expected 64, got N"` otherwise. `Sign` produces a 64-byte
|
||||||
|
`ed25519.Sign(priv, wasmBytes)` over the **raw module bytes** — not over the hash, and with no domain
|
||||||
|
separation prefix.
|
||||||
|
|
||||||
|
:::warning[`ExportPrivateKey` hands out the raw signing key]
|
||||||
|
It returns `s.privateKey` directly — the live 64-byte `ed25519.PrivateKey` slice, not a copy. The
|
||||||
|
caller can read it, and can also **mutate the signer's key in place** through the returned slice.
|
||||||
|
Anything that receives this value can forge signatures for every module your key covers. Do not log
|
||||||
|
it, serialize it, or pass it across a trust boundary; if you must persist a signing key, encrypt it
|
||||||
|
with [AEAD](/symmetric/aead) and keep the plaintext lifetime as short as possible.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Signed modules
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SignedModule struct {
|
||||||
|
Module []byte `json:"-"` // WASM bytecode, EXCLUDED from JSON
|
||||||
|
Hash string `json:"hash"` // hex SHA-256 of Module
|
||||||
|
Signature []byte `json:"signature"` // Ed25519, 64 bytes
|
||||||
|
SignerID string `json:"signer_id"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func SignModule(signer *Signer, module []byte, signerID, version string) (*SignedModule, error)
|
||||||
|
func VerifySignedModule(verifier *SignatureVerifier, module *SignedModule) error
|
||||||
|
```
|
||||||
|
|
||||||
|
`VerifySignedModule` runs two checks in order:
|
||||||
|
|
||||||
|
1. Recompute the SHA-256 hash over `module.Module` and compare with `module.Hash`; mismatch yields
|
||||||
|
`"hash mismatch: expected …, got …"`.
|
||||||
|
2. If `SignerID` is non-empty, `verifier.VerifyWithKey(SignerID, Module, Signature)`; otherwise
|
||||||
|
`verifier.Verify(Module, Signature)`, which tries every trusted key in turn.
|
||||||
|
|
||||||
|
Grounded in `TestSignedModule` (`wasm/signer_test.go`):
|
||||||
|
|
||||||
|
```go wasm_sign_verify.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/sonr-io/crypto/wasm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Publisher side.
|
||||||
|
signer, err := wasm.NewSigner()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
module := []byte("test wasm module") // in practice, the .wasm file contents
|
||||||
|
|
||||||
|
signed, err := wasm.SignModule(signer, module, "test-signer", "v1.0.0")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("hash:", signed.Hash)
|
||||||
|
publicKeyHex := signer.GetPublicKeyHex() // ship this out of band
|
||||||
|
|
||||||
|
// Host side: trust is provisioned from the out-of-band key, not from `signed`.
|
||||||
|
verifier := wasm.NewSignatureVerifier()
|
||||||
|
if err := verifier.AddTrustedKeyFromHex("test-signer", publicKeyHex); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println("trusted:", verifier.GetTrustedKeyIDs())
|
||||||
|
|
||||||
|
fmt.Println("ok:", wasm.VerifySignedModule(verifier, signed)) // nil
|
||||||
|
|
||||||
|
// Tampering is caught at the hash check.
|
||||||
|
signed.Module = []byte("tampered")
|
||||||
|
fmt.Println("tampered:", wasm.VerifySignedModule(verifier, signed)) // "hash mismatch"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
:::note[`Module` is not serialized]
|
||||||
|
`SignedModule.Module` carries `json:"-"`, so marshalling a `SignedModule` drops the bytecode. The
|
||||||
|
JSON is metadata only; ship the `.wasm` file alongside it and reattach it to `Module` before calling
|
||||||
|
`VerifySignedModule`, or the hash check compares against an empty module.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## The `SignatureVerifier`
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewSignatureVerifier() *SignatureVerifier
|
||||||
|
func (v *SignatureVerifier) AddTrustedKey(keyID string, publicKey ed25519.PublicKey) error
|
||||||
|
func (v *SignatureVerifier) AddTrustedKeyFromHex(keyID, publicKeyHex string) error
|
||||||
|
func (v *SignatureVerifier) RemoveTrustedKey(keyID string)
|
||||||
|
func (v *SignatureVerifier) GetTrustedKeyIDs() []string
|
||||||
|
func (v *SignatureVerifier) Verify(wasmBytes, signature []byte) error
|
||||||
|
func (v *SignatureVerifier) VerifyWithKey(keyID string, wasmBytes, signature []byte) error
|
||||||
|
```
|
||||||
|
|
||||||
|
`AddTrustedKey` requires exactly `ed25519.PublicKeySize` (32) bytes. The map is guarded by a
|
||||||
|
`sync.RWMutex`, so a verifier is safe for concurrent use.
|
||||||
|
|
||||||
|
Prefer `VerifyWithKey` over `Verify`. `Verify` iterates the whole trusted set and succeeds if *any*
|
||||||
|
key validates, so it tells you the module is signed by someone you trust but not by **whom** — and it
|
||||||
|
does not report which key matched. `VerifyWithKey` binds the check to an expected signer.
|
||||||
|
|
||||||
|
## Manifests
|
||||||
|
|
||||||
|
A manifest decouples signature metadata from the module file, and supports multiple signatures.
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SignatureManifest struct {
|
||||||
|
ModuleHash string `json:"module_hash"`
|
||||||
|
Signatures []SignatureEntry `json:"signatures"`
|
||||||
|
TrustedKeys []TrustedKeyEntry `json:"trusted_keys"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignatureEntry struct {
|
||||||
|
Signature string `json:"signature"` // base64 std encoding
|
||||||
|
SignerID string `json:"signer_id"`
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Algorithm string `json:"algorithm"` // always "Ed25519"
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrustedKeyEntry struct {
|
||||||
|
KeyID string `json:"key_id"`
|
||||||
|
PublicKey string `json:"public_key"` // base64 std encoding
|
||||||
|
AddedAt time.Time `json:"added_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
Purpose string `json:"purpose"` // e.g. "code-signing"
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateSignatureManifest(module []byte, signer *Signer, signerID string) (*SignatureManifest, error)
|
||||||
|
func ExportManifest(manifest *SignatureManifest) ([]byte, error)
|
||||||
|
func ImportManifest(data []byte) (*SignatureManifest, error)
|
||||||
|
func VerifyWithManifest(module []byte, manifest *SignatureManifest) error
|
||||||
|
```
|
||||||
|
|
||||||
|
`CreateSignatureManifest` emits a manifest with exactly one `SignatureEntry` and one
|
||||||
|
`TrustedKeyEntry` (`Purpose: "code-signing"`). `VerifyWithManifest` checks the module hash, then
|
||||||
|
`ExpiresAt` on the manifest, then builds a fresh verifier from `manifest.TrustedKeys`, skipping any
|
||||||
|
entry whose own `ExpiresAt` has passed.
|
||||||
|
|
||||||
|
:::danger[`VerifyWithManifest` trusts the keys inside the manifest]
|
||||||
|
It constructs its verifier from `manifest.TrustedKeys` — keys carried by the very document whose
|
||||||
|
authenticity is in question. An attacker who can replace both the module and its manifest simply
|
||||||
|
signs the replacement with their own key, lists that key in `TrustedKeys`, and
|
||||||
|
`VerifyWithManifest` returns `nil`.
|
||||||
|
|
||||||
|
`VerifyWithManifest` therefore establishes only **internal consistency**: this manifest describes
|
||||||
|
this module. It establishes **no trust**. To get a real decision, verify against a key set you
|
||||||
|
provisioned yourself:
|
||||||
|
|
||||||
|
```go
|
||||||
|
manifest, err := wasm.ImportManifest(manifestJSON)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Independent trust anchor — not manifest.TrustedKeys.
|
||||||
|
verifier := wasm.NewSignatureVerifier()
|
||||||
|
if err := verifier.AddTrustedKeyFromHex("release-key", pinnedPublicKeyHex); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the manifest describes this module and has not expired.
|
||||||
|
if err := wasm.VerifyWithManifest(module, manifest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then check at least one signature against YOUR key.
|
||||||
|
verified := false
|
||||||
|
for _, entry := range manifest.Signatures {
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(entry.Signature)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if verifier.VerifyWithKey("release-key", module, sig) == nil {
|
||||||
|
verified = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !verified {
|
||||||
|
return errors.New("no signature from a pinned key")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::warning[Expiry is optional and unauthenticated]
|
||||||
|
`ExpiresAt` is a `*time.Time`; a `nil` value means "never expires" and `VerifyWithManifest` accepts
|
||||||
|
it. Since the manifest is unsigned as a whole, an attacker rewriting the manifest can also clear or
|
||||||
|
extend `ExpiresAt`. Only the individual `Signature` values are cryptographically protected, and each
|
||||||
|
covers the module bytes alone — not `ModuleHash`, not `SignerID`, not `Timestamp`, and not any
|
||||||
|
expiry field.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Hash pinning
|
||||||
|
|
||||||
|
```go
|
||||||
|
func NewHashVerifier() *HashVerifier
|
||||||
|
func (v *HashVerifier) ComputeHash(wasmBytes []byte) string // hex SHA-256
|
||||||
|
func (v *HashVerifier) AddTrustedHash(name, hash string)
|
||||||
|
func (v *HashVerifier) GetTrustedHash(name string) (string, bool)
|
||||||
|
func (v *HashVerifier) VerifyHash(name string, wasmBytes []byte) error
|
||||||
|
func (v *HashVerifier) VerifyHashWithFallback(name string, wasmBytes []byte, fallbackHashes []string) error
|
||||||
|
func (v *HashVerifier) ClearTrustedHashes()
|
||||||
|
```
|
||||||
|
|
||||||
|
`VerifyHash` errors with `"no trusted hash found for WASM module: <name>"` when the name is unknown —
|
||||||
|
so an unregistered module is denied by default, which is the right behaviour. The map is
|
||||||
|
`sync.RWMutex`-guarded.
|
||||||
|
|
||||||
|
:::danger[`VerifyHashWithFallback` mutates your pin set]
|
||||||
|
On a fallback match it calls `AddTrustedHash(name, computedHash)`, **overwriting the pinned hash for
|
||||||
|
that name**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
for _, fallbackHash := range fallbackHashes {
|
||||||
|
if computedHash == fallbackHash {
|
||||||
|
v.AddTrustedHash(name, computedHash) // pin replaced
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every subsequent `VerifyHash(name, …)` now accepts the fallback build and rejects the original. Two
|
||||||
|
consequences:
|
||||||
|
|
||||||
|
- Pinning becomes trust-on-first-use with silent promotion. If the fallback list is ever wider than
|
||||||
|
you intended — read from config, a response body, a rollback table — the pin follows it.
|
||||||
|
- The change is invisible: nothing is returned or logged to say the pin moved.
|
||||||
|
|
||||||
|
If you need to accept several builds, keep them in your own set and call `VerifyHash` (or compare
|
||||||
|
`ComputeHash` output) against each, so the pin set stays under your control.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Hash chains
|
||||||
|
|
||||||
|
```go
|
||||||
|
type HashEntry struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
PreviousHash string `json:"previous_hash"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHashChain() *HashChain
|
||||||
|
func (hc *HashChain) AddEntry(version, hash string, timestamp int64) error
|
||||||
|
func (hc *HashChain) GetLatestEntry() (*HashEntry, error)
|
||||||
|
func (hc *HashChain) VerifyChain() error
|
||||||
|
```
|
||||||
|
|
||||||
|
`AddEntry` appends an entry whose `PreviousHash` is copied from the previous entry's `Hash` (empty
|
||||||
|
for the first). `VerifyChain` accepts an empty chain, requires the first entry's `PreviousHash` to be
|
||||||
|
empty, and then checks that each `PreviousHash` equals the preceding `Hash`. `GetLatestEntry` returns
|
||||||
|
a **copy** of the last entry, or `"hash chain is empty"`.
|
||||||
|
|
||||||
|
:::warning[The chain is a linkage check, not a cryptographic commitment]
|
||||||
|
`AddEntry` always sets `PreviousHash` correctly, so `VerifyChain` **cannot fail** for a chain built
|
||||||
|
through `AddEntry`. It only becomes meaningful for a chain deserialized from an untrusted source —
|
||||||
|
which is exactly how `TestHashChain_BrokenChain` exercises it, by assigning the internal slice
|
||||||
|
directly.
|
||||||
|
|
||||||
|
Even then, `PreviousHash` is a plain string field, not a hash *over* the previous entry. Nothing
|
||||||
|
binds `Version` or `Timestamp` to anything, and no entry is signed. An attacker who can rewrite the
|
||||||
|
chain can produce a self-consistent chain of their own choosing. Treat it as an audit-trail
|
||||||
|
convenience for update ordering, and get your integrity from `SignatureVerifier` and `HashVerifier`.
|
||||||
|
:::
|
||||||
|
|
||||||
|
## `SecurityPolicy`
|
||||||
|
|
||||||
|
<TypeTable
|
||||||
|
type={{
|
||||||
|
"RequireHashVerification": {
|
||||||
|
type: "bool",
|
||||||
|
required: true,
|
||||||
|
description: "Intended to require a hash check. NOT read by Validate.",
|
||||||
|
default: "true"
|
||||||
|
},
|
||||||
|
"RequireSignature": {
|
||||||
|
type: "bool",
|
||||||
|
required: true,
|
||||||
|
description: "Intended to require a signature. NOT read by Validate. Source comment: \"Will be enabled in next phase\".",
|
||||||
|
default: "false"
|
||||||
|
},
|
||||||
|
"AllowedHashes": {
|
||||||
|
type: "[]string",
|
||||||
|
required: true,
|
||||||
|
description: "Intended allow-list of module hashes. NOT read by Validate.",
|
||||||
|
default: "[] (empty)"
|
||||||
|
},
|
||||||
|
"MaxModuleSize": {
|
||||||
|
type: "int64",
|
||||||
|
required: true,
|
||||||
|
description: "Maximum module size in bytes. The ONLY field Validate enforces; skipped entirely when <= 0.",
|
||||||
|
default: "10485760 (10 MiB)"
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
:::danger[`Validate` only checks the size]
|
||||||
|
`SecurityPolicy.Validate(wasmBytes []byte) error` is, in full:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (p *SecurityPolicy) Validate(wasmBytes []byte) error {
|
||||||
|
if p.MaxModuleSize > 0 && int64(len(wasmBytes)) > p.MaxModuleSize {
|
||||||
|
return fmt.Errorf("WASM module size %d exceeds maximum allowed size %d",
|
||||||
|
len(wasmBytes), p.MaxModuleSize)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`RequireHashVerification`, `RequireSignature` and `AllowedHashes` are never read — not here, and
|
||||||
|
nowhere else in the package. Setting `RequireSignature: true` and calling `Validate` gives you a
|
||||||
|
size check and nothing else, while reading like an enforced signature requirement.
|
||||||
|
|
||||||
|
`TestSecurityPolicy` asserts exactly this and no more: a 1 KiB module passes, an 11 MiB module fails.
|
||||||
|
|
||||||
|
**Do not use `SecurityPolicy` as a gate.** Sequence the checks yourself:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := policy.Validate(moduleBytes); err != nil { // size only
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := hashes.VerifyHash(name, moduleBytes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := signatures.VerifyWithKey(signerID, moduleBytes, sig); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
|
## `VerificationError`
|
||||||
|
|
||||||
|
A structured error type for reporting a failed check. It is exported and its `Error()` renders
|
||||||
|
module, reason, expected and actual hash — but **no function in the package returns it**. Every
|
||||||
|
failure path uses `fmt.Errorf` instead. Use it in your own verification wrapper if you want typed
|
||||||
|
errors:
|
||||||
|
|
||||||
|
```go
|
||||||
|
return &wasm.VerificationError{
|
||||||
|
Module: name,
|
||||||
|
ExpectedHash: expected,
|
||||||
|
ActualHash: verifier.ComputeHash(moduleBytes),
|
||||||
|
Reason: "pinned hash mismatch",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caveats summary
|
||||||
|
|
||||||
|
:::info[What the tests cover]
|
||||||
|
`wasm/signer_test.go` and `wasm/verifier_test.go` are reasonably thorough for this package: signer
|
||||||
|
construction and key-size validation, signing and tamper detection, trusted-key add/remove/list,
|
||||||
|
`SignModule`/`VerifySignedModule`, manifest creation, `VerifyWithManifest` including hash mismatch
|
||||||
|
and expiry, manifest JSON round trip, hash computation and pinning, fallback verification, hash
|
||||||
|
chains including a broken chain, `SecurityPolicy` size limits, and `VerificationError` formatting.
|
||||||
|
|
||||||
|
What they do not cover is the *semantics* of the gaps above: no test asserts that
|
||||||
|
`RequireSignature: true` is enforced (it is not), or that `VerifyWithManifest` establishes trust (it
|
||||||
|
does not), or that a fallback match leaves the pin set unchanged (it does not).
|
||||||
|
:::
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
<CardGroup cols={2}>
|
||||||
|
<Card title="AEAD" href="/symmetric/aead" icon="lock">
|
||||||
|
Encrypting a signing key at rest.
|
||||||
|
</Card>
|
||||||
|
<Card title="Signatures" href="/signatures" icon="fingerprint">
|
||||||
|
Ed25519's siblings, and when a different signature scheme fits better.
|
||||||
|
</Card>
|
||||||
|
<Card title="Security Notes" href="/reference/security" icon="triangle-alert">
|
||||||
|
Every stub and defect in the module, in one place.
|
||||||
|
</Card>
|
||||||
|
<Card title="Identity Overview" href="/identity" icon="fingerprint">
|
||||||
|
How this fits with enclaves, DIDs and capability tokens.
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
+122
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineMeta } from "blume";
|
||||||
|
|
||||||
|
export default defineMeta({
|
||||||
|
title: "Reference",
|
||||||
|
icon: "library",
|
||||||
|
order: 8,
|
||||||
|
pages: ["packages", "security"],
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
```
|
||||||
|
:::
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"],
|
||||||
|
});
|
||||||
@@ -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 Chaum–Pedersen 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>
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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).
|
||||||
@@ -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>
|
||||||
@@ -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"],
|
||||||
|
});
|
||||||
@@ -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 24–48 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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",
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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 4–6 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 Diffie–Hellman-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 1–6, 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 1–4 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 6–10 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>
|
||||||
@@ -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>
|
||||||
@@ -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 1–5 establish the joint public key with Schnorr proofs of knowledge of each share. Rounds
|
||||||
|
6–10 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 1–2 do the share re-randomisation; 2–7 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 6–10 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>
|
||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"],
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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 3–5, 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>
|
||||||
Reference in New Issue
Block a user