Initial Commit
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# `client-finish`
|
||||
|
||||
## Purpose
|
||||
|
||||
Complete the SRP-6a handshake on the client side after the server has emitted its public value `B`. Derives the shared secret `S = (B - k * g^x)^(a + u * x) mod N`, hashes it into the session key `K = H(S)`, and emits the client proof `M1 = H(H(N) XOR H(g) | H(I) | salt | A | B | K)`. The function is the heart of the protocol; correctness of every intermediate is anchored against RFC 5054 Appendix B test vectors and against a locally-generated 2048/SHA-256 fixture.
|
||||
|
||||
## Features
|
||||
|
||||
- Single call computing `u`, `x`, `S`, `K`, and `M1`.
|
||||
- `B mod N == 0` gate per RFC 5054 §2.5.4, raising `SRP.PUBLIC_KEY_INVALID`.
|
||||
- `u ≡ 0 mod N` gate inherited from `computeU` — same error code.
|
||||
- `modPow` routes through `@noble/curves` for both `g^x` and the final `base^exp`.
|
||||
- Returns `S` alongside `K` and `M1` so consumers can reuse the raw shared secret for KDF chains.
|
||||
- Emits `srp.handshake_duration_ms{role=client,phase=finish}`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { clientFinishHandshake, clientStartHandshake } from "@suaveplan/srp/client";
|
||||
import {
|
||||
serverStartHandshake,
|
||||
serverFinishHandshake,
|
||||
} 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 { K, M1 } = clientFinishHandshake("alice", "password123", salt, A, B, a, params);
|
||||
const { M2 } = serverFinishHandshake("alice", salt, A, B, b, v, M1, params);
|
||||
console.log(K.byteLength, M1.byteLength, M2.byteLength);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { clientFinishHandshake } from "@suaveplan/srp/client";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
import { SRP_CODES, SrpError } from "@suaveplan/srp/errors";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const salt = new Uint8Array(16);
|
||||
const A = new Uint8Array(256);
|
||||
A[255] = 1;
|
||||
const a = new Uint8Array(32);
|
||||
a[31] = 1;
|
||||
const Bzero = new Uint8Array(256); // B ≡ 0 mod N — must abort.
|
||||
try {
|
||||
clientFinishHandshake("alice", "password123", salt, A, Bzero, a, params);
|
||||
} catch (err) {
|
||||
if (err instanceof SrpError && err.code === SRP_CODES.PUBLIC_KEY_INVALID) {
|
||||
console.log("aborted per RFC 5054 §2.5.4");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `clientFinishHandshake(I, P, salt, A, B, a, params): { K, M1, S }`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------------ | -------------------------------------------------------- |
|
||||
| `I` | `string` | Username. |
|
||||
| `P` | `string` | Password. |
|
||||
| `salt` | `Uint8Array` | Server-supplied salt. |
|
||||
| `A` | `Uint8Array` | Client public value from `clientStartHandshake`. |
|
||||
| `B` | `Uint8Array` | Server public value. |
|
||||
| `a` | `Uint8Array` | Client private exponent from `clientStartHandshake`. |
|
||||
| `params` | `SrpParams` | Group + hash bundle. |
|
||||
|
||||
Returns `K`, `M1`, and `S` — all `params.hash.digestSize` bytes except `S` which is `params.N.byteLength` bytes. Throws `SrpError(SRP.PUBLIC_KEY_INVALID)` when `B mod N == 0` or `u ≡ 0 mod N`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The intermediate normalisation `base = ((base % N) + N) % N` exists because JavaScript's `%` operator returns a value with the sign of the dividend, so `(B - k*g^x) % N` may be negative when `k * g^x > B`. We normalise into `[0, N)` before passing to `modPow` because `@noble/curves`' modular pow expects a non-negative base. RFC 5054 §2.6's formula `(B - k * g^x)` is implicitly in `Z_N`; the explicit modular reduction makes that explicit at the language level.
|
||||
|
||||
The function deliberately returns `S` alongside `K` and `M1`. `K = H(S)` collapses the shared secret into a fixed-width session key, but some downstream protocols (e.g. an HKDF expand from the SRP-derived material) want the raw `S` instead. Both fields are emitted so the caller can pick.
|
||||
@@ -0,0 +1,108 @@
|
||||
import { sha1 } from "@noble/hashes/sha1.js";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
A_PRIV_HEX,
|
||||
A_PUB_HEX,
|
||||
B_PUB_HEX,
|
||||
fromHex,
|
||||
I,
|
||||
P,
|
||||
S_HEX,
|
||||
SALT_HEX,
|
||||
} from "../__fixtures__/rfc5054-appendix-b.js";
|
||||
import { SRP_CODES, SrpError } from "../errors/errors.js";
|
||||
import { g_1024, N_1024 } from "../groups/1024/groups-1024.js";
|
||||
import { nobleHashBridge } from "../hash/hash-bridge.js";
|
||||
import type { SrpParams } from "../params/params.js";
|
||||
import { clientFinishHandshake } from "./client-finish.js";
|
||||
|
||||
function params1024Sha1(): SrpParams {
|
||||
return {
|
||||
N: N_1024,
|
||||
g: g_1024,
|
||||
bitLength: 1024,
|
||||
hash: nobleHashBridge("SHA1"),
|
||||
algorithm: "SHA1",
|
||||
};
|
||||
}
|
||||
|
||||
describe("clientFinishHandshake", () => {
|
||||
it("matches RFC 5054 Appendix B K", { timeout: 5000 }, () => {
|
||||
const params = params1024Sha1();
|
||||
const { K, S } = clientFinishHandshake(
|
||||
I,
|
||||
P,
|
||||
fromHex(SALT_HEX),
|
||||
fromHex(A_PUB_HEX),
|
||||
fromHex(B_PUB_HEX),
|
||||
fromHex(A_PRIV_HEX),
|
||||
params
|
||||
);
|
||||
expect(Array.from(S)).toEqual(Array.from(fromHex(S_HEX)));
|
||||
// K = H(S) — verify against the SHA-1 reference applied to S directly.
|
||||
expect(Array.from(K)).toEqual(Array.from(sha1(fromHex(S_HEX))));
|
||||
});
|
||||
|
||||
it("M1 matches RFC 2945 H(H(N) XOR H(g) | H(I) | salt | A | B | K)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const params = params1024Sha1();
|
||||
const { M1, K } = clientFinishHandshake(
|
||||
I,
|
||||
P,
|
||||
fromHex(SALT_HEX),
|
||||
fromHex(A_PUB_HEX),
|
||||
fromHex(B_PUB_HEX),
|
||||
fromHex(A_PRIV_HEX),
|
||||
params
|
||||
);
|
||||
// Recompute M1 inline using the same formula via raw noble/sha1
|
||||
// and ensure the function produces the same bytes.
|
||||
const hN = sha1(params.N);
|
||||
const hG = sha1(params.g);
|
||||
const hNxorG = new Uint8Array(hN.length);
|
||||
for (let i = 0; i < hN.length; i++) {
|
||||
hNxorG[i] = (hN[i] ?? 0) ^ (hG[i] ?? 0);
|
||||
}
|
||||
const hI = sha1(new TextEncoder().encode(I));
|
||||
const buf = new Uint8Array(
|
||||
hNxorG.length + hI.length + 16 + 128 + 128 + K.length
|
||||
);
|
||||
let off = 0;
|
||||
buf.set(hNxorG, off);
|
||||
off += hNxorG.length;
|
||||
buf.set(hI, off);
|
||||
off += hI.length;
|
||||
buf.set(fromHex(SALT_HEX), off);
|
||||
off += 16;
|
||||
buf.set(fromHex(A_PUB_HEX), off);
|
||||
off += 128;
|
||||
buf.set(fromHex(B_PUB_HEX), off);
|
||||
off += 128;
|
||||
buf.set(K, off);
|
||||
const expected = sha1(buf);
|
||||
expect(Array.from(M1)).toEqual(Array.from(expected));
|
||||
});
|
||||
|
||||
it("rejects B mod N == 0 with SRP.PUBLIC_KEY_INVALID", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const params = params1024Sha1();
|
||||
const Bzero = new Uint8Array(128); // all zero -> B mod N == 0
|
||||
try {
|
||||
clientFinishHandshake(
|
||||
I,
|
||||
P,
|
||||
fromHex(SALT_HEX),
|
||||
fromHex(A_PUB_HEX),
|
||||
Bzero,
|
||||
fromHex(A_PRIV_HEX),
|
||||
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,70 @@
|
||||
import { SRP_CODES, srpError } from "../errors/errors.js";
|
||||
import { digestSync } from "../hash/hash-bridge.js";
|
||||
import { computeK } from "../math/k.js";
|
||||
import { bigIntToBytes, bytesToBigInt, modPow } from "../math/modpow.js";
|
||||
import { computeM1 } from "../math/proof.js";
|
||||
import { computeU } from "../math/u.js";
|
||||
import { computeX } from "../math/x.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 client side.
|
||||
*
|
||||
* Given the client's private exponent `a`, the server's public value
|
||||
* `B`, and the identity / password / salt tuple, derive:
|
||||
* - `u = H(PAD(A) | PAD(B))`
|
||||
* - `x = H(salt | H(I | ":" | P))`
|
||||
* - `S = (B - k * g^x)^(a + u * x) mod N`
|
||||
* - `K = H(S)`
|
||||
* - `M1 = H(H(N) XOR H(g) | H(I) | salt | A | B | K)`
|
||||
*
|
||||
* Throws `SrpError(SRP.PUBLIC_KEY_INVALID)` when `B mod N == 0` per
|
||||
* RFC 5054 §2.5.4 ("client MUST abort"). The `u ≡ 0 mod N` check
|
||||
* happens inside `computeU`.
|
||||
*/
|
||||
export function clientFinishHandshake(
|
||||
I: string,
|
||||
P: string,
|
||||
salt: Uint8Array,
|
||||
A: Uint8Array,
|
||||
B: Uint8Array,
|
||||
a: Uint8Array,
|
||||
params: SrpParams
|
||||
): { K: Uint8Array; M1: Uint8Array; S: Uint8Array } {
|
||||
const t0 = performance.now();
|
||||
const NInt = bytesToBigInt(params.N);
|
||||
const BInt = bytesToBigInt(B);
|
||||
if (BInt % NInt === 0n) {
|
||||
recordSrpError(SRP_CODES.PUBLIC_KEY_INVALID);
|
||||
recordOutcome("client", "public_key_invalid");
|
||||
throw srpError(
|
||||
SRP_CODES.PUBLIC_KEY_INVALID,
|
||||
"B mod N == 0 — RFC 5054 §2.5.4 requires aborting the handshake"
|
||||
);
|
||||
}
|
||||
const gInt = bytesToBigInt(params.g);
|
||||
const aInt = bytesToBigInt(a);
|
||||
const k = computeK(params);
|
||||
const kInt = bytesToBigInt(k);
|
||||
const u = computeU(A, B, params);
|
||||
const uInt = bytesToBigInt(u);
|
||||
const x = computeX(I, P, salt, params);
|
||||
const xInt = bytesToBigInt(x);
|
||||
|
||||
// S = (B - k * g^x)^(a + u * x) mod N
|
||||
const gx = modPow(gInt, xInt, NInt);
|
||||
let base = BInt - ((kInt * gx) % NInt);
|
||||
base = ((base % NInt) + NInt) % NInt;
|
||||
const exp = aInt + uInt * xInt;
|
||||
const SInt = modPow(base, exp, NInt);
|
||||
const S = bigIntToBytes(SInt, params.N.byteLength);
|
||||
const K = digestSync(params.hash, S);
|
||||
const M1 = computeM1(I, salt, A, B, K, params);
|
||||
recordDuration("client", "finish", performance.now() - t0);
|
||||
return { K, M1, S };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# `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
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
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.
|
||||
@@ -0,0 +1,56 @@
|
||||
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 { params2048Sha256 } from "../params/params.js";
|
||||
import { clientStartHandshake } from "./client-start.js";
|
||||
|
||||
function seededRng(seed: number): Rng {
|
||||
let state = seed >>> 0;
|
||||
return {
|
||||
nextUint32: () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state;
|
||||
},
|
||||
nextFloat: () => 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
describe("clientStartHandshake", () => {
|
||||
it("a carries at least 256 bits of entropy", { timeout: 5000 }, () => {
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const { a, A } = clientStartHandshake(seededRng(1), params);
|
||||
expect(a.byteLength).toBe(32);
|
||||
expect(A.byteLength).toBe(256);
|
||||
});
|
||||
|
||||
it("A is reproducible from a deterministic RNG", { timeout: 5000 }, () => {
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const r1 = clientStartHandshake(seededRng(42), params);
|
||||
const r2 = clientStartHandshake(seededRng(42), params);
|
||||
expect(Array.from(r1.A)).toEqual(Array.from(r2.A));
|
||||
expect(Array.from(r1.a)).toEqual(Array.from(r2.a));
|
||||
});
|
||||
|
||||
it("throws SRP.PUBLIC_KEY_INVALID when synthetic params force A ≡ 0 mod N", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// g = 0 forces g^a ≡ 0 mod N for any a > 0. validateSrpParams
|
||||
// would reject this shape, but clientStartHandshake itself does
|
||||
// not re-validate, so we exercise the defensive abort.
|
||||
const badParams = {
|
||||
N: new Uint8Array([0x07]),
|
||||
g: new Uint8Array([0]),
|
||||
bitLength: 8,
|
||||
hash: nobleHashBridge("SHA256"),
|
||||
algorithm: "SHA256" as const,
|
||||
};
|
||||
try {
|
||||
clientStartHandshake(seededRng(1), badParams);
|
||||
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,52 @@
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { SRP_CODES, srpError } from "../errors/errors.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 — client kicks off the handshake by drawing a
|
||||
* private exponent `a ≥ 256` bits and emitting `A = g^a mod N`.
|
||||
*
|
||||
* If a synthetic / pathological RNG produces an `a` whose resulting
|
||||
* `A mod N === 0n`, the function re-draws automatically; in practice
|
||||
* (with a real CSPRNG over a safe-prime group) this never trips, but
|
||||
* the gate satisfies RFC 5054 §2.5.4's "MUST abort if A ≡ 0 mod N"
|
||||
* requirement on the originating side.
|
||||
*/
|
||||
export function clientStartHandshake(
|
||||
rng: Rng,
|
||||
params: SrpParams
|
||||
): { a: Uint8Array; A: Uint8Array } {
|
||||
const t0 = performance.now();
|
||||
const NInt = bytesToBigInt(params.N);
|
||||
const gInt = bytesToBigInt(params.g);
|
||||
|
||||
// RFC 5054 §2.5.4 requires aborting when A ≡ 0 mod N. With a
|
||||
// well-formed group where `gcd(g, N) === 1` (every RFC 5054
|
||||
// Appendix A group satisfies this — `g ∈ {2, 5, 19}` and `N` is
|
||||
// prime), `g^a mod N` is never zero for any non-zero `a`. We
|
||||
// surface the check defensively but never expect the loop to
|
||||
// iterate more than once with a valid `(g, N)` pair.
|
||||
const a = rngBytes(rng, PRIVATE_EXPONENT_BYTES);
|
||||
const aInt = bytesToBigInt(a);
|
||||
const A = modPow(gInt, aInt, NInt);
|
||||
if (A === 0n) {
|
||||
recordSrpError(SRP_CODES.PUBLIC_KEY_INVALID);
|
||||
recordOutcome("client", "public_key_invalid");
|
||||
throw srpError(
|
||||
SRP_CODES.PUBLIC_KEY_INVALID,
|
||||
"clientStartHandshake produced A ≡ 0 mod N — invalid group parameters"
|
||||
);
|
||||
}
|
||||
const APadded = bigIntToBytes(A, params.N.byteLength);
|
||||
recordDuration("client", "start", performance.now() - t0);
|
||||
return { a, A: APadded };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# `client-verify`
|
||||
|
||||
## Purpose
|
||||
|
||||
Verify the server's proof `M2 = H(A | M1 | K)` per RFC 2945 §3 on the client side. After the client emits `M1` and the server emits `M2`, the client must check that `M2` is what the server would have emitted only if it knew the verifier — proving mutual authentication. A mismatch indicates a malicious or buggy server; the function throws `SrpError(SRP.MUTUAL_AUTH_FAILED)` immediately.
|
||||
|
||||
## Features
|
||||
|
||||
- Constant-time `M2` comparison via `@suaveplan/crypto-utils.constantTimeEqual`.
|
||||
- Length-mismatch fast-fails as `MUTUAL_AUTH_FAILED` (still constant-time relative to the input lengths).
|
||||
- Emits `srp.outcome_total{role=client,outcome=ok}` on success or `srp.outcome_total{role=client,outcome=mutual_auth_failed}` plus `srp.errors_total{code=SRP.MUTUAL_AUTH_FAILED}` on failure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { clientVerifyServerProof } from "@suaveplan/srp/client";
|
||||
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 correctM2 = new Uint8Array(32); // replace with server-supplied bytes
|
||||
try {
|
||||
clientVerifyServerProof(M1, correctM2, K, A, params);
|
||||
console.log("mutual auth ok");
|
||||
} catch (err) {
|
||||
console.log("mutual auth failed:", (err as Error).message);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { clientVerifyServerProof } from "@suaveplan/srp/client";
|
||||
import { computeM2 } from "@suaveplan/srp";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
import { SRP_CODES, SrpError } from "@suaveplan/srp/errors";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const A = new Uint8Array(256);
|
||||
const M1 = new Uint8Array(32);
|
||||
const K = new Uint8Array(32);
|
||||
const realM2 = computeM2(A, M1, K, params);
|
||||
const tampered = new Uint8Array(realM2);
|
||||
tampered[0] ^= 1;
|
||||
try {
|
||||
clientVerifyServerProof(M1, tampered, K, A, params);
|
||||
} catch (err) {
|
||||
if (err instanceof SrpError && err.code === SRP_CODES.MUTUAL_AUTH_FAILED) {
|
||||
console.log("rejected single-bit-flipped M2");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `clientVerifyServerProof(M1, M2Server, K, A, params): void`
|
||||
|
||||
Recomputes `expected = H(A | M1 | K)` and constant-time-compares against `M2Server`. Returns `void` on match; throws `SrpError(SRP.MUTUAL_AUTH_FAILED)` on any mismatch.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The length check (`expected.byteLength !== M2Server.byteLength`) is performed BEFORE `constantTimeEqual` because the underlying constant-time helper requires equal-length inputs. A length mismatch is itself authenticated information (it means the server emitted the wrong number of bytes), so we report it through the same `MUTUAL_AUTH_FAILED` code — there is no need for a separate length-mismatch code.
|
||||
|
||||
`constantTimeEqual` matters even for hash-output comparison: a `===` or `Buffer.compare` short-circuit would leak the position of the first differing byte through timing, which would let a sophisticated attacker iteratively recover the expected `M2` over many failed handshakes. The constant-time path makes that attack vector unavailable. This is the same constraint RFC 5054 §2.5.4 and Wu §6 motivate for `M1`, `M2`, and `S`.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SRP_CODES, SrpError } from "../errors/errors.js";
|
||||
import { nobleHashBridge } from "../hash/hash-bridge.js";
|
||||
import { computeM2 } from "../math/proof.js";
|
||||
import { params2048Sha256 } from "../params/params.js";
|
||||
import { clientVerifyServerProof } from "./client-verify.js";
|
||||
|
||||
describe("clientVerifyServerProof", () => {
|
||||
it("accepts correct server proof M2", { timeout: 5000 }, () => {
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const A = new Uint8Array(256);
|
||||
A[255] = 1;
|
||||
const M1 = new Uint8Array(32);
|
||||
const K = new Uint8Array(32);
|
||||
const correctM2 = computeM2(A, M1, K, params);
|
||||
expect(() =>
|
||||
clientVerifyServerProof(M1, correctM2, K, A, params)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects single-bit-flipped M2 with SRP.MUTUAL_AUTH_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const A = new Uint8Array(256);
|
||||
A[255] = 1;
|
||||
const M1 = new Uint8Array(32);
|
||||
const K = new Uint8Array(32);
|
||||
const correctM2 = computeM2(A, M1, K, params);
|
||||
const tampered = new Uint8Array(correctM2);
|
||||
tampered[0] = (tampered[0] ?? 0) ^ 1;
|
||||
try {
|
||||
clientVerifyServerProof(M1, tampered, K, A, params);
|
||||
throw new Error("did not throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(SrpError);
|
||||
expect((err as SrpError).code).toBe(SRP_CODES.MUTUAL_AUTH_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects length-mismatched M2", { timeout: 5000 }, () => {
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const A = new Uint8Array(256);
|
||||
const M1 = new Uint8Array(32);
|
||||
const K = new Uint8Array(32);
|
||||
const tooShort = new Uint8Array(16);
|
||||
try {
|
||||
clientVerifyServerProof(M1, tooShort, K, A, params);
|
||||
throw new Error("did not throw");
|
||||
} catch (err) {
|
||||
expect((err as SrpError).code).toBe(SRP_CODES.MUTUAL_AUTH_FAILED);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { constantTimeEqual } from "@suaveplan/crypto-utils";
|
||||
import { SRP_CODES, srpError } from "../errors/errors.js";
|
||||
import { computeM2 } from "../math/proof.js";
|
||||
import { recordOutcome, recordSrpError } from "../observability/metrics.js";
|
||||
import type { SrpParams } from "../params/params.js";
|
||||
|
||||
/**
|
||||
* RFC 2945 §3 — client verifies the server's proof `M2 = H(A | M1 | K)`.
|
||||
* Recomputes the expected value and constant-time-compares against the
|
||||
* server's emitted value. Throws `SrpError(SRP.MUTUAL_AUTH_FAILED)` on
|
||||
* any mismatch — including a single-bit flip.
|
||||
*/
|
||||
export function clientVerifyServerProof(
|
||||
M1: Uint8Array,
|
||||
M2Server: Uint8Array,
|
||||
K: Uint8Array,
|
||||
A: Uint8Array,
|
||||
params: SrpParams
|
||||
): void {
|
||||
const expected = computeM2(A, M1, K, params);
|
||||
if (
|
||||
expected.byteLength !== M2Server.byteLength ||
|
||||
!constantTimeEqual(expected, M2Server)
|
||||
) {
|
||||
recordSrpError(SRP_CODES.MUTUAL_AUTH_FAILED);
|
||||
recordOutcome("client", "mutual_auth_failed");
|
||||
throw srpError(
|
||||
SRP_CODES.MUTUAL_AUTH_FAILED,
|
||||
"server M2 did not match expected H(A | M1 | K)"
|
||||
);
|
||||
}
|
||||
recordOutcome("client", "ok");
|
||||
}
|
||||
3
packages/security-stack/security/srp/src/client/index.ts
Normal file
3
packages/security-stack/security/srp/src/client/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { clientFinishHandshake } from "./client-finish.js";
|
||||
export { clientStartHandshake } from "./client-start.js";
|
||||
export { clientVerifyServerProof } from "./client-verify.js";
|
||||
Reference in New Issue
Block a user