Skip to content

Replace @oslojs/webauthn + crypto sig verification with hand-rolled WebAuthn#1254

Closed
ascorbic wants to merge 2 commits into
mainfrom
passkey-handroll-webauthn
Closed

Replace @oslojs/webauthn + crypto sig verification with hand-rolled WebAuthn#1254
ascorbic wants to merge 2 commits into
mainfrom
passkey-handroll-webauthn

Conversation

@ascorbic
Copy link
Copy Markdown
Collaborator

@ascorbic ascorbic commented Jun 1, 2026

What

Replaces the @oslojs/webauthn dependency and @oslojs/crypto's ECDSA/RSA signature verification in the passkey path with a hand-rolled, strict WebAuthn parser plus WebCrypto-based verification.

This is a response to a heads-up from the @oslojs maintainer (pilcrow), who flagged that he'd avoid these packages where possible: @oslojs/crypto for signature verification (prefer an actively-maintained primitive — WebCrypto), and @oslojs/webauthn because its general-purpose CBOR decoder is broader than a passkey RP needs and it doesn't account for extension data in the authenticator data.

Scope

Deliberately narrow — only the two things flagged:

  • @oslojs/webauthn → removed entirely (it was only used in the passkey path). Catalog pin dropped.
  • @oslojs/crypto → removed from the passkey path, replaced with crypto.subtle. It stays for tokens.ts/OAuth (sha256/hmac/constantTimeEqual), which are synchronous and not what was flagged — migrating those is a separate sync→async change.
  • @oslojs/encoding (base64url) is kept — not flagged, trivial, not a security-parser concern.

How it's built

Module Responsibility
cbor.ts Strict, scoped CBOR decoder — definite-length maps/ints/byte strings (+ boolean/null simple values for extensions). Rejects tags, floats, indefinite-length, over-long inputs; depth-capped; duplicate-key-rejecting.
authenticator-data.ts authData / attestation object / clientDataJSON parsing. Consumes and validates the extension block instead of ignoring it; asserts full-buffer consumption.
der.ts RSA SPKI encoding + ECDSA DER→raw signature conversion (the WebCrypto footgun).
cose-key.ts COSE EC2/RSA → stored SEC1/SPKI; validates curve, coordinate length, key-type/alg consistency.
verify.ts crypto.subtle signature verification + SHA-256.

Backward compatibility

Stored key formats are byte-for-byte unchanged (SEC1 uncompressed for ES256, SPKI DER for RS256) — verified against @oslojs/crypto's actual output during review. Existing passkeys keep working; no migration.

One behavioral improvement: newly-registered passkeys now record correct deviceType/backedUp values from the authenticator's BE/BS backup flags, which the previous parser did not expose (it hardcoded singleDevice/false).

Verification

  • Tests: 77 in @emdash-cms/auth (real attestation/assertion fixtures — no parser mocking), incl. direct unit tests for cbor.ts/der.ts and a regression test for boolean-valued extensions (hmac-secret); 40 core passkey tests green.
  • Adversarial review (3 independent passes): no auth-bypass / forgery / replay path; caught and fixed an over-strict extension parser that would have locked out hmac-secret hardware keys, an 8-byte-int RangeError leak, and added an RSA key-size bound.
  • Security review: zero HIGH/MEDIUM findings; algorithm-confusion, message binding, challenge/origin/rpId binding, and fail-closed verification all verified.

Note for reviewers

The residual risk in hand-rolled WebAuthn is differential behavior vs real authenticators, not the security patterns (which are covered). Before this is battle-tested I'd want a corpus of real authenticator output (Chrome/Safari/a hardware key) exercised against the parser — the boolean-extension regression caught in review is the archetype of what unit tests with synthetic fixtures can miss.


Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks passkey-handroll-webauthn. Updated automatically when the playground redeploys.

ascorbic added 2 commits June 1, 2026 08:24
…ebAuthn

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.
- 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.
Copilot AI review requested due to automatic review settings June 1, 2026 08:11
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 1, 2026

🦋 Changeset detected

Latest commit: ae166ed

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

This PR includes changesets to release 14 packages
Name Type
@emdash-cms/auth Patch
emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd 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 Jun 1, 2026

PR template validation failed

Please fix the following issues by editing your PR description:

  • This PR does not use the required PR template. Please edit the description to use the PR template. Copy it into your PR description and fill out all sections.

See CONTRIBUTING.md for the full contribution policy.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 1, 2026

Scope check

This PR changes 1,299 lines across 16 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions github-actions Bot added the review/needs-review No maintainer or bot review yet label Jun 1, 2026
@ascorbic ascorbic marked this pull request as draft June 1, 2026 08:12
@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs ae166ed Jun 01 2026, 08:13 AM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground ae166ed Jun 01 2026, 08:13 AM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache ae166ed Jun 01 2026, 08:14 AM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 1, 2026

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

emdash

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

create-emdash

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

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

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: ae166ed

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR removes the @oslojs/webauthn dependency and replaces the passkey (WebAuthn) parsing + signature verification path with a strict, hand-rolled WebAuthn/CBOR/COSE/DER implementation and crypto.subtle-based verification, while keeping stored credential key formats compatible (ES256 SEC1 uncompressed; RS256 SPKI DER).

