Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
export { serverFinishHandshake } from "./server-finish.js";
export { serverComputeM2 } from "./server-m2.js";
export { serverStartHandshake } from "./server-start.js";

View File

@@ -0,0 +1,74 @@
# `server-finish`
## Purpose
Complete the SRP-6a handshake on the server side after receiving the client's public value `A` and proof `M1`. Validates `A mod N != 0` (RFC 5054 §2.5.4 abort condition), derives the server-side shared secret `S = (A * v^u)^b mod N`, hashes it into the session key `K = H(S)`, recomputes the expected `M1`, and constant-time-compares against the client's `M1`. On match emits `M2 = H(A | M1 | K)` per RFC 2945 §3. On mismatch throws `SrpError(SRP.AUTH_FAILED)`.
## Features
- `A mod N == 0` gate per RFC 5054 §2.5.4.
- `u ≡ 0 mod N` gate inherited from `computeU`.
- Constant-time `M1` comparison via `@suaveplan/crypto-utils.constantTimeEqual`.
- Returns `K`, `M2`, and `S` so consumers can chain into a KDF on `S` directly.
- Emits `srp.handshake_duration_ms{role=server,phase=finish}` plus the appropriate outcome counter (`ok` / `auth_failed` / `public_key_invalid`).
## Usage: Basic Example
```ts
import { serverFinishHandshake } from "@suaveplan/srp/server";
import { params2048Sha256 } from "@suaveplan/srp/params";
import { nobleHashBridge } from "@suaveplan/srp/hash";
const params = params2048Sha256(nobleHashBridge("SHA256"));
const salt = new Uint8Array(16);
const A = new Uint8Array(256);
A[255] = 1;
const B = new Uint8Array(256);
B[255] = 2;
const b = new Uint8Array(32);
b[31] = 1;
const v = new Uint8Array(256);
v[255] = 1;
const M1Client = new Uint8Array(32); // server expects to recompute this and compare
try {
serverFinishHandshake("alice", salt, A, B, b, v, M1Client, params);
} catch (err) {
console.log("auth failed:", (err as Error).message);
}
```
## Usage: Advanced Example
```ts
import { serverFinishHandshake, serverStartHandshake } from "@suaveplan/srp/server";
import { clientFinishHandshake, clientStartHandshake } from "@suaveplan/srp/client";
import { computeVerifier } from "@suaveplan/srp/verifier";
import { params2048Sha256 } from "@suaveplan/srp/params";
import { nobleHashBridge } from "@suaveplan/srp/hash";
const params = params2048Sha256(nobleHashBridge("SHA256"));
const salt = new Uint8Array(16);
const rng = {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n: number) => crypto.getRandomValues(new Uint8Array(n)),
};
const { v } = computeVerifier("alice", "password123", salt, params);
const { a, A } = clientStartHandshake(rng, params);
const { b, B } = serverStartHandshake(v, rng, params);
const client = clientFinishHandshake("alice", "password123", salt, A, B, a, params);
const server = serverFinishHandshake("alice", salt, A, B, b, v, client.M1, params);
console.log(Buffer.from(client.K).equals(Buffer.from(server.K))); // true
```
## API Reference
### `serverFinishHandshake(I, salt, A, B, b, v, M1Client, params): { K, M2, S }`
Throws `SrpError(SRP.PUBLIC_KEY_INVALID)` when `A mod N == 0` or `u ≡ 0 mod N`. Throws `SrpError(SRP.AUTH_FAILED)` when the recomputed `M1` does not constant-time-match `M1Client`. On success returns the session key `K`, the server proof `M2`, and the raw shared secret `S`.
## Implementation Notes
The order `validate A mod N → compute u → compute S → compute K → compute expectedM1 → compare M1Client` is intentional: the cheap modular check (`A mod N == 0`) happens first so an adversary cannot force the server to spend a `modPow` on garbage; `computeU` runs next because it has its own abort gate (`u ≡ 0`); only then do we pay the full `modPow` chain. This ordering minimises wasted server CPU on adversarial inputs without leaking any new timing channel (every legitimate handshake takes the same path).
The constant-time `M1` compare is the last gate before emitting `M2`. We use `expectedM1` (the server's locally-computed proof) inside `computeM2` rather than `M1Client` — even though they are byte-identical when the compare succeeds — because reusing the server-side computation eliminates a class of subtle bugs where a malicious client substitutes a near-miss `M1` that happens to match by the time the compare runs.

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { clientFinishHandshake } from "../client/client-finish.js";
import { clientStartHandshake } from "../client/client-start.js";
import { SRP_CODES, SrpError } from "../errors/errors.js";
import { nobleHashBridge } from "../hash/hash-bridge.js";
import { params2048Sha256 } from "../params/params.js";
import { computeVerifier } from "../verifier/verifier.js";
import { serverFinishHandshake } from "./server-finish.js";
import { serverStartHandshake } from "./server-start.js";
function seededRng(seed: number): Rng {
let state = seed >>> 0;
return {
nextUint32: () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state;
},
nextFloat: () => 0.5,
};
}
describe("serverFinishHandshake", () => {
it("accepts correct client proof and emits M2 = H(A | M1 | K)", {
timeout: 5000,
}, () => {
const params = params2048Sha256(nobleHashBridge("SHA256"));
const salt = new Uint8Array(16);
const { v } = computeVerifier("alice", "pw", salt, params);
const { a, A } = clientStartHandshake(seededRng(1), params);
const { b, B } = serverStartHandshake(v, seededRng(2), params);
const client = clientFinishHandshake(
"alice",
"pw",
salt,
A,
B,
a,
params
);
const server = serverFinishHandshake(
"alice",
salt,
A,
B,
b,
v,
client.M1,
params
);
expect(Array.from(server.K)).toEqual(Array.from(client.K));
expect(server.M2.byteLength).toBe(32);
});
it("rejects single-bit-flipped M1 with SRP.AUTH_FAILED", {
timeout: 5000,
}, () => {
const params = params2048Sha256(nobleHashBridge("SHA256"));
const salt = new Uint8Array(16);
const { v } = computeVerifier("alice", "pw", salt, params);
const { a, A } = clientStartHandshake(seededRng(1), params);
const { b, B } = serverStartHandshake(v, seededRng(2), params);
const client = clientFinishHandshake(
"alice",
"pw",
salt,
A,
B,
a,
params
);
const tampered = new Uint8Array(client.M1);
tampered[0] = (tampered[0] ?? 0) ^ 1;
try {
serverFinishHandshake("alice", salt, A, B, b, v, tampered, params);
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(SrpError);
expect((err as SrpError).code).toBe(SRP_CODES.AUTH_FAILED);
}
});
it("rejects length-mismatched M1 with SRP.AUTH_FAILED", {
timeout: 5000,
}, () => {
const params = params2048Sha256(nobleHashBridge("SHA256"));
const salt = new Uint8Array(16);
const { v } = computeVerifier("alice", "pw", salt, params);
const { A } = clientStartHandshake(seededRng(1), params);
const { b, B } = serverStartHandshake(v, seededRng(2), params);
const shortM1 = new Uint8Array(16);
try {
serverFinishHandshake("alice", salt, A, B, b, v, shortM1, params);
throw new Error("did not throw");
} catch (err) {
expect((err as SrpError).code).toBe(SRP_CODES.AUTH_FAILED);
}
});
it("rejects A mod N == 0 with SRP.PUBLIC_KEY_INVALID", {
timeout: 5000,
}, () => {
const params = params2048Sha256(nobleHashBridge("SHA256"));
const salt = new Uint8Array(16);
const { v } = computeVerifier("alice", "pw", salt, params);
const { b, B } = serverStartHandshake(v, seededRng(2), params);
const Azero = new Uint8Array(256);
try {
serverFinishHandshake(
"alice",
salt,
Azero,
B,
b,
v,
new Uint8Array(32),
params
);
throw new Error("did not throw");
} catch (err) {
expect((err as SrpError).code).toBe(SRP_CODES.PUBLIC_KEY_INVALID);
}
});
});

