first commit
This commit is contained in:
87
docs/security-stack/security/srp/src/client/client-finish.md
Normal file
87
docs/security-stack/security/srp/src/client/client-finish.md
Normal file
@@ -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.
|
||||
70
docs/security-stack/security/srp/src/client/client-start.md
Normal file
70
docs/security-stack/security/srp/src/client/client-start.md
Normal file
@@ -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.
|
||||
68
docs/security-stack/security/srp/src/client/client-verify.md
Normal file
68
docs/security-stack/security/srp/src/client/client-verify.md
Normal file
@@ -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`.
|
||||
71
docs/security-stack/security/srp/src/errors/errors.md
Normal file
71
docs/security-stack/security/srp/src/errors/errors.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# `errors`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module hosts the stable error-code registry and the `SrpError` class for the `@suaveplan/srp` package. Every code listed here is thrown by at least one production path under `src/` — the audit test in `errors.test.ts` enforces that contract so dead codes cannot accumulate. Consumers branch on `error.code` (a literal-typed `SrpErrorCode`) rather than parsing `error.message`, which keeps error-handling resilient across releases.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `SRP_CODES` registry via `defineCodes("SRP", […])`.
|
||||
- Literal-typed `SrpErrorCode` union derived from the registry.
|
||||
- `SrpError extends SuaveplanError<SrpErrorCode>` carrying the code on the instance.
|
||||
- `srpError(code, message, options)` factory that prefixes the code into the message string so log scrapers and `expect(...).toThrow(/SRP\./)` matchers find a stable substring without inspecting `.code`.
|
||||
- Each `SrpError` instance is `instanceof SuaveplanError`, so the global error-classification surface (OTel span events, structured logging) sees it without per-package wiring.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SRP_CODES, SrpError, srpError } from "@suaveplan/srp/errors";
|
||||
|
||||
throw srpError(SRP_CODES.AUTH_FAILED, "client M1 did not match expected M1");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SrpError, SRP_CODES } from "@suaveplan/srp/errors";
|
||||
|
||||
try {
|
||||
runSrpHandshake();
|
||||
} catch (err) {
|
||||
if (err instanceof SrpError && err.code === SRP_CODES.PUBLIC_KEY_INVALID) {
|
||||
// RFC 5054 §2.5.4 — abort the session and rotate ephemeral keys.
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SRP_CODES`
|
||||
|
||||
Frozen record mapping each reason to `"SRP.<REASON>"`. Six codes:
|
||||
|
||||
- `SRP.INVALID_PARAMS` — malformed `SrpParams`, oversized `PAD` input, or other structural validation failure.
|
||||
- `SRP.PUBLIC_KEY_INVALID` — `A ≡ 0 mod N`, `B ≡ 0 mod N`, or `u ≡ 0 mod N`.
|
||||
- `SRP.AUTH_FAILED` — server rejected the client's `M1`.
|
||||
- `SRP.MUTUAL_AUTH_FAILED` — client rejected the server's `M2`.
|
||||
- `SRP.GROUP_UNSUPPORTED` — group parameters fail structural validation.
|
||||
- `SRP.HASH_UNSUPPORTED` — hash algorithm outside `{"SHA256", "SHA1"}`.
|
||||
|
||||
### `SrpErrorCode`
|
||||
|
||||
Literal-typed union of every value in `SRP_CODES`.
|
||||
|
||||
### `class SrpError extends SuaveplanError<SrpErrorCode>`
|
||||
|
||||
Carries `.code: SrpErrorCode`. Constructor accepts `(code, message, options?)`. The `options.cause` field, when an `Error`, threads through `SuaveplanError`'s cause-chain machinery.
|
||||
|
||||
### `srpError(code, message, options?)`
|
||||
|
||||
Factory returning an `SrpError`. Prefixes the message with `${code}:` for stable substring matching. `options.cause` accepts any `unknown` and is only attached when it is an `Error` instance.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is exhaustively audited at test time: `errors.test.ts` walks `SRP_CODES`, scans `src/` for a matching throw site for each code, and fails if any code is unreachable. This forces every new code to either ship with a real production path or be removed from the registry. The audit deliberately excludes `*.test.ts` files so test-only fixtures cannot satisfy the contract.
|
||||
|
||||
`srpError` prefixes the code into the message because `SuaveplanError`'s base constructor uses the `messageKey` argument for i18n resolution; in this package every code is internal and has a short English-only message, so prefixing keeps the substring stable while preserving the structured `.code` field. Callers that want a different presentation MAY construct `SrpError` directly.
|
||||
@@ -0,0 +1,53 @@
|
||||
# `groups-1024`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
RFC 5054 Appendix A 1024-bit safe-prime group. Required by `@suaveplan/srp`'s conformance test suite because RFC 5054 Appendix B publishes test vectors only against this 1024-bit group (with SHA-1). Otherwise legacy — modern deployments SHOULD use the bundled 2048-bit group from `@suaveplan/srp/groups` or larger. This subpath is opt-in so consumers that only use the 2048-bit group do not pay for the 1024-bit modulus in their bundle.
|
||||
|
||||
## Features
|
||||
|
||||
- `N_1024` byte-exact to RFC 5054 Appendix A "1024-bit Group".
|
||||
- `g_1024 = Uint8Array([2])`.
|
||||
- `BIT_LENGTH` literal `1024`.
|
||||
- Subpath-only — `import` from `@suaveplan/srp/groups/1024`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { N_1024, g_1024, BIT_LENGTH } from "@suaveplan/srp/groups/1024";
|
||||
|
||||
console.log(N_1024.byteLength); // 128
|
||||
console.log(BIT_LENGTH); // 1024
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { N_1024 } from "@suaveplan/srp/groups/1024";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
import type { SrpParams } from "@suaveplan/srp/params";
|
||||
|
||||
// Build a custom 1024/SHA-1 params object for replaying RFC 5054 Appendix B.
|
||||
const params: SrpParams = {
|
||||
N: N_1024,
|
||||
g: new Uint8Array([2]),
|
||||
bitLength: 1024,
|
||||
hash: nobleHashBridge("SHA1"),
|
||||
algorithm: "SHA1",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Description |
|
||||
| ------------ | ------------ | --------------------------------- |
|
||||
| `N_1024` | `Uint8Array` | 128-byte 1024-bit modulus. |
|
||||
| `g_1024` | `Uint8Array` | Single-byte generator `0x02`. |
|
||||
| `BIT_LENGTH` | `1024` | Literal bit length. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 1024-bit group is too small for production today (Pollard's rho on a 1024-bit safe-prime discrete log is within reach of nation-state actors, per modern guidance), so this subpath exists solely as a test-vector substrate. The package's spec-conformance test runs the full RFC 5054 Appendix B handshake against this group and asserts every intermediate (`k`, `x`, `v`, `u`, `S`, `K`, `M1`, `M2`) byte-exactly against the RFC's reference values. The 2048-bit group ships with no RFC test vector, so we use the 1024-bit run to anchor correctness and a locally-derived 2048/SHA-256 fixture to anchor the modern default.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `groups-1536`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
RFC 5054 Appendix A 1536-bit safe-prime group. Reachable via `@suaveplan/srp/groups/1536`. The 1536-bit group sits between the legacy 1024-bit floor and the modern 2048-bit default; it is rarely chosen for new deployments today but is documented in RFC 5054 for interoperability with older SRP deployments that pinned the size. Included here for completeness so a consumer migrating an existing SRP database does not have to vendor the modulus.
|
||||
|
||||
## Features
|
||||
|
||||
- `N_1536` byte-exact to RFC 5054 Appendix A "1536-bit Group".
|
||||
- `g_1536 = Uint8Array([2])`.
|
||||
- `BIT_LENGTH` literal `1536`.
|
||||
- Subpath-only — tree-shaken out of every consumer that doesn't import it.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { N_1536, g_1536, BIT_LENGTH } from "@suaveplan/srp/groups/1536";
|
||||
|
||||
console.log(N_1536.byteLength); // 192
|
||||
console.log(BIT_LENGTH); // 1536
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { N_1536 } from "@suaveplan/srp/groups/1536";
|
||||
import { defaultHashBridge } from "@suaveplan/srp/hash";
|
||||
import type { SrpParams } from "@suaveplan/srp/params";
|
||||
|
||||
const params: SrpParams = {
|
||||
N: N_1536,
|
||||
g: new Uint8Array([2]),
|
||||
bitLength: 1536,
|
||||
hash: defaultHashBridge("SHA256"),
|
||||
algorithm: "SHA256",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Description |
|
||||
| ------------ | ------------ | -------------------------- |
|
||||
| `N_1536` | `Uint8Array` | 192-byte 1536-bit modulus. |
|
||||
| `g_1536` | `Uint8Array` | Generator `0x02`. |
|
||||
| `BIT_LENGTH` | `1536` | Literal bit length. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 1536-bit group is exported as a tree-shakeable subpath so the default 2048-bit consumer never pulls these 192 bytes into its bundle. The byte-exact correctness check lives in the spec-conformance suite, which scans every group module against the RFC 5054 Appendix A literals at test time.
|
||||
|
||||
A 1536-bit `modPow` is roughly 1.7x slower than the 2048-bit default at the language layer, but in practice falls within the same perceptual budget as the default (still under 50 ms on contemporary hardware for one handshake step). The size choice is appropriate for legacy interoperability — for greenfield deployments the 2048-bit group is preferred because it crosses the 112-bit symmetric-equivalent threshold by a comfortable margin while remaining cheaper than 3072 or 4096.
|
||||
|
||||
The generator `g = 2` matches every other small RFC 5054 group (1024, 1536, 2048); the larger groups (3072, 4096, 6144) use `g = 5` and 8192 uses `g = 19`. Consumers building custom `SrpParams` from these constants MUST use `g_1536` rather than hard-coding any other value, because the generator was chosen at RFC-design time to produce a large prime-order subgroup of `Z_N*` — substituting an arbitrary generator would weaken the discrete-log hardness.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `groups-3072`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
RFC 5054 Appendix A 3072-bit safe-prime group with generator `g = 5`. Reachable via `@suaveplan/srp/groups/3072`. A 3072-bit modulus crosses the ~128-bit symmetric-security threshold and is appropriate for deployments that anticipate long secret lifetimes (multi-year auth-token rotation) or that store the verifier database in an environment with a future-quantum-threat tolerance one step above the 2048-bit default.
|
||||
|
||||
## Features
|
||||
|
||||
- `N_3072` byte-exact to RFC 5054 Appendix A "3072-bit Group".
|
||||
- `g_3072 = Uint8Array([5])` (RFC 5054 mandates `g = 5` for this size).
|
||||
- `BIT_LENGTH` literal `3072`.
|
||||
- Subpath-only — bundle-size cost paid only by importers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { N_3072, g_3072, BIT_LENGTH } from "@suaveplan/srp/groups/3072";
|
||||
|
||||
console.log(N_3072.byteLength); // 384
|
||||
console.log(g_3072[0]); // 5
|
||||
console.log(BIT_LENGTH); // 3072
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { N_3072, g_3072 } from "@suaveplan/srp/groups/3072";
|
||||
import { defaultHashBridge } from "@suaveplan/srp/hash";
|
||||
import type { SrpParams } from "@suaveplan/srp/params";
|
||||
|
||||
const params: SrpParams = {
|
||||
N: N_3072,
|
||||
g: g_3072,
|
||||
bitLength: 3072,
|
||||
hash: defaultHashBridge("SHA256"),
|
||||
algorithm: "SHA256",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Description |
|
||||
| ------------ | ------------ | -------------------------- |
|
||||
| `N_3072` | `Uint8Array` | 384-byte 3072-bit modulus. |
|
||||
| `g_3072` | `Uint8Array` | Generator `0x05`. |
|
||||
| `BIT_LENGTH` | `3072` | Literal bit length. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Unlike the 1024/1536/2048-bit groups which all use `g = 2`, RFC 5054 specifies `g = 5` for 3072, 4096, 6144, and 8192-bit groups (the chosen generator depends on the modulus's quadratic-residue structure, picked at RFC-design time so `g` generates a large prime-order subgroup of `Z_N*`). Consumers building `SrpParams` from raw constants MUST use `g_3072` rather than hard-coding `0x02`.
|
||||
|
||||
`modPow` over a 3072-bit modulus takes roughly 4x the wall-clock time of the 2048-bit default; expect ~20–80 ms per handshake step. For interactive login this is still well under perceptual latency.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `groups-4096`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
RFC 5054 Appendix A 4096-bit safe-prime group with generator `g = 5`. Reachable via `@suaveplan/srp/groups/4096`. Suitable for deployments that want headroom above the contemporary 2048-bit default at the cost of roughly 8x the `modPow` wall-clock time. Recommended when verifier-database lifetime is measured in decades or when the threat model includes well-resourced precomputed-discrete-log attacks against bundled groups.
|
||||
|
||||
## Features
|
||||
|
||||
- `N_4096` byte-exact to RFC 5054 Appendix A "4096-bit Group".
|
||||
- `g_4096 = Uint8Array([5])`.
|
||||
- `BIT_LENGTH` literal `4096`.
|
||||
- Subpath-only.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { N_4096, g_4096, BIT_LENGTH } from "@suaveplan/srp/groups/4096";
|
||||
|
||||
console.log(N_4096.byteLength); // 512
|
||||
console.log(BIT_LENGTH); // 4096
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { N_4096, g_4096 } from "@suaveplan/srp/groups/4096";
|
||||
import { defaultHashBridge } from "@suaveplan/srp/hash";
|
||||
import type { SrpParams } from "@suaveplan/srp/params";
|
||||
|
||||
const params: SrpParams = {
|
||||
N: N_4096,
|
||||
g: g_4096,
|
||||
bitLength: 4096,
|
||||
hash: defaultHashBridge("SHA256"),
|
||||
algorithm: "SHA256",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Description |
|
||||
| ------------ | ------------ | -------------------------- |
|
||||
| `N_4096` | `Uint8Array` | 512-byte 4096-bit modulus. |
|
||||
| `g_4096` | `Uint8Array` | Generator `0x05`. |
|
||||
| `BIT_LENGTH` | `4096` | Literal bit length. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
A 4096-bit `modPow` takes roughly 8x the time of a 2048-bit `modPow`. On a contemporary laptop this is ~40–160 ms per handshake step. Interactive login flows that target sub-200 ms perceptual budgets SHOULD pre-warm `modPow` (run a no-op call during page load) or schedule the handshake outside the critical path.
|
||||
|
||||
The byte-exact correctness gate lives in the spec-conformance suite. The constant is reviewed against RFC 5054 Appendix A's literal at every package release.
|
||||
|
||||
The generator `g = 5` is unique to the larger RFC 5054 groups (3072, 4096, 6144); the smaller groups (1024, 1536, 2048) all use `g = 2`. The chosen generator depends on the modulus's quadratic-residue structure — RFC 5054 picked it once at design time so `g` generates a large prime-order subgroup of `Z_N*`. Consumers building `SrpParams` from raw constants MUST use `g_4096` rather than hard-coding `0x02`; using the wrong generator weakens the discrete-log hardness guarantee that backs SRP-6a's zero-knowledge property.
|
||||
@@ -0,0 +1,56 @@
|
||||
# `groups-6144`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
RFC 5054 Appendix A 6144-bit safe-prime group with generator `g = 5`. Reachable via `@suaveplan/srp/groups/6144`. Niche size, included for completeness with RFC 5054. Use only when interoperating with an existing deployment that pinned this size; modern greenfield deployments should choose 2048-bit or 4096-bit instead, since 6144 imposes ~27x the `modPow` cost of 2048 without a proportional security headroom over 4096.
|
||||
|
||||
## Features
|
||||
|
||||
- `N_6144` byte-exact to RFC 5054 Appendix A "6144-bit Group".
|
||||
- `g_6144 = Uint8Array([5])`.
|
||||
- `BIT_LENGTH` literal `6144`.
|
||||
- Subpath-only.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { N_6144, g_6144, BIT_LENGTH } from "@suaveplan/srp/groups/6144";
|
||||
|
||||
console.log(N_6144.byteLength); // 768
|
||||
console.log(BIT_LENGTH); // 6144
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { N_6144, g_6144 } from "@suaveplan/srp/groups/6144";
|
||||
import { defaultHashBridge } from "@suaveplan/srp/hash";
|
||||
import type { SrpParams } from "@suaveplan/srp/params";
|
||||
|
||||
const params: SrpParams = {
|
||||
N: N_6144,
|
||||
g: g_6144,
|
||||
bitLength: 6144,
|
||||
hash: defaultHashBridge("SHA256"),
|
||||
algorithm: "SHA256",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Description |
|
||||
| ------------ | ------------ | -------------------------- |
|
||||
| `N_6144` | `Uint8Array` | 768-byte 6144-bit modulus. |
|
||||
| `g_6144` | `Uint8Array` | Generator `0x05`. |
|
||||
| `BIT_LENGTH` | `6144` | Literal bit length. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
A 6144-bit `modPow` is roughly 27x slower than the 2048-bit default and typically not worth the cost compared to either 4096 (similar headroom against precomputation, ~3x faster) or 8192 (significantly more headroom for the same wall-clock pain). This subpath exists to preserve RFC 5054 interoperability rather than to offer a recommended choice.
|
||||
|
||||
The generator `g = 5` matches the 3072 and 4096-bit groups; only 8192 uses a different generator (`g = 19`). The choice was fixed at RFC-design time to produce a large prime-order subgroup of `Z_N*` — substituting any other generator would weaken the discrete-log hardness that backs SRP-6a's zero-knowledge property. Consumers building custom `SrpParams` from raw constants MUST use `g_6144` rather than hard-coding any other value.
|
||||
|
||||
Interactive login flows that select the 6144-bit group SHOULD schedule the handshake off the perceptual critical path — for example, in a Web Worker or as a deferred background promise — because the per-step wall time will dominate any concurrent rendering work. The benchmark suite includes 6144-bit numbers for capacity planning but the package default remains the 2048-bit group, which sits at the contemporary security floor while keeping a `modPow` cheap enough for an interactive login.
|
||||
@@ -0,0 +1,55 @@
|
||||
# `groups-8192`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
RFC 5054 Appendix A 8192-bit safe-prime group with generator `g = 19`. Reachable via `@suaveplan/srp/groups/8192`. The largest bundled group RFC 5054 specifies. Use only when verifier-database lifetime extends beyond the practical horizon of precomputed-discrete-log attacks against bundled primes; for almost every contemporary deployment the 2048-bit default is sufficient and the 8192-bit group's ~64x `modPow` slowdown is not justified.
|
||||
|
||||
## Features
|
||||
|
||||
- `N_8192` byte-exact to RFC 5054 Appendix A "8192-bit Group".
|
||||
- `g_8192 = Uint8Array([19])` — RFC 5054 mandates `g = 19` for this size.
|
||||
- `BIT_LENGTH` literal `8192`.
|
||||
- Subpath-only.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { N_8192, g_8192, BIT_LENGTH } from "@suaveplan/srp/groups/8192";
|
||||
|
||||
console.log(N_8192.byteLength); // 1024
|
||||
console.log(g_8192[0]); // 19
|
||||
console.log(BIT_LENGTH); // 8192
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { N_8192, g_8192 } from "@suaveplan/srp/groups/8192";
|
||||
import { defaultHashBridge } from "@suaveplan/srp/hash";
|
||||
import type { SrpParams } from "@suaveplan/srp/params";
|
||||
|
||||
const params: SrpParams = {
|
||||
N: N_8192,
|
||||
g: g_8192,
|
||||
bitLength: 8192,
|
||||
hash: defaultHashBridge("SHA256"),
|
||||
algorithm: "SHA256",
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Description |
|
||||
| ------------ | ------------ | --------------------------- |
|
||||
| `N_8192` | `Uint8Array` | 1024-byte 8192-bit modulus. |
|
||||
| `g_8192` | `Uint8Array` | Generator `0x13` (19). |
|
||||
| `BIT_LENGTH` | `8192` | Literal bit length. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
An 8192-bit `modPow` typically costs hundreds of milliseconds on contemporary hardware. Interactive login flows MUST NOT use this group on the request critical path; instead schedule the handshake off the perceptual deadline (e.g. background-thread the auth call, show a placeholder for the duration). The benchmark suite skips 8192 by default because the per-iteration cost dominates the warmup statistics.
|
||||
|
||||
The generator `g = 19` is unique to this size in RFC 5054. Consumers building `SrpParams` from raw constants MUST use `g_8192` rather than hard-coding `0x02` or `0x05`.
|
||||
58
docs/security-stack/security/srp/src/groups/groups-2048.md
Normal file
58
docs/security-stack/security/srp/src/groups/groups-2048.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# `groups-2048`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
- [groups-1024](./1024/groups-1024.md) — <!-- begin:auto-docs --> _(no submodules)_- [1536](./1536/groups-1536.md) — <!-- begin:auto-docs --> _(no submodules)_ - [groups-1536](./1536/groups-1536.md) — <!-- begin:auto-docs --> _(no submodules)_- [3072](./3072/groups-3072.md) — <!-- begin:auto-docs --> _(no submodules)_ - [groups-3072](./3072/groups-3072.md) — <!-- begin:auto-docs --> _(no submodules)_- [4096](./4096/groups-4096.md) — <!-- begin:auto-docs --> _(no submodules)_ - [groups-4096](./4096/groups-4096.md) — <!-- begin:auto-docs --> _(no submodules)_- [6144](./6144/groups-6144.md) — <!-- begin:auto-docs --> _(no submodules)_ - [groups-6144](./6144/groups-6144.md) — <!-- begin:auto-docs --> _(no submodules)_- [8192](./8192/groups-8192.md) — <!-- begin:auto-docs --> _(no submodules)_ - [groups-8192](./8192/groups-8192.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-2048](./groups-2048.md) — <!-- begin:auto-docs --> _(no submodules)_- groups-1024 — <!-- begin:auto-docs --> _(no submodul…
|
||||
- [groups-1024](./1024/groups-1024.md) — <!-- begin:auto-docs --> _(no submodules)_- [1536](./1536/groups-1536.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-1536](./1536/groups-1536.md) — <!-- begin:auto-docs --> _(no submodules)_- [3072](./3072/groups-3072.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-3072](./3072/groups-3072.md) — <!-- begin:auto-docs --> _(no submodules)_- [4096](./4096/groups-4096.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-4096](./4096/groups-4096.md) — <!-- begin:auto-docs --> _(no submodules)_- [6144](./6144/groups-6144.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-6144](./6144/groups-6144.md) — <!-- begin:auto-docs --> _(no submodules)_- [8192](./8192/groups-8192.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-8192](./8192/groups-8192.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-2048](./groups-2048.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-1024](./1024/groups-1024.md) — <!-- begin:auto-docs --> _(no submodules)_- [1536](./1536/groups-1536.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-1536](./1536/groups-1536.md) — <!-- begin:auto-docs --> _(no submodules)_- [3072](./3072/groups-3072.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-3072](./3072/groups-3072.md) — <!-- begin:auto-docs --> _(no submodules)_- [4096](./4096/groups-4096.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-4096](./4096/groups-4096.md) — <!-- begin:auto-docs --> _(no submodules)_- [6144](./6144/groups-6144.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-6144](./6144/groups-6144.md) — <!-- begin:auto-docs --> _(no submodules)_- [8192](./8192/groups-8192.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-8192](./8192/groups-8192.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-2048](./groups-2048.md) — <!-- begin:auto-docs --> _(no submodules)_- groups-1024 — <!-- begin:aut…
|
||||
- [groups-1024](./1024/groups-1024.md) — <!-- begin:auto-docs --> _(no submodules)_- [1536](./1536/groups-1536.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-1536](./1536/groups-1536.md) — <!-- begin:auto-docs --> _(no submodules)_- [3072](./3072/groups-3072.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-3072](./3072/groups-3072.md) — <!-- begin:auto-docs --> _(no submodules)_- [4096](./4096/groups-4096.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-4096](./4096/groups-4096.md) — <!-- begin:auto-docs --> _(no submodules)_- [6144](./6144/groups-6144.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-6144](./6144/groups-6144.md) — <!-- begin:auto-docs --> _(no submodules)_- [8192](./8192/groups-8192.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-8192](./8192/groups-8192.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-2048](./groups-2048.md) — <!-- begin:auto-docs --> _(no submodules)_- groups-1024 — <!-- begin:auto-docs --> _(no submodu…
|
||||
- [groups-1024](./1024/groups-1024.md) — <!-- begin:auto-docs --> _(no submodules)_- [1536](./1536/groups-1536.md) — <!-- begin:auto-docs --> _(no submodules)_ - [groups-1536](./1536/groups-1536.md) — <!-- begin:auto-docs --> _(no submodules)_- [3072](./3072/groups-3072.md) — <!-- begin:auto-docs --> _(no submodules)_ - [groups-3072](./3072/groups-3072.md) — <!-- begin:auto-docs --> _(no submodules)_- [4096](./4096/groups-4096.md) — <!-- begin:auto-docs --> _(no submodules)_ - [groups-4096](./4096/groups-4096.md) — <!-- begin:auto-docs --> _(no submodules)_- [6144](./6144/groups-6144.md) — <!-- begin:auto-docs --> _(no submodules)_ - [groups-6144](./6144/groups-6144.md) — <!-- begin:auto-docs --> _(no submodules)_- [8192](./8192/groups-8192.md) — <!-- begin:auto-docs --> _(no submodules)_ - [groups-8192](./8192/groups-8192.md) — <!-- begin:auto-docs --> _(no submodules)_- [groups-2048](./groups-2048.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Purpose
|
||||
|
||||
The bundled 2048-bit safe-prime group from RFC 5054 Appendix A. This is the default SRP-6a group every consumer of `@suaveplan/srp` gets without any additional import: `N_2048` is a 256-byte modulus, `g_2048` is the single byte `0x02`, and `BIT_LENGTH` is the literal `2048`. RFC 5054 deliberately bundles a small set of fixed safe primes to side-step trust-on-first-use over arbitrary moduli; we ship the 2048-bit one as the default because it sits at the contemporary security floor while keeping a 2048-bit `modPow` cheap enough (~5–20 ms) for an interactive login.
|
||||
|
||||
## Features
|
||||
|
||||
- `N_2048` byte-exact to RFC 5054 Appendix A "2048-bit Group" literal.
|
||||
- `g_2048` equal to `Uint8Array([2])`.
|
||||
- `BIT_LENGTH` literal-typed `2048`.
|
||||
- Frozen `Uint8Array`s — consumers cannot mutate the shared constant.
|
||||
- Reachable from the root `@suaveplan/srp` barrel AND from `@suaveplan/srp/groups`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { N_2048, g_2048, BIT_LENGTH } from "@suaveplan/srp/groups";
|
||||
|
||||
console.log(N_2048.byteLength); // 256
|
||||
console.log(g_2048[0]); // 2
|
||||
console.log(BIT_LENGTH); // 2048
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { N_2048, g_2048 } from "@suaveplan/srp/groups";
|
||||
import { defaultHashBridge } from "@suaveplan/srp/hash";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
|
||||
const params = params2048Sha256(defaultHashBridge("SHA256"));
|
||||
// params.N is the same shared reference as N_2048.
|
||||
// Use this params object for client + server handshakes.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Export | Type | Description |
|
||||
| ------------ | -------------- | -------------------------------------------------------------- |
|
||||
| `N_2048` | `Uint8Array` | 256-byte modulus from RFC 5054 Appendix A. |
|
||||
| `g_2048` | `Uint8Array` | Single-byte generator `0x02`. |
|
||||
| `BIT_LENGTH` | `2048` | Literal bit length of `N_2048`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The modulus is encoded as a hex literal in source rather than embedded as a base64 blob so a human reader can diff it against RFC 5054 Appendix A line-by-line. The hex string is split into eight 64-char chunks matching the eight rows the RFC prints. The byte-exact unit test (`groups-2048.test.ts`) reconstructs the same hex from the `Uint8Array`, uppercases it, and asserts equality against the same literal.
|
||||
|
||||
`g_2048` is `Uint8Array([2])` rather than a `bigint` because every place SRP mixes group elements into a hash uses `PAD(g)` over the group modulus; expressing `g` as a one-byte buffer up-front avoids a runtime `bigint`-to-bytes conversion at every hash site. The single-byte representation also matches the byte-shape every `Uint8Array`-keyed helper in this package expects.
|
||||
|
||||
The `BIT_LENGTH` literal is typed `2048` (not `number`) so consumers narrowing on the constant get a literal type back. This matters in test files where we assert that the bundled group is exactly the RFC's 2048-bit choice and not, say, a future 3072-bit upgrade.
|
||||
78
docs/security-stack/security/srp/src/hash/hash-bridge.md
Normal file
78
docs/security-stack/security/srp/src/hash/hash-bridge.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# `hash-bridge`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Two `HashLike` implementations for `@suaveplan/srp` — `nobleHashBridge` (sync, `@noble/hashes`-backed) and `subtleHashBridge` (async, `crypto.subtle.digest`-backed) — plus a `defaultHashBridge(algorithm)` factory that picks the right one for the host runtime. Both backends produce byte-identical digests for the same input; the difference is whether the call returns a `Uint8Array` directly or a `Promise<Uint8Array>`. The package's SRP-6a handshake functions are CPU-bound and synchronous, so they require the sync noble bridge or a pre-resolved subtle output; `digestSync` is the gate that surfaces the mismatch.
|
||||
|
||||
## Features
|
||||
|
||||
- `nobleHashBridge("SHA256" | "SHA1")` — sync digest via `@noble/hashes/sha2` and `@noble/hashes/sha1`.
|
||||
- `subtleHashBridge(algorithm, signal?)` — async digest via `globalThis.crypto.subtle.digest`. Honours an optional `AbortSignal` both at construction and per-call.
|
||||
- `defaultHashBridge(algorithm)` — picks subtle when available, falls back to noble.
|
||||
- `digestSync(hash, input)` — internal helper that runs a `HashLike` and throws `SRP.HASH_UNSUPPORTED` if the result is a Promise (used to gate sync handshake paths).
|
||||
- Unsupported algorithm → throws `SrpError(SRP.HASH_UNSUPPORTED)`.
|
||||
- AbortSignal short-circuit: pre-aborted signals reject before any `crypto.subtle.digest` invocation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { nobleHashBridge, subtleHashBridge, defaultHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const noble = nobleHashBridge("SHA256");
|
||||
const subtle = subtleHashBridge("SHA256");
|
||||
const auto = defaultHashBridge("SHA256");
|
||||
|
||||
const input = new TextEncoder().encode("hello");
|
||||
console.log(noble.digest(input).byteLength); // 32
|
||||
console.log((await subtle.digest(input)).byteLength); // 32
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { subtleHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const controller = new AbortController();
|
||||
const hash = subtleHashBridge("SHA256");
|
||||
const input = new Uint8Array(1024 * 1024);
|
||||
|
||||
setTimeout(() => controller.abort(new Error("deadline")), 10);
|
||||
try {
|
||||
await hash.digest(input, controller.signal);
|
||||
} catch (err) {
|
||||
console.log("aborted:", (err as Error).message);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `nobleHashBridge(algorithm: SrpHashAlgorithm): HashLike`
|
||||
|
||||
Sync `@noble/hashes` backend. `digest` returns `Uint8Array` directly. SHA-256 → 32-byte output; SHA-1 → 20-byte output. Throws `SrpError(SRP.HASH_UNSUPPORTED)` on any other algorithm.
|
||||
|
||||
### `subtleHashBridge(algorithm: SrpHashAlgorithm, signal?: AbortSignal): HashLike`
|
||||
|
||||
Async `crypto.subtle.digest` backend. `digest` returns `Promise<Uint8Array>`. The optional construction-time `signal` applies to every `digest` call; an optional per-call `signal` overrides it. Pre-aborted signals reject before invoking `crypto.subtle.digest`; mid-flight aborts propagate after the digest completes (`crypto.subtle.digest` does not natively support cancellation, but the post-digest re-check ensures the caller does not receive a stale result).
|
||||
|
||||
### `defaultHashBridge(algorithm: SrpHashAlgorithm): HashLike`
|
||||
|
||||
Platform selector. Returns `subtleHashBridge(algorithm)` when `globalThis.crypto.subtle.digest` is callable, otherwise returns `nobleHashBridge(algorithm)`. This factory captures the platform decision once so universal consumers do not branch at every call site.
|
||||
|
||||
### `digestSync(hash: HashLike, input: Uint8Array): Uint8Array`
|
||||
|
||||
Sync gate: calls `hash.digest(input)` and asserts the result is a `Uint8Array`. Throws `SrpError(SRP.HASH_UNSUPPORTED)` if the impl returns a Promise. Every SRP handshake path in this package uses `digestSync` so the protocol cannot accidentally consume an async backend mid-step.
|
||||
|
||||
### `type SrpHashAlgorithm = "SHA256" | "SHA1"`
|
||||
|
||||
Stable algorithm tags supported by this package.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The async-only `subtleHashBridge` exists for two scenarios: (1) browser bundles that already ship `crypto.subtle` and want to avoid pulling `@noble/hashes` into the bundle, and (2) deployments that want hardware-backed digests routed through the runtime's native subtle implementation. For the SRP-6a handshake itself, however, the sync noble bridge is mandatory because the protocol's `H(H(N) XOR H(g) | H(I) | s | A | B | K)` chain interleaves multiple digests with `modPow` calls — driving the whole chain through async digests would require restructuring the handshake API around Promises, which the spec explicitly avoids.
|
||||
|
||||
`digestSync`'s throw rather than implicit-await design is deliberate: the package would otherwise leak Promise-based bugs (forgetting to await one digest in the chain would produce nonsense bytes downstream) into silent handshake failures rather than loud, debuggable errors. The throw at the bridge boundary stops the protocol immediately with a clear code.
|
||||
|
||||
AbortSignal handling in `subtleHashBridge` checks the signal both before and after the `crypto.subtle.digest` call. The pre-check ensures a pre-aborted signal never reaches the runtime; the post-check protects the caller from receiving a stale digest when the abort fires while the digest was in flight (the runtime may or may not propagate the cancellation, so the post-check is the safety net).
|
||||
52
docs/security-stack/security/srp/src/math/k.md
Normal file
52
docs/security-stack/security/srp/src/math/k.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# `k`
|
||||
|
||||
## Purpose
|
||||
|
||||
Compute the SRP-6a multiplier parameter `k = H(N | PAD(g))` per RFC 5054 §2.5.3. The multiplier is the structural change that distinguishes SRP-6a from SRP-6 (the latter set `k = 3`, which broke under a generator-substitution attack). Mixing both `N` and the padded generator into a hash binds `k` to the chosen group so the multiplier cannot be transplanted between groups by an attacker who controls one side of the handshake.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function of `(N, g, hash)`.
|
||||
- Returns the hash's native digest size (32 bytes for SHA-256, 20 for SHA-1).
|
||||
- Uses `PAD` to zero-extend `g` to `byteLength(N)` before hashing, per RFC 5054 §2.5.3.
|
||||
- Caller-side memoization safe — `params` is treated as immutable.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { computeK } from "@suaveplan/srp";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const k = computeK(params);
|
||||
console.log(k.byteLength); // 32
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { computeK } from "@suaveplan/srp";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const kCached = computeK(params);
|
||||
// Reuse k across many handshakes — k is a pure function of (N, g, hash).
|
||||
function getCachedK(): Uint8Array {
|
||||
return kCached;
|
||||
}
|
||||
console.log(getCachedK().byteLength);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `computeK(params: SrpParams): Uint8Array`
|
||||
|
||||
Returns `H(N | PAD(g))` using `params.hash`. Result length equals `params.hash.digestSize`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`k` is a pure function of `(N, g, hash)` and is therefore safe to memoize across handshakes; consumers that mount a long-lived `SrpParams` value can cache `k` outside the per-request critical path. This package does NOT cache automatically because the `SrpParams.hash` field MAY be a stateful subtle bridge that the consumer rotates per session; deferring the memoization decision to the caller keeps the contract simple.
|
||||
|
||||
The digest call goes through `digestSync` (from `../hash/hash-bridge.js`), which throws `SRP.HASH_UNSUPPORTED` if the consumer accidentally wires an async `HashLike` into a sync handshake path. Without that gate, an awaited Promise would silently coerce to `[object Promise]` bytes downstream.
|
||||
63
docs/security-stack/security/srp/src/math/modpow.md
Normal file
63
docs/security-stack/security/srp/src/math/modpow.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# `modpow`
|
||||
|
||||
## Purpose
|
||||
|
||||
Modular exponentiation primitive for `@suaveplan/srp`. This is a thin wrapper over `@noble/curves`'s hardened `pow(num, power, modulo)` from `@noble/curves/abstract/modular`. Every SRP-6a path that needs `base^exp mod N` routes through this one entry point so the codebase never inlines TypeScript's native `BigInt ** BigInt % N` operator — a pattern explicitly forbidden by RFC 5054 §2.5.4's timing-attack guidance because the standard-library BigInt power-and-mod does NOT execute in constant time.
|
||||
|
||||
This module also publishes two utility encoders, `bytesToBigInt` and `bigIntToBytes`, that convert between big-endian `Uint8Array` (the on-wire shape for SRP group elements) and `bigint` (the shape `modPow` accepts). Co-locating the encoders here means every group-element conversion in `src/` flows through one audited helper.
|
||||
|
||||
## Features
|
||||
|
||||
- `modPow(base, exp, modulus): bigint` — single entry point for `base^exp mod modulus`.
|
||||
- `bytesToBigInt(bytes): bigint` — big-endian decode.
|
||||
- `bigIntToBytes(value, length?): Uint8Array` — big-endian encode with optional zero-padded fixed width.
|
||||
- Backed by `@noble/curves/abstract/modular.pow` — vetted, hardened impl.
|
||||
- Banned-pattern source scan in `groups.spec-conformance.test.ts` enforces zero `BigInt ** BigInt %` patterns under `src/`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { modPow, bytesToBigInt, bigIntToBytes } from "@suaveplan/srp";
|
||||
|
||||
const base = bytesToBigInt(new Uint8Array([2]));
|
||||
const exp = bytesToBigInt(new Uint8Array([3]));
|
||||
const mod = bytesToBigInt(new Uint8Array([11]));
|
||||
const result = modPow(base, exp, mod); // 2^3 mod 11 = 8n
|
||||
const back = bigIntToBytes(result, 1); // Uint8Array([8])
|
||||
console.log(result, back);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { modPow, bytesToBigInt, bigIntToBytes } from "@suaveplan/srp";
|
||||
import { N_2048 } from "@suaveplan/srp/groups";
|
||||
|
||||
// Verifier-side: v = g^x mod N where g=2 and x is derived from H(salt|H(I:P)).
|
||||
const N = bytesToBigInt(N_2048);
|
||||
const g = 2n;
|
||||
const x = bytesToBigInt(new Uint8Array(32)); // pretend x is a 32-byte hash
|
||||
const v = modPow(g, x, N);
|
||||
const vBytes = bigIntToBytes(v, N_2048.byteLength);
|
||||
console.log(vBytes.byteLength); // 256
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `modPow(base: bigint, exp: bigint, modulus: bigint): bigint`
|
||||
|
||||
Returns `base^exp mod modulus`. Delegates to `@noble/curves/abstract/modular.pow`. Throws whatever the noble impl throws on bad input (e.g. negative modulus).
|
||||
|
||||
### `bytesToBigInt(bytes: Uint8Array): bigint`
|
||||
|
||||
Decode a big-endian `Uint8Array` to a non-negative `bigint`. Empty input returns `0n`.
|
||||
|
||||
### `bigIntToBytes(value: bigint, length?: number): Uint8Array`
|
||||
|
||||
Encode a non-negative `bigint` to a big-endian `Uint8Array`. When `length` is supplied, the output is zero-padded on the left to that width; when `length` is shorter than the natural byte width of `value`, throws `RangeError`. Without `length`, returns the smallest non-empty representation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`bigIntToBytes` zero-pads on the left because SRP's wire format treats every group element as a fixed-width big-endian buffer matching `byteLength(N)`. Calling `bigIntToBytes(v, N.byteLength)` is the canonical "encode a group element for transmission or hashing" call; the `PAD` helper in `pad.ts` performs the same left-zero-extension on values whose width is already known to fit, which is the contract RFC 5054 §2.5.3 specifies.
|
||||
|
||||
The `pow` import from `@noble/curves/abstract/modular.js` is the single dependency line in this file. Updating the noble library version updates every SRP `modPow` call site simultaneously — there is no `base ** exp % modulus` fallback to fall out of sync.
|
||||
51
docs/security-stack/security/srp/src/math/pad.md
Normal file
51
docs/security-stack/security/srp/src/math/pad.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# `pad`
|
||||
|
||||
## Purpose
|
||||
|
||||
The `PAD(x, N)` helper from RFC 5054 §2.5.3. Returns a left-zero-extended copy of `x` whose length equals `N.byteLength`. SRP-6a mixes group elements (`A`, `B`, `g`) into hashes only AFTER they have been padded to the full byte-length of the modulus; without this padding the hash output would depend on whether the integer-decoding step happened to strip leading zeros, which would let an attacker malleate the hash by manipulating an otherwise-equivalent group element.
|
||||
|
||||
## Features
|
||||
|
||||
- Left-zero-extension to `N.byteLength`.
|
||||
- `Uint8Array.slice()`-based defensive copy when input already matches the target width (so the caller cannot mutate the shared modulus buffer through the result).
|
||||
- Throws `SrpError(SRP.INVALID_PARAMS)` on oversize input.
|
||||
- Allocation-free fast path for the equal-length case (still returns a fresh `Uint8Array` for ownership clarity).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PAD } from "@suaveplan/srp";
|
||||
import { N_2048 } from "@suaveplan/srp/groups";
|
||||
|
||||
const short = new Uint8Array([0xab, 0xcd]);
|
||||
const padded = PAD(short, N_2048);
|
||||
console.log(padded.byteLength); // 256
|
||||
console.log(padded.slice(254)); // Uint8Array([0xab, 0xcd])
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PAD } from "@suaveplan/srp";
|
||||
import { N_2048, g_2048 } from "@suaveplan/srp/groups";
|
||||
import { sha256 } from "@noble/hashes/sha2";
|
||||
|
||||
// k = H(N | PAD(g)) per RFC 5054 §2.5.3.
|
||||
const concatenated = new Uint8Array(N_2048.byteLength + N_2048.byteLength);
|
||||
concatenated.set(N_2048, 0);
|
||||
concatenated.set(PAD(g_2048, N_2048), N_2048.byteLength);
|
||||
const k = sha256(concatenated);
|
||||
console.log(k.byteLength); // 32
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PAD(x: Uint8Array, N: Uint8Array): Uint8Array`
|
||||
|
||||
Returns a fresh `Uint8Array` of length `N.byteLength` whose trailing `x.byteLength` bytes equal `x` and whose preceding bytes are `0`. Throws `SrpError(SRP.INVALID_PARAMS)` when `x.byteLength > N.byteLength`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The defensive-copy on the equal-length path matters because callers MAY pass a constant reference (e.g. a frozen modulus from `@suaveplan/srp/groups`); returning the same reference would let downstream code accidentally mutate the shared buffer. The cost is one allocation per call, which is negligible compared to a `modPow` over a 2048-bit modulus.
|
||||
|
||||
`PAD` never validates that `x` decodes to a non-zero `Z_N` element — that check lives in the higher-level handshake functions, which reject `A ≡ 0 mod N`, `B ≡ 0 mod N`, and `u ≡ 0 mod N` per RFC 5054 §2.5.4 with `SRP.PUBLIC_KEY_INVALID`. The pad helper's only job is byte-shape normalisation.
|
||||
64
docs/security-stack/security/srp/src/math/proof.md
Normal file
64
docs/security-stack/security/srp/src/math/proof.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# `proof`
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal helpers for computing the SRP-6a / RFC 2945 §3 mutual-authentication proofs `M1` and `M2`. Both client and server compute these values: the client emits `M1 = H(H(N) XOR H(g) | H(I) | salt | A | B | K)` to prove it knows the password, and the server emits `M2 = H(A | M1 | K)` to prove it knows the verifier. Co-locating the math in one module keeps the formula in exactly one place so any future correction (e.g. a hash-binding change) lands consistently across both roles.
|
||||
|
||||
## Features
|
||||
|
||||
- `computeM1(I, salt, A, B, K, params)` — RFC 2945 §3 client proof.
|
||||
- `computeM2(A, M1, K, params)` — RFC 2945 §3 server proof.
|
||||
- Equal-length XOR helper for the `H(N) XOR H(g)` term — bound to the chosen `params.hash` digest size.
|
||||
- UTF-8 encode `I` once per call.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { computeM1, computeM2 } from "@suaveplan/srp";
|
||||
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);
|
||||
const B = new Uint8Array(256);
|
||||
const K = new Uint8Array(32);
|
||||
const M1 = computeM1("alice", salt, A, B, K, params);
|
||||
const M2 = computeM2(A, M1, K, params);
|
||||
console.log(M1.byteLength, M2.byteLength); // 32 32
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { computeM1, computeM2 } from "@suaveplan/srp";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
import { constantTimeEqual } from "@suaveplan/crypto-utils";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const salt = new Uint8Array(16);
|
||||
const A = new Uint8Array(256);
|
||||
const B = new Uint8Array(256);
|
||||
const K = new Uint8Array(32);
|
||||
|
||||
const clientM1 = computeM1("alice", salt, A, B, K, params);
|
||||
const serverExpectedM1 = computeM1("alice", salt, A, B, K, params);
|
||||
console.log(constantTimeEqual(clientM1, serverExpectedM1));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `computeM1(I, salt, A, B, K, params): Uint8Array`
|
||||
|
||||
Returns the client proof `H(H(N) XOR H(g) | H(I) | salt | A | B | K)`. Result length equals `params.hash.digestSize`.
|
||||
|
||||
### `computeM2(A, M1, K, params): Uint8Array`
|
||||
|
||||
Returns the server proof `H(A | M1 | K)`. Result length equals `params.hash.digestSize`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `H(N) XOR H(g)` term is a deliberate design choice in RFC 2945 — it commits both ends to the chosen group AND ties the proof to the hash function. An attacker who substituted a weakened group during transport would produce a different `H(N) XOR H(g)` value than the legitimate counterpart computed, causing the proof to fail.
|
||||
|
||||
`computeM1` deliberately accepts `A` and `B` as-is rather than re-padding them. RFC 2945's formula is over the un-padded public values, AND the values supplied to `computeM1` from the handshake functions are already produced by `bigIntToBytes(_, params.N.byteLength)` and so are already zero-padded to the modulus width. Double-padding would produce off-by-N-byte digests.
|
||||
58
docs/security-stack/security/srp/src/math/rng-bytes.md
Normal file
58
docs/security-stack/security/srp/src/math/rng-bytes.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# `rng-bytes`
|
||||
|
||||
## Purpose
|
||||
|
||||
Adapter helper that draws `n` bytes from any `Rng` implementing the `@suaveplan/types` contract. The `Rng` interface mandates `nextUint32` but `nextBytes` is optional; SRP's client/server start functions need a `≥ 256`-bit random exponent, so this helper unifies the two paths. When the supplied `Rng` exposes `nextBytes`, that path is used directly; otherwise the helper packs `nextUint32` calls into a byte buffer with little-endian field extraction. Both paths produce uniform output across the full byte range.
|
||||
|
||||
## Features
|
||||
|
||||
- Single entry point regardless of whether the `Rng` provides `nextBytes`.
|
||||
- Little-endian packing of `nextUint32` for portability across deterministic seeded RNGs.
|
||||
- No allocation overhead on the `nextBytes` fast path.
|
||||
- Pure function — no global state.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { rngBytes } from "@suaveplan/srp";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
const rng: Rng = {
|
||||
nextUint32: () => 0x12345678,
|
||||
nextFloat: () => 0.5,
|
||||
};
|
||||
const bytes = rngBytes(rng, 32);
|
||||
console.log(bytes.byteLength); // 32
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { rngBytes } from "@suaveplan/srp";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
|
||||
// Web-Crypto-backed RNG with native nextBytes — the fast path.
|
||||
const cryptoRng: Rng = {
|
||||
nextUint32: () => {
|
||||
const buf = new Uint32Array(1);
|
||||
crypto.getRandomValues(buf);
|
||||
return buf[0];
|
||||
},
|
||||
nextFloat: () => crypto.getRandomValues(new Uint32Array(1))[0] / 2 ** 32,
|
||||
nextBytes: (n: number) => crypto.getRandomValues(new Uint8Array(n)),
|
||||
};
|
||||
const a = rngBytes(cryptoRng, 32);
|
||||
console.log(a.byteLength);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `rngBytes(rng: Rng, n: number): Uint8Array`
|
||||
|
||||
Returns an `n`-byte `Uint8Array` of uniformly distributed bytes. When `rng.nextBytes` exists, delegates directly. Otherwise packs `nextUint32` outputs into the buffer in little-endian byte order.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The little-endian pack order on the `nextUint32` fallback is arbitrary — the resulting bytes feed straight into a `bigInt` decode that does NOT preserve endianness of the source 32-bit words, so any consistent order is correct. We pick little-endian because it matches the layout of `Uint32Array` views over `ArrayBuffer` on every modern platform, so an implementation that wants to optimize further can swap to a typed-array view without changing behaviour.
|
||||
|
||||
The helper is deliberately not memoized: each call draws fresh entropy. Callers that need a deterministic exponent for testing supply a seeded `Rng` instance whose `nextUint32` reproduces the same word stream; `rngBytes` then yields a deterministic byte sequence from that stream.
|
||||
61
docs/security-stack/security/srp/src/math/u.md
Normal file
61
docs/security-stack/security/srp/src/math/u.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# `u`
|
||||
|
||||
## Purpose
|
||||
|
||||
Compute the SRP-6a scrambling value `u = H(PAD(A) | PAD(B))` per RFC 5054 §2.5.4. `u` mixes the two ephemeral public values into a single nonce that binds both sides of the handshake into the shared-secret derivation. Without it, an active attacker who observes one handshake could replay parts of it; with it, every handshake's shared secret depends on a fresh pair of `(A, B)` values.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function of `(A, B, params)`.
|
||||
- `PAD` both `A` and `B` to `byteLength(N)` before hashing, per RFC 5054 §2.5.3.
|
||||
- Returns the hash's native digest size.
|
||||
- Throws `SrpError(SRP.PUBLIC_KEY_INVALID)` on `u ≡ 0 mod N` — RFC 5054 §2.5.4 mandates abort because `u = 0` lets the client recover the shared secret without the password.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { computeU } from "@suaveplan/srp";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const A = new Uint8Array(256);
|
||||
A[255] = 1;
|
||||
const B = new Uint8Array(256);
|
||||
B[255] = 2;
|
||||
const u = computeU(A, B, params);
|
||||
console.log(u.byteLength); // 32
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { computeU } 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"));
|
||||
try {
|
||||
// Constructed inputs whose hash happens to be ≡ 0 mod N is vanishingly
|
||||
// unlikely with a real hash, but the gate fires deterministically when
|
||||
// it does.
|
||||
computeU(new Uint8Array(256), new Uint8Array(256), params);
|
||||
} catch (err) {
|
||||
if (err instanceof SrpError && err.code === SRP_CODES.PUBLIC_KEY_INVALID) {
|
||||
console.log("u ≡ 0; aborted per RFC 5054 §2.5.4");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `computeU(A: Uint8Array, B: Uint8Array, params: SrpParams): Uint8Array`
|
||||
|
||||
Returns `H(PAD(A) | PAD(B))`. Throws `SrpError(SRP.PUBLIC_KEY_INVALID)` when the decoded `u mod N` is zero.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `u ≡ 0` check is performed AFTER the hash so consumers cannot bypass it by crafting `A` or `B` whose untreated values would produce zero — every input that decodes to zero goes through the same path. The probability of `H(PAD(A) | PAD(B)) ≡ 0 mod N` is approximately `2^-256` for SHA-256; the gate exists for protocol correctness rather than for any expected runtime hit.
|
||||
|
||||
`PAD` is applied to both inputs before concatenation because hashing raw `A` and `B` (potentially leading-zero-stripped from `bigIntToBytes` representations) would let an attacker malleate either value's representation without changing its semantic content. RFC 5054 §2.5.3 mandates the pad for exactly this reason.
|
||||
53
docs/security-stack/security/srp/src/math/x.md
Normal file
53
docs/security-stack/security/srp/src/math/x.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# `x`
|
||||
|
||||
## Purpose
|
||||
|
||||
Compute the SRP-6a private exponent `x = H(salt | H(I | ":" | P))` per RFC 5054 §2.6. `x` ties username + password + salt to a deterministic exponent in `Z_N`, producing the verifier `v = g^x mod N` that the server stores. Both `I` (username) and `P` (password) are encoded as UTF-8 bytes before hashing. The colon separator is fixed by RFC 5054 and is consumed verbatim — consumers MUST NOT pre-quote or escape colons inside `I` or `P`; that is the consumer's responsibility upstream.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function of `(I, P, salt, params)`.
|
||||
- UTF-8 encoding of `I` and `P` so non-ASCII usernames work.
|
||||
- Two-stage hash: inner `H(I | ":" | P)` then outer `H(salt | innerHash)`.
|
||||
- Deterministic — same inputs always produce the same `x`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { computeX } from "@suaveplan/srp";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const salt = new Uint8Array(16);
|
||||
const x = computeX("alice@example.com", "correct horse battery staple", salt, params);
|
||||
console.log(x.byteLength); // 32
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { computeX } from "@suaveplan/srp";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const salt = new Uint8Array([1, 2, 3, 4]);
|
||||
|
||||
// Determinism — both calls return byte-identical x for identical inputs.
|
||||
const x1 = computeX("alice", "secret", salt, params);
|
||||
const x2 = computeX("alice", "secret", salt, params);
|
||||
console.log(x1.toString() === x2.toString());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `computeX(I: string, P: string, salt: Uint8Array, params: SrpParams): Uint8Array`
|
||||
|
||||
Returns `H(salt | H(I | ":" | P))`. Result length equals `params.hash.digestSize`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The colon separator in `I | ":" | P` is part of the SRP-6a definition; without it, an attacker could pivot a credential with username `"ab"` + password `"cd"` against a credential with username `"abc"` + password `"d"` (same `I | P` byte stream). RFC 5054 §2.6 fixes the separator to `0x3a` (`":"`); we encode it as UTF-8 once at module load and reuse the cached buffer across calls.
|
||||
|
||||
`x` is only ever derived on the client side — the server stores the resulting verifier `v = g^x mod N` and never sees `x` directly. The function therefore lives in `math/` rather than `client/` because it is a primitive used by both `computeVerifier` and `clientFinishHandshake`; co-locating it under either of those modules would obscure the dependency relationship.
|
||||
@@ -0,0 +1,79 @@
|
||||
# `metrics`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrument façade for `@suaveplan/srp`. Exposes three lazy-resolved instruments: a `srp.handshake_duration_ms` histogram (bucketed by `role` and `phase`), a `srp.outcome_total` counter (bucketed by `role` and `outcome`), and a `srp.errors_total` counter (bucketed by `code`). The instruments are created against `metrics.getMeter("@suaveplan/srp")`; test fixtures that swap the global `MeterProvider` between cases call `resetSrpMetricsForTesting()` to drop the cached references and force re-resolution against the new provider.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy instrument resolution — test-friendly when global meter provider rotates between cases.
|
||||
- Three instruments covering handshake duration, outcome distribution, and error-code frequency.
|
||||
- Low-cardinality attribute set (`role`, `phase`, `outcome`, `code`) so dashboards stay cheap.
|
||||
- Stable attribute values backed by literal-typed enums (`SrpRole`, `SrpPhase`, `SrpOutcome`).
|
||||
- `resetSrpMetricsForTesting()` escape hatch for tests.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordDuration, recordOutcome, recordSrpError } from "@suaveplan/srp";
|
||||
import { SRP_CODES } from "@suaveplan/srp/errors";
|
||||
|
||||
recordDuration("client", "start", 12.5);
|
||||
recordOutcome("server", "ok");
|
||||
recordSrpError(SRP_CODES.AUTH_FAILED);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
MeterProvider,
|
||||
InMemoryMetricExporter,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { recordOutcome, resetSrpMetricsForTesting } from "@suaveplan/srp";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(0);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 10 });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetSrpMetricsForTesting();
|
||||
|
||||
recordOutcome("server", "ok");
|
||||
await reader.forceFlush();
|
||||
console.log(exporter.getMetrics().length);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `recordDuration(role, phase, durationMs): void`
|
||||
|
||||
Record a handshake duration sample on the `srp.handshake_duration_ms` histogram.
|
||||
|
||||
### `recordOutcome(role, outcome): void`
|
||||
|
||||
Increment the `srp.outcome_total` counter.
|
||||
|
||||
### `recordSrpError(code): void`
|
||||
|
||||
Increment the `srp.errors_total` counter for a specific `SrpErrorCode`.
|
||||
|
||||
### `resetSrpMetricsForTesting(): void`
|
||||
|
||||
Drop cached instruments so the next call resolves against the current global `MeterProvider`. Tests-only.
|
||||
|
||||
### Types
|
||||
|
||||
- `type SrpRole = "client" | "server"`
|
||||
- `type SrpPhase = "start" | "finish"`
|
||||
- `type SrpOutcome = "ok" | "auth_failed" | "mutual_auth_failed" | "public_key_invalid"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Lazy-resolution of meters and instruments is the standard pattern across crypto packages because tests rotate the global `MeterProvider` between cases — caching the meter at module load would pin every emission to the first provider seen, which makes tests false-negative. The reset escape hatch is only ever called from tests; production code never touches it.
|
||||
|
||||
The attribute set is intentionally tight. We omit per-handshake fields like `user_id` or `tenant_id` because OTel metrics are aggregate, not per-event — high-cardinality attributes blow up storage cost without adding query value. Consumers that want per-user breakdowns should layer a `Logger`-based audit trail on top of the SRP error path.
|
||||
74
docs/security-stack/security/srp/src/pake/pake-provider.md
Normal file
74
docs/security-stack/security/srp/src/pake/pake-provider.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `pake-provider`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`PakeProvider` is the augmented-password-authenticated-key-exchange seam for `@suaveplan/srp`. SRP-6a is the first implementation (`srpPakeProvider`); the interface is intentionally minimal so a future OPAQUE provider (`@suaveplan/opaque`, not yet shipped) can drop in without changing the `@suaveplan/vault` consumer surface. The seam covers three lifecycle methods: `registerCredential` (turn `(I, P, salt)` into a server-storable record), `clientHandshake` (drive the client side once the server's public value `B` is known), and `serverHandshake` (drive the server side once the client's `A` and `M1` are known).
|
||||
|
||||
## Features
|
||||
|
||||
- Stable interface shape independent of the underlying PAKE algorithm.
|
||||
- `srpPakeProvider(params)` returns a fully-wired SRP-6a implementation.
|
||||
- `clientHandshake` accepts the client's pre-drawn private exponent so callers can replay deterministic handshakes from a seeded RNG.
|
||||
- `serverHandshake` accepts the server's pre-drawn `(b, B)` so callers can replay deterministic handshakes symmetrically.
|
||||
- Outcome shape is consistent with `clientFinishHandshake` / `serverFinishHandshake` — `K`, `S`, `M1`/`M2` are all surfaced.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { srpPakeProvider } from "@suaveplan/srp/pake";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const provider = srpPakeProvider(params2048Sha256(nobleHashBridge("SHA256")));
|
||||
const salt = new Uint8Array(16);
|
||||
const registration = provider.registerCredential("alice", "password123", salt);
|
||||
console.log(registration.v.byteLength); // 256
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { srpPakeProvider } from "@suaveplan/srp/pake";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
import { clientStartHandshake } from "@suaveplan/srp/client";
|
||||
import { serverStartHandshake } from "@suaveplan/srp/server";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const provider = srpPakeProvider(params);
|
||||
const rng = {
|
||||
nextUint32: () => 0,
|
||||
nextFloat: () => 0,
|
||||
nextBytes: (n: number) => crypto.getRandomValues(new Uint8Array(n)),
|
||||
};
|
||||
const salt = new Uint8Array(16);
|
||||
const reg = provider.registerCredential("alice", "password123", salt);
|
||||
const { a } = clientStartHandshake(rng, params);
|
||||
const { b, B } = serverStartHandshake(reg.v, rng, params);
|
||||
const client = provider.clientHandshake("alice", "password123", salt, B, a);
|
||||
const server = provider.serverHandshake(reg, client.A, client.M1, B, b);
|
||||
console.log(Buffer.from(client.K).equals(Buffer.from(server.K))); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `srpPakeProvider(params: SrpParams): PakeProvider`
|
||||
|
||||
Returns a SRP-6a implementation of the `PakeProvider` interface bound to the supplied `params`.
|
||||
|
||||
### `interface PakeProvider`
|
||||
|
||||
| Method | Signature | Description |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `registerCredential` | `(I, P, salt) → PakeRegistration` | Compute `{ I, salt, v }` server-storable record. |
|
||||
| `clientHandshake` | `(I, P, salt, serverPublic, clientPrivate) → PakeClientHandshake` | Drive the client; `clientPrivate` is the `a` from `clientStartHandshake`. |
|
||||
| `serverHandshake` | `(stored, clientPublic, clientProof, B, b) → PakeServerHandshake` | Drive the server; `(B, b)` come from `serverStartHandshake`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`clientHandshake` accepts the client's private exponent and recomputes `A = g^a mod N` rather than accepting `A` directly. This costs one extra `modPow` per call but lets the seam absorb any future PAKE-algorithm change that derives `A`-equivalent values differently from `clientPrivate`. The cost is amortised against the dominant `modPow` chain inside `clientFinishHandshake`, so the wall-time impact is roughly +20%.
|
||||
|
||||
The `PakeProvider` interface deliberately does NOT include `clientStartHandshake` / `serverStartHandshake` because the start step is a pure RNG draw plus a `modPow` — the values produced are not augmented-PAKE-specific, so the seam exposes only the augmented-step entry points. Consumers driving the seam externally call `clientStartHandshake` and `serverStartHandshake` from `@suaveplan/srp/client` and `@suaveplan/srp/server` respectively, then thread the resulting public values into `PakeProvider`.
|
||||
71
docs/security-stack/security/srp/src/params/params.md
Normal file
71
docs/security-stack/security/srp/src/params/params.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# `params`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`SrpParams` is the per-handshake parameter bundle every SRP-6a call in `@suaveplan/srp` consumes. It pairs a safe-prime group `(N, g)`, the bit length of `N`, an injected `HashLike` hash function, and a stable algorithm tag (`"SHA256"` or `"SHA1"`). One `SrpParams` value drives every step of a handshake — `computeVerifier`, `clientStartHandshake`, `clientFinishHandshake`, `serverStartHandshake`, `serverFinishHandshake`, `serverComputeM2` — so the same group and hash bind every intermediate value into the proof chain.
|
||||
|
||||
## Features
|
||||
|
||||
- `params2048Sha256(hash)` convenience factory for the recommended default (RFC 5054 Appendix A 2048-bit group + SHA-256).
|
||||
- `validateSrpParams(params)` structural validation throwing `SRP.INVALID_PARAMS` on malformed shapes.
|
||||
- Algorithm gate throwing `SRP.HASH_UNSUPPORTED` for any value outside `{"SHA256", "SHA1"}`.
|
||||
- `g >= N` rejected at the boundary so subsequent `modPow` calls cannot escape the intended group.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
console.log(params.bitLength); // 2048
|
||||
console.log(params.algorithm); // "SHA256"
|
||||
console.log(params.N.byteLength); // 256
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { validateSrpParams, type SrpParams } from "@suaveplan/srp/params";
|
||||
import { N_4096, g_4096 } from "@suaveplan/srp/groups/4096";
|
||||
import { defaultHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const params: SrpParams = {
|
||||
N: N_4096,
|
||||
g: g_4096,
|
||||
bitLength: 4096,
|
||||
hash: defaultHashBridge("SHA256"),
|
||||
algorithm: "SHA256",
|
||||
};
|
||||
validateSrpParams(params);
|
||||
console.log("4096-bit SHA-256 params validated");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface SrpParams`
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------- | ---------------------------------------------------- |
|
||||
| `N` | `Uint8Array` | Safe-prime modulus as big-endian bytes. |
|
||||
| `g` | `Uint8Array` | Generator as big-endian bytes. |
|
||||
| `bitLength` | `number` | Bit length of `N`; MUST equal `N.byteLength * 8`. |
|
||||
| `hash` | `HashLike` | Injected hash bridge (sync or async). |
|
||||
| `algorithm` | `"SHA256" \| "SHA1"`| Stable algorithm tag. |
|
||||
|
||||
### `params2048Sha256(hash: HashLike): SrpParams`
|
||||
|
||||
Returns a validated `SrpParams` for the 2048-bit group + SHA-256.
|
||||
|
||||
### `validateSrpParams(params: SrpParams): void`
|
||||
|
||||
Throws `SrpError(SRP.INVALID_PARAMS)` when `N.byteLength` is not a multiple of 4, `bitLength` does not match `N.byteLength * 8`, `g <= 1`, or `g >= N`. Throws `SrpError(SRP.HASH_UNSUPPORTED)` when `algorithm` is not in `{"SHA256", "SHA1"}`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 4-byte alignment check on `N.byteLength` reflects the operational shape of every documented RFC 5054 group — 128, 192, 256, 384, 512, 768, 1024 bytes — and serves as a structural smoke-test: a modulus that is not 4-byte aligned almost certainly came from a decoding bug rather than an actual RFC 5054 group.
|
||||
|
||||
`validateSrpParams` is called from `params2048Sha256` at construction time so the factory-returned `SrpParams` is already validated when handed to a handshake function. Consumers building `SrpParams` from raw constants (custom groups, test fixtures) MUST call `validateSrpParams` explicitly; the handshake functions DO NOT re-validate on every call to keep the per-handshake overhead minimal.
|
||||
70
docs/security-stack/security/srp/src/schemas/schemas.md
Normal file
70
docs/security-stack/security/srp/src/schemas/schemas.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# `schemas`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Zod boundary schemas for the public surface of `@suaveplan/srp`. `SrpParamsSchema` validates a candidate `SrpParams` object (byte shape, algorithm tag, generator/modulus relationship); `SrpHandshakeStateSchema` validates the per-handshake state envelope both sides keep across the two round-trips; `VerifierSchema` validates the server-side `{ I, salt, v }` record. Every schema-validation failure surfaces as `SrpError(SRP.INVALID_PARAMS)` via the `parseSrpParams` helper so consumers branch on a stable code rather than parsing Zod's diagnostic text.
|
||||
|
||||
## Features
|
||||
|
||||
- `SrpParamsSchema` enforces `N.byteLength` multiple-of-4, `bitLength === N.byteLength * 8`, `g > 1`, `g < N`, `algorithm ∈ {"SHA256", "SHA1"}`.
|
||||
- `SrpHandshakeStateSchema` enforces non-empty `I`, `Uint8Array`-typed `salt`/`A`/`B`, role tag.
|
||||
- `VerifierSchema` validates the server-side persistence record.
|
||||
- `parseSrpParams(value)` rethrows Zod failures as `SrpError(SRP.INVALID_PARAMS)`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseSrpParams } from "@suaveplan/srp";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
|
||||
const params = parseSrpParams(params2048Sha256(nobleHashBridge("SHA256")));
|
||||
console.log(params.algorithm); // "SHA256"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SrpHandshakeStateSchema, VerifierSchema } from "@suaveplan/srp";
|
||||
|
||||
const verifier = VerifierSchema.parse({
|
||||
I: "alice@example.com",
|
||||
salt: new Uint8Array(16),
|
||||
v: new Uint8Array(256),
|
||||
});
|
||||
const state = SrpHandshakeStateSchema.parse({
|
||||
I: "alice@example.com",
|
||||
salt: new Uint8Array(16),
|
||||
A: new Uint8Array(256),
|
||||
B: new Uint8Array(256),
|
||||
role: "server",
|
||||
});
|
||||
console.log(verifier.I, state.role);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SrpParamsSchema: z.ZodSchema<SrpParams>`
|
||||
|
||||
Validates byte shape (`N` multiple of 4, `bitLength === N.byteLength * 8`), algorithm tag, and `1 < g < N`.
|
||||
|
||||
### `SrpHandshakeStateSchema`
|
||||
|
||||
Validates `{ I: string; salt: Uint8Array; A: Uint8Array; B: Uint8Array; role: "client" | "server" }`.
|
||||
|
||||
### `VerifierSchema`
|
||||
|
||||
Validates `{ I: string; salt: Uint8Array; v: Uint8Array }`.
|
||||
|
||||
### `parseSrpParams(value: unknown): SrpParams`
|
||||
|
||||
Wraps `SrpParamsSchema.safeParse` and rethrows failures as `SrpError(SRP.INVALID_PARAMS)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schemas use `z.custom<Uint8Array>` rather than the deprecated `z.instanceof` because `instanceof Uint8Array` does not survive cross-realm boundaries in some runtimes (e.g. Cloudflare Workers). The `v instanceof Uint8Array` check is sufficient for our universal target environments (Node, Bun, browsers) and gives Zod a clean predicate for error reporting.
|
||||
|
||||
`parseSrpParams` deliberately maps Zod's structured error into a single `SrpError(SRP.INVALID_PARAMS)` rather than re-throwing the `ZodError`. Consumers of `@suaveplan/srp` should not need a Zod-specific catch path; `error.code === "SRP.INVALID_PARAMS"` is the documented contract.
|
||||
74
docs/security-stack/security/srp/src/server/server-finish.md
Normal file
74
docs/security-stack/security/srp/src/server/server-finish.md
Normal 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.
|
||||
65
docs/security-stack/security/srp/src/server/server-m2.md
Normal file
65
docs/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.
|
||||
74
docs/security-stack/security/srp/src/server/server-start.md
Normal file
74
docs/security-stack/security/srp/src/server/server-start.md
Normal 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.
|
||||
65
docs/security-stack/security/srp/src/verifier/verifier.md
Normal file
65
docs/security-stack/security/srp/src/verifier/verifier.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# `verifier`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Compute the SRP-6a verifier `v = g^x mod N` where `x = H(salt | H(I | ":" | P))`, per RFC 5054 §2.6. The verifier is what the server stores on registration; the password never leaves the client. The deterministic mapping from `(I, P, salt)` to `(x, v)` means an attacker who steals the verifier database cannot recover passwords without solving a discrete-log problem in the chosen safe-prime group — for the bundled 2048-bit group, that puts the attacker at the same security level as a 2048-bit RSA key.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure deterministic function of `(I, P, salt, params)`.
|
||||
- Returns both `x` (private; consumer should zeroize immediately after use) and `v` (public; server-side storage).
|
||||
- `v` is zero-padded to `byteLength(N)` so on-wire / on-disk shape is fixed.
|
||||
- `modPow` routes through `@noble/curves`'s hardened path.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
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 { x, v } = computeVerifier("alice", "password123", salt, params);
|
||||
console.log(x.byteLength, v.byteLength); // 32 256
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { computeVerifier } from "@suaveplan/srp/verifier";
|
||||
import { params2048Sha256 } from "@suaveplan/srp/params";
|
||||
import { nobleHashBridge } from "@suaveplan/srp/hash";
|
||||
import { zeroize } from "@suaveplan/crypto-utils";
|
||||
|
||||
const params = params2048Sha256(nobleHashBridge("SHA256"));
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const { x, v } = computeVerifier("alice", "correct horse battery staple", salt, params);
|
||||
|
||||
// Persist v + salt server-side; zeroize x locally.
|
||||
const serverRecord = { I: "alice", salt, v };
|
||||
zeroize(x);
|
||||
console.log(serverRecord.v.byteLength);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `computeVerifier(I, P, salt, params): { x, v }`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ------------ | ---------------------------------------- |
|
||||
| `I` | `string` | Username / identity, UTF-8 encoded. |
|
||||
| `P` | `string` | Password, UTF-8 encoded. |
|
||||
| `salt` | `Uint8Array` | Random per-user salt. |
|
||||
| `params` | `SrpParams` | Group + hash bundle. |
|
||||
|
||||
Returns `{ x, v }`. `x` is `params.hash.digestSize` bytes; `v` is `params.N.byteLength` bytes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function returns BOTH `x` and `v` even though only `v` is sent to the server. The reason: most consumers compute the verifier as part of a multi-step registration flow that also needs `x` for an immediate `clientFinishHandshake` (e.g. "register, then log in" UX patterns). Returning `x` saves one redundant hash chain; consumers that never use `x` should `zeroize(x)` immediately after the call.
|
||||
|
||||
The `bigIntToBytes(vInt, params.N.byteLength)` call zero-pads `v` on the left to the modulus width. This matters because `v` is later mixed into hashes via `PAD` on the server side; storing the un-padded representation would force every server-side handshake step to re-pad on read, and would produce different on-disk bytes for the same logical verifier depending on its leading-zero pattern.
|
||||
Reference in New Issue
Block a user