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