View File

@@ -0,0 +1,74 @@
import { constantTimeEqual } from "@suaveplan/crypto-utils";
import { SRP_CODES, srpError } from "../errors/errors.js";
import { digestSync } from "../hash/hash-bridge.js";
import { bigIntToBytes, bytesToBigInt, modPow } from "../math/modpow.js";
import { computeM1, computeM2 } from "../math/proof.js";
import { computeU } from "../math/u.js";
import {
recordDuration,
recordOutcome,
recordSrpError,
} from "../observability/metrics.js";
import type { SrpParams } from "../params/params.js";
/**
* RFC 5054 §2.6 — finish the handshake on the server side.
*
* Validates `A mod N != 0` (throws `SRP.PUBLIC_KEY_INVALID`), computes
* `u = H(PAD(A) | PAD(B))`
* `S = (A * v^u)^b mod N`
* `K = H(S)`
* `expectedM1 = H(H(N) XOR H(g) | H(I) | salt | A | B | K)`
* and constant-time-compares `expectedM1` against `M1Client`. On mismatch
* throws `SrpError(SRP.AUTH_FAILED)`. On success emits
* `M2 = H(A | M1 | K)`.
*/
export function serverFinishHandshake(
I: string,
salt: Uint8Array,
A: Uint8Array,
B: Uint8Array,
b: Uint8Array,
v: Uint8Array,
M1Client: Uint8Array,
params: SrpParams
): { K: Uint8Array; M2: Uint8Array; S: Uint8Array } {
const t0 = performance.now();
const NInt = bytesToBigInt(params.N);
const AInt = bytesToBigInt(A);
if (AInt % NInt === 0n) {
recordSrpError(SRP_CODES.PUBLIC_KEY_INVALID);
recordOutcome("server", "public_key_invalid");
throw srpError(
SRP_CODES.PUBLIC_KEY_INVALID,
"A mod N == 0 — RFC 5054 §2.5.4 requires aborting the handshake"
);
}
const vInt = bytesToBigInt(v);
const bInt = bytesToBigInt(b);
const u = computeU(A, B, params);
const uInt = bytesToBigInt(u);
// S = (A * v^u)^b mod N
const vu = modPow(vInt, uInt, NInt);
const base = (AInt * vu) % NInt;
const SInt = modPow(base, bInt, NInt);
const S = bigIntToBytes(SInt, params.N.byteLength);
const K = digestSync(params.hash, S);
const expectedM1 = computeM1(I, salt, A, B, K, params);
if (
expectedM1.byteLength !== M1Client.byteLength ||
!constantTimeEqual(expectedM1, M1Client)
) {
recordSrpError(SRP_CODES.AUTH_FAILED);
recordOutcome("server", "auth_failed");
throw srpError(
SRP_CODES.AUTH_FAILED,
"client M1 did not match expected H(H(N) XOR H(g) | H(I) | salt | A | B | K)"
);
}
const M2 = computeM2(A, expectedM1, K, params);
recordDuration("server", "finish", performance.now() - t0);
recordOutcome("server", "ok");
return { K, M2, S };
}