Changes:

  • Dropped @oslojs/webauthn from the workspace and @emdash-cms/auth package deps; adjusted lockfile and Vite prebundle includes accordingly.
  • Introduced strict CBOR decoding, authenticator data parsing (including extension consumption), COSE-key translation, minimal DER helpers, and WebCrypto verification primitives.
  • Updated passkey registration/authentication flows and tests to exercise real attestation/assertion fixtures (including boolean-valued extensions).

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
pnpm-workspace.yaml Removes @oslojs/webauthn from the catalog.
pnpm-lock.yaml Removes @oslojs/webauthn and its transitive entries from the lockfile.
packages/core/src/astro/integration/vite-config.ts Drops prebundle includes for removed passkey dependencies.
packages/auth/src/passkey/verify.ts Adds WebCrypto-based SHA-256 + assertion signature verification.
packages/auth/src/passkey/register.ts Switches registration parsing/verification to new strict WebAuthn modules and WebCrypto RP ID hash check.
packages/auth/src/passkey/register.test.ts Replaces mocked parsing with real CBOR-built attestation fixtures for ES256/RS256.
packages/auth/src/passkey/der.ts Adds minimal DER helpers (RSA SPKI encoding, ECDSA DER→raw signature conversion).
packages/auth/src/passkey/der.test.ts Adds unit tests for DER helpers and RSA SPKI round-tripping.
packages/auth/src/passkey/cose-key.ts Adds COSE→stored-key conversion/validation for ES256 + RS256.
packages/auth/src/passkey/cbor.ts Adds strict, scoped CBOR decoder used by WebAuthn parsing.
packages/auth/src/passkey/cbor.test.ts Adds CBOR decoder unit tests (incl. boolean extension values).
packages/auth/src/passkey/authenticator-data.ts Adds binary parsers for authenticatorData, attestation object, and clientDataJSON.
packages/auth/src/passkey/authenticate.ts Switches authentication parsing/verification to new strict WebAuthn modules + WebCrypto verification.
packages/auth/src/passkey/authenticate.test.ts Updates assertion signing/message building and adds a boolean-extension assertion test.
packages/auth/package.json Removes @oslojs/webauthn dependency from @emdash-cms/auth.
.changeset/passkey-handrolled-webauthn.md Documents the dependency removal and new strict parsing/verification behavior.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +149 to +154
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;
Comment on lines +4 to +8
* 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
Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Choose a reason for hiding this comment

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

This PR is a well-justified, narrowly-scoped replacement of @oslojs/webauthn and @oslojs/crypto signature verification in the passkey path with hand-rolled CBOR/WebAuthn parsers and WebCrypto primitives. The approach is sound: stored key formats are unchanged, the CBOR decoder is strictly limited to the types WebAuthn needs, and the WebCrypto verification path correctly handles the ECDSA DER→raw footgun. The adversarial-review notes in the description are reflected in the code (duplicate-key rejection, depth cap, RSA key-size bound, extension consumption).

I read the full diff, traced every changed hunk, and spot-checked call sites and sibling tests. The implementation is clean and follows AGENTS.md conventions (.js imports, type-only imports, changeset present).

One latent correctness issue in the DER encoder: encodeLength in der.ts uses JavaScript's signed right-shift (n >>= 8), which produces incorrect length bytes for values ≥ 2^31. This is unreachable with current RSA bounds (~1100 bytes max), but the utility should use Math.floor(n / 256) to remain correct.

Test coverage: authenticator-data.ts and cose-key.ts are exercised indirectly through register.test.ts and authenticate.test.ts, but direct unit tests for their edge cases (truncated auth data, malformed COSE keys, wrong key-type/algorithm combos) would strengthen the safety net for hand-rolled binary parsers. The existing 77 auth + 40 core passkey tests are a solid baseline.

Behavioral changes noted and accepted: malformed DER signatures now surface as invalid_signature instead of invalid_response (fail-closed, less information leakage); newly-registered passkeys correctly report deviceType/backedUp from authenticator flags instead of hardcoding singleDevice/false.

Verdict: comment — the implementation is correct and the approach is right, but fix the unreachable-yet-real DER length bug before landing.

const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
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.

[suggestion] n >>= 8 is a signed 32-bit right shift in JavaScript. For any length whose highest set bit is at position 31 or higher, the sign bit is preserved and the loop produces incorrect bytes (e.g. 0x80000000 shifts to 0xff800000, not 0x00800000).

While current callers never pass values that large (max RSA SPKI is ~1100 bytes), encodeLength is a general DER utility and the bug is subtle enough to bite if the bounds change or the function is reused elsewhere.

Suggested change
for (const part of parts) {
while (n > 0) {
bytes.unshift(n & 0xff);
n = Math.floor(n / 256);
}

@github-actions github-actions Bot removed the review/needs-review No maintainer or bot review yet label Jun 1, 2026
@ascorbic ascorbic closed this Jun 4, 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.

2 participants