Skip to content

fix(auth): support RS256 algorithm for passkeys#893

Merged
ascorbic merged 16 commits into
emdash-cms:mainfrom
j-liszt:fix/rs256-passkey-support
May 7, 2026
Merged

fix(auth): support RS256 algorithm for passkeys#893
ascorbic merged 16 commits into
emdash-cms:mainfrom
j-liszt:fix/rs256-passkey-support

Conversation

@j-liszt
Copy link
Copy Markdown
Contributor

@j-liszt j-liszt commented May 1, 2026

What does this PR do?

This PR implements full support for multiple cryptographic algorithms in Passkey authentication, moving away from the previous ES256-only implementation.

The Problem:
While many mobile devices (iOS/Android) default to ES256 (ECDSA), many desktop environments and hardware security keys (notably Windows Hello and certain TPM-backed authenticators) utilize RS256 (RSA).

Since EmDash was hardcoded to only support ECDSA, users on these platforms were completely blocked from completing the initial Setup Wizard. During the admin account creation step, the registration would fail with an "Unsupported algorithm" error, making it impossible for Windows users to initialize the CMS. Additionally, any user who managed to register with an RSA key (through a different flow) would find themselves unable to log in, receiving "Invalid Signature" (401) errors.

The Solution:
To align with FIDO2/WebAuthn best practices for Relying Parties (RP), this PR introduces a polymorphic verification layer:

  • Schema Evolution: Added an algorithm column to the credentials table to persist the COSE algorithm identifier.
  • Standard Compliance: Implemented RSA signature verification (RSASSA-PKCS1-v1_5 with SHA-256) alongside existing ECDSA logic.
  • Backward Compatibility: Legacy credentials without an explicit algorithm entry are treated as ES256 (-7), ensuring zero disruption for existing users.
  • Enhanced Registration: Updated the registration flow to correctly identify, encode (using PKIX for RSA keys), and store the specific algorithm provided by the authenticator.

Closes #421

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation and pnpm locale:extract has been run (if applicable)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code

Test output

Updated packages/auth/src/passkey/authenticate.test.ts to include the algorithm property in credential mocks. Verified that pnpm typecheck passes globally across the workspace. Manual verification performed by initializing a fresh EmDash instance on a Windows environment using Windows Hello (RS256), which previously failed but now completes successfully.

Open question for reviewers

Should we explicitly limit the supported RSA algorithms or is the current RSASSA-PKCS1-v1_5 implementation sufficient as a baseline for wider platform compatibility?

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 1, 2026

🦋 Changeset detected

Latest commit: 15bfbd9

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@emdash-cms/auth Patch
emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@j-liszt
Copy link
Copy Markdown
Contributor Author

j-liszt commented May 1, 2026

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request May 1, 2026
@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented May 1, 2026

/review

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 1, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@893

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@893

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@893

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@893

emdash

npm i https://pkg.pr.new/emdash@893

create-emdash

npm i https://pkg.pr.new/create-emdash@893

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@893

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@893

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@893

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@893

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@893

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@893

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@893

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@893

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@893

commit: 15bfbd9

ask-bonk[bot]
ask-bonk Bot previously requested changes May 1, 2026
Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tackling this — Windows Hello / TPM-backed authenticators being locked out of setup is a real, painful bug, and the polymorphic verification design is the right shape. However, this PR has two blocking issues that need to be fixed before it can merge.

1. The migration is not forward-only (blocking)

008_auth.ts was released in v0.x and has already been applied on every existing install. Modifying that file in place adds the column for fresh databases only — Kysely sees 008_auth in _emdash_migrations and skips it on existing installs. After this PR, an existing install will fail to register any new passkey: the kysely adapter writes algorithm: <number> into a column that doesn't exist on the upgraded DB. That's exactly the Windows-Hello user this PR is meant to unblock.

Fix: revert packages/core/src/database/migrations/008_auth.ts to its pre-PR state, and add a brand-new migration packages/core/src/database/migrations/036_credential_algorithm.ts:

import { type Kysely } from "kysely";

export async function up(db: Kysely<unknown>): Promise<void> {
	await db.schema
		.alterTable("credentials")
		.addColumn("algorithm", "integer", (col) => col.notNull().defaultTo(-7))
		.execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
	await db.schema.alterTable("credentials").dropColumn("algorithm").execute();
}

