feat: release OTP and Passkey Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user