Files
2026-09-02 15:29:51 -04:00

245 lines
11 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Verifiable Random Function
description: A bespoke VRF over Edwards25519 using SHAKE256 and the Elligator map — unpredictable outputs that anyone holding the public key can verify.
sidebar:
order: 5
icon: dice-5
---
A verifiable random function is a keyed hash with a proof. Given a secret key and an input message,
it produces an output that looks uniformly random to anyone without the key, yet is **uniquely
determined** by the key and message, and comes with a proof that lets anyone holding the public key
confirm the output is the right one. It is the primitive you want whenever a system needs randomness
that participants cannot grind and cannot dispute.
```go
import "github.com/sonr-io/crypto/vrf"
```
## When to use one
- **Leader election.** Each validator computes `VRF_sk(round_seed)`. Whoever's output falls below a
threshold is the leader, and can prove it. Nobody can pre-compute another validator's output, and
nobody can retry with a different key without publishing that key.
- **Verifiable lotteries.** Draw a winner from a beacon value; the operator proves the draw was
honest without revealing the key.
- **Private lookup keys.** In a key-transparency directory (this construction's origin), the map
index for a username is `VRF_sk(username)`, so the directory can prove a name's absence without
its tree structure leaking the set of registered names to an enumerating client.
Do **not** reach for a VRF where a plain signature would do — this package offers no way to sign
arbitrary data, and verification only ever answers "is this the correct output for this message".
And do not treat the output as a commitment: it is a deterministic function of the message, so once
a proof is published anyone holding the public key can confirm a *guess* at the message by
re-verifying against it. A VRF hides the output from people without the key; it does not hide the
input from people who can guess it.
## The construction
The package doc comment states the scheme exactly. `E` is Curve25519 in Edwards coordinates, `h` is
SHA-3 (specifically SHAKE256 throughout the implementation), `f` is the Elligator map, and `8` is the
cofactor:
$$
H(n) = f(h(n))^8, \qquad \mathrm{VRF}_x(n) = h\!\left(n,\, H(n)^x\right)
$$
The proof is a ChaumPedersen style sigma protocol made non-interactive, proving that the same
secret `x` relates `g → g^x` and `H(n) → H(n)^x`:
$$
\mathrm{Prove}_x(n) = \bigl(c,\; t = r - c\cdot x,\; \mathit{ii} = H(n)^x\bigr)
$$
with `r = h(x, n)` supplying the proof's randomness — so proving, like computing, is fully
deterministic. Verification recomputes the challenge from `g^t · P^c` and `H(n)^t · ii^c` and checks
it equals the challenge carried in the proof, and separately checks that the claimed output equals
`h(n, ii)`.
Concretely, in `vrf.go`: `hashToCurve` runs `sha3.ShakeSum256` over the message, maps the digest with
`extra25519.HashToEdwards`, then applies three successive `GeDouble` calls — multiplication by the
cofactor 8 — to land in the prime-order subgroup. The challenge is
`SHAKE256(g ‖ H(n) ‖ pk ‖ H(n)^x ‖ g^r ‖ H(n)^r ‖ n)` reduced mod the group order. In the code the
challenge scalar is named `s`, which is why the proof layout below reads `s ‖ t ‖ ii` rather than
`c ‖ t ‖ ii`.
## Sizes and constants
| Constant | Value | Meaning |
| --- | --- | --- |
| `PublicKeySize` | `32` | Compressed Edwards point |
| `PrivateKeySize` | `64` | 32-byte seed followed by the 32-byte public key |
| `Size` | `32` | The VRF output |
| `ProofSize` | `96` | `s ‖ t ‖ H(n)^x`, three 32-byte values |
`ErrGetPubKey` is the package's only exported error value; it is declared but never returned by any
exported function in `vrf.go` — `Public()` signals failure through its boolean instead.
## API
<TypeTable
type={{
"GenerateKey(rnd io.Reader)": {
type: "(PrivateKey, error)",
description: "Reads 32 bytes of seed from rnd (crypto/rand when nil), expands it, and writes the derived public key into bytes 32..63. Returns a 64-byte PrivateKey.",
},
"PrivateKey.Public()": {
type: "(PublicKey, bool)",
description: "Returns the trailing 32 bytes of the private key. The bool reports whether the internal type assertion succeeded; in practice it is always true for a well-formed key.",
},
"PrivateKey.Compute(m []byte)": {
type: "[]byte",
description: "The 32-byte VRF output alone. One scalar multiplication plus a hash. No error return — a malformed key produces garbage rather than a failure.",
},
"PrivateKey.Prove(m []byte)": {
type: "(vrf, proof []byte)",
description: "The same 32-byte output plus a 96-byte proof. Roughly three scalar multiplications. Deterministic — no reader, no nonce.",
},
"PublicKey.Verify(m, vrfBytes, proof []byte)": {
type: "bool",
description: "Checks the output against the proof under this public key. Returns false on any length mismatch, any bad point encoding, and any check failure. No error channel.",
},
}}
/>
`Compute` and `Prove` return **the same output** for the same key and message — `Prove` just also
gives you the evidence. Use `Compute` when the holder needs the value locally (deciding whether it
even won a leader election, indexing its own directory) and `Prove` only when the value must be
published. That distinction is the main performance lever in the package: skipping the proof avoids
two of the three scalar multiplications.
## Example
Grounded in `TestHonestComplete` and `TestConvertPrivateKeyToPublicKey`.
```go vrf.go
package main
import (
"bytes"
"fmt"
"log"
"github.com/sonr-io/crypto/vrf"
)
func main() {
// nil reader means crypto/rand.
sk, err := vrf.GenerateKey(nil)
if err != nil {
log.Fatal(err)
}
pk, ok := sk.Public()
if !ok {
log.Fatal(vrf.ErrGetPubKey)
}
round := []byte("epoch-4711")
// Cheap path: the holder just wants the value.
out := sk.Compute(round)
// Publishing path: the value plus evidence.
outFromProof, proof := sk.Prove(round)
fmt.Println("Compute == Prove:", bytes.Equal(out, outFromProof)) // true
fmt.Println("output bytes:", len(out), "proof bytes:", len(proof)) // 32 96
// Anyone with pk can check it.
fmt.Println("verified:", pk.Verify(round, outFromProof, proof)) // true
// Any single flipped bit in the proof fails the check.
tampered := append([]byte(nil), proof...)
tampered[0] ^= 0x01
fmt.Println("tampered verified:", pk.Verify(round, outFromProof, tampered)) // false
}
```
`TestFlipBitForgery` in the package flips bits across the proof and asserts every variant fails.
## Properties a caller can rely on
| Property | What it means here |
| --- | --- |
| **Uniqueness** | For a fixed key and message there is exactly one output that will verify. A prover cannot shop for a favourable value. This is what a plain signature cannot give you. |
| **Pseudorandomness** | Without the secret key, the output is indistinguishable from a uniform 32-byte string, so future outputs cannot be predicted from past ones. |
| **Public verifiability** | Anyone with the 32-byte public key can check an output against its proof — no interaction with the prover, no shared secret. |
| **Determinism** | Both `Compute` and `Prove` derive all internal randomness from the key and message, so there is no RNG at evaluation time and nothing to fail open. |
## Caveats
:::danger[This is a bespoke construction with no standards claim]
The package doc names no RFC and no paper. It is **not** RFC 9381 (`draft-irtf-cfrg-vrf`) — that
standard specifies SHA-512 with `try-and-increment` or `hash_to_curve` for `ECVRF-EDWARDS25519-SHA512-*`
ciphersuites, and a differently structured proof and encoding. This package uses SHAKE256 throughout
and the Elligator map, and packs the proof as `s ‖ t ‖ ii`.
The design matches the VRF shipped with the CONIKS key-transparency work, but nothing in this
repository asserts conformance to any published specification, and there are no cross-implementation
test vectors — `vrf_test.go` contains three self-consistency tests and four benchmarks, nothing more.
**Assume zero interoperability** with any other VRF implementation. If your protocol requires a
counterparty running different software to verify these proofs, this package is the wrong choice.
:::
:::danger[The 64-byte key is not an Ed25519 key, despite looking like one]
`PrivateKey` is `[]byte` with the same 64-byte seed-then-public-key layout as
`crypto/ed25519.PrivateKey`, and `Public()` is implemented by converting to
`golang.org/x/crypto/ed25519.PrivateKey` and calling through. But `GenerateKey` derives the scalar by
expanding the seed with **SHAKE256**, where Ed25519 uses SHA-512. The public key written into bytes
32..63 therefore corresponds to a *different* scalar than standard Ed25519 would derive from the same
seed.
Measured against this repository, for one generated key:
```
ed25519.Sign with the vrf key, verified under its own embedded pubkey: false
ed25519.NewKeyFromSeed(sk[:32]) derives the same public key: false
```
The types will not stop you — both are `[]byte` with identical lengths. Never share a seed, a key,
or a signature between `vrf` and `crypto/ed25519`.
:::
:::warning[No error channel anywhere on the hot path]
`Compute` returns only `[]byte`; `Prove` returns only two slices; `Verify` returns only `bool`. A
truncated key, a wrong-length public key, or a corrupted proof all surface as `false` or as silently
wrong bytes. `Compute` in particular does no validation at all — calling it on a short or zero
`PrivateKey` will panic or return meaningless output rather than report anything. Validate lengths
against `PrivateKeySize` and `PublicKeySize` at your trust boundary.
:::
:::warning[Vendored curve code]
The implementation depends on `internal/ed25519/edwards25519` and `internal/ed25519/extra25519`,
vendored copies rather than the maintained `filippo.io/edwards25519`. `extra25519.HashToEdwards` is
the Elligator implementation, and the package doc notes the map "covers half of E" — the cofactor
clearing by three doublings is what brings the result into the prime-order subgroup. None of this
code is constant-time by construction, and none of it receives upstream security fixes. See
[security notes](/reference/security).
:::
:::note[`Verify` does not check that the public key is in the prime-order subgroup]
It calls `FromBytesBaseGroup` on the encoded point, which rejects non-canonical encodings, but the
protocol's security against a maliciously chosen public key rests on the cofactor clearing inside
`hashToCurve` rather than on validating the key. If public keys arrive from untrusted parties in your
protocol, validate them yourself before storing.
:::
## Related
<CardGroup cols={2}>
<Card title="Schnorr proofs" href="/zero-knowledge/schnorr" icon="binary">
The general sigma protocol this VRF's proof is a specialisation of.
</Card>
<Card title="Threshold Ed25519" href="/threshold/threshold-ed25519" icon="users">
Standards-conformant Ed25519 from distributed shares — the interoperable neighbour of this
package's non-standard key handling.
</Card>
<Card title="Curve abstraction" href="/foundations/curves" icon="git-branch">
The library's Ed25519 curve type, which this package deliberately bypasses.
</Card>
<Card title="Security notes" href="/reference/security" icon="shield">
Vendored code, non-standard constructions, and unvalidated inputs across the library.
</Card>
</CardGroup>