Register it in packages/core/src/database/migrations/runner.ts (static import + entry in MIGRATIONS). The DEFAULT -7 makes existing rows valid (they're all ES256 today, matching the previous hardcoded behavior). See AGENTS.md → Rules: "Database migrations are forward-only — never write one that leaves existing content inaccessible." The inline CREATE TABLE in packages/auth/src/adapters/kysely.ts (used by standalone consumers of @emdash-cms/auth) is a separate path — that change is fine to keep.

2. Workspace typecheck almost certainly does not pass (blocking)

algorithm is now a required field on NewCredential and VerifiedRegistration, but three call sites outside packages/auth were not updated and will fail compilation:

packages/core/tests/unit/auth/passkey-management.test.ts — two adapter.createCredential({...}) literals omit algorithm:

  • Lines 34–43 (in the createTestCredential helper)
  • Lines 239–248 (in the "should preserve all credential properties" test)

Fix: add algorithm: -7, to both.

packages/core/tests/integration/astro/setup-admin-nonce-success.test.ts — the verifyRegistrationResponse mock at lines 28–35 returns a VerifiedRegistration shape without algorithm. Add algorithm: -7, to the returned object.

The PR description says pnpm typecheck passes — please re-run it from the repo root (not just inside packages/auth). The Credential / NewCredential / VerifiedRegistration types are imported into those test files, so a workspace-level typecheck has to surface these.

3. Open question answered

Should we explicitly limit the supported RSA algorithms or is the current RSASSA-PKCS1-v1_5 implementation sufficient as a baseline?

Limiting to RSASSA-PKCS1-v1_5 + SHA-256 is exactly right — that's what COSE alg -257 (RS256) means by definition, and it's what Windows Hello / TPM authenticators emit. Don't expand scope here. If a future authenticator uses RS384/RS512/PS256 you'd add separate cases, but those are vanishingly rare in WebAuthn deployments today. The current pubKeyCredParams advertises only [ES256, RS256], which keeps the verifier and the announced algorithm list in sync — good.

Smaller notes

A few non-blocking comments inline.

.addColumn("id", "text", (col) => col.primaryKey()) // Base64url credential ID
.addColumn("user_id", "text", (col) => col.notNull())
.addColumn("public_key", binaryType(db), (col) => col.notNull()) // COSE public key
.addColumn("algorithm", "integer", (col) => col.notNull().defaultTo(-7)) // COSE algorithm (default ES256)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — forward-only migration violation. See review body for full explanation and the suggested replacement migration.

Short version: this file was released in v0.x. Existing installs already have an _emdash_migrations row for 008_auth, so this addColumn will never run on them — only on brand-new databases. Result: the very Windows Hello users this PR targets will hit a runtime SQL error when they try to register, because the kysely adapter writes algorithm into a column that doesn't exist on the upgraded DB.

Revert this file to its pre-PR state and add a new 036_credential_algorithm.ts migration with ALTER TABLE credentials ADD COLUMN algorithm INTEGER NOT NULL DEFAULT -7, registered in runner.ts.


if (algorithm === coseAlgorithmES256) {
// Verify it's EC2 key type
// Verify EC2 key type for ES256
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale comment in this block at line 158 (just above the hunk): // Currently only supporting ES256 (ECDSA with P-256) is no longer true. Worth refreshing to something like // Supports ES256 (ECDSA P-256, stored as SEC1) and RS256 (RSA, stored as PKIX).

Comment thread packages/auth/src/passkey/authenticate.ts Outdated
Comment thread packages/auth/src/adapters/kysely.ts Outdated
id: row.id,
userId: row.user_id,
publicKey: row.public_key,
algorithm: row.algorithm ?? -7, // Default to ES256 (-7) for backward compatibility with legacy rows
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ?? -7 fallback is sensible defensive coding for the inline-schema path on line 676 (where the CREATE TABLE has DEFAULT -7). Once a proper ALTER TABLE migration is added on the EmDash core path with the same default, all rows will have a non-null algorithm and this fallback becomes dead code there — but it's still useful for standalone @emdash-cms/auth consumers who use the inline schema. Keep it; just consider broadening the comment so it doesn't suggest "legacy rows" is the only case.

No change required.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 1, 2026

Review submitted successfully as CHANGES_REQUESTED with 4 inline comments. The two blocking issues are clearly called out:

  1. Migration violation008_auth.ts was edited in-place but it's already applied on every existing install; needs a new 036_credential_algorithm.ts with ALTER TABLE.
  2. TypeScript breaks — three test call sites outside packages/auth are missing the now-required algorithm field, so workspace typecheck must be failing.

Plus the open question is answered (RSASSA-PKCS1-v1_5 is the right scope) and two smaller nits (stale comment, error code naming).

github run

@j-liszt
Copy link
Copy Markdown
Contributor Author

j-liszt commented May 1, 2026

Thanks for the detailed review @ask-bonk! I've addressed both blocking issues and the smaller nits:

  1. Migration Violation: Reverted 008_auth.ts to its original state and added a new forward-only migration 036_credential_algorithm.ts using ALTER TABLE. Registered it in runner.ts.
  2. TypeScript Breaks: Updated all three test call sites in packages/core to include the missing algorithm field. Verified that pnpm typecheck now passes globally for both @emdash-cms/auth and @emdash-cms/core.
  3. Nits & Comments: Updated the stale comment in register.ts and renamed the error code to unsupported_algorithm (and updated the type definition accordingly).

Ready for another look!

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 1, 2026

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 1, 2026

@ascorbic Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

Copy link
Copy Markdown
Contributor Author

@j-liszt j-liszt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All requested changes have been addressed:

  • Created a new forward-only migration (036) for the algorithm column.
  • Fixed global type safety issues in core tests.
  • Made the migration idempotent to ensure it passes integration tests.
  • Updated error naming and stale comments as requested.

The code now passes both typechecks and integration tests locally. Ready for another review!

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented May 2, 2026

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for iterating on the previous review — the migration revert + new 036_credential_algorithm.ts, the workspace-level type fixes, and the renamed error code all look right. The actual signature-verification logic is correctly wired (oslojs's verifyRSASSAPKCS1v15Signature with sha256ObjectIdentifier is exactly the right call for COSE alg -257/RS256, and PKIX is the right encoding for stored RSA public keys). Backwards compat for legacy ES256 rows via ?? -7 is reasonable.

A few things to address before merge — one is likely a real Postgres-build break, the rest are smaller:

Likely Postgres build break

packages/core/src/database/migrations/036_credential_algorithm.ts uses PRAGMA table_info(credentials) directly. That's SQLite-only. Core supports Postgres (see 008_auth.ts using binaryType(db)/currentTimestamp(db) from dialect-helpers.ts, and 019_i18n.ts/035_bounded_404_log.ts taking the same care). There's already a dialect-aware columnExists(db, table, column) helper in packages/core/src/database/dialect-helpers.ts that's used by 035_bounded_404_log.ts for exactly this pattern — please switch to it. setupTestPostgresDatabase() in the integration suite (when run with PG_CONNECTION_STRING) will fail otherwise.

Smaller things

  • No automated test for the new RS256 signature path. The whole point of this PR is that ES256-only verification was silently locking out Windows Hello users; if the RSA branch ever regresses (e.g. someone refactors the encoding step) we'd hit the same bug class with no warning. A unit test in authenticate.test.ts that generates an RSA-2048 key with node:crypto, encodes the public key as PKIX, signs an assertion, and asserts a successful verification would be straightforward and would mirror the existing ES256 helper.
  • The migrations test name and assertions in packages/core/tests/integration/database/migrations.test.ts are now slightly out of sync with what the test actually does — see inline.
  • Throwing a PasskeyAuthenticationError from the registration path is mildly off-genre — see inline.

The inline AUTH_TABLES_SQL path (CREATE TABLE IF NOT EXISTS credentials …) will not retroactively add the algorithm column for standalone @emdash-cms/auth consumers whose credentials table predates this change. That's a pre-existing limitation of the inline-schema pattern, not something this PR introduced — but it does mean any standalone consumer needs to apply ALTER TABLE credentials ADD COLUMN algorithm INTEGER NOT NULL DEFAULT -7 themselves. Worth a one-line note in the @emdash-cms/auth changeset entry so it's not a surprise.


export async function up(db: Kysely<unknown>): Promise<void> {
const tableInfo = await sql<{ name: string }>`PRAGMA table_info(credentials)`.execute(db);
const columnExists = tableInfo.rows.some((col) => col.name === "algorithm");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Postgres incompatibility. PRAGMA table_info is SQLite-only — this migration will fail under Postgres (setupTestPostgresDatabase() / PG_CONNECTION_STRING test runs, and any user running core on PG). The repo already has a dialect-aware helper for exactly this:

Suggested change
const columnExists = tableInfo.rows.some((col) => col.name === "algorithm");
import { type Kysely } from "kysely";
import { columnExists } from "../dialect-helpers.js";
export async function up(db: Kysely<unknown>): Promise<void> {
if (await columnExists(db, "credentials", "algorithm")) {
return;
}

(Then drop the sql import and the tableInfo/columnExists block, and keep the existing addColumn call.) See packages/core/src/database/migrations/035_bounded_404_log.ts for the same pattern in use.

}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.alterTable("credentials").dropColumn("algorithm").execute();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the down won't be reachable on Postgres in its current form either (the up would have errored earlier), but if up is fixed via columnExists, this dropColumn is fine on both dialects (Kysely emits portable ALTER TABLE … DROP COLUMN). Consider gating with columnExists here too for symmetry — running down twice would otherwise throw.

Non-blocking; down is rarely run in practice.

.where("name", "in", [
"034_published_at_index",
"035_bounded_404_log",
"036_credential_algorithm",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things:

  1. The test title (line 110) still says "034 and 035" but the body now also deletes 036_credential_algorithm. A future reader greping for "036" in tests won't find this regression coverage. Either rename the test (e.g. "should re-run migrations 034, 035, and 036 when schema changes were partially applied") or split 036 into its own test.

  2. Since 036 is deleted from _emdash_migrations here, please also assert it was re-applied — otherwise this test wouldn't catch a regression where the new migration's idempotency check never re-runs:

Suggested change
"036_credential_algorithm",
"036_credential_algorithm",
])
.execute();
const { applied } = await runMigrations(db);
expect(applied).toContain("034_published_at_index");
expect(applied).toContain("035_bounded_404_log");
expect(applied).toContain("036_credential_algorithm");

Comment thread packages/auth/src/passkey/register.ts Outdated
throw new PasskeyAuthenticationError(
"unsupported_algorithm",
"Unsupported credential algorithm",
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mildly off-genre: every other failure mode in verifyRegistrationResponse throws a plain Error (lines 105, 112, 119, 127, 143, 148, 153, 166, 170, 181), but this branch reaches across to ./authenticate.js to throw a PasskeyAuthenticationError. Pick one:

  • Drop back to throw new Error("Unsupported algorithm: " + algorithm); for consistency with the rest of this function, or
  • Introduce a PasskeyRegistrationError class with its own code union and migrate the other plain Errors to it (separate PR).

The current state is the worst of both worlds — typed for one branch, untyped for the others, with a class name (...AuthenticationError) that doesn't match the call site. Non-blocking, and the route handler at packages/core/src/astro/routes/api/auth/passkey/register/verify.ts doesn't instanceof it today, so nothing breaks — but it'll cost cleanup later.

"unsupported_algorithm",
"Unsupported credential algorithm",
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, but worth noting there's no automated test for either new branch of this if/else if/else. The whole point of this PR is that the missing RSA branch silently locked out Windows Hello users; a unit test that generates an RSA-2048 key with node:crypto, encodes via RSAPublicKey(...).encodePKIX(), signs an assertion, and asserts a successful verification would mirror the existing createValidAssertion() helper in authenticate.test.ts and prevent the same class of regression. The unsupported_algorithm branch is also worth a one-line test (set credential.algorithm = 0 and expect the typed error).

Manual verification on Windows is great but won't catch a future refactor.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 2, 2026

Review posted successfully (id 4215121445). All five inline comments accepted, plus the summary body.

Summary of the review:

  • Likely Postgres build break (high priority): Migration 036_credential_algorithm.ts uses raw PRAGMA table_info, which is SQLite-only. The repo has a dialect-aware columnExists helper used by 035; suggested swapping to it.
  • Migration down symmetry (nit): Once up is fixed, also gate down's dropColumn with columnExists for symmetry.
  • Migrations test out of sync: Test title still says "034 and 035" while body now also includes 036; test doesn't assert 036 was re-applied. Suggested rename + extra assertion.
  • Inconsistent error class in register.ts: Throwing PasskeyAuthenticationError from the registration path while every other failure throws plain Error — pick one.
  • No automated test for the RS256 path: The whole motivating bug had no regression test before; would be cheap to add one mirroring the existing ES256 helper.

Plus a non-blocking heads-up in the summary that the inline AUTH_TABLES_SQL path won't retroactively add the column for standalone @emdash-cms/auth consumers with pre-existing DBs — worth a line in the changeset.

github run

@github-actions github-actions Bot added size/L and removed size/M labels May 2, 2026
Copy link
Copy Markdown
Contributor Author

@j-liszt j-liszt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary of changes addressing the latest review:

  • Database & Migrations: Switched to the dialect-aware columnExists helper in 036_credential_algorithm.ts to ensure Postgres compatibility. Added idempotency gates to both up and down functions for symmetry.
  • Testing:
    • Added automated regression tests for the RS256 path in authenticate.test.ts using real RSA-2048 key generation and signature verification.
    • Added a test case for the unsupported_algorithm error branch.
    • Renamed and updated the migration integration test to correctly assert the re-application of migration 036.
  • Error Handling & Consistency:
    • Replaced PasskeyAuthenticationError with a plain Error in the registration path (register.ts) for consistency with the existing codebase.
    • Improved error messages in both registration and authentication to include the dynamic algorithm ID for better debugging.
  • Documentation: Added a note to the @emdash-cms/auth changeset regarding the mandatory manual schema update (ALTER TABLE) for standalone consumers.

Verified with pnpm test and typecheck. The PR is now ready for final review.

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented May 3, 2026

/review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third pass: the dialect-aware migration + new tests + error-class consistency from the previous round all landed correctly. The motivating bug (ES256-only verification locking out Windows Hello / TPM-backed authenticators) is genuinely fixed, and the polymorphic verification path is wired up correctly end-to-end. decodePKIXRSAPublicKey + verifyRSASSAPKCS1v15Signature + sha256ObjectIdentifier is exactly the right call for COSE alg -257/RS256, and PKIX encoding from the COSE RSA key matches what the verifier decodes (the new authenticate-side test exercises this round-trip).

A few smaller things remain — none are blocking:

Stale type in packages/core/src/database/types.ts

CredentialTable (around line 72) still doesn't have an algorithm column. Today nothing in packages/core queries credentials directly through this typed Kysely schema — @emdash-cms/auth owns those reads/writes via its own CredentialTable interface inside adapters/kysely.ts — so this isn't a runtime bug. But the type now lies about the schema, and the // COSE public key comment is also misleading (the stored public key is no longer raw COSE; it's SEC1 for ECDSA or PKIX for RSA, per the encoding done in register.ts). Cheap to keep in sync — add algorithm: number; and update the comment on public_key.

No test coverage for the registration RSA branch

The new createValidRS256Assertion() in authenticate.test.ts hand-rolls the PKIX from node:crypto, which exercises the authenticate side but not verifyRegistrationResponse's new RSA branch — see inline.

Nit: changeset wording for standalone consumers

The note tells standalone @emdash-cms/auth consumers to run ALTER TABLE credentials ADD COLUMN algorithm INTEGER NOT NULL DEFAULT -7 manually. Worth mentioning that the DEFAULT -7 is what makes existing rows continue to work (they're all ES256 today by definition of the previous hardcoded behavior), so they don't need any data backfill — only the schema change.

No other findings.

}
const cosePublicKey = credential.publicKey.rsa();
// Encode as PKIX format for storage
encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKIX();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No automated coverage for this branch. The new createValidRS256Assertion() in authenticate.test.ts hand-rolls the PKIX encoding via node:crypto.publicKey.export({ type: 'spki' }) — it doesn't run a real or fake COSE-RSA attestation through verifyRegistrationResponse, so the path that calls credential.publicKey.rsa() and new RSAPublicKey(n, e).encodePKIX() is untested. If cosePublicKey.rsa() ever returns a different shape, or encodePKIX() is replaced with encodePKCS1() in a refactor, the bug class this PR exists to fix re-emerges with no test failure.

A test that synthesizes a CBOR attestation object with an RSA COSE key (or mocks the parsed authenticatorData.credential.publicKey into verifyRegistrationResponse) and asserts the resulting verified.publicKey round-trips through decodePKIXRSAPublicKey would close the loop. Non-blocking but cheap insurance for the motivating bug.

Comment thread packages/auth/src/adapters/kysely.ts Outdated
id: row.id,
userId: row.user_id,
publicKey: row.public_key,
algorithm: row.algorithm ?? -7, // Default to ES256 (-7) for compatibility with credentials created without an explicit algorithm
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: now that migration 036 adds the column with NOT NULL DEFAULT -7, the ?? -7 fallback is dead code on the EmDash core path — every row will have a non-null algorithm. It's still meaningful for standalone @emdash-cms/auth consumers who used the old inline CREATE TABLE (without the algorithm column) and haven't yet run the manual ALTER TABLE from the changeset note... but in that case Kysely would error on the SELECT before this fallback runs (the column doesn't exist in the DB), so it doesn't actually help. Consider either dropping the fallback or rewording the comment so it's clear it's purely defensive against rows written by an older codepath, not a recovery mechanism for unmigrated DBs.

if (!(await columnExists(db, "credentials", "algorithm"))) {
await db.schema
.alterTable("credentials")
.addColumn("algorithm", "integer", (col) => col.notNull().defaultTo(-7))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads-up — not a bug, just a note for the next person: defaultTo(-7) on a notNull integer column works on both SQLite and Postgres because Kysely emits the integer literal as-is. If a future migration ever needs a non-trivial default expression for an integer column, it'll need to go through sql\...`` to render correctly on both dialects. This one is fine.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 3, 2026

Review submitted successfully (id 4216003420) with all three inline comments accepted.

Summary of what I posted:

  • Top-level: The motivating bug (ES256-only locking out Windows Hello) is genuinely fixed; oslojs APIs are wired correctly; previous round's blockers (forward-only migration, dialect-aware columnExists, error class consistency, regression test name) all landed. Three smaller items remain, none blocking.
  • Stale type in packages/core/src/database/types.ts — flagged in the body since the file isn't in this PR's diff. CredentialTable lacks algorithm and its // COSE public key comment is now wrong (it's SEC1 / PKIX). No runtime impact today since core doesn't query credentials through this typed schema, but the type lies about the schema.
  • packages/auth/src/passkey/register.ts:184 — the new RSA branch in verifyRegistrationResponse (the cosePublicKey.rsa() + RSAPublicKey(...).encodePKIX() step) has no test. The new createValidRS256Assertion() hand-rolls PKIX via node:crypto and exercises only the authenticate side. A future refactor that swaps encodePKIX() for encodePKCS1() would re-introduce the original bug class with no test failure.
  • packages/auth/src/adapters/kysely.ts:608 — the ?? -7 fallback is effectively dead on the EmDash core path (migration adds NOT NULL DEFAULT -7) and doesn't help standalone consumers either (Kysely errors on the SELECT before the coalesce runs). Drop or reword the comment.
  • 036_credential_algorithm.ts:9 — non-bug heads-up that defaultTo(-7) works on both dialects because it's a clean integer literal; future non-trivial defaults would need sql\...``.

github run

j-liszt and others added 4 commits May 3, 2026 17:49
- Update CredentialTable type in core to include algorithm column
- Add robust RSA-2048 automated test for registration flow using BigInt representations
- Remove dead fallback code in Kysely adapter
- Clarify schema backwards compatibility in changeset
Copy link
Copy Markdown
Contributor Author

@j-liszt j-liszt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough review, I've addressed all the remaining non-blocking items from the third pass. Here is what was updated:

1. Test coverage for the registration RSA branch

Added a robust test in register.test.ts to ensure the verifyRegistrationResponse RSA branch is fully covered. We mocked the parsed authenticatorData.credential.publicKey to inject native BigInt representations of an RSA key and successfully asserted that the resulting verified.publicKey round-trips correctly through decodePKIXRSAPublicKey(). This locks down encodePKIX() and prevents any future regressions.

2. Stale type in packages/core/src/database/types.ts

Added the algorithm: number; property to the CredentialTable interface and updated the // COSE public key comment to accurately reflect that the stored value is now SEC1 for ECDSA or PKIX for RSA. Types and schemas are fully in sync again.

3. Dead fallback code in Kysely adapter

Dropped the ?? -7 fallback in packages/auth/src/adapters/kysely.ts entirely. Since the migration forces NOT NULL DEFAULT -7, and standalone consumers missing the column would crash on the SELECT phase anyway, the fallback was indeed dead code.

4. Changeset wording for standalone consumers

Updated the .changeset wording to explicitly mention that the DEFAULT -7 constraint automatically handles backwards compatibility for all existing rows (which are ES256 by definition), meaning standalone consumers only need the schema change and do not require any manual data backfilling.

Ready for the review! Let me know if you need anything else.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions github-actions Bot mentioned this pull request May 5, 2026
18 tasks
@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented May 6, 2026

Thanks for handling the detailed reviews! ANything security-related needs a very detailed review, so thanks for your patience. Right now the only thing remaining is to deal with the conflict in migration files – another PR has merged that used the same migraiton number, so it just needs some renaming and renumbering and then should be good to go.

Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@ascorbic ascorbic merged commit f8ee1ed into emdash-cms:main May 7, 2026
29 checks passed
@emdashbot emdashbot Bot mentioned this pull request May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[SETUP_VERIFY_ERROR] Error: RS256 not yet supported - please use ES256

2 participants