P3.6.1 — VRF Stub (Leader Election) — Audit
Step 1 of the 5-step chain (CLAUDE.md §6). Phase 3 θ Wave 3 — HMAC-SHA256
VRF stub for arbiter leader election per ADR-002 Option A. The interface
is shaped for transparent future swap to Option B (@noble/curves
RFC 9381 ECVRF) without API change.
§1. Surface inventory at base SHA 498e6ea5
| Path | Exists? | Role |
|---|---|---|
src/domains/consensus/ |
Yes | Existing θ domain directory (P3.1.1 / P3.1.2 / P3.3.1 / P3.4.1 already landed) |
src/domains/consensus/messages.ts |
Yes (P3.1.1) | Reference for module header style + Buffer hex convention |
src/domains/consensus/quorum.ts |
Yes (P3.1.2) | Reference for bigint-only / pure-function conventions |
src/domains/consensus/gossip-wire.ts |
Yes (P3.3.1) | Reference for crypto NAMED-imports + canonical scan guard |
src/domains/consensus/time-anchors.ts |
Yes (P3.4.1) | Reference for STA broadcast / median / drift detection patterns |
src/domains/consensus/vrf-stub.ts |
No — to create | HMAC-SHA256 VRF + VrfProvider interface |
src/__tests__/domains/consensus/vrf-stub.test.ts |
No — to create | Determinism + roundtrip + collision-free + uniformity |
node:crypto |
Built-in | createHmac (HMAC-SHA256) — already used by gossip-wire / time-anchors |
@noble/curves |
NOT installed | Phase 1.5 swap target (Option B); MUST NOT add as dep in this slice |
No source path collision with the new file. Greenfield slice — zero
edits to existing modules. Vote / Quorum / Gossip surfaces are not
consumed by this slice; only node:crypto is imported.
§2. Spec inventory
2.1 Leader election (consensus.md §149–161)
broadcast( VIEW_CHANGE(round_id, current_leader, reason="timeout") )
collect VIEW_CHANGE messages from peers
if view_change_count >= quorum:
next_leader = VRF(prev_merkle_root, round_id) # see ADR-002
proceed round_id with leader = next_leader
The VRF seed is the previous round’s Merkle root, making leader selection deterministic-given-history but unpredictable-in-advance — an arbiter cannot campaign to be the next leader.
The slice supplies selectLeader(arbiters, seed, round_id) to drop into
that pseudocode line.
2.2 ADR-002 Option A — HMAC-SHA256 internal (PROPOSED)
output = HMAC-SHA256(privKey, seed || input) # 32 bytes
proof = HMAC-SHA256(privKey, output || seed) # 32 bytes — redundant
# placeholder; real
# ECVRF π lands later
Properties: deterministic (yes), unpredictable before privKey reveal (yes for symmetric key), externally verifiable (NO — requires secret key, no zero-knowledge property). Honestly disclosed in module header.
2.3 ADR-005 §Decision precedent
The router interface is pluggable from day one — the scoring function is a stub, not an absence. An executor in Phase 1.5 replaces the stub without touching the interface.
Pattern adopted verbatim: VrfProvider interface with two methods;
HmacVrfProvider implements; defaultVrf re-export point; thin
function wrappers vrfEval / vrfVerify delegate through defaultVrf.
Phase 1.5 swap-in is defaultVrf = new NobleCurvesVrfProvider() — one
line, no interface change.
§3. Determinism + crypto inventory
| Forbidden | Replacement | Reason |
|---|---|---|
Math.random() |
n/a (no randomness needed) | κ determinism inherited |
Date.now() |
n/a (no clocks) | wall-clock forbidden in θ |
crypto.randomBytes |
n/a (no nonces; HMAC is deterministic) | determinism |
| floating-point | bigint for round_id; Buffer for keys/outputs |
κ determinism |
number for round_id |
bigint in API; string for canonical hash input |
κ rule §13 |
| Mutating arbiters array | new arrays / index-only | pure function |
Crypto NAMED imports (gossip-wire convention):
import { createHmac, timingSafeEqual } from 'node:crypto';
No crypto.X dotted access in module body. Module-corpus forbidden-token
self-scan inherited from time-anchors / gossip-wire test pattern is
applied identically (the JSDoc strip allows mentions inside the header).
§4. Risks + mitigations
| Risk | Mitigation |
|---|---|
| Caller misuses VRF stub for external proofs | Module header explicitly warns “NOT RFC 9381, externally unverifiable”; ADR-002 cited; test asserts the warning string appears in module text |
Modulo bias in selectLeader for n not power-of-2 |
Documented limitation; arbiter committees ≤ 1000 → bias < 2^-22; for 4-arbiter Phase 0 / 10-arbiter Phase 5 the bias is negligible |
HMAC key reuse across vrfEval and selectLeader |
selectLeader uses a fixed seed-derived public-bytes key per Option A so verification is possible by any party that holds seed (i.e. no actual secret) — explicit comment in source flags that selectLeader is a public-output flavor; the secret-key form vrfEval(seed, input, privKey) is the future ECVRF call-shape |
Premature @noble/curves swap |
package.json not touched in this PR; ADR-002 Status is PROPOSED, not Accepted; commit the stub unconditionally per dispatch packet |
selectLeader empty arbiters |
VrfError('arbiters must be non-empty') thrown |
| Non-power-of-2 sizes (e.g. 7, 10) | Document modulo bias; assert collision-free property holds in tests over the test range |
| Module-corpus token scan | Add a vrf-stub.test.ts self-scan mirroring the existing pattern from time-anchors.test.ts and gossip-wire.test.ts |
§5. Acceptance corpus preview
Tests to ship in src/__tests__/domains/consensus/vrf-stub.test.ts:
- Determinism — same input always same output: 10000 iterations
over
vrfEval(seed, input, privKey)with identical args produce byte-identical(output, proof). - Collision-free over test range: 10000 distinct
(seed, input)pairs under the sameprivKeyproduce 10000 distinct outputs (assertion viaSet<hex>.size === 10000). - Roundtrip verify (correct key):
vrfVerify(seed, input, output, proof, pubKey)returnstruewhen(output, proof)were produced byvrfEvalwith the corresponding private key. - Roundtrip verify (wrong key): substituting any other key
returns
false. - Tamper detection: flipping a bit in
outputorproofreturnsfalse. selectLeaderdeterminism: same(arbiters, seed, round_id)always returns the same arbiter id.selectLeadersingle-arbiter:selectLeader(["A"], seed, r)returns"A"for any seed/round.selectLeaderuniformity smoke: 1000 random round_ids over a 4-arbiter set produce a chi-squared statistic below a loose 99%-CI bound. The point is to catch a stuck/biased implementation, not to prove cryptographic uniformity.VrfProviderinterface contract:HmacVrfProviderinstance exposes the same methods asdefaultVrf; the function exports (vrfEval,vrfVerify) delegate throughdefaultVrf.- Module-corpus token scan: source body contains no
Math./Date./crypto.X/Number(/floating-point literal patterns. - Empty arbiters guard:
selectLeader([], any, any)throwsVrfError. - Variable arbiter sizes:
selectLeaderreturns a valid index forn = 1, 2, 3, 4, 7, 10, 13, 100.
Expected delta: +30 tests roughly. Baseline 2744; target ≈ 2774.
§6. Migration / activation
Zero migration. No DB schema, no rules, no MCP tools. Pure library.
The slice merges, the next time a θ caller needs leader election it
imports selectLeader from src/domains/consensus/vrf-stub.js.
When Phase 1.5+ accepts ADR-002 Option B:
- Add
@noble/curvestopackage.jsondependencies. - Implement
NobleCurvesVrfProviderin same file or sibling. - Replace
export const defaultVrf: VrfProvider = new HmacVrfProvider();with... = new NobleCurvesVrfProvider();. - Update the module header warning to point to the new behavior.
- Existing callers of
vrfEval/vrfVerify/selectLeadersee no API change.
No task_breakdown.md table edit required — P3.6.1 ships per spec and
the next slice (P3.7.1 θ MCP Tool Surface) will register a
vrf_eval-shaped MCP tool if/when Phase 3 reaches MCP surface work.
§7. Self-review
- Greenfield — no existing module touched.
- No new npm deps (
@noble/curvesdeferred to Phase 1.5 per ADR-002). - κ determinism preserved (bigint round_id, Buffer inputs, no Math/Date/random/floats).
- ADR-005 stub-shipping precedent honored (interface present, scoring logic stubbed, public API stable).
- Module header warning string is mandatory and test-asserted.
selectLeadermodulo-bias caveat documented.- Test corpus covers determinism, roundtrip, collision-free, uniformity, single-arbiter, empty arbiters guard.
Ready to proceed to Step 2 (contract).