From e7757a71d4df6a4996f3b35c08b2144eb1d0ae5d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 1 Jun 2026 08:24:33 +0100 Subject: [PATCH 1/2] Replace @oslojs/webauthn + crypto sig verification with hand-rolled WebAuthn Hand-roll a strict, scoped WebAuthn parser and move ECDSA/RSA signature verification to WebCrypto, removing @oslojs/webauthn entirely and dropping @oslojs/crypto from the passkey path. - cbor.ts: strict CBOR decoder (definite-length maps/ints/byte strings only; rejects tags, floats, indefinite lengths, over-long inputs; depth-capped) - cose-key.ts / der.ts: COSE EC2/RSA -> stored SEC1/SPKI (unchanged formats) - authenticator-data.ts: authData/attestation/clientData parsing; validates extension data and asserts full-buffer consumption instead of ignoring it - verify.ts: crypto.subtle verification incl. ECDSA DER->raw conversion Stored key formats are unchanged so existing credentials keep working. Registration now records deviceType/backedUp from BE/BS flags, which the oslo parser did not expose. Tests use real attestation/assertion fixtures instead of mocking the parser. @oslojs/crypto stays for tokens.ts/oauth (sha256/hmac) -- out of scope. --- .changeset/passkey-handrolled-webauthn.md | 8 + packages/auth/package.json | 1 - .../auth/src/passkey/authenticate.test.ts | 19 +- packages/auth/src/passkey/authenticate.ts | 87 +++----- .../auth/src/passkey/authenticator-data.ts | 159 ++++++++++++++ packages/auth/src/passkey/cbor.ts | 169 ++++++++++++++ packages/auth/src/passkey/cose-key.ts | 94 ++++++++ packages/auth/src/passkey/der.ts | 125 +++++++++++ packages/auth/src/passkey/register.test.ts | 206 +++++++++++------- packages/auth/src/passkey/register.ts | 96 +++----- packages/auth/src/passkey/verify.ts | 87 ++++++++ .../core/src/astro/integration/vite-config.ts | 2 - pnpm-lock.yaml | 37 ---- pnpm-workspace.yaml | 1 - 14 files changed, 832 insertions(+), 259 deletions(-) create mode 100644 .changeset/passkey-handrolled-webauthn.md create mode 100644 packages/auth/src/passkey/authenticator-data.ts create mode 100644 packages/auth/src/passkey/cbor.ts create mode 100644 packages/auth/src/passkey/cose-key.ts create mode 100644 packages/auth/src/passkey/der.ts create mode 100644 packages/auth/src/passkey/verify.ts diff --git a/.changeset/passkey-handrolled-webauthn.md b/.changeset/passkey-handrolled-webauthn.md new file mode 100644 index 000000000..69c04bd3b --- /dev/null +++ b/.changeset/passkey-handrolled-webauthn.md @@ -0,0 +1,8 @@ +--- +"@emdash-cms/auth": patch +"emdash": patch +--- + +Replaces the `@oslojs/webauthn` dependency and the `@oslojs/crypto` signature-verification primitives in the passkey path with a hand-rolled, strict WebAuthn parser and WebCrypto-based verification. The new CBOR decoder only accepts the definite-length maps, integers, and byte strings that COSE keys and the attestation object use -- rejecting tags, floats, indefinite lengths, over-long inputs, and (unlike before) validating extension data rather than ignoring it. ECDSA and RSA signatures are now verified via `crypto.subtle`. + +Stored credential formats are unchanged (SEC1 uncompressed for ES256, SPKI for RS256), so existing passkeys keep working. Newly registered passkeys now record the correct `deviceType`/`backedUp` values from the authenticator's backup-eligibility flags, which the previous parser did not expose. diff --git a/packages/auth/package.json b/packages/auth/package.json index faeac2952..1efbc4fee 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -40,7 +40,6 @@ "dependencies": { "@oslojs/crypto": "catalog:", "@oslojs/encoding": "catalog:", - "@oslojs/webauthn": "catalog:", "ulidx": "^2.4.1", "zod": "catalog:" }, diff --git a/packages/auth/src/passkey/authenticate.test.ts b/packages/auth/src/passkey/authenticate.test.ts index 7e3cc7b6a..d2fd72e80 100644 --- a/packages/auth/src/passkey/authenticate.test.ts +++ b/packages/auth/src/passkey/authenticate.test.ts @@ -1,21 +1,22 @@ import { createHash, generateKeyPairSync, sign } from "node:crypto"; -import { - createAssertionSignatureMessage, - coseAlgorithmES256, - coseAlgorithmRS256, -} from "@oslojs/webauthn"; import { describe, it, expect, vi } from "vitest"; import type { AuthAdapter, Credential } from "../types.js"; import { authenticateWithPasskey, PasskeyAuthenticationError } from "./authenticate.js"; +import { COSE_ALG_ES256, COSE_ALG_RS256 } from "./cose-key.js"; import type { ChallengeStore } from "./types.js"; +/** WebAuthn signs over authenticatorData || SHA256(clientDataJSON). */ +function assertionSignatureMessage(authenticatorData: Buffer, clientDataJSON: Buffer): Buffer { + return Buffer.concat([authenticatorData, createHash("sha256").update(clientDataJSON).digest()]); +} + const credential: Credential = { id: "registered-credential", userId: "user_1", publicKey: new Uint8Array(), - algorithm: coseAlgorithmES256, + algorithm: COSE_ALG_ES256, counter: 0, deviceType: "singleDevice", backedUp: false, @@ -78,7 +79,7 @@ function createValidAssertion(opts: { rpId?: string; origin?: string } = {}) { const signatureCounter = Buffer.alloc(4); signatureCounter.writeUInt32BE(1); const authenticatorData = Buffer.concat([rpIdHash, Buffer.from([0x01]), signatureCounter]); - const signatureMessage = createAssertionSignatureMessage(authenticatorData, clientDataJSON); + const signatureMessage = assertionSignatureMessage(authenticatorData, clientDataJSON); const signatureBytes = sign("sha256", signatureMessage, privateKey); return { @@ -130,7 +131,7 @@ function createValidRS256Assertion(opts: { rpId?: string; origin?: string } = {} const signatureCounter = Buffer.alloc(4); signatureCounter.writeUInt32BE(1); const authenticatorData = Buffer.concat([rpIdHash, Buffer.from([0x01]), signatureCounter]); - const signatureMessage = createAssertionSignatureMessage(authenticatorData, clientDataJSON); + const signatureMessage = assertionSignatureMessage(authenticatorData, clientDataJSON); // RSA signatures in WebAuthn use RSASSA-PKCS1-v1_5 + SHA-256 const signatureBytes = sign("sha256", signatureMessage, privateKey); @@ -138,7 +139,7 @@ function createValidRS256Assertion(opts: { rpId?: string; origin?: string } = {} return { credential: { ...credential, - algorithm: coseAlgorithmRS256, + algorithm: COSE_ALG_RS256, publicKey: new Uint8Array(publicKeyBytes), }, response: { diff --git a/packages/auth/src/passkey/authenticate.ts b/packages/auth/src/passkey/authenticate.ts index 713592a9b..5bdcb2a85 100644 --- a/packages/auth/src/passkey/authenticate.ts +++ b/packages/auth/src/passkey/authenticate.ts @@ -1,34 +1,13 @@ /** * Passkey authentication (credential assertion) - * - * Based on oslo webauthn documentation: - * https://webauthn.oslojs.dev/examples/authentication */ -import { - verifyECDSASignature, - p256, - decodeSEC1PublicKey, - decodePKIXECDSASignature, -} from "@oslojs/crypto/ecdsa"; -import { - decodePKIXRSAPublicKey, - verifyRSASSAPKCS1v15Signature, - sha256ObjectIdentifier, -} from "@oslojs/crypto/rsa"; -import { sha256 } from "@oslojs/crypto/sha2"; import { encodeBase64urlNoPadding, decodeBase64urlIgnorePadding } from "@oslojs/encoding"; -import { - parseAuthenticatorData, - parseClientDataJSON, - ClientDataType, - createAssertionSignatureMessage, - coseAlgorithmES256, - coseAlgorithmRS256, -} from "@oslojs/webauthn"; import { generateToken } from "../tokens.js"; import type { Credential, AuthAdapter, User } from "../types.js"; +import { parseAuthenticatorData, parseClientDataJSON } from "./authenticator-data.js"; +import { COSE_ALG_ES256, COSE_ALG_RS256 } from "./cose-key.js"; import type { AuthenticationOptions, AuthenticationResponse, @@ -36,6 +15,7 @@ import type { ChallengeStore, PasskeyConfig, } from "./types.js"; +import { verifyAssertionSignature, verifyRpIdHash } from "./verify.js"; const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes @@ -89,14 +69,6 @@ function parseAuthenticationData(authenticatorData: Uint8Array) { } } -function decodeAssertionSignature(signature: Uint8Array) { - try { - return decodePKIXECDSASignature(signature); - } catch { - throw invalidPasskeyResponseError(); - } -} - /** * Generate authentication options for signing in with a passkey */ @@ -142,12 +114,14 @@ export async function verifyAuthenticationResponse( decodeAuthenticationResponse(response); // Verify client data type - if (clientData.type !== ClientDataType.Get) { + if (clientData.type !== "webauthn.get") { throw new PasskeyAuthenticationError("invalid_client_data_type", "Invalid client data type"); } - // Verify challenge - convert Uint8Array back to base64url string (no padding, matching stored format) - const challengeString = encodeBase64urlNoPadding(clientData.challenge); + // Verify challenge - normalize to base64url no-padding to match the stored format + const challengeString = encodeBase64urlNoPadding( + decodeBase64urlIgnorePadding(clientData.challenge), + ); const challengeData = await challengeStore.get(challengeString); if (!challengeData) { throw new PasskeyAuthenticationError("challenge_not_found", "Challenge not found or expired"); @@ -175,12 +149,12 @@ export async function verifyAuthenticationResponse( const authData = parseAuthenticationData(authenticatorData); // Verify RP ID hash - if (!authData.verifyRelyingPartyIdHash(config.rpId)) { + if (!(await verifyRpIdHash(authData.rpIdHash, config.rpId))) { throw new PasskeyAuthenticationError("invalid_rp_id_hash", "Invalid RP ID hash"); } // Verify flags - if (!authData.userPresent) { + if (!authData.flags.userPresent) { throw new PasskeyAuthenticationError( "user_presence_not_verified", "User presence not verified", @@ -195,8 +169,12 @@ export async function verifyAuthenticationResponse( ); } - // Create the message that was signed - const signatureMessage = createAssertionSignatureMessage(authenticatorData, clientDataJSON); + if (credential.algorithm !== COSE_ALG_ES256 && credential.algorithm !== COSE_ALG_RS256) { + throw new PasskeyAuthenticationError( + "unsupported_algorithm", + `Unsupported credential algorithm: ${credential.algorithm}`, + ); + } // Ensure public key is a Uint8Array (may come as Buffer from some DB drivers) const publicKeyBytes = @@ -204,29 +182,18 @@ export async function verifyAuthenticationResponse( ? credential.publicKey : new Uint8Array(credential.publicKey); - // Verify signature based on the stored algorithm - let signatureValid = false; - const hash = sha256(signatureMessage); - - if (credential.algorithm === coseAlgorithmES256) { - // Verify ECDSA signature - const ecdsaPublicKey = decodeSEC1PublicKey(p256, publicKeyBytes); - const ecdsaSignature = decodeAssertionSignature(signature); - signatureValid = verifyECDSASignature(ecdsaPublicKey, hash, ecdsaSignature); - } else if (credential.algorithm === coseAlgorithmRS256) { - // Verify RSA signature - const rsaPublicKey = decodePKIXRSAPublicKey(publicKeyBytes); - signatureValid = verifyRSASSAPKCS1v15Signature( - rsaPublicKey, - sha256ObjectIdentifier, - hash, + // A malformed signature or stored key surfaces as a failed verification, not a throw. + let signatureValid: boolean; + try { + signatureValid = await verifyAssertionSignature({ + algorithm: credential.algorithm, + publicKey: publicKeyBytes, + authenticatorData, + clientDataJSON, signature, - ); - } else { - throw new PasskeyAuthenticationError( - "unsupported_algorithm", - `Unsupported credential algorithm: ${credential.algorithm}`, - ); + }); + } catch { + signatureValid = false; } if (!signatureValid) { diff --git a/packages/auth/src/passkey/authenticator-data.ts b/packages/auth/src/passkey/authenticator-data.ts new file mode 100644 index 000000000..133d9ef18 --- /dev/null +++ b/packages/auth/src/passkey/authenticator-data.ts @@ -0,0 +1,159 @@ +/** + * Binary parsers for the WebAuthn authenticator data, attestation object, and + * client data JSON. + * + * authenticatorData layout (WebAuthn L3 §6.1): + * rpIdHash (32) | flags (1) | signCount (4) | + * [attestedCredentialData if AT] | [extensions (CBOR map) if ED] + * + * Unlike a parser that stops after the public key, this consumes and validates + * the extensions block when the ED flag is set and asserts the whole buffer is + * consumed -- trailing bytes are a malformed input, not something to ignore. + */ + +import { CborReader, decodeCbor } from "./cbor.js"; +import type { CborMap } from "./cbor.js"; + +const MIN_AUTH_DATA_LENGTH = 37; +const AAGUID_LENGTH = 16; + +const FLAG_UP = 0x01; +const FLAG_UV = 0x04; +const FLAG_BE = 0x08; +const FLAG_BS = 0x10; +const FLAG_AT = 0x40; +const FLAG_ED = 0x80; + +export class WebAuthnDataError extends Error { + constructor(message: string) { + super(message); + this.name = "WebAuthnDataError"; + } +} + +export interface AuthenticatorDataFlags { + userPresent: boolean; + userVerified: boolean; + backupEligible: boolean; + backupState: boolean; + attestedCredentialData: boolean; + extensionData: boolean; +} + +export interface AttestedCredential { + id: Uint8Array; + publicKey: CborMap; +} + +export interface ParsedAuthenticatorData { + rpIdHash: Uint8Array; + flags: AuthenticatorDataFlags; + signatureCounter: number; + credential?: AttestedCredential; +} + +export function parseAuthenticatorData(bytes: Uint8Array): ParsedAuthenticatorData { + if (bytes.length < MIN_AUTH_DATA_LENGTH) { + throw new WebAuthnDataError("authenticator data too short"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + + const rpIdHash = bytes.slice(0, 32); + const flagBits = bytes[32]!; + const flags: AuthenticatorDataFlags = { + userPresent: (flagBits & FLAG_UP) !== 0, + userVerified: (flagBits & FLAG_UV) !== 0, + backupEligible: (flagBits & FLAG_BE) !== 0, + backupState: (flagBits & FLAG_BS) !== 0, + attestedCredentialData: (flagBits & FLAG_AT) !== 0, + extensionData: (flagBits & FLAG_ED) !== 0, + }; + const signatureCounter = view.getUint32(33, false); + + let offset = MIN_AUTH_DATA_LENGTH; + let credential: AttestedCredential | undefined; + + if (flags.attestedCredentialData) { + if (bytes.length < offset + AAGUID_LENGTH + 2) { + throw new WebAuthnDataError("truncated attested credential data"); + } + offset += AAGUID_LENGTH; // aaguid is not used + const credentialIdLength = view.getUint16(offset, false); + offset += 2; + if (bytes.length < offset + credentialIdLength) { + throw new WebAuthnDataError("truncated credential id"); + } + const id = bytes.slice(offset, offset + credentialIdLength); + offset += credentialIdLength; + + const reader = new CborReader(bytes.subarray(offset)); + const publicKey = reader.read(); + if (!(publicKey instanceof Map)) { + throw new WebAuthnDataError("credential public key is not a COSE map"); + } + offset += reader.offset; + credential = { id, publicKey }; + } + + if (flags.extensionData) { + const reader = new CborReader(bytes.subarray(offset)); + const extensions = reader.read(); + if (!(extensions instanceof Map)) { + throw new WebAuthnDataError("extension data is not a CBOR map"); + } + offset += reader.offset; + } + + if (offset !== bytes.length) { + throw new WebAuthnDataError("unexpected trailing authenticator data"); + } + + return { rpIdHash, flags, signatureCounter, credential }; +} + +export interface ParsedAttestationObject { + format: string; + authenticatorData: ParsedAuthenticatorData; +} + +export function parseAttestationObject(bytes: Uint8Array): ParsedAttestationObject { + const decoded = decodeCbor(bytes); + if (!(decoded instanceof Map)) { + throw new WebAuthnDataError("attestation object is not a CBOR map"); + } + const format = decoded.get("fmt"); + const authData = decoded.get("authData"); + if (typeof format !== "string") { + throw new WebAuthnDataError("attestation object missing fmt"); + } + if (!(authData instanceof Uint8Array)) { + throw new WebAuthnDataError("attestation object missing authData"); + } + return { format, authenticatorData: parseAuthenticatorData(authData) }; +} + +export interface ParsedClientData { + type: string; + challenge: string; + origin: string; +} + +export function parseClientDataJSON(bytes: Uint8Array): ParsedClientData { + let parsed: unknown; + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + parsed = JSON.parse(text); + } catch { + throw new WebAuthnDataError("client data is not valid JSON"); + } + if (typeof parsed !== "object" || parsed === null) { + throw new WebAuthnDataError("client data is not an object"); + } + const type = "type" in parsed ? parsed.type : undefined; + const challenge = "challenge" in parsed ? parsed.challenge : undefined; + const origin = "origin" in parsed ? parsed.origin : undefined; + if (typeof type !== "string" || typeof challenge !== "string" || typeof origin !== "string") { + throw new WebAuthnDataError("client data missing required fields"); + } + return { type, challenge, origin }; +} diff --git a/packages/auth/src/passkey/cbor.ts b/packages/auth/src/passkey/cbor.ts new file mode 100644 index 000000000..ff77d120d --- /dev/null +++ b/packages/auth/src/passkey/cbor.ts @@ -0,0 +1,169 @@ +/** + * Strict, minimal CBOR decoder (RFC 8949) scoped to WebAuthn. + * + * WebAuthn only ever hands us definite-length maps/arrays of integers, byte + * strings, and text strings (COSE keys, the attestation object, extension + * maps). This decoder supports exactly that and rejects everything else -- + * tags, floats, simple values, indefinite-length items, and anything that + * over-runs the buffer. A scoped parser is a smaller attack surface than a + * general one: malformed or unexpected input is rejected rather than coerced. + */ + +const MAX_DEPTH = 16; + +export type CborValue = number | Uint8Array | string | CborValue[] | CborMap; +export type CborMap = Map; + +export class CborError extends Error { + constructor(message: string) { + super(message); + this.name = "CborError"; + } +} + +/** + * Decode a single CBOR item and assert the entire buffer was consumed. + * Use this when the input is expected to be exactly one item with no trailing + * bytes (the attestation object, an extension map). + */ +export function decodeCbor(bytes: Uint8Array): CborValue { + const reader = new CborReader(bytes); + const value = reader.read(); + if (!reader.atEnd) { + throw new CborError("unexpected trailing bytes after CBOR item"); + } + return value; +} + +export class CborReader { + private readonly view: DataView; + offset: number; + + constructor(private readonly bytes: Uint8Array) { + this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + this.offset = 0; + } + + get atEnd(): boolean { + return this.offset >= this.bytes.length; + } + + read(): CborValue { + return this.readValue(0); + } + + private readValue(depth: number): CborValue { + if (depth > MAX_DEPTH) { + throw new CborError("CBOR nesting too deep"); + } + const initial = this.readByte(); + const major = initial >> 5; + const info = initial & 0x1f; + + switch (major) { + case 0: + return this.readArgument(info); + case 1: + return -1 - this.readArgument(info); + case 2: + return this.readBytes(this.readLength(info)); + case 3: + return this.readText(this.readLength(info)); + case 4: + return this.readArray(this.readLength(info), depth); + case 5: + return this.readMap(this.readLength(info), depth); + default: + // 6 = tag, 7 = float/simple -- unsupported. + throw new CborError(`unsupported CBOR major type ${major}`); + } + } + + private readArgument(info: number): number { + if (info < 24) return info; + switch (info) { + case 24: + return this.readByte(); + case 25: { + const v = this.view.getUint16(this.take(2), false); + return v; + } + case 26: + return this.view.getUint32(this.take(4), false); + case 27: { + const high = this.view.getUint32(this.take(4), false); + const low = this.view.getUint32(this.offset, false); + this.offset += 4; + if (high > 0x001f_ffff) { + // Would exceed Number.MAX_SAFE_INTEGER; no legitimate WebAuthn + // integer or length is this large. + throw new CborError("CBOR integer too large"); + } + return high * 0x1_0000_0000 + low; + } + default: + // 28-30 reserved, 31 indefinite-length. + throw new CborError(`unsupported CBOR additional info ${info}`); + } + } + + private readLength(info: number): number { + const length = this.readArgument(info); + if (length > this.bytes.length - this.offset) { + throw new CborError("CBOR length exceeds buffer"); + } + return length; + } + + private readArray(length: number, depth: number): CborValue[] { + const items: CborValue[] = []; + for (let i = 0; i < length; i++) { + items.push(this.readValue(depth + 1)); + } + return items; + } + + private readMap(length: number, depth: number): CborMap { + const map: CborMap = new Map(); + for (let i = 0; i < length; i++) { + const key = this.readValue(depth + 1); + if (typeof key !== "number" && typeof key !== "string") { + throw new CborError("CBOR map key must be an integer or text string"); + } + if (map.has(key)) { + throw new CborError("duplicate CBOR map key"); + } + map.set(key, this.readValue(depth + 1)); + } + return map; + } + + private readByte(): number { + if (this.offset >= this.bytes.length) { + throw new CborError("unexpected end of CBOR input"); + } + return this.bytes[this.offset++]!; + } + + private readBytes(length: number): Uint8Array { + const start = this.take(length); + return this.bytes.slice(start, start + length); + } + + private readText(length: number): string { + const start = this.take(length); + return new TextDecoder("utf-8", { fatal: true }).decode( + this.bytes.subarray(start, start + length), + ); + } + + /** Reserve `count` bytes, returning the start offset and advancing past them. */ + private take(count: number): number { + if (count > this.bytes.length - this.offset) { + throw new CborError("unexpected end of CBOR input"); + } + const start = this.offset; + this.offset += count; + return start; + } +} diff --git a/packages/auth/src/passkey/cose-key.ts b/packages/auth/src/passkey/cose-key.ts new file mode 100644 index 000000000..fd5177aab --- /dev/null +++ b/packages/auth/src/passkey/cose-key.ts @@ -0,0 +1,94 @@ +/** + * COSE_Key (RFC 9052) interpretation for the two algorithms WebAuthn passkeys + * use in practice: ES256 (ECDSA P-256) and RS256 (RSASSA-PKCS1-v1.5). + * + * Keys are encoded into the formats EmDash stores and WebCrypto imports: + * - EC2 -> SEC1 uncompressed point (`0x04 || x || y`), imported as `raw`. + * - RSA -> SubjectPublicKeyInfo (SPKI) DER, imported as `spki`. + * These match what `@oslojs/crypto` produced, so stored credentials keep working. + */ + +import type { CborMap, CborValue } from "./cbor.js"; +import { encodeRsaSpki } from "./der.js"; + +export const COSE_ALG_ES256 = -7; +export const COSE_ALG_RS256 = -257; + +const KTY_EC2 = 2; +const KTY_RSA = 3; +const CRV_P256 = 1; + +const LABEL_KTY = 1; +const LABEL_ALG = 3; +const LABEL_EC2_CRV = -1; +const LABEL_EC2_X = -2; +const LABEL_EC2_Y = -3; +const LABEL_RSA_N = -1; +const LABEL_RSA_E = -2; + +const P256_COORDINATE_BYTES = 32; + +export class CoseKeyError extends Error { + constructor(message: string) { + super(message); + this.name = "CoseKeyError"; + } +} + +export interface StoredPublicKey { + algorithm: number; + publicKey: Uint8Array; +} + +function getInt(map: CborMap, label: number): number { + const value = map.get(label); + if (typeof value !== "number") { + throw new CoseKeyError(`COSE key label ${label} must be an integer`); + } + return value; +} + +function getBytes(map: CborMap, label: number): Uint8Array { + const value: CborValue | undefined = map.get(label); + if (!(value instanceof Uint8Array)) { + throw new CoseKeyError(`COSE key label ${label} must be a byte string`); + } + return value; +} + +/** Read the algorithm identifier without committing to a key type. */ +export function coseKeyAlgorithm(map: CborMap): number { + return getInt(map, LABEL_ALG); +} + +/** Convert a parsed COSE key map into the stored public-key bytes. */ +export function coseKeyToStored(map: CborMap): StoredPublicKey { + const algorithm = coseKeyAlgorithm(map); + const kty = getInt(map, LABEL_KTY); + + if (algorithm === COSE_ALG_ES256) { + if (kty !== KTY_EC2) throw new CoseKeyError("ES256 requires an EC2 key"); + if (getInt(map, LABEL_EC2_CRV) !== CRV_P256) { + throw new CoseKeyError("ES256 requires the P-256 curve"); + } + const x = getBytes(map, LABEL_EC2_X); + const y = getBytes(map, LABEL_EC2_Y); + if (x.length !== P256_COORDINATE_BYTES || y.length !== P256_COORDINATE_BYTES) { + throw new CoseKeyError("invalid P-256 coordinate length"); + } + const publicKey = new Uint8Array(1 + x.length + y.length); + publicKey[0] = 0x04; + publicKey.set(x, 1); + publicKey.set(y, 1 + x.length); + return { algorithm, publicKey }; + } + + if (algorithm === COSE_ALG_RS256) { + if (kty !== KTY_RSA) throw new CoseKeyError("RS256 requires an RSA key"); + const n = getBytes(map, LABEL_RSA_N); + const e = getBytes(map, LABEL_RSA_E); + return { algorithm, publicKey: encodeRsaSpki(n, e) }; + } + + throw new CoseKeyError(`unsupported COSE algorithm: ${algorithm}`); +} diff --git a/packages/auth/src/passkey/der.ts b/packages/auth/src/passkey/der.ts new file mode 100644 index 000000000..c3145377b --- /dev/null +++ b/packages/auth/src/passkey/der.ts @@ -0,0 +1,125 @@ +/** + * Minimal ASN.1 DER for the two WebAuthn key algorithms. + * + * - RSA public keys are stored as SubjectPublicKeyInfo (SPKI) so they import + * directly into WebCrypto and match the format previously produced by + * `@oslojs/crypto`'s `RSAPublicKey.encodePKIX()`. + * - ECDSA assertion signatures arrive as a DER-encoded `Ecdsa-Sig-Value` + * sequence, but `crypto.subtle.verify` wants the raw `r || s` form. This is + * the single most common WebAuthn-on-WebCrypto footgun. + */ + +export class DerError extends Error { + constructor(message: string) { + super(message); + this.name = "DerError"; + } +} + +// rsaEncryption: OBJECT IDENTIFIER 1.2.840.113549.1.1.1 +const RSA_ENCRYPTION_OID = new Uint8Array([ + 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, +]); +const ASN1_NULL = new Uint8Array([0x05, 0x00]); + +const TAG_INTEGER = 0x02; +const TAG_BIT_STRING = 0x03; +const TAG_SEQUENCE = 0x30; + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +function encodeLength(length: number): Uint8Array { + if (length < 0x80) return new Uint8Array([length]); + const bytes: number[] = []; + let n = length; + while (n > 0) { + bytes.unshift(n & 0xff); + n >>= 8; + } + return new Uint8Array([0x80 | bytes.length, ...bytes]); +} + +function tlv(tag: number, content: Uint8Array): Uint8Array { + return concat([new Uint8Array([tag]), encodeLength(content.length), content]); +} + +/** Encode an unsigned big-endian integer as a DER INTEGER (positive, minimal). */ +function encodeUint(bytes: Uint8Array): Uint8Array { + let start = 0; + while (start < bytes.length - 1 && bytes[start] === 0) start++; + let body = bytes.subarray(start); + if (body.length === 0) body = new Uint8Array([0]); + // Prepend 0x00 when the high bit is set so the value stays positive. + if ((body[0]! & 0x80) !== 0) { + body = concat([new Uint8Array([0]), body]); + } + return tlv(TAG_INTEGER, body); +} + +/** Build a SubjectPublicKeyInfo for an RSA public key from COSE (n, e) bytes. */ +export function encodeRsaSpki(modulus: Uint8Array, exponent: Uint8Array): Uint8Array { + const rsaPublicKey = tlv(TAG_SEQUENCE, concat([encodeUint(modulus), encodeUint(exponent)])); + const algorithm = tlv(TAG_SEQUENCE, concat([RSA_ENCRYPTION_OID, ASN1_NULL])); + const subjectPublicKey = tlv(TAG_BIT_STRING, concat([new Uint8Array([0x00]), rsaPublicKey])); + return tlv(TAG_SEQUENCE, concat([algorithm, subjectPublicKey])); +} + +/** + * Convert a DER `Ecdsa-Sig-Value` (SEQUENCE { INTEGER r, INTEGER s }) into the + * fixed-width `r || s` form WebCrypto expects. `size` is the coordinate width + * in bytes (32 for P-256). + */ +export function ecdsaDerToRaw(der: Uint8Array, size: number): Uint8Array { + let offset = 0; + + const readByte = (): number => { + if (offset >= der.length) throw new DerError("unexpected end of DER signature"); + return der[offset++]!; + }; + + const readLength = (): number => { + const first = readByte(); + if (first < 0x80) return first; + const count = first & 0x7f; + // Lengths here are tiny (two ~32-byte integers); reject long forms. + if (count !== 1) throw new DerError("unsupported DER length"); + return readByte(); + }; + + const readInteger = (): Uint8Array => { + if (readByte() !== TAG_INTEGER) throw new DerError("expected DER INTEGER"); + const length = readLength(); + if (length === 0 || length > der.length - offset) { + throw new DerError("invalid DER INTEGER length"); + } + const value = der.subarray(offset, offset + length); + offset += length; + // Strip the single leading 0x00 used to keep the value positive. + let start = 0; + while (start < value.length - 1 && value[start] === 0) start++; + const trimmed = value.subarray(start); + if (trimmed.length > size) throw new DerError("DER integer wider than coordinate size"); + const padded = new Uint8Array(size); + padded.set(trimmed, size - trimmed.length); + return padded; + }; + + if (readByte() !== TAG_SEQUENCE) throw new DerError("expected DER SEQUENCE"); + const seqLength = readLength(); + if (seqLength !== der.length - offset) throw new DerError("DER sequence length mismatch"); + + const r = readInteger(); + const s = readInteger(); + if (offset !== der.length) throw new DerError("unexpected trailing bytes in DER signature"); + + return concat([r, s]); +} diff --git a/packages/auth/src/passkey/register.test.ts b/packages/auth/src/passkey/register.test.ts index 2a3cb61da..0c650e35b 100644 --- a/packages/auth/src/passkey/register.test.ts +++ b/packages/auth/src/passkey/register.test.ts @@ -1,21 +1,11 @@ -import { generateKeyPairSync } from "node:crypto"; +import { createHash, createPublicKey, generateKeyPairSync } from "node:crypto"; -import { decodePKIXRSAPublicKey } from "@oslojs/crypto/rsa"; -import { encodeBase64urlNoPadding } from "@oslojs/encoding"; -import { parseAttestationObject, coseAlgorithmRS256, COSEKeyType } from "@oslojs/webauthn"; import { describe, expect, it, vi } from "vitest"; +import { COSE_ALG_ES256, COSE_ALG_RS256 } from "./cose-key.js"; import { verifyRegistrationResponse } from "./register.js"; import type { ChallengeStore, PasskeyConfig } from "./types.js"; -/** - * Locks in origin-check parity with `authenticate.ts`. The two functions - * share the same 3-line block; without this test, a divergence would slip - * through. The challenge mock satisfies the prior steps so origin verification - * is the next gate the function reaches — `attestationObject` is junk, which - * never gets parsed because the origin check fires first. - */ - const config: PasskeyConfig = { rpName: "Test Site", rpId: "example.com", @@ -38,25 +28,104 @@ function makeChallengeStore(): ChallengeStore { }; } -vi.mock("@oslojs/webauthn", async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - parseAttestationObject: vi.fn(mod.parseAttestationObject), - }; -}); +// --- Minimal CBOR encoder for building real attestation fixtures --- + +function cborHead(major: number, value: number): Uint8Array { + const tag = major << 5; + if (value < 24) return new Uint8Array([tag | value]); + if (value < 0x100) return new Uint8Array([tag | 24, value]); + if (value < 0x10000) return new Uint8Array([tag | 25, value >> 8, value & 0xff]); + return new Uint8Array([ + tag | 26, + value >>> 24, + (value >> 16) & 0xff, + (value >> 8) & 0xff, + value & 0xff, + ]); +} + +function concat(parts: Uint8Array[]): Uint8Array { + return Buffer.concat(parts.map((p) => Buffer.from(p))); +} + +function cborUint(n: number): Uint8Array { + return cborHead(0, n); +} + +function cborInt(n: number): Uint8Array { + return n < 0 ? cborHead(1, -1 - n) : cborHead(0, n); +} + +function cborBytes(b: Uint8Array): Uint8Array { + return concat([cborHead(2, b.length), b]); +} + +function cborText(s: string): Uint8Array { + const bytes = new TextEncoder().encode(s); + return concat([cborHead(3, bytes.length), bytes]); +} + +function cborMap(entries: Array<[Uint8Array, Uint8Array]>): Uint8Array { + return concat([cborHead(5, entries.length), ...entries.flat()]); +} + +function buildAuthData(coseKey: Uint8Array): Uint8Array { + const rpIdHash = createHash("sha256").update(config.rpId).digest(); + const flags = Buffer.from([0x41]); // AT | UP + const signCount = Buffer.alloc(4); + const aaguid = Buffer.alloc(16); + const credId = Buffer.alloc(16, 1); + const credIdLen = Buffer.alloc(2); + credIdLen.writeUInt16BE(credId.length); + return concat([rpIdHash, flags, signCount, aaguid, credIdLen, credId, coseKey]); +} + +function buildAttestationObject(coseKey: Uint8Array): string { + const attestationObject = cborMap([ + [cborText("fmt"), cborText("none")], + [cborText("attStmt"), cborMap([])], + [cborText("authData"), cborBytes(buildAuthData(coseKey))], + ]); + return base64url(attestationObject); +} + +function clientData(origin = config.origins[0]!): string { + const challenge = base64url(Buffer.from("test-challenge")); + return base64url(Buffer.from(JSON.stringify({ type: "webauthn.create", challenge, origin }))); +} + +function es256CoseKey(): { coseKey: Uint8Array; x: Buffer; y: Buffer } { + const { publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = publicKey.export({ format: "jwk" }); + const x = Buffer.from(jwk.x!, "base64url"); + const y = Buffer.from(jwk.y!, "base64url"); + const coseKey = cborMap([ + [cborInt(1), cborUint(2)], // kty: EC2 + [cborInt(3), cborInt(COSE_ALG_ES256)], + [cborInt(-1), cborUint(1)], // crv: P-256 + [cborInt(-2), cborBytes(x)], + [cborInt(-3), cborBytes(y)], + ]); + return { coseKey, x, y }; +} + +function rs256CoseKey(): { + coseKey: Uint8Array; + jwk: ReturnType["publicKey"]["export"]>; +} { + const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = publicKey.export({ format: "jwk" }); + const coseKey = cborMap([ + [cborInt(1), cborUint(3)], // kty: RSA + [cborInt(3), cborInt(COSE_ALG_RS256)], + [cborInt(-1), cborBytes(Buffer.from((jwk as { n: string }).n, "base64url"))], + [cborInt(-2), cborBytes(Buffer.from((jwk as { e: string }).e, "base64url"))], + ]); + return { coseKey, jwk }; +} describe("verifyRegistrationResponse", () => { it("rejects an origin not in the accepted list", async () => { - const challenge = encodeBase64urlNoPadding(new TextEncoder().encode("test-challenge")); - const clientDataJSON = Buffer.from( - JSON.stringify({ - type: "webauthn.create", - challenge, - origin: "https://attacker.com", - }), - ); - await expect( verifyRegistrationResponse( config, @@ -65,7 +134,7 @@ describe("verifyRegistrationResponse", () => { rawId: "test-credential", type: "public-key", response: { - clientDataJSON: base64url(clientDataJSON), + clientDataJSON: clientData("https://attacker.com"), attestationObject: "AA", }, }, @@ -74,49 +143,28 @@ describe("verifyRegistrationResponse", () => { ).rejects.toThrow(/Invalid origin: https:\/\/attacker\.com not in/); }); - it("processes an RS256 registration correctly and encodes to PKIX", async () => { - const challenge = encodeBase64urlNoPadding(new TextEncoder().encode("test-challenge")); - const clientDataJSON = Buffer.from( - JSON.stringify({ - type: "webauthn.create", - challenge, - origin: "https://example.com", - }), - ); - - // Generate a real RSA key pair to get valid modulus and exponent - const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); - const jwk = publicKey.export({ format: "jwk" }); - const nBuf = Buffer.from(jwk.n!, "base64url"); - const eBuf = Buffer.from(jwk.e!, "base64url"); - - // oslojs expects these to be BigInts for its internal math - const n = BigInt("0x" + nBuf.toString("hex")); - const e = BigInt("0x" + eBuf.toString("hex")); - - // Mock the parsed attestation object to bypass CBOR parsing and inject our RSA key - vi.mocked(parseAttestationObject).mockReturnValueOnce({ - authenticatorData: { - rpIdHash: new Uint8Array(32), - verifyRelyingPartyIdHash: () => true, - userPresent: true, - userVerified: true, - flags: { uv: true, up: true, be: false, bs: false, at: true, ed: false }, - signatureCounter: 0, - credential: { - id: new Uint8Array(16), - publicKey: { - algorithm: () => coseAlgorithmRS256, - type: () => COSEKeyType.RSA, - rsa: () => ({ n, e }), - }, + it("parses a real ES256 attestation and stores a SEC1 uncompressed point", async () => { + const { coseKey, x, y } = es256CoseKey(); + const result = await verifyRegistrationResponse( + config, + { + id: "test-credential", + rawId: "test-credential", + type: "public-key", + response: { + clientDataJSON: clientData(), + attestationObject: buildAttestationObject(coseKey), }, }, - attestationStatement: { - format: "none", - }, - } as any); + makeChallengeStore(), + ); + expect(result.algorithm).toBe(COSE_ALG_ES256); + expect(result.publicKey).toEqual(new Uint8Array(Buffer.concat([Buffer.from([0x04]), x, y]))); + }); + + it("parses a real RS256 attestation and stores an importable SPKI key", async () => { + const { coseKey, jwk } = rs256CoseKey(); const result = await verifyRegistrationResponse( config, { @@ -124,19 +172,21 @@ describe("verifyRegistrationResponse", () => { rawId: "test-credential", type: "public-key", response: { - clientDataJSON: base64url(clientDataJSON), - attestationObject: "AA", // Mocked + clientDataJSON: clientData(), + attestationObject: buildAttestationObject(coseKey), }, }, makeChallengeStore(), ); - expect(result.algorithm).toBe(coseAlgorithmRS256); - expect(result.publicKey).toBeInstanceOf(Uint8Array); - - // Verify the round-trip: encodePKIX() was called, so decodePKIXRSAPublicKey() should work - const decoded = decodePKIXRSAPublicKey(result.publicKey); - expect(decoded.n).toEqual(n); - expect(decoded.e).toEqual(e); + expect(result.algorithm).toBe(COSE_ALG_RS256); + // The stored SPKI round-trips back to the original modulus/exponent. + const roundTripped = createPublicKey({ + key: Buffer.from(result.publicKey), + format: "der", + type: "spki", + }).export({ format: "jwk" }); + expect((roundTripped as { n: string }).n).toBe((jwk as { n: string }).n); + expect((roundTripped as { e: string }).e).toBe((jwk as { e: string }).e); }); }); diff --git a/packages/auth/src/passkey/register.ts b/packages/auth/src/passkey/register.ts index b193e42e2..0b24c8559 100644 --- a/packages/auth/src/passkey/register.ts +++ b/packages/auth/src/passkey/register.ts @@ -1,26 +1,13 @@ /** * Passkey registration (credential creation) - * - * Based on oslo webauthn documentation: - * https://webauthn.oslojs.dev/examples/registration */ -import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; -import { RSAPublicKey } from "@oslojs/crypto/rsa"; import { encodeBase64urlNoPadding, decodeBase64urlIgnorePadding } from "@oslojs/encoding"; -import { - parseAttestationObject, - parseClientDataJSON, - coseAlgorithmES256, - coseAlgorithmRS256, - coseEllipticCurveP256, - ClientDataType, - AttestationStatementFormat, - COSEKeyType, -} from "@oslojs/webauthn"; import { generateToken } from "../tokens.js"; import type { Credential, NewCredential, AuthAdapter, User, DeviceType } from "../types.js"; +import { parseAttestationObject, parseClientDataJSON } from "./authenticator-data.js"; +import { COSE_ALG_ES256, COSE_ALG_RS256, coseKeyToStored } from "./cose-key.js"; import type { RegistrationOptions, RegistrationResponse, @@ -28,6 +15,7 @@ import type { ChallengeStore, PasskeyConfig, } from "./types.js"; +import { verifyRpIdHash } from "./verify.js"; const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes @@ -67,8 +55,8 @@ export async function generateRegistrationOptions( displayName: user.name || user.email, }, pubKeyCredParams: [ - { type: "public-key", alg: coseAlgorithmES256 }, // ES256 (-7) - { type: "public-key", alg: coseAlgorithmRS256 }, // RS256 (-257) + { type: "public-key", alg: COSE_ALG_ES256 }, // ES256 (-7) + { type: "public-key", alg: COSE_ALG_RS256 }, // RS256 (-257) ], timeout: 60000, attestation: "none", // We don't need attestation for our use case @@ -100,12 +88,14 @@ export async function verifyRegistrationResponse( const clientData = parseClientDataJSON(clientDataJSON); // Verify client data - if (clientData.type !== ClientDataType.Create) { + if (clientData.type !== "webauthn.create") { throw new Error("Invalid client data type"); } - // Verify challenge - convert Uint8Array back to base64url string (no padding, matching stored format) - const challengeString = encodeBase64urlNoPadding(clientData.challenge); + // Verify challenge - normalize to base64url no-padding to match the stored format + const challengeString = encodeBase64urlNoPadding( + decodeBase64urlIgnorePadding(clientData.challenge), + ); const challengeData = await challengeStore.get(challengeString); if (!challengeData) { throw new Error("Challenge not found or expired"); @@ -126,24 +116,17 @@ export async function verifyRegistrationResponse( throw new Error(`Invalid origin: ${clientData.origin} not in [${config.origins.join(", ")}]`); } - // Parse attestation object - const attestation = parseAttestationObject(attestationObject); - - // We only support 'none' attestation for simplicity - if (attestation.attestationStatement.format !== AttestationStatementFormat.None) { - // For other formats, we'd need to verify the attestation statement - // For now, we just ignore it and trust the credential - } - - const { authenticatorData } = attestation; + // Parse attestation object. Registration options request 'none' attestation, + // so there is no attestation statement to verify -- we only extract the key. + const { authenticatorData } = parseAttestationObject(attestationObject); // Verify RP ID hash - if (!authenticatorData.verifyRelyingPartyIdHash(config.rpId)) { + if (!(await verifyRpIdHash(authenticatorData.rpIdHash, config.rpId))) { throw new Error("Invalid RP ID hash"); } // Verify flags - if (!authenticatorData.userPresent) { + if (!authenticatorData.flags.userPresent) { throw new Error("User presence not verified"); } @@ -152,49 +135,20 @@ export async function verifyRegistrationResponse( throw new Error("No credential data in attestation"); } - const { credential } = authenticatorData; - - // Verify algorithm is supported and encode public key - // Supports ES256 (ECDSA P-256, stored as SEC1) and RS256 (RSA, stored as PKIX) - const algorithm = credential.publicKey.algorithm(); - let encodedPublicKey: Uint8Array; - - if (algorithm === coseAlgorithmES256) { - // Verify EC2 key type for ES256 - if (credential.publicKey.type() !== COSEKeyType.EC2) { - throw new Error("Expected EC2 key type for ES256"); - } - const cosePublicKey = credential.publicKey.ec2(); - if (cosePublicKey.curve !== coseEllipticCurveP256) { - throw new Error("Expected P-256 curve for ES256"); - } - // Encode as SEC1 uncompressed format for storage - encodedPublicKey = new ECDSAPublicKey( - p256, - cosePublicKey.x, - cosePublicKey.y, - ).encodeSEC1Uncompressed(); - } else if (algorithm === coseAlgorithmRS256) { - // Verify RSA key type for RS256 - if (credential.publicKey.type() !== COSEKeyType.RSA) { - throw new Error("Expected RSA key type for RS256"); - } - const cosePublicKey = credential.publicKey.rsa(); - // Encode as PKIX format for storage - encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKIX(); - } else { - throw new Error(`Unsupported credential algorithm: ${algorithm}`); - } + // Encode the COSE public key into the stored format: + // ES256 -> SEC1 uncompressed point, RS256 -> SPKI DER. + const { algorithm, publicKey } = coseKeyToStored(authenticatorData.credential.publicKey); - // Determine device type and backup status - // Note: oslo webauthn doesn't expose backup flags, so we default to singleDevice - // In practice, most modern passkeys are multi-device (e.g., iCloud Keychain, Google Password Manager) - const deviceType: DeviceType = "singleDevice"; - const backedUp = false; + // Backup-eligible credentials sync across devices (iCloud Keychain, Google + // Password Manager); backup state reflects whether they currently are. + const deviceType: DeviceType = authenticatorData.flags.backupEligible + ? "multiDevice" + : "singleDevice"; + const backedUp = authenticatorData.flags.backupState; return { credentialId: response.id, - publicKey: encodedPublicKey, + publicKey, algorithm, counter: authenticatorData.signatureCounter, deviceType, diff --git a/packages/auth/src/passkey/verify.ts b/packages/auth/src/passkey/verify.ts new file mode 100644 index 000000000..923ff260a --- /dev/null +++ b/packages/auth/src/passkey/verify.ts @@ -0,0 +1,87 @@ +/** + * Signature and hash primitives via WebCrypto (`crypto.subtle`), replacing + * `@oslojs/crypto`. Available on both Workers and Node, and actively maintained + * as a platform primitive. + */ + +import { COSE_ALG_ES256, COSE_ALG_RS256 } from "./cose-key.js"; +import { ecdsaDerToRaw } from "./der.js"; + +const P256_COORDINATE_BYTES = 32; + +/** Copy into a standalone ArrayBuffer; WebCrypto mishandles offset subarrays. */ +function toBuffer(bytes: Uint8Array): ArrayBuffer { + return new Uint8Array(bytes).buffer; +} + +export async function sha256(data: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", toBuffer(data)); + return new Uint8Array(digest); +} + +export async function verifyRpIdHash(rpIdHash: Uint8Array, rpId: string): Promise { + const expected = await sha256(new TextEncoder().encode(rpId)); + if (rpIdHash.length !== expected.length) return false; + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= rpIdHash[i]! ^ expected[i]!; + } + return diff === 0; +} + +export interface AssertionSignatureInput { + algorithm: number; + publicKey: Uint8Array; + authenticatorData: Uint8Array; + clientDataJSON: Uint8Array; + signature: Uint8Array; +} + +/** + * Verify a passkey assertion signature over `authenticatorData || SHA256(clientDataJSON)`. + * `subtle.verify` hashes the message itself, so we pass the concatenation and + * let it apply SHA-256. + */ +export async function verifyAssertionSignature(input: AssertionSignatureInput): Promise { + const { algorithm, publicKey, authenticatorData, clientDataJSON, signature } = input; + + const clientDataHash = await sha256(clientDataJSON); + const message = new Uint8Array(authenticatorData.length + clientDataHash.length); + message.set(authenticatorData, 0); + message.set(clientDataHash, authenticatorData.length); + + if (algorithm === COSE_ALG_ES256) { + const key = await crypto.subtle.importKey( + "raw", + toBuffer(publicKey), + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + const rawSignature = ecdsaDerToRaw(signature, P256_COORDINATE_BYTES); + return crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + toBuffer(rawSignature), + toBuffer(message), + ); + } + + if (algorithm === COSE_ALG_RS256) { + const key = await crypto.subtle.importKey( + "spki", + toBuffer(publicKey), + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + return crypto.subtle.verify( + { name: "RSASSA-PKCS1-v1_5" }, + key, + toBuffer(signature), + toBuffer(message), + ); + } + + return false; +} diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 669de9076..cc5f6fcea 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -392,9 +392,7 @@ export function createViteConfig( // Deeper transitive deps "emdash > sanitize-html > parse5", "emdash > @emdash-cms/gutenberg-to-portable-text > @wordpress/block-serialization-default-parser", - "emdash > @emdash-cms/auth > @oslojs/crypto/ecdsa", "emdash > @emdash-cms/auth > @oslojs/crypto/sha2", - "emdash > @emdash-cms/auth > @oslojs/webauthn", // MCP SDK — server/index.js statically imports ajv (CJS-only). // Pre-bundling converts CJS to ESM so workerd can load it. "emdash > @modelcontextprotocol/sdk > ajv", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c60fa566..2bc1591ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,9 +117,6 @@ catalogs: '@oslojs/encoding': specifier: ^1.1.0 version: 1.1.0 - '@oslojs/webauthn': - specifier: ^1.0.0 - version: 1.0.0 '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10 @@ -1168,9 +1165,6 @@ importers: '@oslojs/encoding': specifier: 'catalog:' version: 1.1.0 - '@oslojs/webauthn': - specifier: 'catalog:' - version: 1.0.0 kysely: specifier: ^0.29.0 version: 0.29.2 @@ -4445,30 +4439,18 @@ packages: '@oslojs/binary@1.0.0': resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} - '@oslojs/cbor@1.0.0': - resolution: {integrity: sha512-AY6Lknexs7n2xp8Cgey95c+975VG7XOk4UEdRdNFxHmDDbuf47OC/LAVRsl14DeTLwo8W6xr3HLFwUFmKcndTQ==} - - '@oslojs/crypto@1.0.0': - resolution: {integrity: sha512-dVz8TkkgYdr3tlwxHd7SCYGxoN7ynwHLA0nei/Aq9C+ERU0BK+U8+/3soEzBUxUNKYBf42351DyJUZ2REla50w==} - '@oslojs/crypto@1.0.1': resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} '@oslojs/encoding@0.4.1': resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==} - '@oslojs/encoding@1.0.0': - resolution: {integrity: sha512-dyIB0SdZgMm5BhGwdSp8rMxEFIopLKxDG1vxIBaiogyom6ZqH2aXPb6DEC2WzOOWKdPSq1cxdNeRx2wAn1Z+ZQ==} - '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} '@oslojs/jwt@0.2.0': resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} - '@oslojs/webauthn@1.0.0': - resolution: {integrity: sha512-2ZRpbt3msNURwvjmavzq9vrNlxUnWFBGMYqbC1kO3fYBLskL7r4DiLJT1wbtLoI+hclFwjhl48YhRFBl6RWg1A==} - '@oxc-project/types@0.112.0': resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} @@ -14481,15 +14463,6 @@ snapshots: '@oslojs/binary@1.0.0': {} - '@oslojs/cbor@1.0.0': - dependencies: - '@oslojs/binary': 1.0.0 - - '@oslojs/crypto@1.0.0': - dependencies: - '@oslojs/asn1': 1.0.0 - '@oslojs/binary': 1.0.0 - '@oslojs/crypto@1.0.1': dependencies: '@oslojs/asn1': 1.0.0 @@ -14497,22 +14470,12 @@ snapshots: '@oslojs/encoding@0.4.1': {} - '@oslojs/encoding@1.0.0': {} - '@oslojs/encoding@1.1.0': {} '@oslojs/jwt@0.2.0': dependencies: '@oslojs/encoding': 0.4.1 - '@oslojs/webauthn@1.0.0': - dependencies: - '@oslojs/asn1': 1.0.0 - '@oslojs/binary': 1.0.0 - '@oslojs/cbor': 1.0.0 - '@oslojs/crypto': 1.0.0 - '@oslojs/encoding': 1.0.0 - '@oxc-project/types@0.112.0': {} '@oxc-project/types@0.114.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cb196cc30..d51d22208 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -102,7 +102,6 @@ catalog: "@lingui/react": ^5.9.4 "@oslojs/crypto": ^1.0.1 "@oslojs/encoding": ^1.1.0 - "@oslojs/webauthn": ^1.0.0 "@phosphor-icons/react": ^2.1.10 "@tanstack/react-query": 5.90.21 "@tanstack/react-router": 1.163.2 From ae166ed1a9c54bc839f16ed3ff36e4f3b8197ea0 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 1 Jun 2026 08:35:54 +0100 Subject: [PATCH 2/2] Address adversarial review of hand-rolled WebAuthn - Fix extension-data regression: the scoped CBOR decoder now accepts the boolean/null simple values that real authenticator extensions emit (hmac-secret etc.), so ED-flagged assertions from hardware keys no longer fail login. Floats, tags, and indefinite-length items stay rejected. - CBOR: route the 8-byte integer low word through the bounds check so a truncated integer surfaces as CborError, not RangeError. - COSE: reject non-integer numbers as labels; bound RSA modulus/exponent size so a malicious 'none'-attestation authenticator can't register an oversized key that slows every later auth. - Add direct unit tests for cbor.ts and der.ts (malformed inputs, round-trips) and a regression test for boolean-extension assertions. Reviews found no auth-bypass/forgery/replay path; stored key formats verified byte-identical to the oslo output, so existing credentials keep working. --- .../auth/src/passkey/authenticate.test.ts | 31 +++++++- packages/auth/src/passkey/cbor.test.ts | 68 ++++++++++++++++ packages/auth/src/passkey/cbor.ts | 30 ++++++- packages/auth/src/passkey/cose-key.ts | 11 ++- packages/auth/src/passkey/der.test.ts | 78 +++++++++++++++++++ 5 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 packages/auth/src/passkey/cbor.test.ts create mode 100644 packages/auth/src/passkey/der.test.ts diff --git a/packages/auth/src/passkey/authenticate.test.ts b/packages/auth/src/passkey/authenticate.test.ts index d2fd72e80..09b2979d1 100644 --- a/packages/auth/src/passkey/authenticate.test.ts +++ b/packages/auth/src/passkey/authenticate.test.ts @@ -52,7 +52,9 @@ function base64url(bytes: Uint8Array): string { return Buffer.from(bytes).toString("base64url"); } -function createValidAssertion(opts: { rpId?: string; origin?: string } = {}) { +function createValidAssertion( + opts: { rpId?: string; origin?: string; extensionData?: Buffer } = {}, +) { const rpId = opts.rpId ?? config.rpId; const origin = opts.origin ?? config.origins[0]; if (!origin) throw new Error("origin must be defined for createValidAssertion"); @@ -78,7 +80,13 @@ function createValidAssertion(opts: { rpId?: string; origin?: string } = {}) { const rpIdHash = createHash("sha256").update(rpId).digest(); const signatureCounter = Buffer.alloc(4); signatureCounter.writeUInt32BE(1); - const authenticatorData = Buffer.concat([rpIdHash, Buffer.from([0x01]), signatureCounter]); + const flags = Buffer.from([opts.extensionData ? 0x81 : 0x01]); // UP, plus ED when extensions present + const authenticatorData = Buffer.concat([ + rpIdHash, + flags, + signatureCounter, + ...(opts.extensionData ? [opts.extensionData] : []), + ]); const signatureMessage = assertionSignatureMessage(authenticatorData, clientDataJSON); const signatureBytes = sign("sha256", signatureMessage, privateKey); @@ -284,6 +292,25 @@ describe("authenticateWithPasskey", () => { expect(user).toMatchObject({ id: "user_1" }); }); + it("accepts an assertion whose authenticator data carries a boolean extension", async () => { + // ED flag set with {"hmac-secret": true} -- a CBOR boolean. The strict + // parser must consume it rather than reject the login. + const ext = Buffer.from([0xa1, 0x6b, ...Buffer.from("hmac-secret"), 0xf5]); + const { + credential: validCredential, + response, + challengeStore, + } = createValidAssertion({ extensionData: ext }); + const adapter = { + getCredentialById: vi.fn(async () => validCredential), + updateCredentialCounter: vi.fn(async () => undefined), + getUserById: vi.fn(async () => ({ id: "user_1" })), + } as unknown as AuthAdapter; + + const user = await authenticateWithPasskey(config, adapter, response, challengeStore); + expect(user).toMatchObject({ id: "user_1" }); + }); + it("throws a typed error for an unsupported algorithm", async () => { const { credential: validCredential, response, challengeStore } = createValidAssertion(); const adapter = { diff --git a/packages/auth/src/passkey/cbor.test.ts b/packages/auth/src/passkey/cbor.test.ts new file mode 100644 index 000000000..9df308cc0 --- /dev/null +++ b/packages/auth/src/passkey/cbor.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { CborError, CborReader, decodeCbor } from "./cbor.js"; + +describe("decodeCbor", () => { + it("decodes a COSE-shaped map with integer keys and byte/text values", () => { + // {1: 2, -1: h'AABB'} + const bytes = new Uint8Array([0xa2, 0x01, 0x02, 0x20, 0x42, 0xaa, 0xbb]); + const map = decodeCbor(bytes); + expect(map).toBeInstanceOf(Map); + const m = map as Map; + expect(m.get(1)).toBe(2); + expect(m.get(-1)).toEqual(new Uint8Array([0xaa, 0xbb])); + }); + + it("decodes negative integers including the two-byte form (-257)", () => { + expect(decodeCbor(new Uint8Array([0x26]))).toBe(-7); + expect(decodeCbor(new Uint8Array([0x39, 0x01, 0x00]))).toBe(-257); + }); + + it("supports the boolean/null simple values real extensions use", () => { + // {"hmac-secret": true} + const key = new TextEncoder().encode("hmac-secret"); + const bytes = new Uint8Array([0xa1, 0x6b, ...key, 0xf5]); + const map = decodeCbor(bytes) as Map; + expect(map.get("hmac-secret")).toBe(true); + expect(decodeCbor(new Uint8Array([0xf4]))).toBe(false); + expect(decodeCbor(new Uint8Array([0xf6]))).toBe(null); + }); + + it("rejects trailing bytes after a complete item", () => { + expect(() => decodeCbor(new Uint8Array([0x00, 0x00]))).toThrow(CborError); + }); + + it("rejects duplicate map keys", () => { + expect(() => decodeCbor(new Uint8Array([0xa2, 0x01, 0x00, 0x01, 0x01]))).toThrow(CborError); + }); + + it("rejects tags, indefinite-length items, and floats", () => { + expect(() => decodeCbor(new Uint8Array([0xc0, 0x00]))).toThrow(CborError); // tag + expect(() => decodeCbor(new Uint8Array([0x9f, 0xff]))).toThrow(CborError); // indefinite array + expect(() => decodeCbor(new Uint8Array([0xfa, 0x00, 0x00, 0x00, 0x00]))).toThrow(CborError); // float32 + }); + + it("rejects nesting deeper than the depth cap", () => { + const bytes = new Uint8Array([...Array(20).fill(0x81), 0x00]); + expect(() => decodeCbor(bytes)).toThrow(CborError); + }); + + it("rejects a length that overruns the buffer", () => { + // byte string claiming 4 bytes, only 1 present + expect(() => decodeCbor(new Uint8Array([0x44, 0xaa]))).toThrow(CborError); + }); + + it("surfaces a truncated 8-byte integer as CborError, not RangeError", () => { + // major-0 info-27 header, then fewer than 8 argument bytes + const bytes = new Uint8Array([0x1b, 0, 0, 0, 0, 0x01]); + expect(() => decodeCbor(bytes)).toThrow(CborError); + }); + + it("tracks offset so a caller can find where one item ends", () => { + const bytes = new Uint8Array([0x42, 0xaa, 0xbb, 0xff, 0xff]); + const reader = new CborReader(bytes); + expect(reader.read()).toEqual(new Uint8Array([0xaa, 0xbb])); + expect(reader.offset).toBe(3); + expect(reader.atEnd).toBe(false); + }); +}); diff --git a/packages/auth/src/passkey/cbor.ts b/packages/auth/src/passkey/cbor.ts index ff77d120d..b9f52ff8f 100644 --- a/packages/auth/src/passkey/cbor.ts +++ b/packages/auth/src/passkey/cbor.ts @@ -11,7 +11,7 @@ const MAX_DEPTH = 16; -export type CborValue = number | Uint8Array | string | CborValue[] | CborMap; +export type CborValue = number | boolean | null | Uint8Array | string | CborValue[] | CborMap; export type CborMap = Map; export class CborError extends Error { @@ -73,12 +73,35 @@ export class CborReader { return this.readArray(this.readLength(info), depth); case 5: return this.readMap(this.readLength(info), depth); + case 7: + return this.readSimple(info); default: - // 6 = tag, 7 = float/simple -- unsupported. + // 6 = tag -- unsupported. throw new CborError(`unsupported CBOR major type ${major}`); } } + /** + * Major type 7: only the boolean/null simple values that authenticator + * extension outputs use (e.g. `hmac-secret`). Floats, custom simple values, + * and the indefinite-length break are rejected. Extension contents are never + * interpreted -- this exists so the extension block can be consumed and the + * full-buffer assertion can hold. + */ + private readSimple(info: number): boolean | null { + switch (info) { + case 20: + return false; + case 21: + return true; + case 22: + case 23: + return null; + default: + throw new CborError(`unsupported CBOR simple value ${info}`); + } + } + private readArgument(info: number): number { if (info < 24) return info; switch (info) { @@ -92,8 +115,7 @@ export class CborReader { return this.view.getUint32(this.take(4), false); case 27: { const high = this.view.getUint32(this.take(4), false); - const low = this.view.getUint32(this.offset, false); - this.offset += 4; + const low = this.view.getUint32(this.take(4), false); if (high > 0x001f_ffff) { // Would exceed Number.MAX_SAFE_INTEGER; no legitimate WebAuthn // integer or length is this large. diff --git a/packages/auth/src/passkey/cose-key.ts b/packages/auth/src/passkey/cose-key.ts index fd5177aab..96efa9e24 100644 --- a/packages/auth/src/passkey/cose-key.ts +++ b/packages/auth/src/passkey/cose-key.ts @@ -27,6 +27,9 @@ const LABEL_RSA_N = -1; const LABEL_RSA_E = -2; const P256_COORDINATE_BYTES = 32; +// Bound the attacker-supplied RSA modulus (attestation is 'none') so a malicious +// authenticator can't register an oversized key that slows every later auth. +const MAX_RSA_MODULUS_BYTES = 1024; // 8192-bit export class CoseKeyError extends Error { constructor(message: string) { @@ -42,7 +45,7 @@ export interface StoredPublicKey { function getInt(map: CborMap, label: number): number { const value = map.get(label); - if (typeof value !== "number") { + if (typeof value !== "number" || !Number.isInteger(value)) { throw new CoseKeyError(`COSE key label ${label} must be an integer`); } return value; @@ -87,6 +90,12 @@ export function coseKeyToStored(map: CborMap): StoredPublicKey { if (kty !== KTY_RSA) throw new CoseKeyError("RS256 requires an RSA key"); const n = getBytes(map, LABEL_RSA_N); const e = getBytes(map, LABEL_RSA_E); + if (n.length === 0 || n.length > MAX_RSA_MODULUS_BYTES) { + throw new CoseKeyError("invalid RSA modulus length"); + } + if (e.length === 0 || e.length > 8) { + throw new CoseKeyError("invalid RSA exponent length"); + } return { algorithm, publicKey: encodeRsaSpki(n, e) }; } diff --git a/packages/auth/src/passkey/der.test.ts b/packages/auth/src/passkey/der.test.ts new file mode 100644 index 000000000..c11e32132 --- /dev/null +++ b/packages/auth/src/passkey/der.test.ts @@ -0,0 +1,78 @@ +import { createPublicKey, generateKeyPairSync } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { DerError, ecdsaDerToRaw, encodeRsaSpki } from "./der.js"; + +describe("ecdsaDerToRaw", () => { + it("left-pads short integers to the coordinate width", () => { + // SEQUENCE { INTEGER 1, INTEGER 2 } + const der = new Uint8Array([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]); + const raw = ecdsaDerToRaw(der, 32); + expect(raw.length).toBe(64); + expect(raw[31]).toBe(1); + expect(raw[63]).toBe(2); + expect(raw.slice(0, 31).every((b) => b === 0)).toBe(true); + }); + + it("strips the positive-sign 0x00 padding byte", () => { + // INTEGER 0x00FF (leading zero keeps it positive) -> last byte 0xFF + const der = new Uint8Array([0x30, 0x07, 0x02, 0x02, 0x00, 0xff, 0x02, 0x01, 0x01]); + const raw = ecdsaDerToRaw(der, 32); + expect(raw[31]).toBe(0xff); + expect(raw[63]).toBe(1); + }); + + it("rejects trailing bytes", () => { + const der = new Uint8Array([0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02, 0x99]); + expect(() => ecdsaDerToRaw(der, 32)).toThrow(DerError); + }); + + it("rejects an integer wider than the coordinate size", () => { + const wide = new Uint8Array(33).fill(0x11); + const der = new Uint8Array([0x30, 0x26, 0x02, 0x21, ...wide, 0x02, 0x01, 0x01]); + expect(() => ecdsaDerToRaw(der, 32)).toThrow(DerError); + }); + + it("rejects a non-SEQUENCE or non-INTEGER structure", () => { + expect(() => ecdsaDerToRaw(new Uint8Array([0x02, 0x01, 0x01]), 32)).toThrow(DerError); + expect(() => ecdsaDerToRaw(new Uint8Array([0x30, 0x03, 0x04, 0x01, 0x01]), 32)).toThrow( + DerError, + ); + }); + + it("rejects a sequence length that disagrees with the buffer", () => { + const der = new Uint8Array([0x30, 0x10, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02]); + expect(() => ecdsaDerToRaw(der, 32)).toThrow(DerError); + }); +}); + +describe("encodeRsaSpki", () => { + it("produces SPKI that Node parses back to the original modulus and exponent", () => { + const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = publicKey.export({ format: "jwk" }) as { n: string; e: string }; + const n = Buffer.from(jwk.n, "base64url"); + const e = Buffer.from(jwk.e, "base64url"); + + const spki = encodeRsaSpki(n, e); + const roundTripped = createPublicKey({ + key: Buffer.from(spki), + format: "der", + type: "spki", + }).export({ format: "jwk" }) as { n: string; e: string }; + + expect(roundTripped.n).toBe(jwk.n); + expect(roundTripped.e).toBe(jwk.e); + }); + + it("normalizes a modulus carrying a spurious leading zero byte", () => { + const { publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = publicKey.export({ format: "jwk" }) as { n: string; e: string }; + const n = Buffer.from(jwk.n, "base64url"); + const e = Buffer.from(jwk.e, "base64url"); + + const canonical = encodeRsaSpki(n, e); + const padded = encodeRsaSpki(Buffer.concat([Buffer.from([0x00]), n]), e); + expect(Buffer.from(padded)).toEqual(Buffer.from(canonical)); + }); +});