119 lines
3.8 KiB
TypeScript
119 lines
3.8 KiB
TypeScript
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);
|
|
}
|