feat: release OTP and Passkey Tools 0.1.0

This commit is contained in:
2026-08-19 12:19:35 +02:00
commit f1cb2d7151
67 changed files with 12802 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
import {
base64UrlToBytes,
bytesToBase64Url,
bytesToHex,
bytesToUtf8,
} from "../crypto/encoding";
import {
cborMap,
decodeCbor,
decodeCompleteCbor,
type CborValue,
} from "./cbor";
export interface AuthenticatorFlags {
userPresent: boolean;
userVerified: boolean;
backupEligible: boolean;
backupState: boolean;
attestedCredentialData: boolean;
extensionData: boolean;
raw: number;
}
export interface AttestedCredentialData {
aaguid: string;
credentialId: string;
credentialPublicKey: Map<CborValue, CborValue>;
}
export interface ParsedAuthenticatorData {
rpIdHash: string;
flags: AuthenticatorFlags;
signCount: number;
attestedCredential?: AttestedCredentialData;
extensions?: CborValue;
bytesRead: number;
raw: Uint8Array;
}
export interface ParsedAttestation {
format: string;
statement: Map<CborValue, CborValue>;
authenticator: ParsedAuthenticatorData;
}
export interface CollectedClientData {
type: string;
challenge: string;
origin: string;
crossOrigin?: boolean;
tokenBinding?: unknown;
[key: string]: unknown;
}
function readU16(bytes: Uint8Array, offset: number): number {
if (offset + 2 > bytes.byteLength)
throw new Error("Authenticator data ends unexpectedly.");
return (bytes[offset]! << 8) | bytes[offset + 1]!;
}
function readU32(bytes: Uint8Array, offset: number): number {
if (offset + 4 > bytes.byteLength)
throw new Error("Authenticator data ends unexpectedly.");
return new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(
0,
false,
);
}
export function parseAuthenticatorData(
bytes: Uint8Array,
): ParsedAuthenticatorData {
if (bytes.byteLength < 37)
throw new Error("Authenticator data must contain at least 37 bytes.");
const flagByte = bytes[32]!;
const flags: AuthenticatorFlags = {
userPresent: Boolean(flagByte & 0x01),
userVerified: Boolean(flagByte & 0x04),
backupEligible: Boolean(flagByte & 0x08),
backupState: Boolean(flagByte & 0x10),
attestedCredentialData: Boolean(flagByte & 0x40),
extensionData: Boolean(flagByte & 0x80),
raw: flagByte,
};
if (flags.backupState && !flags.backupEligible) {
throw new Error(
"Authenticator flags set backup state without backup eligibility.",
);
}
let offset = 37;
let attestedCredential: AttestedCredentialData | undefined;
if (flags.attestedCredentialData) {
if (offset + 18 > bytes.byteLength)
throw new Error("Attested credential data is truncated.");
const aaguidBytes = bytes.slice(offset, offset + 16);
offset += 16;
const credentialLength = readU16(bytes, offset);
offset += 2;
if (
credentialLength === 0 ||
offset + credentialLength > bytes.byteLength
) {
throw new Error("Attested credential ID length is invalid.");
}
const credentialId = bytes.slice(offset, offset + credentialLength);
offset += credentialLength;
const decodedKey = decodeCbor(bytes.slice(offset));
const credentialPublicKey = cborMap(
decodedKey.value,
"Credential public key",
);
offset += decodedKey.bytesRead;
const hex = bytesToHex(aaguidBytes);
attestedCredential = {
aaguid: `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`,
credentialId: bytesToBase64Url(credentialId),
credentialPublicKey,
};
}
let extensions: CborValue | undefined;
if (flags.extensionData) {
const decoded = decodeCbor(bytes.slice(offset));
extensions = decoded.value;
offset += decoded.bytesRead;
}
if (offset !== bytes.byteLength) {
throw new Error(
"Authenticator data contains bytes not described by its flags.",
);
}
return {
rpIdHash: bytesToHex(bytes.slice(0, 32)),
flags,
signCount: readU32(bytes, 33),
...(attestedCredential ? { attestedCredential } : {}),
...(extensions !== undefined ? { extensions } : {}),
bytesRead: offset,
raw: bytes,
};
}
export function parseAttestationObject(
input: string | Uint8Array,
): ParsedAttestation {
const bytes = typeof input === "string" ? base64UrlToBytes(input) : input;
const map = cborMap(decodeCompleteCbor(bytes), "Attestation object");
const format = map.get("fmt");
const authData = map.get("authData");
const statement = map.get("attStmt");
if (typeof format !== "string")
throw new Error("Attestation format is missing.");
if (!(authData instanceof Uint8Array))
throw new Error("Attestation authData is missing.");
return {
format,
statement: cborMap(statement ?? null, "Attestation statement"),
authenticator: parseAuthenticatorData(authData),
};
}
export function parseClientData(
input: string | Uint8Array,
): CollectedClientData {
const bytes = typeof input === "string" ? base64UrlToBytes(input) : input;
let value: unknown;
try {
value = JSON.parse(bytesToUtf8(bytes));
} catch {
throw new Error("Client data is not valid UTF-8 JSON.");
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Client data must be a JSON object.");
}
const object = value as Record<string, unknown>;
if (
typeof object.type !== "string" ||
typeof object.challenge !== "string" ||
typeof object.origin !== "string"
) {
throw new Error("Client data requires type, challenge and origin strings.");
}
if (
object.crossOrigin !== undefined &&
typeof object.crossOrigin !== "boolean"
) {
throw new Error("Client data crossOrigin must be boolean when present.");
}
return object as CollectedClientData;
}
export function cborDiagnostic(value: CborValue): unknown {
if (value instanceof Uint8Array) {
return { base64url: bytesToBase64Url(value), bytes: value.byteLength };
}
if (Array.isArray(value)) return value.map(cborDiagnostic);
if (value instanceof Map) {
return Object.fromEntries(
[...value].map(([key, item]) => [String(key), cborDiagnostic(item)]),
);
}
return typeof value === "bigint" ? `${value.toString()}n` : value;
}