Initial Commit

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

View File

@@ -0,0 +1,118 @@
import { constantTimeEqual, fromBase64Url } from "@suaveplan/crypto-utils";
import { WEBAUTHN_CODES, WebAuthnError } from "../errors/error-codes.js";
import {
type ClientDataJSON,
ClientDataJSONSchema,
} from "../schemas/schemas.js";
const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
/** Discriminated `type` field accepted by the WebAuthn registration ceremony. */
export const CLIENT_DATA_TYPE_CREATE = "webauthn.create";
/** Discriminated `type` field accepted by the WebAuthn assertion ceremony. */
export const CLIENT_DATA_TYPE_GET = "webauthn.get";
/**
* Parse a raw `clientDataJSON` byte string into a typed
* {@link ClientDataJSON} object. Throws `WEBAUTHN.MALFORMED_RESPONSE`
* when the bytes are not valid UTF-8 JSON or do not conform to the
* `ClientDataJSONSchema`.
*/
export function parseClientDataJSON(bytes: Uint8Array): ClientDataJSON {
let text: string;
try {
text = TEXT_DECODER.decode(bytes);
} catch (cause) {
throw new WebAuthnError(
WEBAUTHN_CODES.MALFORMED_RESPONSE,
"error.webauthn.client-data.invalid-utf8",
{ cause }
);
}
let json: unknown;
try {
json = JSON.parse(text);
} catch (cause) {
throw new WebAuthnError(
WEBAUTHN_CODES.MALFORMED_RESPONSE,
"error.webauthn.client-data.invalid-json",
{ cause }
);
}
const parsed = ClientDataJSONSchema.safeParse(json);
if (!parsed.success) {
throw new WebAuthnError(
WEBAUTHN_CODES.MALFORMED_RESPONSE,
"error.webauthn.client-data.invalid-shape",
{ context: { issues: parsed.error.issues }, cause: parsed.error }
);
}
return parsed.data;
}
/** Validation context shared between register and authenticate. */
export interface ValidateClientDataInput {
readonly clientDataJSON: Uint8Array;
readonly expectedType:
| typeof CLIENT_DATA_TYPE_CREATE
| typeof CLIENT_DATA_TYPE_GET;
readonly expectedOrigin: string;
readonly expectedChallenge: Uint8Array;
}
/**
* Validate a parsed `clientDataJSON` against the caller's expected
* type, origin, and challenge. Mismatches throw a typed
* {@link WebAuthnError} with the appropriate W3C-aligned code.
*/
export function validateClientDataJSON(
input: ValidateClientDataInput
): ClientDataJSON {
const data = parseClientDataJSON(input.clientDataJSON);
if (data.type !== input.expectedType) {
throw new WebAuthnError(
WEBAUTHN_CODES.MALFORMED_RESPONSE,
"error.webauthn.client-data.type-mismatch",
{
context: {
expected: input.expectedType,
got: data.type,
},
}
);
}
if (!constantTimeEqual(utf8(data.origin), utf8(input.expectedOrigin))) {
throw new WebAuthnError(
WEBAUTHN_CODES.ORIGIN_MISMATCH,
"error.webauthn.client-data.origin-mismatch",
{
context: {
expected: input.expectedOrigin,
got: data.origin,
},
}
);
}
let decodedChallenge: Uint8Array;
try {
decodedChallenge = fromBase64Url(data.challenge);
} catch (cause) {
throw new WebAuthnError(
WEBAUTHN_CODES.MALFORMED_RESPONSE,
"error.webauthn.client-data.challenge-not-base64url",
{ cause }
);
}
if (!constantTimeEqual(decodedChallenge, input.expectedChallenge)) {
throw new WebAuthnError(
WEBAUTHN_CODES.CHALLENGE_MISMATCH,
"error.webauthn.client-data.challenge-mismatch"
);
}
return data;
}
const ENCODER = new TextEncoder();
function utf8(s: string): Uint8Array {
return ENCODER.encode(s);
}