387 lines
11 KiB
TypeScript
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,
|
|
};
|
|
}
|