View File

@@ -0,0 +1,65 @@
# `server-m2`
## Purpose
Split-roundtrip helper that computes the server proof `M2 = H(A | M1 | K)` independently of `serverFinishHandshake`. Useful for transports that ack `M1` over one HTTP request (server validates client proof, persists the session) and emit `M2` from a different worker or route (perhaps a follow-up acknowledgement endpoint, or a fan-out broadcast confirming successful authentication). The second-half route does not need to redo the `modPow` + KDF chain; it just needs `(A, M1, K)` (which it can fetch from a session store) and this helper.
## Features
- Single function call returning `H(A | M1 | K)`.
- Byte-identical to the `M2` field returned by `serverFinishHandshake` for the same `(A, M1, K)` triple.
- Deterministic — same inputs always produce the same bytes.
- Wraps the shared `computeM2` helper in `math/proof.ts` so the proof formula lives in exactly one place.
## Usage: Basic Example
```ts
import { serverComputeM2 } from "@suaveplan/srp/server";
import { params2048Sha256 } from "@suaveplan/srp/params";
import { nobleHashBridge } from "@suaveplan/srp/hash";
const params = params2048Sha256(nobleHashBridge("SHA256"));
const A = new Uint8Array(256);
const M1 = new Uint8Array(32);
const K = new Uint8Array(32);
const M2 = serverComputeM2(A, M1, K, params);
console.log(M2.byteLength); // 32
```
## Usage: Advanced Example
```ts
import { serverComputeM2, serverFinishHandshake } from "@suaveplan/srp/server";
import { clientFinishHandshake, clientStartHandshake } from "@suaveplan/srp/client";
import { serverStartHandshake } from "@suaveplan/srp/server";
import { computeVerifier } from "@suaveplan/srp/verifier";
import { params2048Sha256 } from "@suaveplan/srp/params";
import { nobleHashBridge } from "@suaveplan/srp/hash";
const params = params2048Sha256(nobleHashBridge("SHA256"));
const salt = new Uint8Array(16);
const rng = {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n: number) => crypto.getRandomValues(new Uint8Array(n)),
};
const { v } = computeVerifier("alice", "password123", salt, params);
const { a, A } = clientStartHandshake(rng, params);
const { b, B } = serverStartHandshake(v, rng, params);
const client = clientFinishHandshake("alice", "password123", salt, A, B, a, params);
const inline = serverFinishHandshake("alice", salt, A, B, b, v, client.M1, params);
const split = serverComputeM2(A, client.M1, inline.K, params);
console.log(Buffer.from(inline.M2).equals(Buffer.from(split)));
```
## API Reference
### `serverComputeM2(A, M1, K, params): Uint8Array`
Returns `H(A | M1 | K)` of length `params.hash.digestSize`.
## Implementation Notes
The split-roundtrip pattern matters operationally: many real deployments separate the "validate client proof" step (which needs the full SRP state machine — `v`, `b`, `B`) from the "ack mutual authentication" step (which only needs `A`, `M1`, and the session key `K`). The first step can run on a stateful auth node with the verifier DB; the second can run on any edge that has been handed the session-key blob. `serverComputeM2` is the API surface that supports that split without forcing the second step to re-derive `S` and `K`.
The function is intentionally trivial — a single forward to `computeM2`. The wrapper exists for API stability: if RFC 2945 ever extends `M2` (e.g. adds a session-binding field), this entry point is where the change lands, and `serverFinishHandshake` is updated to call into the same helper.

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { clientFinishHandshake } from "../client/client-finish.js";
import { clientStartHandshake } from "../client/client-start.js";
import { nobleHashBridge } from "../hash/hash-bridge.js";
import { params2048Sha256 } from "../params/params.js";
import { computeVerifier } from "../verifier/verifier.js";
import { serverFinishHandshake } from "./server-finish.js";
import { serverComputeM2 } from "./server-m2.js";
import { serverStartHandshake } from "./server-start.js";
function seededRng(seed: number): Rng {
let state = seed >>> 0;
return {
nextUint32: () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state;
},
nextFloat: () => 0.5,
};
}
describe("serverComputeM2", () => {
it("matches inline serverFinishHandshake M2 output", {
timeout: 5000,
}, () => {
const params = params2048Sha256(nobleHashBridge("SHA256"));
const salt = new Uint8Array(16);
const { v } = computeVerifier("alice", "pw", salt, params);
const { a, A } = clientStartHandshake(seededRng(1), params);
const { b, B } = serverStartHandshake(v, seededRng(2), params);
const client = clientFinishHandshake(
"alice",
"pw",
salt,
A,
B,
a,
params
);
const server = serverFinishHandshake(
"alice",
salt,
A,
B,
b,
v,
client.M1,
params
);
const split = serverComputeM2(A, client.M1, server.K, params);
expect(Array.from(split)).toEqual(Array.from(server.M2));
});
it("deterministic for fixed inputs", { timeout: 5000 }, () => {
const params = params2048Sha256(nobleHashBridge("SHA256"));
const A = new Uint8Array(256);
const M1 = new Uint8Array(32);
const K = new Uint8Array(32);
const a = serverComputeM2(A, M1, K, params);
const b = serverComputeM2(A, M1, K, params);
expect(Array.from(a)).toEqual(Array.from(b));
});
});

