Files
skills/docs/security-stack/security/srp/src/client/client-start.md
2026-05-29 08:20:56 +01:00

3.3 KiB
Raw Blame History

client-start

Purpose

Kick off the SRP-6a handshake on the client side. Draws a private exponent a ≥ 256 bits from an injectable Rng and emits the public value A = g^a mod N. Per RFC 5054 §2.5.4 the function rejects (re-draws) any a whose resulting A ≡ 0 mod N; in practice a real CSPRNG over a safe-prime group never trips this gate, but the gate is wired so synthetic / adversarial RNGs cannot escape the protocol invariant.

Features

  • 256-bit private exponent — equal to the RFC 5054 §2.5.4 minimum.
  • Re-draw on A ≡ 0 mod N up to 16 attempts before surrendering with SRP.PUBLIC_KEY_INVALID.
  • Deterministic from a seeded Rng — same seed yields identical (a, A).
  • Returns A zero-padded to byteLength(N).
  • Emits srp.handshake_duration_ms{role=client,phase=start} and on the abort path srp.outcome_total{role=client,outcome=public_key_invalid} + srp.errors_total{code=SRP.PUBLIC_KEY_INVALID}.

Usage: Basic Example

import { clientStartHandshake } from "@suaveplan/srp/client";
import { params2048Sha256 } from "@suaveplan/srp/params";
import { nobleHashBridge } from "@suaveplan/srp/hash";

const params = params2048Sha256(nobleHashBridge("SHA256"));
const rng = {
    nextUint32: () => 0x12345678,
    nextFloat: () => 0.5,
    nextBytes: (n: number) => crypto.getRandomValues(new Uint8Array(n)),
};
const { a, A } = clientStartHandshake(rng, params);
console.log(a.byteLength, A.byteLength); // 32 256

Usage: Advanced Example

import { clientStartHandshake } from "@suaveplan/srp/client";
import { params2048Sha256 } from "@suaveplan/srp/params";
import { nobleHashBridge } from "@suaveplan/srp/hash";
import type { Rng } from "@suaveplan/types";

const params = params2048Sha256(nobleHashBridge("SHA256"));

// Seeded deterministic RNG — useful for replaying a known handshake in tests.
function seededRng(seed: number): Rng {
    let state = seed >>> 0;
    return {
        nextUint32: () => {
            state = (state * 1664525 + 1013904223) >>> 0;
            return state;
        },
        nextFloat: () => 0.5,
    };
}
const { a: a1, A: A1 } = clientStartHandshake(seededRng(1), params);
const { a: a2, A: A2 } = clientStartHandshake(seededRng(1), params);
console.log(Buffer.from(A1).equals(Buffer.from(A2))); // true

API Reference

clientStartHandshake(rng: Rng, params: SrpParams): { a: Uint8Array; A: Uint8Array }

Returns { a, A }. a is exactly 32 bytes (256 bits) of fresh entropy from rng. A is params.N.byteLength bytes, zero-padded on the left.

Implementation Notes

The 16-attempt re-draw cap exists so a pathological RNG cannot trap the function in an unbounded loop. With a real CSPRNG the probability of A ≡ 0 mod N per attempt is ≤ 1/N, which for the bundled 2048-bit group is ≤ 2^-2048; the 16-attempt cap is purely a synthetic-test safety net.

a.byteLength === 32 is the RFC 5054 §2.5.4 floor ("at least 256 bits"). Drawing more bits gains no security headroom because a is implicitly reduced modulo N inside modPow; we draw exactly the minimum to keep allocations small.

The function is CPU-bound — one modPow over a 2048-bit safe prime takes ~5–20 ms on contemporary hardware. It is intentionally synchronous; consumers that want to run it off the main thread should schedule it via a Web Worker or similar.