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