View File

@@ -0,0 +1,18 @@
import { computeM2 } from "../math/proof.js";
import type { SrpParams } from "../params/params.js";
/**
* Split-roundtrip helper for transports that ack `M1` over one HTTP
* request and emit `M2` from a different worker / route. Equivalent
* to the `M2` field returned by `serverFinishHandshake` for the same
* `(A, M1, K)` triple; provided here so the second-half route does
* not need to redo the modpow + KDF chain.
*/
export function serverComputeM2(
A: Uint8Array,
M1: Uint8Array,
K: Uint8Array,
params: SrpParams
): Uint8Array {
return computeM2(A, M1, K, params);
}

View File

@@ -0,0 +1,74 @@
# `server-start`
## Purpose
Begin the SRP-6a handshake on the server side. Loads the stored verifier `v` (alongside the user's `(I, salt)`), draws a `≥ 256`-bit private exponent `b`, and emits the server public value `B = (k * v + g^b) mod N` per RFC 5054 §2.5.4. The augmented form (multiplying `k` into the verifier) is what distinguishes SRP-6a from SRP-6. The function re-draws `b` if the resulting `B mod N == 0`, up to 16 attempts.
## Features
- 256-bit private exponent — matches RFC 5054 §2.5.4 minimum.
- `k = H(N | PAD(g))` precomputed once per call (could be memoized externally for further savings).
- `B ≡ 0 mod N` re-draw with 16-attempt safety cap.
- Returns `B` zero-padded to `byteLength(N)`.
- Emits `srp.handshake_duration_ms{role=server,phase=start}` and on abort `srp.outcome_total{role=server,outcome=public_key_invalid}` + `srp.errors_total{code=SRP.PUBLIC_KEY_INVALID}`.
## Usage: Basic Example
```ts
import { serverStartHandshake } from "@suaveplan/srp/server";
import { params2048Sha256 } from "@suaveplan/srp/params";
import { nobleHashBridge } from "@suaveplan/srp/hash";
import { computeVerifier } from "@suaveplan/srp/verifier";
const params = params2048Sha256(nobleHashBridge("SHA256"));
const salt = new Uint8Array(16);
const { v } = computeVerifier("alice", "password123", salt, params);
const rng = {
nextUint32: () => 0,
nextFloat: () => 0,
nextBytes: (n: number) => crypto.getRandomValues(new Uint8Array(n)),
};
const { b, B } = serverStartHandshake(v, rng, params);
console.log(b.byteLength, B.byteLength); // 32 256
```
## Usage: Advanced Example
```ts
import { serverStartHandshake } from "@suaveplan/srp/server";
import { params2048Sha256 } from "@suaveplan/srp/params";
import { nobleHashBridge } from "@suaveplan/srp/hash";
import { computeVerifier } from "@suaveplan/srp/verifier";
import type { Rng } from "@suaveplan/types";
const params = params2048Sha256(nobleHashBridge("SHA256"));
const salt = new Uint8Array(16);
const { v } = computeVerifier("alice", "password123", salt, params);
// Synthetic deterministic RNG to demonstrate reproducibility.
function seededRng(seed: number): Rng {
let state = seed >>> 0;
return {
nextUint32: () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state;
},
nextFloat: () => 0.5,
};
}
const r1 = serverStartHandshake(v, seededRng(42), params);
const r2 = serverStartHandshake(v, seededRng(42), params);
console.log(Buffer.from(r1.B).equals(Buffer.from(r2.B))); // true
```
## API Reference
### `serverStartHandshake(v, rng, params): { b, B }`
Returns `{ b, B }`. `b` is 32 bytes of fresh entropy from `rng`; `B` is `params.N.byteLength` bytes, zero-padded.
## Implementation Notes
`kvModN = (k * v) mod N` is computed once at the top of the function and reused inside the re-draw loop because it does not depend on `b`. The only loop-variant work is `g^b mod N` (the `modPow` call) and the final `((kvModN + gb) mod N)`. This matters because the dominant cost is `modPow`; pulling `k*v` out of the loop saves wall time on the (vanishingly unlikely) re-draw path.
The re-draw cap is the same `16` as the client side. Per RFC 5054 §2.5.4 the requirement is "MUST abort if `B ≡ 0 mod N`"; the cap converts that into a finite-bound failure rather than an unbounded loop on a pathological RNG.

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Rng } from "@suaveplan/types";
import { SRP_CODES, SrpError } from "../errors/errors.js";
import { nobleHashBridge } from "../hash/hash-bridge.js";
import { computeK } from "../math/k.js";
import { params2048Sha256 } from "../params/params.js";
import { computeVerifier } from "../verifier/verifier.js";
import { serverStartHandshake } from "./server-start.js";
function seededRng(seed: number): Rng {
let state = seed >>> 0;
return {
nextUint32: () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state;
},
nextFloat: () => 0.5,
};
}
describe("serverStartHandshake", () => {
it("b carries at least 256 bits of entropy", { timeout: 5000 }, () => {
const params = params2048Sha256(nobleHashBridge("SHA256"));
const { v } = computeVerifier(
"alice",
"pw",
new Uint8Array(16),
params
);
const { b, B } = serverStartHandshake(v, seededRng(1), params);
expect(b.byteLength).toBe(32);
expect(B.byteLength).toBe(256);
});
it("B is reproducible from a deterministic RNG", { timeout: 5000 }, () => {
const params = params2048Sha256(nobleHashBridge("SHA256"));
const { v } = computeVerifier(
"alice",
"pw",
new Uint8Array(16),
params
);
const r1 = serverStartHandshake(v, seededRng(123), params);
const r2 = serverStartHandshake(v, seededRng(123), params);
expect(Array.from(r1.B)).toEqual(Array.from(r2.B));
});
it("re-rolls when first draw produces B mod N == 0", {
timeout: 5000,
}, () => {
const params = params2048Sha256(nobleHashBridge("SHA256"));
const { v } = computeVerifier(
"alice",
"pw",
new Uint8Array(16),
params
);
// Force the first draw to be all-zero bytes (so g^b == 1 and
// B = (k*v + 1) mod N, which is non-zero); second-draw RNG yields
// a random uint32 seed. We exercise the loop indirectly by
// checking that B is never zero across many draws.
const { B } = serverStartHandshake(v, seededRng(7), params);
let nonZero = false;
for (const b of B) {
if (b !== 0) {
nonZero = true;
break;
}
}
expect(nonZero).toBe(true);
});
it("throws SRP.PUBLIC_KEY_INVALID when 16 draws all yield B ≡ 0 mod N", {
timeout: 5000,
}, () => {
// Synthetic tiny group: N = 3, g = 2. With v = 2 the precomputed
// k*v mod N = (k * 2) mod 3. We pick a hash that makes k mod 3 == 2
// (using the actual SHA-256 of N|PAD(g) — its decoded mod 3 may
// not be exactly 2). Instead, force the situation analytically:
// construct an Rng whose nextBytes always returns an odd value,
// and craft params such that for any odd b, g^b ≡ N - kvModN
// mod N — i.e. B ≡ 0. We use g = N - 1 (so g^b ≡ ±1, with -1 for
// odd b) and choose v so k*v ≡ 1 mod N. Then for odd b,
// B = (1 + (N-1)) mod N = 0.
const params = {
N: new Uint8Array([0x07]), // N = 7
g: new Uint8Array([0x06]), // g = 6 = N-1
bitLength: 8,
hash: nobleHashBridge("SHA256"),
algorithm: "SHA256" as const,
};
// Solve for v: with these N,g the multiplier k = H(N | PAD(g)) is
// fixed by the hash. We compute it once and pick v so that
// (k*v) mod N == 1. If k mod N == 0 (improbable, but possible),
// we cannot construct a viable v — but for our concrete N=7 and
// these bytes the hash output mod 7 is non-zero, so a v exists.
// We compute v algorithmically inside the test.
const NInt = 7n;
const k = computeK(params);
let kInt = 0n;
for (const byte of k) kInt = (kInt << 8n) | BigInt(byte);
kInt %= NInt;
// Modular inverse of k mod 7 via brute force.
let kInv = 0n;
for (let i = 1n; i < NInt; i++) {
if ((kInt * i) % NInt === 1n) {
kInv = i;
break;
}
}
// v = kInv mod N.
const v = new Uint8Array([Number(kInv)]);
// RNG always yields odd 32-bit values -> b odd -> g^b ≡ -1 mod N.
const oddRng: Rng = {
nextUint32: () => 0xffff_ffff,
nextFloat: () => 0,
nextBytes: (n: number) => {
const arr = new Uint8Array(n);
arr.fill(0xff);
return arr;
},
};
try {
serverStartHandshake(v, oddRng, params);
throw new Error("did not throw");
} catch (err) {
expect(err).toBeInstanceOf(SrpError);
expect((err as SrpError).code).toBe(SRP_CODES.PUBLIC_KEY_INVALID);
}
});
});

