Files
auth-tools/src/webauthn/verify.ts
T

387 lines
11 KiB
TypeScript

import {
base64UrlToBytes,
bytesToArrayBuffer,
bytesToBase64Url,
bytesToHex,
utf8ToBytes,
} from "../crypto/encoding";
import type { CborValue } from "./cbor";
import { parseAuthenticatorData, parseClientData } from "./parser";
export type CheckStatus = "pass" | "fail" | "warning" | "information";
export interface VerificationCheck {
name: string;
status: CheckStatus;
detail: string;
}
export interface AssertionVerificationInput {
clientDataJSON: string;
authenticatorData: string;
signature: string;
credentialPublicKey: string | Map<CborValue, CborValue>;
expectedChallenge: string;
expectedOrigin: string;
expectedRpId: string;
requireUserVerification?: boolean;
previousSignCount?: number;
}
export interface AssertionVerificationResult {
verified: boolean;
checks: VerificationCheck[];
signCount: number;
}
function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
if (left.byteLength !== right.byteLength) return false;
let difference = 0;
for (let index = 0; index < left.byteLength; index += 1)
difference |= left[index]! ^ right[index]!;
return difference === 0;
}
async function digest(bytes: Uint8Array): Promise<Uint8Array> {
return new Uint8Array(
await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(bytes)),
);
}
function coseNumber(map: Map<CborValue, CborValue>, key: number): number {
const value = map.get(key);
if (typeof value !== "number")
throw new Error(`COSE key parameter ${key} is missing.`);
return value;
}
function coseBytes(map: Map<CborValue, CborValue>, key: number): Uint8Array {
const value = map.get(key);
if (!(value instanceof Uint8Array))
throw new Error(`COSE key parameter ${key} is missing.`);
return value;
}
function decodeCoseJson(input: string): Map<CborValue, CborValue> {
let value: unknown;
try {
value = JSON.parse(input);
} catch {
throw new Error("Credential public key JSON is invalid.");
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Credential public key must be a JSON object.");
}
const result = new Map<CborValue, CborValue>();
for (const [rawKey, item] of Object.entries(value)) {
const key = Number(rawKey);
if (!Number.isInteger(key))
throw new Error("COSE key labels must be integers.");
if (typeof item === "string" && /^[-_A-Za-z0-9]+=*$/u.test(item)) {
result.set(key, base64UrlToBytes(item));
} else if (typeof item === "number") result.set(key, item);
else throw new Error(`Unsupported COSE value for label ${rawKey}.`);
}
return result;
}
export function coseKeyToJson(
map: Map<CborValue, CborValue>,
): Record<string, string | number> {
const result: Record<string, string | number> = {};
for (const [key, value] of map) {
if (typeof key !== "number") continue;
if (typeof value === "number") result[String(key)] = value;
else if (value instanceof Uint8Array)
result[String(key)] = bytesToBase64Url(value);
}
return result;
}
function derEcdsaToRaw(signature: Uint8Array, size = 32): Uint8Array {
if (signature[0] !== 0x30)
throw new Error("ECDSA signature is not a DER sequence.");
let offset = 1;
let sequenceLength = signature[offset++]!;
if (sequenceLength & 0x80) {
const lengthBytes = sequenceLength & 0x7f;
if (
lengthBytes === 0 ||
lengthBytes > 2 ||
offset + lengthBytes > signature.length
)
throw new Error("ECDSA DER length is invalid.");
sequenceLength = 0;
for (let index = 0; index < lengthBytes; index += 1)
sequenceLength = sequenceLength * 256 + signature[offset++]!;
}
if (
offset + sequenceLength !== signature.length ||
signature[offset++] !== 0x02
)
throw new Error("ECDSA DER sequence is malformed.");
const rLength = signature[offset++]!;
const r = signature.slice(offset, offset + rLength);
offset += rLength;
if (signature[offset++] !== 0x02)
throw new Error("ECDSA DER sequence lacks s.");
const sLength = signature[offset++]!;
const s = signature.slice(offset, offset + sLength);
if (offset + sLength !== signature.length)
throw new Error("ECDSA DER signature has trailing data.");
const normalize = (integer: Uint8Array): Uint8Array => {
if (!integer.length || (integer[0]! & 0x80) !== 0)
throw new Error("ECDSA DER integer is negative or empty.");
if (integer.length > 1 && integer[0] === 0 && (integer[1]! & 0x80) === 0)
throw new Error("ECDSA DER integer is not minimally encoded.");
let start = 0;
while (start < integer.length - 1 && integer[start] === 0) start += 1;
const stripped = integer.slice(start);
if (stripped.length > size) throw new Error("ECDSA integer is too large.");
const output = new Uint8Array(size);
output.set(stripped, size - stripped.length);
return output;
};
const output = new Uint8Array(size * 2);
output.set(normalize(r));
output.set(normalize(s), size);
return output;
}
async function importCoseKey(map: Map<CborValue, CborValue>): Promise<{
key: CryptoKey;
algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams;
normalizeSignature: (signature: Uint8Array) => Uint8Array;
}> {
const kty = coseNumber(map, 1);
const alg = coseNumber(map, 3);
if (kty === 2 && alg === -7 && coseNumber(map, -1) === 1) {
const jwk: JsonWebKey = {
kty: "EC",
crv: "P-256",
x: bytesToBase64Url(coseBytes(map, -2)),
y: bytesToBase64Url(coseBytes(map, -3)),
ext: true,
};
return {
key: await crypto.subtle.importKey(
"jwk",
jwk,
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"],
),
algorithm: { name: "ECDSA", hash: "SHA-256" },
normalizeSignature: derEcdsaToRaw,
};
}
if (kty === 3 && (alg === -257 || alg === -37)) {
const jwk: JsonWebKey = {
kty: "RSA",
n: bytesToBase64Url(coseBytes(map, -1)),
e: bytesToBase64Url(coseBytes(map, -2)),
ext: true,
};
const name = alg === -37 ? "RSA-PSS" : "RSASSA-PKCS1-v1_5";
return {
key: await crypto.subtle.importKey(
"jwk",
jwk,
{ name, hash: "SHA-256" },
false,
["verify"],
),
algorithm: alg === -37 ? { name, saltLength: 32 } : name,
normalizeSignature: (signature) => signature,
};
}
if (kty === 1 && alg === -8 && coseNumber(map, -1) === 6) {
const jwk: JsonWebKey = {
kty: "OKP",
crv: "Ed25519",
x: bytesToBase64Url(coseBytes(map, -2)),
ext: true,
};
return {
key: await crypto.subtle.importKey("jwk", jwk, "Ed25519", false, [
"verify",
]),
algorithm: "Ed25519",
normalizeSignature: (signature) => signature,
};
}
throw new Error(
`Unsupported COSE key type/algorithm combination: kty ${kty}, alg ${alg}.`,
);
}
function addCheck(
checks: VerificationCheck[],
name: string,
pass: boolean,
detail: string,
): void {
checks.push({ name, status: pass ? "pass" : "fail", detail });
}
export async function verifyAssertion(
input: AssertionVerificationInput,
): Promise<AssertionVerificationResult> {
const checks: VerificationCheck[] = [];
const clientBytes = base64UrlToBytes(input.clientDataJSON);
const authBytes = base64UrlToBytes(input.authenticatorData);
const client = parseClientData(clientBytes);
const authenticator = parseAuthenticatorData(authBytes);
addCheck(
checks,
"Ceremony type",
client.type === "webauthn.get",
`Received ${client.type}.`,
);
const challengeMatches = (() => {
try {
return bytesEqual(
base64UrlToBytes(client.challenge),
base64UrlToBytes(input.expectedChallenge),
);
} catch {
return false;
}
})();
addCheck(
checks,
"Challenge",
challengeMatches,
challengeMatches
? "Challenge matches exactly."
: "Challenge does not match.",
);
let receivedOriginValid = false;
let expectedOriginValid = false;
try {
receivedOriginValid = new URL(client.origin).origin === client.origin;
} catch {
// Reported as a failed syntax check below.
}
try {
expectedOriginValid =
new URL(input.expectedOrigin).origin === input.expectedOrigin;
} catch {
// Reported as a failed syntax check below.
}
addCheck(
checks,
"Origin syntax",
receivedOriginValid && expectedOriginValid,
"Origins must be serialized, absolute URL origins without a path.",
);
addCheck(
checks,
"Origin",
receivedOriginValid &&
expectedOriginValid &&
client.origin === input.expectedOrigin,
`Received ${client.origin}.`,
);
addCheck(
checks,
"Cross-origin",
client.crossOrigin !== true,
client.crossOrigin === true
? "Client data marks this ceremony cross-origin."
: "Ceremony is not marked cross-origin.",
);
const expectedRpHash = await digest(utf8ToBytes(input.expectedRpId));
addCheck(
checks,
"RP ID hash",
bytesToHex(expectedRpHash) === authenticator.rpIdHash,
`Expected SHA-256(${input.expectedRpId}).`,
);
addCheck(
checks,
"User presence",
authenticator.flags.userPresent,
"Authenticator UP flag must be set.",
);
if (input.requireUserVerification)
addCheck(
checks,
"User verification",
authenticator.flags.userVerified,
"User verification was required.",
);
else
checks.push({
name: "User verification",
status: "information",
detail: authenticator.flags.userVerified
? "UV flag is set."
: "UV was not required and is not set.",
});
if (
input.previousSignCount !== undefined &&
input.previousSignCount > 0 &&
authenticator.signCount > 0
) {
addCheck(
checks,
"Signature counter",
authenticator.signCount > input.previousSignCount,
`Previous ${input.previousSignCount}; received ${authenticator.signCount}.`,
);
} else {
checks.push({
name: "Signature counter",
status: "information",
detail: `Received ${authenticator.signCount}; zero/non-incrementing counters can be valid for multi-device credentials.`,
});
}
try {
const cose =
typeof input.credentialPublicKey === "string"
? decodeCoseJson(input.credentialPublicKey)
: input.credentialPublicKey;
const imported = await importCoseKey(cose);
const clientHash = await digest(clientBytes);
const signed = new Uint8Array(authBytes.byteLength + clientHash.byteLength);
signed.set(authBytes);
signed.set(clientHash, authBytes.byteLength);
const signature = imported.normalizeSignature(
base64UrlToBytes(input.signature),
);
const valid = await crypto.subtle.verify(
imported.algorithm,
imported.key,
bytesToArrayBuffer(signature),
bytesToArrayBuffer(signed),
);
addCheck(
checks,
"Cryptographic signature",
valid,
valid
? "Signature verifies with the supplied credential key."
: "Signature verification failed.",
);
} catch (error) {
checks.push({
name: "Cryptographic signature",
status: "fail",
detail:
error instanceof Error
? error.message
: "Signature verification failed.",
});
}
return {
verified: checks.every((check) => check.status !== "fail"),
checks,
signCount: authenticator.signCount,
};
}