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:

  1. Determinism — same input always same output: 10000 iterations over vrfEval(seed, input, privKey) with identical args produce byte-identical (output, proof).
  2. Collision-free over test range: 10000 distinct (seed, input) pairs under the same privKey produce 10000 distinct outputs (assertion via Set<hex>.size === 10000).
  3. Roundtrip verify (correct key): vrfVerify(seed, input, output, proof, pubKey) returns true when (output, proof) were produced by vrfEval with the corresponding private key.
  4. Roundtrip verify (wrong key): substituting any other key returns false.
  5. Tamper detection: flipping a bit in output or proof returns false.
  6. selectLeader determinism: same (arbiters, seed, round_id) always returns the same arbiter id.
  7. selectLeader single-arbiter: selectLeader(["A"], seed, r) returns "A" for any seed/round.
  8. selectLeader uniformity 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.
  9. VrfProvider interface contract: HmacVrfProvider instance exposes the same methods as defaultVrf; the function exports (vrfEval, vrfVerify) delegate through defaultVrf.
  10. Module-corpus token scan: source body contains no Math./Date./crypto.X/Number(/floating-point literal patterns.
  11. Empty arbiters guard: selectLeader([], any, any) throws VrfError.
  12. Variable arbiter sizes: selectLeader returns a valid index for n = 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:

  1. Add @noble/curves to package.json dependencies.
  2. Implement NobleCurvesVrfProvider in same file or sibling.
  3. Replace export const defaultVrf: VrfProvider = new HmacVrfProvider(); with ... = new NobleCurvesVrfProvider();.
  4. Update the module header warning to point to the new behavior.
  5. Existing callers of vrfEval / vrfVerify / selectLeader see 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/curves deferred 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.
  • selectLeader modulo-bias caveat documented.
  • Test corpus covers determinism, roundtrip, collision-free, uniformity, single-arbiter, empty arbiters guard.

Ready to proceed to Step 2 (contract).


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.