View File

@@ -0,0 +1,58 @@
import type { Rng } from "@suaveplan/types";
import { SRP_CODES, srpError } from "../errors/errors.js";
import { computeK } from "../math/k.js";
import { bigIntToBytes, bytesToBigInt, modPow } from "../math/modpow.js";
import { rngBytes } from "../math/rng-bytes.js";
import {
recordDuration,
recordOutcome,
recordSrpError,
} from "../observability/metrics.js";
import type { SrpParams } from "../params/params.js";
const PRIVATE_EXPONENT_BYTES = 32;
/**
* RFC 5054 §2.5.4 — server kicks off the handshake by drawing a
* private exponent `b ≥ 256` bits and emitting
* `B = (k * v + g^b) mod N`
* where `k = H(N | PAD(g))` and `v` is the user's stored verifier.
*
* If `B mod N == 0`, the function re-draws `b` automatically up to
* 16 attempts before surrendering with `SRP.PUBLIC_KEY_INVALID`.
*/
export function serverStartHandshake(
v: Uint8Array,
rng: Rng,
params: SrpParams
): { b: Uint8Array; B: Uint8Array } {
const t0 = performance.now();
const NInt = bytesToBigInt(params.N);
const gInt = bytesToBigInt(params.g);
const vInt = bytesToBigInt(v);
const k = computeK(params);
const kInt = bytesToBigInt(k);
const kvModN = (kInt * vInt) % NInt;
let b: Uint8Array;
let bInt: bigint;
let BInt: bigint;
let attempts = 0;
do {
if (attempts++ > 16) {
recordSrpError(SRP_CODES.PUBLIC_KEY_INVALID);
recordOutcome("server", "public_key_invalid");
throw srpError(
SRP_CODES.PUBLIC_KEY_INVALID,
"serverStartHandshake exceeded 16 RNG re-draws without producing B != 0 mod N"
);
}
b = rngBytes(rng, PRIVATE_EXPONENT_BYTES);
bInt = bytesToBigInt(b);
const gb = modPow(gInt, bInt, NInt);
BInt = (kvModN + gb) % NInt;
} while (BInt === 0n);
const BPadded = bigIntToBytes(BInt, params.N.byteLength);
recordDuration("server", "start", performance.now() - t0);
return { b, B: BPadded };
}