Initial Commit
This commit is contained in:
3
packages/security-stack/security/srp/src/server/index.ts
Normal file
3
packages/security-stack/security/srp/src/server/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { serverFinishHandshake } from "./server-finish.js";
|
||||
export { serverComputeM2 } from "./server-m2.js";
|
||||
export { serverStartHandshake } from "./server-start.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
65
packages/security-stack/security/srp/src/server/server-m2.md
Normal file
65
packages/security-stack/security/srp/src/server/server-m2.md
Normal 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.
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
18
packages/security-stack/security/srp/src/server/server-m2.ts
Normal file
18
packages/security-stack/security/srp/src/server/server-m2.ts
Normal 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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user