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

244 lines
7.8 KiB
TypeScript

import { bytesToArrayBuffer } from "../crypto/encoding";
import { derEcdsaToRaw } from "./verify";
export interface DerElement {
tag: number;
start: number;
contentStart: number;
end: number;
}
export function readDerElement(bytes: Uint8Array, offset: number): DerElement {
if (offset < 0 || offset + 2 > bytes.length)
throw new Error("DER value is truncated.");
const start = offset;
const tag = bytes[offset++]!;
let length = bytes[offset++]!;
if (length & 0x80) {
const count = length & 0x7f;
if (count === 0 || count > 4 || offset + count > bytes.length)
throw new Error("DER length is invalid.");
length = 0;
for (let index = 0; index < count; index += 1)
length = length * 256 + bytes[offset++]!;
}
if (length < 0 || offset + length > bytes.length)
throw new Error("DER content is truncated.");
return { tag, start, contentStart: offset, end: offset + length };
}
export function derChildren(
bytes: Uint8Array,
parent: DerElement,
): DerElement[] {
const children: DerElement[] = [];
let offset = parent.contentStart;
while (offset < parent.end) {
const child = readDerElement(bytes, offset);
if (child.end > parent.end)
throw new Error("DER child exceeds its parent.");
children.push(child);
offset = child.end;
}
return children;
}
function certificateTbs(bytes: Uint8Array): DerElement {
const certificate = readDerElement(bytes, 0);
if (certificate.tag !== 0x30 || certificate.end !== bytes.length)
throw new Error("Attestation certificate is not a complete DER sequence.");
const tbs = derChildren(bytes, certificate)[0];
if (!tbs || tbs.tag !== 0x30)
throw new Error("Certificate TBSCertificate is missing.");
return tbs;
}
export function certificateSpki(bytes: Uint8Array): Uint8Array {
const fields = derChildren(bytes, certificateTbs(bytes));
let index = fields[0]?.tag === 0xa0 ? 1 : 0;
index += 5; // serial, signature, issuer, validity, subject
const spki = fields[index];
if (!spki || spki.tag !== 0x30)
throw new Error("Certificate SubjectPublicKeyInfo is missing.");
return bytes.slice(spki.start, spki.end);
}
export function certificatePublicKeyBytes(bytes: Uint8Array): Uint8Array {
const spki = readDerElement(certificateSpki(bytes), 0);
const children = derChildren(certificateSpki(bytes), spki);
const bitString = children[1];
if (
!bitString ||
bitString.tag !== 0x03 ||
bitString.contentStart >= bitString.end ||
certificateSpki(bytes)[bitString.contentStart] !== 0
)
throw new Error("Certificate public-key bit string is invalid.");
return certificateSpki(bytes).slice(
bitString.contentStart + 1,
bitString.end,
);
}
function containsBytes(certificate: Uint8Array, value: Uint8Array): boolean {
if (!value.length || value.length > certificate.length) return false;
outer: for (
let offset = 0;
offset <= certificate.length - value.length;
offset += 1
) {
for (let index = 0; index < value.length; index += 1)
if (certificate[offset + index] !== value[index]) continue outer;
return true;
}
return false;
}
function oidBytes(oid: string): Uint8Array {
const arcs = oid.split(".").map(Number);
if (
arcs.length < 2 ||
arcs.some((arc) => !Number.isSafeInteger(arc) || arc < 0) ||
arcs[0]! > 2 ||
(arcs[0]! < 2 && arcs[1]! > 39)
)
throw new Error("Object identifier is invalid.");
const output = [arcs[0]! * 40 + arcs[1]!];
for (const arc of arcs.slice(2)) {
const encoded = [arc & 0x7f];
let remaining = Math.floor(arc / 128);
while (remaining) {
encoded.unshift((remaining & 0x7f) | 0x80);
remaining = Math.floor(remaining / 128);
}
output.push(...encoded);
}
return Uint8Array.from(output);
}
export function certificateExtension(
certificate: Uint8Array,
oid: string,
): Uint8Array | undefined {
const fields = derChildren(certificate, certificateTbs(certificate));
const wrapper = fields.find((field) => field.tag === 0xa3);
if (!wrapper) return undefined;
const sequence = derChildren(certificate, wrapper)[0];
if (!sequence || sequence.tag !== 0x30)
throw new Error("Certificate extensions are malformed.");
const expectedOid = oidBytes(oid);
for (const extension of derChildren(certificate, sequence)) {
if (extension.tag !== 0x30)
throw new Error("Certificate extension is malformed.");
const parts = derChildren(certificate, extension);
const name = parts[0];
const value = parts.at(-1);
if (!name || name.tag !== 0x06 || !value || value.tag !== 0x04) continue;
const actualOid = certificate.slice(name.contentStart, name.end);
if (bytesEqual(actualOid, expectedOid))
return certificate.slice(value.contentStart, value.end);
}
return undefined;
}
function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) return false;
return left.every((byte, index) => byte === right[index]);
}
export function extensionContainsBytes(
certificate: Uint8Array,
oid: string,
value: Uint8Array,
): boolean {
const extension = certificateExtension(certificate, oid);
return extension ? containsBytes(extension, value) : false;
}
export interface AttestationAlgorithm {
importAlgorithm:
| AlgorithmIdentifier
| RsaHashedImportParams
| EcKeyImportParams;
verifyAlgorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams;
normalizeSignature(signature: Uint8Array): Uint8Array;
hash: "SHA-256" | "SHA-384" | "SHA-512";
}
export function attestationAlgorithm(
coseAlgorithm: number,
): AttestationAlgorithm {
if (coseAlgorithm === -7)
return {
importAlgorithm: { name: "ECDSA", namedCurve: "P-256" },
verifyAlgorithm: { name: "ECDSA", hash: "SHA-256" },
normalizeSignature: (value) => derEcdsaToRaw(value, 32),
hash: "SHA-256",
};
if (coseAlgorithm === -35)
return {
importAlgorithm: { name: "ECDSA", namedCurve: "P-384" },
verifyAlgorithm: { name: "ECDSA", hash: "SHA-384" },
normalizeSignature: (value) => derEcdsaToRaw(value, 48),
hash: "SHA-384",
};
if (coseAlgorithm === -36)
return {
importAlgorithm: { name: "ECDSA", namedCurve: "P-521" },
verifyAlgorithm: { name: "ECDSA", hash: "SHA-512" },
normalizeSignature: (value) => derEcdsaToRaw(value, 66),
hash: "SHA-512",
};
if (coseAlgorithm === -257)
return {
importAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
verifyAlgorithm: "RSASSA-PKCS1-v1_5",
normalizeSignature: (value) => value,
hash: "SHA-256",
};
if (coseAlgorithm === -258)
return {
importAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" },
verifyAlgorithm: "RSASSA-PKCS1-v1_5",
normalizeSignature: (value) => value,
hash: "SHA-384",
};
if (coseAlgorithm === -259)
return {
importAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" },
verifyAlgorithm: "RSASSA-PKCS1-v1_5",
normalizeSignature: (value) => value,
hash: "SHA-512",
};
if (coseAlgorithm === -37)
return {
importAlgorithm: { name: "RSA-PSS", hash: "SHA-256" },
verifyAlgorithm: { name: "RSA-PSS", saltLength: 32 },
normalizeSignature: (value) => value,
hash: "SHA-256",
};
throw new Error(`Unsupported attestation COSE algorithm ${coseAlgorithm}.`);
}
export async function verifyCertificateSignature(
certificate: Uint8Array,
coseAlgorithm: number,
signature: Uint8Array,
signed: Uint8Array,
): Promise<boolean> {
const algorithm = attestationAlgorithm(coseAlgorithm);
const key = await crypto.subtle.importKey(
"spki",
bytesToArrayBuffer(certificateSpki(certificate)),
algorithm.importAlgorithm,
false,
["verify"],
);
return crypto.subtle.verify(
algorithm.verifyAlgorithm,
key,
bytesToArrayBuffer(algorithm.normalizeSignature(signature)),
bytesToArrayBuffer(signed),
);
}