Files
suaveplan/packages/security-stack/security/srp/src/hash/hash-bridge.ts
2026-05-27 23:47:53 +01:00

126 lines
4.4 KiB
TypeScript

import { sha1 } from "@noble/hashes/sha1.js";
import { sha256 } from "@noble/hashes/sha2.js";
import type { HashLike } from "@suaveplan/types";
import { SRP_CODES, srpError } from "../errors/errors.js";
/** Supported algorithm tags for `@suaveplan/srp`. */
export type SrpHashAlgorithm = "SHA256" | "SHA1";
const ALGORITHMS: readonly SrpHashAlgorithm[] = ["SHA256", "SHA1"] as const;
function digestSizeFor(algorithm: SrpHashAlgorithm): number {
return algorithm === "SHA256" ? 32 : 20;
}
function subtleNameFor(algorithm: SrpHashAlgorithm): string {
return algorithm === "SHA256" ? "SHA-256" : "SHA-1";
}
function assertSupported(algorithm: SrpHashAlgorithm): void {
if (!ALGORITHMS.includes(algorithm)) {
throw srpError(
SRP_CODES.HASH_UNSUPPORTED,
`unsupported algorithm: ${String(algorithm)}`
);
}
}
/** Sync `@noble/hashes`-backed `HashLike`. SHA-256 or SHA-1. */
export function nobleHashBridge(algorithm: SrpHashAlgorithm): HashLike {
assertSupported(algorithm);
const digestSize = digestSizeFor(algorithm);
return {
algorithm,
digestSize,
digest(input: Uint8Array): Uint8Array {
return algorithm === "SHA256" ? sha256(input) : sha1(input);
},
};
}
/**
* Async `crypto.subtle.digest`-backed `HashLike`. Honours an optional
* `AbortSignal` — a pre-aborted signal causes the digest to reject
* before any work is performed; mid-flight aborts propagate to the
* caller.
*
* `signal` MAY be supplied at construction time (applies to every
* `digest` call) AND/OR per-call. If both are present, the per-call
* signal takes precedence.
*/
export function subtleHashBridge(
algorithm: SrpHashAlgorithm,
constructionSignal?: AbortSignal
): HashLike {
assertSupported(algorithm);
const digestSize = digestSizeFor(algorithm);
const subtleName = subtleNameFor(algorithm);
return {
algorithm,
digestSize,
async digest(
input: Uint8Array,
callSignal?: AbortSignal
): Promise<Uint8Array> {
const signal = callSignal ?? constructionSignal;
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new Error("aborted");
}
const subtle = globalThis.crypto?.subtle;
if (subtle === undefined) {
throw srpError(
SRP_CODES.HASH_UNSUPPORTED,
"globalThis.crypto.subtle is not available in this runtime"
);
}
// Copy into a fresh ArrayBuffer to satisfy WebCrypto's
// BufferSource type (excludes SharedArrayBuffer in TS DOM lib).
const copy = new Uint8Array(input.byteLength);
copy.set(input);
const buf = await subtle.digest(subtleName, copy.buffer);
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: new Error("aborted");
}
return new Uint8Array(buf);
},
};
}
/**
* Pick the platform-appropriate backend. When `globalThis.crypto.subtle`
* is available (browsers, Bun, Node >= 20), returns the async subtle
* bridge. Otherwise falls back to the sync `@noble/hashes` bridge.
*/
export function defaultHashBridge(algorithm: SrpHashAlgorithm): HashLike {
assertSupported(algorithm);
if (typeof globalThis.crypto?.subtle?.digest === "function") {
return subtleHashBridge(algorithm);
}
return nobleHashBridge(algorithm);
}
/**
* Run a `HashLike.digest` and assert the result is synchronously
* available. SRP-6a's handshake functions are CPU-bound and cannot
* await an async digest mid-protocol; consumers that want async hashes
* MUST drive the protocol externally and pass intermediates back in.
*
* Throws `SrpError(SRP.HASH_UNSUPPORTED)` if the returned digest is a
* Promise — which is how a caller of the sync handshake API surface
* accidentally passing `subtleHashBridge` discovers the mismatch.
*/
export function digestSync(hash: HashLike, input: Uint8Array): Uint8Array {
const out = hash.digest(input);
if (out instanceof Promise) {
throw srpError(
SRP_CODES.HASH_UNSUPPORTED,
"async HashLike.digest used on a synchronous SRP path; pass a sync bridge (nobleHashBridge) or pre-resolve every digest before calling"
);
}
return out;
}