Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/passkey-handrolled-webauthn.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
"dependencies": {
"@oslojs/crypto": "catalog:",
"@oslojs/encoding": "catalog:",
"@oslojs/webauthn": "catalog:",
"ulidx": "^2.4.1",
"zod": "catalog:"
},
Expand Down
50 changes: 39 additions & 11 deletions packages/auth/src/passkey/authenticate.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -51,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");
Expand All @@ -77,8 +80,14 @@ 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 signatureMessage = createAssertionSignatureMessage(authenticatorData, clientDataJSON);
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);

return {
Expand Down Expand Up @@ -130,15 +139,15 @@ 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);

return {
credential: {
...credential,
algorithm: coseAlgorithmRS256,
algorithm: COSE_ALG_RS256,
publicKey: new Uint8Array(publicKeyBytes),
},
response: {
Expand Down Expand Up @@ -283,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 = {
Expand Down
87 changes: 27 additions & 60 deletions packages/auth/src/passkey/authenticate.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,21 @@
/**
* 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,
VerifiedAuthentication,
ChallengeStore,
PasskeyConfig,
} from "./types.js";
import { verifyAssertionSignature, verifyRpIdHash } from "./verify.js";

const CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand All @@ -195,38 +169,31 @@ 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 =
credential.publicKey instanceof Uint8Array
? 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) {
Expand Down
Loading
Loading