From 1f1ba5403e515ea999fd0608b4225640a98a87c2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 08:29:17 -0700 Subject: [PATCH 1/5] feat(server): add Hono webserver and shared server-lib-common package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Node webserver package `server` running a Hono hello-world, plus `server-lib-common` — a runtime-agnostic package shared between `lib` (frontend) and `server` (backend), the server-side counterpart to `dor-lib-common`. The shared package owns the API contract (route path + response shape) so the two sides can't drift; `server` serves it and `lib` depends on it. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/package.json | 3 +- package.json | 1 + pnpm-lock.yaml | 44 +++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 ++ server-lib-common/package.json | 20 ++++++++++++ server-lib-common/src/index.ts | 24 +++++++++++++++ server-lib-common/test/index.test.mjs | 16 ++++++++++ server-lib-common/tsconfig.json | 20 ++++++++++++ server/package.json | 23 ++++++++++++++ server/src/app.ts | 16 ++++++++++ server/src/index.ts | 9 ++++++ server/test/app.test.mjs | 18 +++++++++++ server/tsconfig.json | 18 +++++++++++ 13 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 server-lib-common/package.json create mode 100644 server-lib-common/src/index.ts create mode 100644 server-lib-common/test/index.test.mjs create mode 100644 server-lib-common/tsconfig.json create mode 100644 server/package.json create mode 100644 server/src/app.ts create mode 100644 server/src/index.ts create mode 100644 server/test/app.test.mjs create mode 100644 server/tsconfig.json diff --git a/lib/package.json b/lib/package.json index 2a2f4dec..5c99ef51 100644 --- a/lib/package.json +++ b/lib/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "pretest": "pnpm --filter dor-lib-common build", + "pretest": "pnpm --filter dor-lib-common build && pnpm --filter server-lib-common build", "test": "vitest run", "test:watch": "vitest", "storybook": "storybook dev -p 6006 --no-open --ci", @@ -26,6 +26,7 @@ "jsonc-parser": "3.3.1", "react": "^19.2.6", "react-dom": "^19.2.6", + "server-lib-common": "workspace:*", "tailwind-merge": "^3.6.0", "tailwind-variants": "^3.2.2" }, diff --git a/package.json b/package.json index b399491e..a15cb11f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dev:lib": "pnpm --filter dormouse-lib dev", "dev:standalone": "node scripts/free-dev-port.mjs && pnpm --filter dormouse-standalone tauri dev", "dev:standalone:ab": "pnpm --filter dormouse-standalone dev:agent-browser", + "dev:server": "pnpm --filter server dev", "dev:website": "pnpm --filter dormouse-website dev", "build:vscode": "pnpm --filter dormouse-lib build && pnpm --filter dormouse build:frontend && pnpm --filter dormouse build", "build:standalone": "pnpm --filter dormouse-standalone tauri build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5bb8c43..4137d2e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: react-dom: specifier: ^19.2.6 version: 19.2.7(react@19.2.7) + server-lib-common: + specifier: workspace:* + version: link:../server-lib-common tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -120,6 +123,31 @@ importers: specifier: ^4.1.6 version: 4.1.9(@types/node@22.20.0)(jsdom@29.1.1)(vite@8.1.0(@types/node@22.20.0)(esbuild@0.28.1)(jiti@2.7.0)) + server: + dependencies: + '@hono/node-server': + specifier: ^1.13.0 + version: 1.19.14(hono@4.12.27) + hono: + specifier: ^4.6.0 + version: 4.12.27 + server-lib-common: + specifier: workspace:* + version: link:../server-lib-common + devDependencies: + '@types/node': + specifier: ^22.12.0 + version: 22.20.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + server-lib-common: + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + standalone: dependencies: '@phosphor-icons/react': @@ -723,6 +751,12 @@ packages: '@noble/hashes': optional: true + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -2671,6 +2705,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hono@4.12.27: + resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} + engines: {node: '>=16.9.0'} + hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -4574,6 +4612,10 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@hono/node-server@1.19.14(hono@4.12.27)': + dependencies: + hono: 4.12.27 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -6283,6 +6325,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.12.27: {} + hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 079919ac..a68199a5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,8 @@ packages: - dor - dor-lib-common - lib + - server + - server-lib-common - standalone - standalone/sidecar - vscode-ext diff --git a/server-lib-common/package.json b/server-lib-common/package.json new file mode 100644 index 00000000..2f30b218 --- /dev/null +++ b/server-lib-common/package.json @@ -0,0 +1,20 @@ +{ + "name": "server-lib-common", + "version": "0.0.0", + "license": "FSL-1.1-MIT", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "pnpm run build && node --test test/*.test.mjs" + }, + "devDependencies": { + "typescript": "^6.0.3" + } +} diff --git a/server-lib-common/src/index.ts b/server-lib-common/src/index.ts new file mode 100644 index 00000000..ef4785b4 --- /dev/null +++ b/server-lib-common/src/index.ts @@ -0,0 +1,24 @@ +/** + * Code shared between the frontend (`lib`) and the backend (`server`) — the + * server-side counterpart to `dor-lib-common`. + * + * Keep this package runtime-agnostic: it is compiled into both a browser bundle + * (via `lib`) and a Node process (`server`), so it must not reach for Node or + * DOM globals. That is why `tsconfig.json` sets `"types": []`. + */ + +/** Path of the greeting endpoint that `server` serves and `lib` can call. */ +export const HELLO_ROUTE = '/api/hello'; + +/** Response body returned by {@link HELLO_ROUTE}. */ +export interface HelloResponse { + readonly message: string; +} + +/** + * Build the {@link HelloResponse} for a caller. Living here — rather than in + * `server` — keeps the frontend and backend agreeing on the exact shape. + */ +export function helloResponse(name = 'world'): HelloResponse { + return { message: `Hello, ${name}!` }; +} diff --git a/server-lib-common/test/index.test.mjs b/server-lib-common/test/index.test.mjs new file mode 100644 index 00000000..41ac4f93 --- /dev/null +++ b/server-lib-common/test/index.test.mjs @@ -0,0 +1,16 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { HELLO_ROUTE, helloResponse } from '../dist/index.js'; + +test('HELLO_ROUTE is the shared API path', () => { + assert.equal(HELLO_ROUTE, '/api/hello'); +}); + +test('helloResponse defaults to world', () => { + assert.deepEqual(helloResponse(), { message: 'Hello, world!' }); +}); + +test('helloResponse greets the given name', () => { + assert.deepEqual(helloResponse('dormouse'), { message: 'Hello, dormouse!' }); +}); diff --git a/server-lib-common/tsconfig.json b/server-lib-common/tsconfig.json new file mode 100644 index 00000000..83571556 --- /dev/null +++ b/server-lib-common/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ES2022", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "incremental": true, + "tsBuildInfoFile": "dist/.tsbuildinfo", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 00000000..f117f321 --- /dev/null +++ b/server/package.json @@ -0,0 +1,23 @@ +{ + "name": "server", + "version": "0.0.0", + "license": "FSL-1.1-MIT", + "private": true, + "type": "module", + "scripts": { + "prebuild": "pnpm --filter server-lib-common build", + "build": "tsc -p tsconfig.json", + "dev": "pnpm run build && node --watch dist/index.js", + "start": "node dist/index.js", + "test": "pnpm run build && node --test test/*.test.mjs" + }, + "dependencies": { + "@hono/node-server": "^1.13.0", + "hono": "^4.6.0", + "server-lib-common": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "typescript": "^6.0.3" + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 00000000..ae08ca1a --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,16 @@ +import { Hono } from 'hono'; +import { HELLO_ROUTE, helloResponse } from 'server-lib-common'; + +/** + * The Hono application. Built here — separate from the `serve()` entrypoint in + * `index.ts` — so tests can exercise routes via `app.request()` without binding + * a port. + */ +export const app = new Hono(); + +app.get('/', (c) => c.text('Hello from Hono!')); + +// The route path and response shape both come from `server-lib-common`, the +// package `lib` (frontend) and `server` (backend) share, so the two sides can +// never drift out of agreement. +app.get(HELLO_ROUTE, (c) => c.json(helloResponse())); diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 00000000..86d3d923 --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,9 @@ +import { serve } from '@hono/node-server'; + +import { app } from './app.js'; + +const port = Number(process.env.PORT ?? 3000); + +serve({ fetch: app.fetch, port }, (info) => { + console.log(`server listening on http://localhost:${info.port}`); +}); diff --git a/server/test/app.test.mjs b/server/test/app.test.mjs new file mode 100644 index 00000000..a88d344e --- /dev/null +++ b/server/test/app.test.mjs @@ -0,0 +1,18 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { HELLO_ROUTE } from 'server-lib-common'; + +import { app } from '../dist/app.js'; + +test('GET / returns the Hono hello-world', async () => { + const res = await app.request('/'); + assert.equal(res.status, 200); + assert.equal(await res.text(), 'Hello from Hono!'); +}); + +test(`GET ${HELLO_ROUTE} returns the shared greeting`, async () => { + const res = await app.request(HELLO_ROUTE); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { message: 'Hello, world!' }); +}); diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 00000000..148673a9 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ES2022", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 055267c768d0982f9472ae6d548d2ce05ec83a58 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 08:47:10 -0700 Subject: [PATCH 2/5] feat(server-lib-common): add remote security model primitives Implements the shared logic of docs/specs/remote-security-model.md: device keys (non-extractable P-256, domain-separated challenge signing), single-use host challenges, WebAuthn assertion verification, the host-authoritative ACL, the pairing ceremony state machine, and the all-layers-must-agree connection authorization. Runtime-agnostic: structural WebCrypto typings plus hand-rolled base64url/UTF-8/DER so the package still compiles against the bare ES2022 lib. Unit tests cover the crypto layer end to end via a simulated WebAuthn authenticator; ACL/pairing/connection tests land next. Co-Authored-By: Claude Fable 5 --- docs/specs/remote-security-model.md | 413 +++++++++++++++++++ server-lib-common/src/index.ts | 15 + server-lib-common/src/security/acl.ts | 159 +++++++ server-lib-common/src/security/bytes.ts | 168 ++++++++ server-lib-common/src/security/challenge.ts | 79 ++++ server-lib-common/src/security/connection.ts | 162 ++++++++ server-lib-common/src/security/deviceKey.ts | 116 ++++++ server-lib-common/src/security/ecdsa.ts | 82 ++++ server-lib-common/src/security/pairing.ts | 153 +++++++ server-lib-common/src/security/passkey.ts | 185 +++++++++ server-lib-common/src/security/webcrypto.ts | 68 +++ server-lib-common/test/bytes.test.mjs | 81 ++++ server-lib-common/test/challenge.test.mjs | 82 ++++ server-lib-common/test/device-key.test.mjs | 103 +++++ server-lib-common/test/ecdsa.test.mjs | 77 ++++ server-lib-common/test/harness/actors.mjs | 262 ++++++++++++ server-lib-common/test/passkey.test.mjs | 156 +++++++ 17 files changed, 2361 insertions(+) create mode 100644 docs/specs/remote-security-model.md create mode 100644 server-lib-common/src/security/acl.ts create mode 100644 server-lib-common/src/security/bytes.ts create mode 100644 server-lib-common/src/security/challenge.ts create mode 100644 server-lib-common/src/security/connection.ts create mode 100644 server-lib-common/src/security/deviceKey.ts create mode 100644 server-lib-common/src/security/ecdsa.ts create mode 100644 server-lib-common/src/security/pairing.ts create mode 100644 server-lib-common/src/security/passkey.ts create mode 100644 server-lib-common/src/security/webcrypto.ts create mode 100644 server-lib-common/test/bytes.test.mjs create mode 100644 server-lib-common/test/challenge.test.mjs create mode 100644 server-lib-common/test/device-key.test.mjs create mode 100644 server-lib-common/test/ecdsa.test.mjs create mode 100644 server-lib-common/test/harness/actors.mjs create mode 100644 server-lib-common/test/passkey.test.mjs diff --git a/docs/specs/remote-security-model.md b/docs/specs/remote-security-model.md new file mode 100644 index 00000000..36d9aa15 --- /dev/null +++ b/docs/specs/remote-security-model.md @@ -0,0 +1,413 @@ +# Remote Security Model + +Our security model is based on two independent security primitives between the Client (Dormouse Pocket), Host (Dormouse Terminal), and coordinating Server: + +* Passkeys verify fresh user presence. + + * Fresh user presence is required by both the coordinating server and the host. + * Passkeys authenticate the user, but do not independently grant host access. + +* Each client device must complete an explicit one-to-one pairing ceremony with each host device. + + * The host maintains its own local ACL of approved clients. + * Approved clients are identified by an asymmetric device keypair generated in the browser and stored locally as a non-extractable WebCrypto key. + +This separation ensures that account-level compromise is insufficient for host access. Adding a new passkey to an account, or compromising the coordinating server, does not authorize a new client: the host will reject any client that has not been locally paired. + +# Goals + +Dormouse enables a user to control a Host (Dormouse Terminal) from a Client (Dormouse Pocket) using only web technologies. + +Primary goals: + +* No native mobile application required +* Strong protection against account compromise +* Strong protection against newly-added credentials +* Strong protection against server compromise +* Explicit host-controlled authorization +* Long-lived trusted client devices +* Modern passkey-based authentication + +Non-goals: + +* Defending against a fully compromised browser runtime +* Defending against a compromised operating system +* Preventing users from intentionally clearing browser data +* Providing permanent device identity guarantees across browser resets + +--- + +# Terminology + +## Client (Dormouse Pocket) + +A browser or installed PWA used to initiate remote-control sessions. + +Responsibilities: + +* Authenticate using passkeys +* Maintain a local device keypair +* Establish connections to Hosts +* Sign Host challenges + +## Host (Dormouse Terminal) + +The machine being remotely controlled. + +Responsibilities: + +* Maintain a local ACL of approved Clients +* Verify Client authorization +* Verify fresh user presence +* Establish and terminate control sessions + +The Host is the final authority for access decisions. + +## Server + +The coordinating service used for accounts, authentication, signaling, and session establishment. + +Responsibilities: + +* Account management +* Passkey registration +* WebAuthn challenge generation +* Signaling and rendezvous +* Revocation propagation + +The Server is not the final authority for Host access. + +--- + +# Trust Model + +Dormouse intentionally separates: + +| Layer | Responsibility | +| ---------- | -------------------------- | +| Passkey | Fresh user presence | +| Device Key | Long-lived client identity | +| Host ACL | Authorization | +| Host | Final access decision | + +No single layer is sufficient to gain access. + +A successful connection requires all layers to agree. + +--- + +# Passkeys + +## Purpose + +Passkeys verify fresh user presence. + +Every connection requires a fresh WebAuthn assertion. + +Fresh user presence is validated by: + +* The Server +* The Host + +A passkey authenticates a user account but does not grant access to any Host. + +## Important Property + +Passkeys are frequently synchronized credentials. + +Examples include: + +* iCloud Keychain +* Google Password Manager +* Platform authenticators +* Hardware security keys + +A single passkey may appear on multiple physical devices. + +Therefore: + +> Passkeys are treated as user credentials, not device identities. + +--- + +# Device Keys + +## Purpose + +Device keys establish long-lived Client identity. + +They provide the capability being authorized by the Host. + +This prevents: + +* Newly-added passkeys from inheriting Host access +* Synced passkeys from automatically becoming trusted devices +* Server-only compromise from granting Host access + +## Generation + +Each Client generates an asymmetric keypair: + +* Signing keypair +* Non-extractable private key +* Generated using WebCrypto + +The public key is used as the Client identifier. + +## Storage + +Recommended implementation: + +* WebCrypto +* IndexedDB +* `extractable: false` +* `navigator.storage.persist()` + +The private key never leaves browser storage under normal operation. + +## Security Properties + +Strengths: + +* Durable across browser restarts +* Durable across page reloads +* Non-extractable through normal browser APIs +* Fully web-native + +Limitations: + +* Active XSS can use the key +* Browser compromise defeats the model +* OS compromise defeats the model +* Clearing browser data destroys the key + +Device-key loss is considered a recoverable event. + +--- + +# Host Authorization + +## Host ACL + +Each Host maintains a local authorization list. + +The ACL is authoritative. + +The Server cannot unilaterally grant access. + +Example: + +```text +Dormouse Terminal + +Authorized Clients: + +- iPhone Safari + Passkey A + Device Key 1 + +- Android Chrome + Passkey A + Device Key 2 + +- MacBook Chrome + Passkey B + Device Key 3 +``` + +## ACL Record + +Recommended fields: + +```text +host_id +account_id +passkey_credential_id +passkey_public_key_hash +device_public_key +approved_at +approved_by +label +revoked_at +``` + +The exact schema may evolve. + +--- + +# Pairing Ceremony + +## Purpose + +Establish trust between one Client and one Host. + +Pairing requires physical access to the Host. + +## Security Property + +A newly-added passkey is not automatically trusted. + +The Client must still complete Host pairing. + +## Flow + +1. Client authenticates using a passkey. +2. Client presents its device public key. +3. Host displays local approval UI. +4. User approves locally on the Host. +5. Host stores: + + * passkey credential identity + * device public key + * authorization metadata +6. Pairing completes. + +The Client is now trusted by that Host. + +No other Hosts are affected. + +--- + +# Connection Establishment + +## Requirements + +A connection succeeds only if: + +1. The passkey successfully proves fresh user presence. +2. The Server recognizes the account. +3. The Host recognizes the passkey credential. +4. The Host recognizes the device key. +5. The Client signs a fresh Host challenge. + +All requirements are mandatory. + +## Flow + +1. Client requests connection. +2. Server validates account state. +3. Host issues a fresh challenge. +4. Client performs WebAuthn authentication. +5. Client signs the Host challenge using its device key. +6. Server validates WebAuthn. +7. Host validates: + + * passkey authorization + * device authorization + * challenge signature +8. Session is established. + +The Host makes the final decision. + +--- + +# Storage Durability + +## iOS + +### Browser Tab + +Durability is limited. + +Browser-managed storage may be removed after periods of inactivity. + +Do not treat browser-tab storage as permanent. + +### Installed PWA + +Preferred mode. + +Dormouse should encourage installation before pairing. + +Recommended behavior: + +* Generate device key only while running as an installed PWA. +* Pair only after installation. + +## Android + +### Browser Tab + +Generally durable. + +Suitable for casual use. + +### Installed PWA + +Preferred mode. + +Provides the strongest web-only durability guarantees. + +--- + +# Installed PWA Detection + +Dormouse can detect whether it is currently running as an installed application: + +```ts +const isInstalledRuntime = + window.matchMedia('(display-mode: standalone)').matches || + window.matchMedia('(display-mode: fullscreen)').matches || + window.matchMedia('(display-mode: minimal-ui)').matches || + window.navigator.standalone === true; +``` + +This indicates that the application is currently running in standalone mode. + +It does not indicate whether the application was previously installed. + +--- + +# Device Key Loss + +Device-key loss is expected. + +Causes include: + +* Browser reset +* Site-data clearing +* Device replacement +* Browser corruption +* PWA removal + +Recovery process: + +1. User authenticates with a passkey. +2. Client generates a new device key. +3. Host performs a new pairing ceremony. +4. Previous device key may be revoked. + +No security compromise occurs. + +--- + +# Future: WebAuthn PRF + +WebAuthn PRF is a future enhancement. + +Potential uses include: + +* End-to-end session key derivation +* Reduced trust in the Server +* Stronger cryptographic channel binding +* Offline operation +* Noise-style authenticated key exchange + +PRF is not required for the core Dormouse security model. + +The MVP relies only on standard WebAuthn assertions and device-key authorization. + +--- + +# Security Guarantees + +Dormouse is designed so that: + +* Adding a new passkey does not grant Host access. +* Compromising the Server does not grant Host access. +* Passkey synchronization does not automatically create trusted Clients. +* Every trusted Client must be explicitly paired with every Host. +* Every connection requires fresh user presence. +* Every access decision is ultimately made by the Host. + +The Host remains the final authority throughout the system. diff --git a/server-lib-common/src/index.ts b/server-lib-common/src/index.ts index ef4785b4..af9cead5 100644 --- a/server-lib-common/src/index.ts +++ b/server-lib-common/src/index.ts @@ -5,8 +5,23 @@ * Keep this package runtime-agnostic: it is compiled into both a browser bundle * (via `lib`) and a Node process (`server`), so it must not reach for Node or * DOM globals. That is why `tsconfig.json` sets `"types": []`. + * + * The `security/` modules implement the primitives of + * `docs/specs/remote-security-model.md`: device keys, host challenges, + * passkey assertion verification, the Host ACL, the pairing ceremony, and + * connection authorization. */ +export * from './security/webcrypto.js'; +export * from './security/bytes.js'; +export * from './security/ecdsa.js'; +export * from './security/deviceKey.js'; +export * from './security/challenge.js'; +export * from './security/passkey.js'; +export * from './security/acl.js'; +export * from './security/pairing.js'; +export * from './security/connection.js'; + /** Path of the greeting endpoint that `server` serves and `lib` can call. */ export const HELLO_ROUTE = '/api/hello'; diff --git a/server-lib-common/src/security/acl.ts b/server-lib-common/src/security/acl.ts new file mode 100644 index 00000000..2527b46c --- /dev/null +++ b/server-lib-common/src/security/acl.ts @@ -0,0 +1,159 @@ +/** + * The Host ACL: the authorization primitive. + * + * Each Host maintains its own local list of approved Clients; it is the + * authoritative record — the Server cannot add to it. An approved Client is + * the *pair* of a passkey credential (who) and a device public key (which + * browser): a connection is authorized only when both appear on the same + * active record. + */ + +export interface HostAclRecord { + readonly hostId: string; + readonly accountId: string; + readonly passkeyCredentialId: string; + /** SHA-256 of the passkey's SPKI public key, base64url (see passkey.ts). */ + readonly passkeyPublicKeyHash: string; + /** Base64url raw P-256 point — the Client's identity (see deviceKey.ts). */ + readonly devicePublicKey: string; + /** Epoch milliseconds. */ + readonly approvedAt: number; + /** Who performed the local approval on the Host, e.g. `host-user`. */ + readonly approvedBy: string; + /** Human-readable client name shown in the Host's UI, e.g. `iPhone Safari`. */ + readonly label: string; + /** Epoch milliseconds, or null while the record is active. */ + readonly revokedAt: number | null; +} + +/** Everything the pairing ceremony supplies when approving a Client. */ +export interface ApprovedClient { + readonly accountId: string; + readonly passkeyCredentialId: string; + readonly passkeyPublicKeyHash: string; + readonly devicePublicKey: string; + readonly approvedBy: string; + readonly label: string; +} + +export interface HostAclOptions { + /** Clock returning epoch milliseconds; injectable for tests. */ + readonly now?: () => number; +} + +export class HostAcl { + readonly hostId: string; + readonly #now: () => number; + /** Mutable record objects stay private; every public API returns copies. */ + #records: Array = []; + + constructor(hostId: string, options: HostAclOptions = {}) { + this.hostId = hostId; + this.#now = options.now ?? (() => Date.now()); + } + + /** Restore an ACL from persisted records (the output of {@link records}). */ + static fromRecords( + hostId: string, + records: readonly HostAclRecord[], + options: HostAclOptions = {}, + ): HostAcl { + const acl = new HostAcl(hostId, options); + for (const record of records) { + if (record.hostId !== hostId) { + throw new Error(`ACL record for host ${record.hostId} cannot be loaded into ${hostId}`); + } + acl.#records.push({ ...record }); + } + return acl; + } + + /** + * Add an approved Client. Only the pairing ceremony should call this — it + * is the step that requires local user approval on the Host. Re-approving + * an existing (passkey, device key) pair supersedes the old record. + */ + approve(client: ApprovedClient): HostAclRecord { + const now = this.#now(); + const existing = this.#findActive(client.passkeyCredentialId, client.devicePublicKey); + if (existing) existing.revokedAt = now; + const record = { + ...client, + hostId: this.hostId, + approvedAt: now, + revokedAt: null, + }; + this.#records.push(record); + return { ...record }; + } + + /** All records, including revoked ones (for persistence and audit UI). */ + records(): HostAclRecord[] { + return this.#records.map((record) => ({ ...record })); + } + + activeRecords(): HostAclRecord[] { + return this.#records + .filter((record) => record.revokedAt === null) + .map((record) => ({ ...record })); + } + + /** + * The authorization lookup: an active record where BOTH the passkey + * credential and the device key match. Matching one but not the other is + * not authorization. + */ + findActive(query: { + readonly passkeyCredentialId: string; + readonly devicePublicKey: string; + }): HostAclRecord | undefined { + const found = this.#findActive(query.passkeyCredentialId, query.devicePublicKey); + return found ? { ...found } : undefined; + } + + hasActivePasskey(passkeyCredentialId: string): boolean { + return this.#records.some( + (record) => record.revokedAt === null && record.passkeyCredentialId === passkeyCredentialId, + ); + } + + hasActiveDevice(devicePublicKey: string): boolean { + return this.#records.some( + (record) => record.revokedAt === null && record.devicePublicKey === devicePublicKey, + ); + } + + /** Revoke every active record for a device key; returns how many were revoked. */ + revokeDevice(devicePublicKey: string): number { + return this.#revokeMatching((record) => record.devicePublicKey === devicePublicKey); + } + + /** Revoke every active record for a passkey credential; returns how many were revoked. */ + revokePasskey(passkeyCredentialId: string): number { + return this.#revokeMatching((record) => record.passkeyCredentialId === passkeyCredentialId); + } + + #findActive( + passkeyCredentialId: string, + devicePublicKey: string, + ): (HostAclRecord & { revokedAt: number | null }) | undefined { + return this.#records.find( + (record) => + record.revokedAt === null && + record.passkeyCredentialId === passkeyCredentialId && + record.devicePublicKey === devicePublicKey, + ); + } + + #revokeMatching(matches: (record: HostAclRecord) => boolean): number { + const now = this.#now(); + let revoked = 0; + for (const record of this.#records) { + if (record.revokedAt === null && matches(record)) { + record.revokedAt = now; + revoked++; + } + } + return revoked; + } +} diff --git a/server-lib-common/src/security/bytes.ts b/server-lib-common/src/security/bytes.ts new file mode 100644 index 00000000..c7259db8 --- /dev/null +++ b/server-lib-common/src/security/bytes.ts @@ -0,0 +1,168 @@ +/** + * Byte-level helpers shared by the security primitives. + * + * Implemented from scratch (no `TextEncoder`, `atob`, or `Buffer`) because + * this package compiles against the bare ES2022 lib — see `webcrypto.ts` for + * why. All wire-format values in the security model are base64url strings. + */ + +const B64U_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +const B64U_REVERSE: Int16Array = (() => { + const table = new Int16Array(128).fill(-1); + for (let i = 0; i < B64U_ALPHABET.length; i++) { + table[B64U_ALPHABET.charCodeAt(i)] = i; + } + return table; +})(); + +/** Encode bytes as unpadded base64url. */ +export function toBase64Url(bytes: Uint8Array): string { + let out = ''; + for (let i = 0; i < bytes.length; i += 3) { + const b0 = bytes[i]!; + const b1 = i + 1 < bytes.length ? bytes[i + 1]! : undefined; + const b2 = i + 2 < bytes.length ? bytes[i + 2]! : undefined; + out += B64U_ALPHABET[b0 >> 2]!; + out += B64U_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)]!; + if (b1 !== undefined) out += B64U_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)]!; + if (b2 !== undefined) out += B64U_ALPHABET[b2 & 0x3f]!; + } + return out; +} + +/** + * Decode base64url. Trailing `=` padding is tolerated; anything else invalid + * (bad characters, impossible length, nonzero trailing bits) throws, so a + * given byte string has exactly one accepted encoding. + */ +export function fromBase64Url(text: string): Uint8Array { + let end = text.length; + while (end > 0 && text[end - 1] === '=') end--; + const rem = end % 4; + if (rem === 1) throw new Error('invalid base64url: impossible length'); + const outLength = (end >> 2) * 3 + (rem === 0 ? 0 : rem - 1); + const out = new Uint8Array(outLength); + let bits = 0; + let bitCount = 0; + let outIndex = 0; + for (let i = 0; i < end; i++) { + const code = text.charCodeAt(i); + const value = code < 128 ? B64U_REVERSE[code]! : -1; + if (value < 0) throw new Error(`invalid base64url character at index ${i}`); + bits = (bits << 6) | value; + bitCount += 6; + if (bitCount >= 8) { + bitCount -= 8; + out[outIndex++] = (bits >> bitCount) & 0xff; + } + } + if ((bits & ((1 << bitCount) - 1)) !== 0) { + throw new Error('invalid base64url: nonzero trailing bits'); + } + return out; +} + +/** Encode a string as UTF-8 bytes. */ +export function utf8Encode(text: string): Uint8Array { + const out: number[] = []; + for (const ch of text) { + const cp = ch.codePointAt(0)!; + if (cp <= 0x7f) { + out.push(cp); + } else if (cp <= 0x7ff) { + out.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f)); + } else if (cp <= 0xffff) { + out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); + } else { + out.push( + 0xf0 | (cp >> 18), + 0x80 | ((cp >> 12) & 0x3f), + 0x80 | ((cp >> 6) & 0x3f), + 0x80 | (cp & 0x3f), + ); + } + } + return Uint8Array.from(out); +} + +/** Decode UTF-8 bytes to a string; throws on structurally invalid sequences. */ +export function utf8Decode(bytes: Uint8Array): string { + let out = ''; + let i = 0; + while (i < bytes.length) { + const b0 = bytes[i]!; + let cp: number; + let extra: number; + if (b0 < 0x80) { + cp = b0; + extra = 0; + } else if ((b0 & 0xe0) === 0xc0) { + cp = b0 & 0x1f; + extra = 1; + } else if ((b0 & 0xf0) === 0xe0) { + cp = b0 & 0x0f; + extra = 2; + } else if ((b0 & 0xf8) === 0xf0) { + cp = b0 & 0x07; + extra = 3; + } else { + throw new Error(`invalid UTF-8 lead byte at index ${i}`); + } + if (i + extra >= bytes.length) throw new Error('truncated UTF-8 sequence'); + for (let j = 1; j <= extra; j++) { + const b = bytes[i + j]!; + if ((b & 0xc0) !== 0x80) throw new Error(`invalid UTF-8 continuation byte at index ${i + j}`); + cp = (cp << 6) | (b & 0x3f); + } + if (cp > 0x10ffff) throw new Error(`invalid UTF-8 code point at index ${i}`); + out += String.fromCodePoint(cp); + i += extra + 1; + } + return out; +} + +/** Concatenate byte arrays. */ +export function concatBytes(...parts: readonly Uint8Array[]): Uint8Array { + let total = 0; + for (const part of parts) total += part.length; + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +/** + * Concatenate byte arrays with a 4-byte big-endian length before each part. + * Used to build signing payloads: unlike plain concatenation, the framing + * makes the field boundaries part of the signed bytes, so + * `["ab","c"]` and `["a","bc"]` can never collide. + */ +export function lengthPrefixedConcat(parts: readonly Uint8Array[]): Uint8Array { + let total = 0; + for (const part of parts) total += 4 + part.length; + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out[offset] = (part.length >>> 24) & 0xff; + out[offset + 1] = (part.length >>> 16) & 0xff; + out[offset + 2] = (part.length >>> 8) & 0xff; + out[offset + 3] = part.length & 0xff; + out.set(part, offset + 4); + offset += 4 + part.length; + } + return out; +} + +/** Compare byte arrays without early exit on the first mismatching byte. */ +export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i]! ^ b[i]!; + } + return diff === 0; +} diff --git a/server-lib-common/src/security/challenge.ts b/server-lib-common/src/security/challenge.ts new file mode 100644 index 00000000..4cc42dee --- /dev/null +++ b/server-lib-common/src/security/challenge.ts @@ -0,0 +1,79 @@ +/** + * Host challenges: the freshness primitive. + * + * Every connection attempt consumes a challenge that the Host itself issued + * moments earlier. Challenges are unguessable (256 bits), expire quickly, and + * are single-use — consuming one removes it whether or not the rest of the + * connection attempt succeeds, so a captured request can never be replayed. + */ + +import { toBase64Url } from './bytes.js'; +import { getWebCrypto, type WebCryptoLike } from './webcrypto.js'; + +export const CHALLENGE_BYTE_LENGTH = 32; +export const DEFAULT_CHALLENGE_TTL_MS = 2 * 60 * 1000; + +export interface IssuedChallenge { + /** Base64url challenge bytes; also the handle used to consume it. */ + readonly challenge: string; + readonly issuedAt: number; + readonly expiresAt: number; +} + +export interface HostChallengeIssuerOptions { + readonly ttlMs?: number; + /** Clock returning epoch milliseconds; injectable for tests. */ + readonly now?: () => number; + readonly crypto?: WebCryptoLike; +} + +export class HostChallengeIssuer { + readonly #pending = new Map(); + readonly #ttlMs: number; + readonly #now: () => number; + readonly #crypto: WebCryptoLike; + + constructor(options: HostChallengeIssuerOptions = {}) { + this.#ttlMs = options.ttlMs ?? DEFAULT_CHALLENGE_TTL_MS; + this.#now = options.now ?? (() => Date.now()); + this.#crypto = options.crypto ?? getWebCrypto(); + } + + issue(): IssuedChallenge { + const bytes = this.#crypto.getRandomValues(new Uint8Array(CHALLENGE_BYTE_LENGTH)); + const challenge = toBase64Url(bytes); + const issuedAt = this.#now(); + const expiresAt = issuedAt + this.#ttlMs; + this.#pending.set(challenge, expiresAt); + return { challenge, issuedAt, expiresAt }; + } + + /** + * Redeem a challenge. True only if this issuer issued it, it has not + * expired, and it has not been consumed before. The challenge is removed + * even when expired, so it can never become valid again. + */ + consume(challenge: string): boolean { + const expiresAt = this.#pending.get(challenge); + if (expiresAt === undefined) return false; + this.#pending.delete(challenge); + return this.#now() < expiresAt; + } + + /** Drop expired challenges; returns how many were removed. */ + pruneExpired(): number { + const now = this.#now(); + let pruned = 0; + for (const [challenge, expiresAt] of this.#pending) { + if (now >= expiresAt) { + this.#pending.delete(challenge); + pruned++; + } + } + return pruned; + } + + get pendingCount(): number { + return this.#pending.size; + } +} diff --git a/server-lib-common/src/security/connection.ts b/server-lib-common/src/security/connection.ts new file mode 100644 index 00000000..223d53bc --- /dev/null +++ b/server-lib-common/src/security/connection.ts @@ -0,0 +1,162 @@ +/** + * Connection establishment: the Host's final access decision. + * + * A connection succeeds only if (spec: docs/specs/remote-security-model.md): + * + * 1. The passkey proves fresh user presence. + * 2. The Server recognizes the account. (Server-side; not decided here.) + * 3. The Host recognizes the passkey credential. + * 4. The Host recognizes the device key. + * 5. The Client signed a fresh Host challenge with its device key. + * + * {@link authorizeConnection} is the Host side of that decision. It evaluates + * every layer — never short-circuiting on the first failure — and allows only + * when ALL of them pass. Requirement 2 belongs to the Server; the Host + * repeats the account binding anyway by checking the ACL record's account, + * because the Server is not trusted with the final decision. + */ + +import { HostAcl, type HostAclRecord } from './acl.js'; +import { HostChallengeIssuer } from './challenge.js'; +import { verifyDeviceChallengeSignature } from './deviceKey.js'; +import { + hashPasskeyPublicKey, + verifyPasskeyAssertion, + type PasskeyAssertion, + type PasskeyAssertionResult, +} from './passkey.js'; +import { getWebCrypto, type WebCryptoLike } from './webcrypto.js'; + +/** What a Client submits to open a session (all binary fields base64url). */ +export interface ConnectionRequest { + readonly accountId: string; + /** The Client identity claimed for this connection (see deviceKey.ts). */ + readonly devicePublicKey: string; + /** The Host challenge this request answers. */ + readonly challenge: string; + /** Device-key signature over the challenge (see deviceKey.ts `signDeviceChallenge`). */ + readonly deviceSignature: string; + readonly passkey: { + /** Full SPKI public key; the Host checks it against the ACL's stored hash. */ + readonly publicKey: string; + /** WebAuthn assertion bound to the same Host challenge. */ + readonly assertion: PasskeyAssertion; + }; +} + +/** The Host-side state and policy that decide a connection. */ +export interface HostAuthority { + readonly hostId: string; + readonly acl: HostAcl; + readonly challenges: HostChallengeIssuer; + readonly policy: ConnectionPolicy; +} + +export interface ConnectionPolicy { + /** Relying-party id passkey assertions must be scoped to, e.g. `dormouse.dev`. */ + readonly rpId: string; + /** Web origin(s) Clients may connect from. */ + readonly origin: string | readonly string[]; + /** Demand biometric/PIN user verification, not just user presence. */ + readonly requireUserVerification?: boolean; +} + +export type ConnectionFailure = + /** Challenge unknown, expired, or already used. */ + | 'challenge-invalid' + /** WebAuthn assertion failed; see `passkey.reason` on the decision. */ + | 'passkey-assertion-invalid' + /** No active ACL record includes this passkey credential. */ + | 'passkey-not-paired' + /** No active ACL record includes this device key. */ + | 'device-not-paired' + /** Passkey and device key are each paired, but never together. */ + | 'pairing-mismatch' + /** Presented passkey public key does not hash to the ACL's stored hash. */ + | 'passkey-key-mismatch' + /** The ACL record was approved for a different account. */ + | 'account-mismatch' + /** Device-key signature over the Host challenge did not verify. */ + | 'device-signature-invalid'; + +export interface ConnectionDecision { + readonly allowed: boolean; + /** Empty when allowed; otherwise every layer that failed. */ + readonly failures: readonly ConnectionFailure[]; + /** The authorizing ACL record; null unless allowed. */ + readonly record: HostAclRecord | null; + /** Passkey verification detail (e.g. `userVerified`), for logging/UI. */ + readonly passkey: PasskeyAssertionResult; +} + +/** + * The Host's final access decision. The challenge is consumed up front — + * success or failure, a challenge can only ever be presented once. + */ +export async function authorizeConnection( + host: HostAuthority, + request: ConnectionRequest, + crypto: WebCryptoLike = getWebCrypto(), +): Promise { + const failures: ConnectionFailure[] = []; + + // 5 (freshness half): burn the challenge before any other work. + if (!host.challenges.consume(request.challenge)) { + failures.push('challenge-invalid'); + } + + // 1: fresh user presence — a WebAuthn assertion over this same challenge. + const passkey = await verifyPasskeyAssertion( + request.passkey.assertion, + request.passkey.publicKey, + { + challenge: request.challenge, + origin: host.policy.origin, + rpId: host.policy.rpId, + requireUserVerification: host.policy.requireUserVerification, + }, + crypto, + ); + if (!passkey.ok) failures.push('passkey-assertion-invalid'); + + // 3 + 4: both identities must sit on the same active ACL record. + const passkeyCredentialId = request.passkey.assertion.credentialId; + const record = host.acl.findActive({ + passkeyCredentialId, + devicePublicKey: request.devicePublicKey, + }); + if (!record) { + const passkeyPaired = host.acl.hasActivePasskey(passkeyCredentialId); + const devicePaired = host.acl.hasActiveDevice(request.devicePublicKey); + if (!passkeyPaired) failures.push('passkey-not-paired'); + if (!devicePaired) failures.push('device-not-paired'); + if (passkeyPaired && devicePaired) failures.push('pairing-mismatch'); + } else { + if ((await hashPasskeyPublicKey(request.passkey.publicKey, crypto)) !== record.passkeyPublicKeyHash) { + failures.push('passkey-key-mismatch'); + } + if (record.accountId !== request.accountId) { + failures.push('account-mismatch'); + } + } + + // 5 (identity half): the paired device key must have signed this challenge. + const signatureValid = await verifyDeviceChallengeSignature( + { + hostId: host.hostId, + challenge: request.challenge, + devicePublicKey: request.devicePublicKey, + }, + request.deviceSignature, + crypto, + ); + if (!signatureValid) failures.push('device-signature-invalid'); + + const allowed = failures.length === 0; + return { + allowed, + failures, + record: allowed ? (record ?? null) : null, + passkey, + }; +} diff --git a/server-lib-common/src/security/deviceKey.ts b/server-lib-common/src/security/deviceKey.ts new file mode 100644 index 00000000..4c653f64 --- /dev/null +++ b/server-lib-common/src/security/deviceKey.ts @@ -0,0 +1,116 @@ +/** + * Device keys: long-lived Client identity (spec: docs/specs/remote-security-model.md). + * + * Each Client generates a non-extractable ECDSA P-256 keypair in the browser; + * the base64url raw public key is the Client's identifier everywhere in the + * model (Host ACL records, pairing requests, connection requests). The private + * key signs Host challenges to prove the connecting Client is the paired one — + * a capability a synced or newly-added passkey does not confer. + */ + +import { fromBase64Url, lengthPrefixedConcat, toBase64Url, utf8Encode } from './bytes.js'; +import { getWebCrypto, type CryptoKeyLike, type WebCryptoLike } from './webcrypto.js'; + +export const DEVICE_KEY_ALGORITHM = { name: 'ECDSA', namedCurve: 'P-256' } as const; +export const DEVICE_SIGN_ALGORITHM = { name: 'ECDSA', hash: 'SHA-256' } as const; + +/** + * Domain-separation tag mixed into every device-key signature so a signature + * produced for device auth can never be replayed as any other kind of + * statement (and vice versa). Bump the version on any payload format change. + */ +export const DEVICE_AUTH_DOMAIN = 'dormouse/device-auth/v1'; + +export interface DeviceKeyPair { + readonly publicKey: CryptoKeyLike; + /** Non-extractable: can sign, but the key material never leaves the runtime. */ + readonly privateKey: CryptoKeyLike; + /** Base64url raw P-256 point — the Client's identity string. */ + readonly devicePublicKey: string; +} + +/** + * Generate a Client device keypair. The private key is non-extractable; the + * browser side is expected to persist the `CryptoKey` objects in IndexedDB + * (with `navigator.storage.persist()`) rather than exporting anything. + */ +export async function generateDeviceKeyPair( + crypto: WebCryptoLike = getWebCrypto(), +): Promise { + const pair = await crypto.subtle.generateKey(DEVICE_KEY_ALGORITHM, false, ['sign', 'verify']); + const raw = await crypto.subtle.exportKey('raw', pair.publicKey); + return { + publicKey: pair.publicKey, + privateKey: pair.privateKey, + devicePublicKey: toBase64Url(new Uint8Array(raw)), + }; +} + +/** Import a Client identity string back into a verification key. Throws if malformed. */ +export async function importDevicePublicKey( + devicePublicKey: string, + crypto: WebCryptoLike = getWebCrypto(), +): Promise { + return crypto.subtle.importKey('raw', fromBase64Url(devicePublicKey), DEVICE_KEY_ALGORITHM, true, [ + 'verify', + ]); +} + +/** + * What a device-key signature attests to: "this device key answers this + * challenge from this host". Binding the host id scopes the signature to one + * Host; binding the device public key pins which identity the signer claims. + */ +export interface DeviceAuthContext { + readonly hostId: string; + /** Base64url challenge issued by the Host. */ + readonly challenge: string; + /** Base64url device public key the Client claims as its identity. */ + readonly devicePublicKey: string; +} + +/** The exact bytes a device key signs for {@link DeviceAuthContext}. */ +export function deviceAuthPayload(context: DeviceAuthContext): Uint8Array { + return lengthPrefixedConcat([ + utf8Encode(DEVICE_AUTH_DOMAIN), + utf8Encode(context.hostId), + fromBase64Url(context.challenge), + fromBase64Url(context.devicePublicKey), + ]); +} + +/** Client side: sign a Host challenge with the device private key. Returns base64url. */ +export async function signDeviceChallenge( + privateKey: CryptoKeyLike, + context: DeviceAuthContext, + crypto: WebCryptoLike = getWebCrypto(), +): Promise { + const signature = await crypto.subtle.sign( + DEVICE_SIGN_ALGORITHM, + privateKey, + deviceAuthPayload(context), + ); + return toBase64Url(new Uint8Array(signature)); +} + +/** + * Host side: verify a device-key signature. Returns false (never throws) for + * malformed keys, malformed signatures, or any mismatch with the context. + */ +export async function verifyDeviceChallengeSignature( + context: DeviceAuthContext, + signature: string, + crypto: WebCryptoLike = getWebCrypto(), +): Promise { + try { + const publicKey = await importDevicePublicKey(context.devicePublicKey, crypto); + return await crypto.subtle.verify( + DEVICE_SIGN_ALGORITHM, + publicKey, + fromBase64Url(signature), + deviceAuthPayload(context), + ); + } catch { + return false; + } +} diff --git a/server-lib-common/src/security/ecdsa.ts b/server-lib-common/src/security/ecdsa.ts new file mode 100644 index 00000000..1bea7564 --- /dev/null +++ b/server-lib-common/src/security/ecdsa.ts @@ -0,0 +1,82 @@ +/** + * ECDSA signature format conversion. + * + * WebCrypto produces and consumes "raw" ECDSA signatures (`r || s`, fixed + * width), while WebAuthn authenticators emit ASN.1 DER + * (`SEQUENCE { INTEGER r, INTEGER s }`). Passkey assertion verification needs + * DER→raw; the test harness's simulated authenticator needs raw→DER. + */ + +const P256_COORDINATE_LENGTH = 32; + +/** Convert a raw `r || s` ECDSA signature to ASN.1 DER. */ +export function ecdsaRawToDer(raw: Uint8Array): Uint8Array { + if (raw.length === 0 || raw.length % 2 !== 0) { + throw new Error(`invalid raw ECDSA signature length ${raw.length}`); + } + const half = raw.length / 2; + const r = derInteger(raw.subarray(0, half)); + const s = derInteger(raw.subarray(half)); + const bodyLength = r.length + s.length; + if (bodyLength > 127) throw new Error('ECDSA signature too large for short-form DER'); + const out = new Uint8Array(2 + bodyLength); + out[0] = 0x30; + out[1] = bodyLength; + out.set(r, 2); + out.set(s, 2 + r.length); + return out; +} + +function derInteger(value: Uint8Array): Uint8Array { + let start = 0; + while (start < value.length - 1 && value[start] === 0) start++; + const trimmed = value.subarray(start); + // A set high bit would read as a negative INTEGER; prepend a zero byte. + const pad = trimmed[0]! & 0x80 ? 1 : 0; + const out = new Uint8Array(2 + pad + trimmed.length); + out[0] = 0x02; + out[1] = pad + trimmed.length; + out.set(trimmed, 2 + pad); + return out; +} + +/** + * Convert an ASN.1 DER ECDSA signature to raw `r || s` with fixed-width + * coordinates (P-256 by default). Throws on any structural deviation. + */ +export function ecdsaDerToRaw( + der: Uint8Array, + coordinateLength: number = P256_COORDINATE_LENGTH, +): Uint8Array { + if (der.length < 2 || der[0] !== 0x30) throw new Error('invalid DER: expected SEQUENCE'); + let offset = 1; + let sequenceLength = der[offset++]!; + if (sequenceLength === 0x81) { + sequenceLength = der[offset++]!; + } else if (sequenceLength > 0x80) { + throw new Error('invalid DER: unsupported length encoding'); + } + if (offset + sequenceLength !== der.length) throw new Error('invalid DER: length mismatch'); + const out = new Uint8Array(coordinateLength * 2); + offset = readDerInteger(der, offset, out.subarray(0, coordinateLength)); + offset = readDerInteger(der, offset, out.subarray(coordinateLength)); + if (offset !== der.length) throw new Error('invalid DER: trailing bytes'); + return out; +} + +/** Parse one INTEGER at `offset`, right-align it into `into`, return the next offset. */ +function readDerInteger(der: Uint8Array, offset: number, into: Uint8Array): number { + if (offset + 2 > der.length || der[offset] !== 0x02) { + throw new Error('invalid DER: expected INTEGER'); + } + const length = der[offset + 1]!; + if (length === 0 || length > 0x80) throw new Error('invalid DER: bad INTEGER length'); + const start = offset + 2; + if (start + length > der.length) throw new Error('invalid DER: truncated INTEGER'); + let valueStart = start; + while (valueStart < start + length - 1 && der[valueStart] === 0) valueStart++; + const valueLength = start + length - valueStart; + if (valueLength > into.length) throw new Error('invalid DER: INTEGER too large'); + into.set(der.subarray(valueStart, start + length), into.length - valueLength); + return start + length; +} diff --git a/server-lib-common/src/security/pairing.ts b/server-lib-common/src/security/pairing.ts new file mode 100644 index 00000000..b705b15c --- /dev/null +++ b/server-lib-common/src/security/pairing.ts @@ -0,0 +1,153 @@ +/** + * The pairing ceremony: how a Client earns a Host ACL record. + * + * Pairing is the only path into the ACL, and its critical step — `approve` — + * models the local approval UI on the Host. The Server can relay a pairing + * *request*, but only someone at the Host can turn it into authorization. + * + * Integration contract: the Host must verify a fresh passkey assertion for + * the requesting account (see passkey.ts) before calling {@link begin} — a + * pairing request is meaningless unless the user just proved presence. + */ + +import { toBase64Url } from './bytes.js'; +import { getWebCrypto, type WebCryptoLike } from './webcrypto.js'; +import { HostAcl, type HostAclRecord } from './acl.js'; + +export const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000; +const PAIRING_ID_BYTE_LENGTH = 16; + +/** What a Client submits to request pairing (after passkey authentication). */ +export interface PairingRequest { + readonly accountId: string; + readonly passkeyCredentialId: string; + /** See passkey.ts `hashPasskeyPublicKey`. */ + readonly passkeyPublicKeyHash: string; + /** The Client identity being authorized (see deviceKey.ts). */ + readonly devicePublicKey: string; + /** Client-suggested label; the approver may override it. */ + readonly requestedLabel: string; +} + +export type PairingState = 'pending' | 'approved' | 'denied' | 'expired'; + +/** A snapshot of one pairing attempt, e.g. for the Host's approval UI. */ +export interface PairingTicket { + readonly pairingId: string; + readonly state: PairingState; + readonly request: PairingRequest; + readonly requestedAt: number; + readonly expiresAt: number; +} + +export type PairingErrorCode = 'unknown-pairing' | 'not-pending' | 'expired'; + +export class PairingError extends Error { + readonly code: PairingErrorCode; + + constructor(code: PairingErrorCode, message: string) { + super(message); + this.name = 'PairingError'; + this.code = code; + } +} + +export interface PairingCeremonyOptions { + readonly ttlMs?: number; + /** Clock returning epoch milliseconds; injectable for tests. */ + readonly now?: () => number; + readonly crypto?: WebCryptoLike; +} + +export class PairingCeremony { + readonly #acl: HostAcl; + readonly #tickets = new Map(); + readonly #ttlMs: number; + readonly #now: () => number; + readonly #crypto: WebCryptoLike; + + constructor(acl: HostAcl, options: PairingCeremonyOptions = {}) { + this.#acl = acl; + this.#ttlMs = options.ttlMs ?? DEFAULT_PAIRING_TTL_MS; + this.#now = options.now ?? (() => Date.now()); + this.#crypto = options.crypto ?? getWebCrypto(); + } + + /** Register a pairing request and hand back the ticket to show for approval. */ + begin(request: PairingRequest): PairingTicket { + const pairingId = toBase64Url( + this.#crypto.getRandomValues(new Uint8Array(PAIRING_ID_BYTE_LENGTH)), + ); + const requestedAt = this.#now(); + const ticket: Ticket = { + pairingId, + state: 'pending', + request: { ...request }, + requestedAt, + expiresAt: requestedAt + this.#ttlMs, + }; + this.#tickets.set(pairingId, ticket); + return this.#snapshot(ticket); + } + + get(pairingId: string): PairingTicket | undefined { + const ticket = this.#tickets.get(pairingId); + return ticket ? this.#snapshot(ticket) : undefined; + } + + /** + * The local user approval on the Host. This is the ONLY call that writes to + * the ACL. Throws {@link PairingError} unless the ticket is pending and + * unexpired. + */ + approve(pairingId: string, approval: { approvedBy: string; label?: string }): HostAclRecord { + const ticket = this.#requirePending(pairingId); + ticket.state = 'approved'; + return this.#acl.approve({ + accountId: ticket.request.accountId, + passkeyCredentialId: ticket.request.passkeyCredentialId, + passkeyPublicKeyHash: ticket.request.passkeyPublicKeyHash, + devicePublicKey: ticket.request.devicePublicKey, + approvedBy: approval.approvedBy, + label: approval.label ?? ticket.request.requestedLabel, + }); + } + + /** Reject a pending pairing request; the ACL is untouched. */ + deny(pairingId: string): void { + const ticket = this.#requirePending(pairingId); + ticket.state = 'denied'; + } + + #requirePending(pairingId: string): Ticket { + const ticket = this.#tickets.get(pairingId); + if (!ticket) throw new PairingError('unknown-pairing', `unknown pairing ${pairingId}`); + this.#reapExpiry(ticket); + if (ticket.state === 'expired') { + throw new PairingError('expired', `pairing ${pairingId} expired`); + } + if (ticket.state !== 'pending') { + throw new PairingError('not-pending', `pairing ${pairingId} is already ${ticket.state}`); + } + return ticket; + } + + #reapExpiry(ticket: Ticket): void { + if (ticket.state === 'pending' && this.#now() >= ticket.expiresAt) { + ticket.state = 'expired'; + } + } + + #snapshot(ticket: Ticket): PairingTicket { + this.#reapExpiry(ticket); + return { ...ticket, request: { ...ticket.request } }; + } +} + +interface Ticket { + readonly pairingId: string; + state: PairingState; + readonly request: PairingRequest; + readonly requestedAt: number; + readonly expiresAt: number; +} diff --git a/server-lib-common/src/security/passkey.ts b/server-lib-common/src/security/passkey.ts new file mode 100644 index 00000000..aaa621d9 --- /dev/null +++ b/server-lib-common/src/security/passkey.ts @@ -0,0 +1,185 @@ +/** + * Passkeys: the fresh-user-presence primitive. + * + * Passkeys authenticate the user account; they never independently grant Host + * access (they are user credentials, not device identities — they sync across + * devices). This module verifies a WebAuthn authentication assertion so that + * BOTH the Server and the Host can independently check fresh user presence. + * The Host stores only a hash of each paired passkey's public key; the Client + * presents the full key at connection time and the Host checks it against the + * hash, so a compromised Server cannot substitute a different passkey. + * + * Only ES256 (ECDSA P-256 / SHA-256) is supported — the mandatory-to-implement + * WebAuthn algorithm and what every mainstream passkey provider issues. + */ + +import { concatBytes, constantTimeEqual, fromBase64Url, toBase64Url, utf8Decode, utf8Encode } from './bytes.js'; +import { ecdsaDerToRaw } from './ecdsa.js'; +import { getWebCrypto, type WebCryptoLike } from './webcrypto.js'; + +export const PASSKEY_KEY_ALGORITHM = { name: 'ECDSA', namedCurve: 'P-256' } as const; +export const PASSKEY_SIGN_ALGORITHM = { name: 'ECDSA', hash: 'SHA-256' } as const; + +/** Authenticator-data flag bits (WebAuthn §6.1). */ +const FLAG_USER_PRESENT = 0x01; +const FLAG_USER_VERIFIED = 0x04; +/** rpIdHash (32) + flags (1) + signCount (4). */ +const AUTHENTICATOR_DATA_MIN_LENGTH = 37; + +/** A WebAuthn authentication assertion as it travels over the wire (all base64url). */ +export interface PasskeyAssertion { + readonly credentialId: string; + readonly clientDataJSON: string; + readonly authenticatorData: string; + /** ASN.1 DER ECDSA signature, as produced by authenticators. */ + readonly signature: string; +} + +/** What the verifier demands of an assertion. */ +export interface PasskeyAssertionExpectations { + /** Base64url challenge the assertion must be bound to. */ + readonly challenge: string; + /** Origin(s) the assertion may come from, e.g. `https://dormouse.dev`. */ + readonly origin: string | readonly string[]; + /** Relying-party id the credential must be scoped to, e.g. `dormouse.dev`. */ + readonly rpId: string; + /** Require the authenticator's user-verification flag (biometric/PIN), not just presence. */ + readonly requireUserVerification?: boolean; +} + +export type PasskeyAssertionFailure = + | 'client-data-malformed' + | 'client-data-type' + | 'challenge-mismatch' + | 'origin-mismatch' + | 'authenticator-data-malformed' + | 'rp-id-mismatch' + | 'user-presence-missing' + | 'user-verification-missing' + | 'public-key-invalid' + | 'signature-invalid'; + +export type PasskeyAssertionResult = + | { + readonly ok: true; + readonly userPresent: true; + readonly userVerified: boolean; + readonly signCount: number; + } + | { readonly ok: false; readonly reason: PasskeyAssertionFailure }; + +/** + * Verify a WebAuthn authentication assertion against the given passkey public + * key (base64url SPKI). Never throws; returns the first failure encountered, + * in verification order. + */ +export async function verifyPasskeyAssertion( + assertion: PasskeyAssertion, + passkeyPublicKey: string, + expected: PasskeyAssertionExpectations, + crypto: WebCryptoLike = getWebCrypto(), +): Promise { + let clientData: { type?: unknown; challenge?: unknown; origin?: unknown }; + let clientDataBytes: Uint8Array; + try { + clientDataBytes = fromBase64Url(assertion.clientDataJSON); + const parsed: unknown = JSON.parse(utf8Decode(clientDataBytes)); + if (typeof parsed !== 'object' || parsed === null) throw new Error('not an object'); + clientData = parsed; + } catch { + return { ok: false, reason: 'client-data-malformed' }; + } + + if (clientData.type !== 'webauthn.get') return { ok: false, reason: 'client-data-type' }; + if ( + typeof clientData.challenge !== 'string' || + !base64UrlValuesEqual(clientData.challenge, expected.challenge) + ) { + return { ok: false, reason: 'challenge-mismatch' }; + } + const origins = typeof expected.origin === 'string' ? [expected.origin] : expected.origin; + if (typeof clientData.origin !== 'string' || !origins.includes(clientData.origin)) { + return { ok: false, reason: 'origin-mismatch' }; + } + + let authenticatorData: Uint8Array; + try { + authenticatorData = fromBase64Url(assertion.authenticatorData); + } catch { + return { ok: false, reason: 'authenticator-data-malformed' }; + } + if (authenticatorData.length < AUTHENTICATOR_DATA_MIN_LENGTH) { + return { ok: false, reason: 'authenticator-data-malformed' }; + } + const expectedRpIdHash = new Uint8Array( + await crypto.subtle.digest('SHA-256', utf8Encode(expected.rpId)), + ); + if (!constantTimeEqual(authenticatorData.subarray(0, 32), expectedRpIdHash)) { + return { ok: false, reason: 'rp-id-mismatch' }; + } + const flags = authenticatorData[32]!; + if ((flags & FLAG_USER_PRESENT) === 0) return { ok: false, reason: 'user-presence-missing' }; + const userVerified = (flags & FLAG_USER_VERIFIED) !== 0; + if (expected.requireUserVerification && !userVerified) { + return { ok: false, reason: 'user-verification-missing' }; + } + const signCount = + authenticatorData[33]! * 0x1000000 + + authenticatorData[34]! * 0x10000 + + authenticatorData[35]! * 0x100 + + authenticatorData[36]!; + + let publicKey; + try { + publicKey = await crypto.subtle.importKey( + 'spki', + fromBase64Url(passkeyPublicKey), + PASSKEY_KEY_ALGORITHM, + true, + ['verify'], + ); + } catch { + return { ok: false, reason: 'public-key-invalid' }; + } + + // The authenticator signs authenticatorData || SHA-256(clientDataJSON). + const clientDataHash = new Uint8Array(await crypto.subtle.digest('SHA-256', clientDataBytes)); + let verified: boolean; + try { + verified = await crypto.subtle.verify( + PASSKEY_SIGN_ALGORITHM, + publicKey, + ecdsaDerToRaw(fromBase64Url(assertion.signature)), + concatBytes(authenticatorData, clientDataHash), + ); + } catch { + return { ok: false, reason: 'signature-invalid' }; + } + if (!verified) return { ok: false, reason: 'signature-invalid' }; + + return { ok: true, userPresent: true, userVerified, signCount }; +} + +/** + * The Host ACL stores this hash (not the key itself) at pairing time; at + * connection time the presented key must hash to the stored value. + */ +export async function hashPasskeyPublicKey( + passkeyPublicKey: string, + crypto: WebCryptoLike = getWebCrypto(), +): Promise { + const digest = await crypto.subtle.digest('SHA-256', fromBase64Url(passkeyPublicKey)); + return toBase64Url(new Uint8Array(digest)); +} + +/** + * Compare two base64url strings by decoded value: browsers may re-encode the + * challenge with padding when building clientDataJSON. + */ +function base64UrlValuesEqual(a: string, b: string): boolean { + try { + return constantTimeEqual(fromBase64Url(a), fromBase64Url(b)); + } catch { + return false; + } +} diff --git a/server-lib-common/src/security/webcrypto.ts b/server-lib-common/src/security/webcrypto.ts new file mode 100644 index 00000000..b19f060a --- /dev/null +++ b/server-lib-common/src/security/webcrypto.ts @@ -0,0 +1,68 @@ +/** + * Minimal structural typings for the WebCrypto API. + * + * This package compiles with `"lib": ["ES2022"]` and `"types": []` so it can + * ship to both the browser (`lib`) and Node (`server`) without pulling in DOM + * or Node type definitions. Both runtimes expose the same WebCrypto + * implementation on `globalThis.crypto`; the interfaces here describe just the + * slice of it that the security primitives use, and real `CryptoKey` / + * `SubtleCrypto` objects satisfy them structurally. + */ + +export interface CryptoKeyLike { + readonly type: 'public' | 'private' | 'secret'; + readonly extractable: boolean; + readonly algorithm: object; + readonly usages: readonly string[]; +} + +export interface CryptoKeyPairLike { + readonly publicKey: CryptoKeyLike; + readonly privateKey: CryptoKeyLike; +} + +/** The subset of `SubtleCrypto` used by this package (asymmetric keys only). */ +export interface SubtleCryptoLike { + generateKey( + algorithm: object, + extractable: boolean, + keyUsages: readonly string[], + ): Promise; + exportKey(format: string, key: CryptoKeyLike): Promise; + importKey( + format: string, + keyData: Uint8Array, + algorithm: object, + extractable: boolean, + keyUsages: readonly string[], + ): Promise; + sign(algorithm: object, key: CryptoKeyLike, data: Uint8Array): Promise; + verify( + algorithm: object, + key: CryptoKeyLike, + signature: Uint8Array, + data: Uint8Array, + ): Promise; + digest(algorithm: string, data: Uint8Array): Promise; +} + +export interface WebCryptoLike { + readonly subtle: SubtleCryptoLike; + getRandomValues(array: T): T; +} + +/** + * The runtime's WebCrypto implementation. Every crypto-touching function in + * this package takes an optional `crypto` parameter defaulting to this, so + * tests can inject fakes and exotic runtimes can supply their own. + */ +export function getWebCrypto(): WebCryptoLike { + const crypto = (globalThis as { crypto?: WebCryptoLike }).crypto; + if (!crypto || !crypto.subtle) { + throw new Error( + 'WebCrypto is unavailable: globalThis.crypto.subtle is required ' + + '(all modern browsers and Node >= 20 provide it)', + ); + } + return crypto; +} diff --git a/server-lib-common/test/bytes.test.mjs b/server-lib-common/test/bytes.test.mjs new file mode 100644 index 00000000..e9d104dd --- /dev/null +++ b/server-lib-common/test/bytes.test.mjs @@ -0,0 +1,81 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + concatBytes, + constantTimeEqual, + fromBase64Url, + lengthPrefixedConcat, + toBase64Url, + utf8Decode, + utf8Encode, +} from '../dist/index.js'; + +test('base64url round-trips all byte values and lengths 0..8', () => { + const everyByte = Uint8Array.from({ length: 256 }, (_, i) => i); + assert.deepEqual(fromBase64Url(toBase64Url(everyByte)), everyByte); + for (let length = 0; length <= 8; length++) { + const bytes = globalThis.crypto.getRandomValues(new Uint8Array(length)); + assert.deepEqual(fromBase64Url(toBase64Url(bytes)), bytes, `length ${length}`); + } +}); + +test('base64url matches known vectors', () => { + assert.equal(toBase64Url(new Uint8Array(0)), ''); + assert.equal(toBase64Url(Uint8Array.of(0xff)), '_w'); + assert.equal(toBase64Url(utf8Encode('hello')), 'aGVsbG8'); + assert.deepEqual(fromBase64Url('aGVsbG8'), utf8Encode('hello')); +}); + +test('base64url tolerates trailing padding', () => { + assert.deepEqual(fromBase64Url('aGVsbG8='), utf8Encode('hello')); +}); + +test('base64url rejects malformed input', () => { + assert.throws(() => fromBase64Url('a+b/'), /invalid base64url/); + assert.throws(() => fromBase64Url('ab cd'), /invalid base64url/); + assert.throws(() => fromBase64Url('abcde'), /impossible length/); + // '_x' decodes to 0xff plus nonzero leftover bits — not a canonical encoding. + assert.throws(() => fromBase64Url('_x'), /nonzero trailing bits/); +}); + +test('utf8 round-trips ascii, multibyte, and astral text', () => { + for (const text of ['', 'hello', 'héllo wörld', '€100', '日本語', '🐭 dormouse 🛡️']) { + assert.equal(utf8Decode(utf8Encode(text)), text, JSON.stringify(text)); + } +}); + +test('utf8Encode matches known multibyte vector', () => { + // '€' is U+20AC → E2 82 AC. + assert.deepEqual(utf8Encode('€'), Uint8Array.of(0xe2, 0x82, 0xac)); +}); + +test('utf8Decode rejects structurally invalid sequences', () => { + assert.throws(() => utf8Decode(Uint8Array.of(0x80)), /lead byte/); + assert.throws(() => utf8Decode(Uint8Array.of(0xe2, 0x82)), /truncated/); + assert.throws(() => utf8Decode(Uint8Array.of(0xe2, 0x41, 0x41)), /continuation/); +}); + +test('concatBytes joins parts in order', () => { + assert.deepEqual( + concatBytes(Uint8Array.of(1, 2), new Uint8Array(0), Uint8Array.of(3)), + Uint8Array.of(1, 2, 3), + ); +}); + +test('lengthPrefixedConcat makes field boundaries unambiguous', () => { + const ab_c = lengthPrefixedConcat([utf8Encode('ab'), utf8Encode('c')]); + const a_bc = lengthPrefixedConcat([utf8Encode('a'), utf8Encode('bc')]); + assert.notDeepEqual(ab_c, a_bc); + assert.deepEqual( + lengthPrefixedConcat([utf8Encode('ab')]), + Uint8Array.of(0, 0, 0, 2, 0x61, 0x62), + ); +}); + +test('constantTimeEqual compares content, not identity', () => { + assert.equal(constantTimeEqual(Uint8Array.of(1, 2, 3), Uint8Array.of(1, 2, 3)), true); + assert.equal(constantTimeEqual(Uint8Array.of(1, 2, 3), Uint8Array.of(1, 2, 4)), false); + assert.equal(constantTimeEqual(Uint8Array.of(1, 2), Uint8Array.of(1, 2, 3)), false); + assert.equal(constantTimeEqual(new Uint8Array(0), new Uint8Array(0)), true); +}); diff --git a/server-lib-common/test/challenge.test.mjs b/server-lib-common/test/challenge.test.mjs new file mode 100644 index 00000000..4d74ffaf --- /dev/null +++ b/server-lib-common/test/challenge.test.mjs @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { DEFAULT_CHALLENGE_TTL_MS, HostChallengeIssuer, fromBase64Url } from '../dist/index.js'; +import { FakeClock } from './harness/actors.mjs'; + +test('issued challenges are 32 bytes and unique', () => { + const issuer = new HostChallengeIssuer(); + const seen = new Set(); + for (let i = 0; i < 100; i++) { + const { challenge } = issuer.issue(); + assert.equal(fromBase64Url(challenge).length, 32); + seen.add(challenge); + } + assert.equal(seen.size, 100); +}); + +test('issue stamps issuedAt and expiresAt from the clock and ttl', () => { + const clock = new FakeClock(); + const issuer = new HostChallengeIssuer({ now: clock.now, ttlMs: 5000 }); + const issued = issuer.issue(); + assert.equal(issued.issuedAt, clock.now()); + assert.equal(issued.expiresAt, clock.now() + 5000); +}); + +test('a fresh challenge consumes exactly once', () => { + const clock = new FakeClock(); + const issuer = new HostChallengeIssuer({ now: clock.now }); + const { challenge } = issuer.issue(); + assert.equal(issuer.consume(challenge), true); + assert.equal(issuer.consume(challenge), false, 'second consume must fail'); +}); + +test('an expired challenge cannot be consumed', () => { + const clock = new FakeClock(); + const issuer = new HostChallengeIssuer({ now: clock.now, ttlMs: 1000 }); + const { challenge } = issuer.issue(); + clock.advance(1000); + assert.equal(issuer.consume(challenge), false); + clock.advance(-500); // even if the clock rewinds, consumption already burned it + assert.equal(issuer.consume(challenge), false); +}); + +test('a challenge consumed just before expiry succeeds', () => { + const clock = new FakeClock(); + const issuer = new HostChallengeIssuer({ now: clock.now, ttlMs: 1000 }); + const { challenge } = issuer.issue(); + clock.advance(999); + assert.equal(issuer.consume(challenge), true); +}); + +test('unknown challenges are rejected', () => { + const issuer = new HostChallengeIssuer(); + assert.equal(issuer.consume('bm90LWEtY2hhbGxlbmdl'), false); + assert.equal(issuer.consume(''), false); +}); + +test('challenges from one issuer are unknown to another', () => { + const a = new HostChallengeIssuer(); + const b = new HostChallengeIssuer(); + const { challenge } = a.issue(); + assert.equal(b.consume(challenge), false); + assert.equal(a.consume(challenge), true); +}); + +test('the default ttl is two minutes', () => { + assert.equal(DEFAULT_CHALLENGE_TTL_MS, 120_000); +}); + +test('pruneExpired removes only expired challenges', () => { + const clock = new FakeClock(); + const issuer = new HostChallengeIssuer({ now: clock.now, ttlMs: 1000 }); + const stale = issuer.issue(); + clock.advance(600); + const fresh = issuer.issue(); + clock.advance(500); // stale is now past ttl, fresh is not + assert.equal(issuer.pendingCount, 2); + assert.equal(issuer.pruneExpired(), 1); + assert.equal(issuer.pendingCount, 1); + assert.equal(issuer.consume(stale.challenge), false); + assert.equal(issuer.consume(fresh.challenge), true); +}); diff --git a/server-lib-common/test/device-key.test.mjs b/server-lib-common/test/device-key.test.mjs new file mode 100644 index 00000000..69f1485c --- /dev/null +++ b/server-lib-common/test/device-key.test.mjs @@ -0,0 +1,103 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + fromBase64Url, + generateDeviceKeyPair, + importDevicePublicKey, + signDeviceChallenge, + toBase64Url, + verifyDeviceChallengeSignature, +} from '../dist/index.js'; + +const CONTEXT = { + hostId: 'host-1', + challenge: toBase64Url(Uint8Array.of(1, 2, 3, 4)), + devicePublicKey: '', // filled per-test +}; + +async function signedContext(keyPair, overrides = {}) { + const context = { ...CONTEXT, devicePublicKey: keyPair.devicePublicKey, ...overrides }; + const signature = await signDeviceChallenge(keyPair.privateKey, context); + return { context, signature }; +} + +test('device public key is a 65-byte uncompressed P-256 point', async () => { + const keyPair = await generateDeviceKeyPair(); + const raw = fromBase64Url(keyPair.devicePublicKey); + assert.equal(raw.length, 65); + assert.equal(raw[0], 0x04); +}); + +test('the private key is non-extractable', async () => { + const keyPair = await generateDeviceKeyPair(); + assert.equal(keyPair.privateKey.extractable, false); + await assert.rejects(globalThis.crypto.subtle.exportKey('pkcs8', keyPair.privateKey)); +}); + +test('sign/verify round-trips', async () => { + const keyPair = await generateDeviceKeyPair(); + const { context, signature } = await signedContext(keyPair); + assert.equal(await verifyDeviceChallengeSignature(context, signature), true); +}); + +test('two generated devices have distinct identities', async () => { + const [a, b] = await Promise.all([generateDeviceKeyPair(), generateDeviceKeyPair()]); + assert.notEqual(a.devicePublicKey, b.devicePublicKey); +}); + +test('verification fails for a signature from a different device', async () => { + const [a, b] = await Promise.all([generateDeviceKeyPair(), generateDeviceKeyPair()]); + // b signs, but the context claims a's identity. + const context = { ...CONTEXT, devicePublicKey: a.devicePublicKey }; + const signature = await signDeviceChallenge(b.privateKey, context); + assert.equal(await verifyDeviceChallengeSignature(context, signature), false); +}); + +test('verification is bound to the host id (domain separation)', async () => { + const keyPair = await generateDeviceKeyPair(); + const { context, signature } = await signedContext(keyPair); + assert.equal( + await verifyDeviceChallengeSignature({ ...context, hostId: 'host-2' }, signature), + false, + ); +}); + +test('verification is bound to the challenge', async () => { + const keyPair = await generateDeviceKeyPair(); + const { context, signature } = await signedContext(keyPair); + const otherChallenge = toBase64Url(Uint8Array.of(9, 9, 9, 9)); + assert.equal( + await verifyDeviceChallengeSignature({ ...context, challenge: otherChallenge }, signature), + false, + ); +}); + +test('a tampered signature fails', async () => { + const keyPair = await generateDeviceKeyPair(); + const { context, signature } = await signedContext(keyPair); + const bytes = fromBase64Url(signature); + bytes[0] ^= 0x01; + assert.equal(await verifyDeviceChallengeSignature(context, toBase64Url(bytes)), false); +}); + +test('verification returns false (not throws) on garbage inputs', async () => { + const keyPair = await generateDeviceKeyPair(); + const { context, signature } = await signedContext(keyPair); + assert.equal( + await verifyDeviceChallengeSignature({ ...context, devicePublicKey: 'not-a-key' }, signature), + false, + ); + assert.equal(await verifyDeviceChallengeSignature(context, 'not-a-signature!'), false); + assert.equal(await verifyDeviceChallengeSignature(context, ''), false); +}); + +test('importDevicePublicKey round-trips a generated identity', async () => { + const keyPair = await generateDeviceKeyPair(); + const imported = await importDevicePublicKey(keyPair.devicePublicKey); + assert.equal(imported.type, 'public'); +}); + +test('importDevicePublicKey rejects malformed identities', async () => { + await assert.rejects(importDevicePublicKey(toBase64Url(Uint8Array.of(1, 2, 3)))); +}); diff --git a/server-lib-common/test/ecdsa.test.mjs b/server-lib-common/test/ecdsa.test.mjs new file mode 100644 index 00000000..41ee82e7 --- /dev/null +++ b/server-lib-common/test/ecdsa.test.mjs @@ -0,0 +1,77 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { ecdsaDerToRaw, ecdsaRawToDer } from '../dist/index.js'; + +test('raw→DER→raw round-trips random signatures', () => { + for (let i = 0; i < 50; i++) { + const raw = globalThis.crypto.getRandomValues(new Uint8Array(64)); + assert.deepEqual(ecdsaDerToRaw(ecdsaRawToDer(raw)), raw); + } +}); + +test('round-trips coordinates with leading zeros', () => { + const raw = new Uint8Array(64); + raw[5] = 0x01; // r has five leading zero bytes + raw[63] = 0x02; // s is tiny + assert.deepEqual(ecdsaDerToRaw(ecdsaRawToDer(raw)), raw); +}); + +test('round-trips coordinates with the high bit set (DER sign padding)', () => { + const raw = new Uint8Array(64).fill(0xff); + const der = ecdsaRawToDer(raw); + // Each 32-byte 0xff… integer needs a 0x00 sign pad: 2 + (2+33)*2 bytes. + assert.equal(der.length, 72); + assert.deepEqual(ecdsaDerToRaw(der), raw); +}); + +test('encodes a known small signature', () => { + const raw = new Uint8Array(64); + raw[31] = 0x01; + raw[63] = 0x02; + assert.deepEqual(ecdsaRawToDer(raw), Uint8Array.of(0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02)); +}); + +test('DER parsing rejects malformed input', () => { + assert.throws(() => ecdsaDerToRaw(new Uint8Array(0)), /SEQUENCE/); + assert.throws(() => ecdsaDerToRaw(Uint8Array.of(0x31, 0x00)), /SEQUENCE/); + assert.throws(() => ecdsaDerToRaw(Uint8Array.of(0x30, 0x06, 0x02, 0x01, 0x01)), /length mismatch/); + assert.throws(() => ecdsaDerToRaw(Uint8Array.of(0x30, 0x04, 0x03, 0x01, 0x01, 0x02)), /INTEGER/); + const valid = ecdsaRawToDer(globalThis.crypto.getRandomValues(new Uint8Array(64))); + const trailing = new Uint8Array(valid.length + 1); + trailing.set(valid); + assert.throws(() => ecdsaDerToRaw(trailing), /invalid DER/); +}); + +test('DER parsing rejects integers wider than the curve', () => { + const raw = globalThis.crypto.getRandomValues(new Uint8Array(64)); + const der = ecdsaRawToDer(raw); + assert.throws(() => ecdsaDerToRaw(der, 16), /too large/); +}); + +test('raw encoding rejects odd lengths', () => { + assert.throws(() => ecdsaRawToDer(new Uint8Array(63)), /length/); + assert.throws(() => ecdsaRawToDer(new Uint8Array(0)), /length/); +}); + +test('interoperates with WebCrypto ECDSA signatures', async () => { + const { privateKey, publicKey } = await globalThis.crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ); + const data = globalThis.crypto.getRandomValues(new Uint8Array(100)); + const raw = new Uint8Array( + await globalThis.crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, data), + ); + const roundTripped = ecdsaDerToRaw(ecdsaRawToDer(raw)); + assert.equal( + await globalThis.crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + publicKey, + roundTripped, + data, + ), + true, + ); +}); diff --git a/server-lib-common/test/harness/actors.mjs b/server-lib-common/test/harness/actors.mjs new file mode 100644 index 00000000..28d83e3e --- /dev/null +++ b/server-lib-common/test/harness/actors.mjs @@ -0,0 +1,262 @@ +/** + * In-memory actors simulating the three parties of the remote security model + * (docs/specs/remote-security-model.md): Client (Dormouse Pocket), Host + * (Dormouse Terminal), and coordinating Server. Everything runs on real + * WebCrypto; only the transport is imaginary. + * + * Tampering is a first-class feature: every actor accepts overrides so tests + * can forge exactly one field at a time and assert the precise deny reason. + */ + +import { + HostAcl, + HostChallengeIssuer, + PairingCeremony, + authorizeConnection, + concatBytes, + ecdsaRawToDer, + generateDeviceKeyPair, + hashPasskeyPublicKey, + signDeviceChallenge, + toBase64Url, + utf8Encode, +} from '../../dist/index.js'; + +const subtle = globalThis.crypto.subtle; + +async function sha256(bytes) { + return new Uint8Array(await subtle.digest('SHA-256', bytes)); +} + +/** Deterministic, manually-advanced clock shared by actors in a scenario. */ +export class FakeClock { + #ms; + + constructor(startMs = 1_700_000_000_000) { + this.#ms = startMs; + } + + now = () => this.#ms; + + advance(ms) { + this.#ms += ms; + } +} + +/** + * A passkey. WebAuthn is simulated faithfully enough to exercise the real + * verifier: clientDataJSON, authenticatorData (rpIdHash/flags/signCount), and + * a DER-encoded ES256 signature over `authData || sha256(clientDataJSON)`. + * + * Passkeys sync across devices: sharing one SimAuthenticator instance between + * two SimClients models exactly that. + */ +export class SimAuthenticator { + static async create({ rpId, userVerification = true } = {}) { + const keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ]); + const spki = new Uint8Array(await subtle.exportKey('spki', keyPair.publicKey)); + const credentialId = toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(16))); + return new SimAuthenticator({ keyPair, spki, credentialId, rpId, userVerification }); + } + + #keyPair; + #signCount = 0; + + constructor({ keyPair, spki, credentialId, rpId, userVerification }) { + this.#keyPair = keyPair; + this.credentialId = credentialId; + this.publicKey = toBase64Url(spki); + this.rpId = rpId; + this.userVerification = userVerification; + } + + /** + * Produce an authentication assertion. `tamper` forges individual pieces: + * { type, challenge, origin, rpId, userPresent, userVerified, signWith } + */ + async assert({ challenge, origin, rpId = this.rpId, tamper = {} }) { + const clientData = { + type: tamper.type ?? 'webauthn.get', + challenge: tamper.challenge ?? challenge, + origin: tamper.origin ?? origin, + crossOrigin: false, + }; + const clientDataJSON = utf8Encode(JSON.stringify(clientData)); + + const rpIdHash = await sha256(utf8Encode(tamper.rpId ?? rpId)); + const userPresent = tamper.userPresent ?? true; + const userVerified = tamper.userVerified ?? this.userVerification; + const flags = (userPresent ? 0x01 : 0x00) | (userVerified ? 0x04 : 0x00); + this.#signCount += 1; + const authenticatorData = concatBytes( + rpIdHash, + Uint8Array.of( + flags, + (this.#signCount >>> 24) & 0xff, + (this.#signCount >>> 16) & 0xff, + (this.#signCount >>> 8) & 0xff, + this.#signCount & 0xff, + ), + ); + + const signingKey = tamper.signWith ?? this.#keyPair.privateKey; + const rawSignature = new Uint8Array( + await subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + signingKey, + concatBytes(authenticatorData, await sha256(clientDataJSON)), + ), + ); + + return { + credentialId: this.credentialId, + clientDataJSON: toBase64Url(clientDataJSON), + authenticatorData: toBase64Url(authenticatorData), + signature: toBase64Url(ecdsaRawToDer(rawSignature)), + }; + } + + /** A different private key, for signature-forgery tests. */ + static async foreignSigningKey() { + const pair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']); + return pair.privateKey; + } +} + +/** The coordinating Server: accounts and passkey registration. Never authoritative. */ +export class SimServer { + #accounts = new Map(); // accountId -> Set of credentialIds + + registerAccount(accountId) { + if (!this.#accounts.has(accountId)) this.#accounts.set(accountId, new Set()); + } + + registerPasskey(accountId, authenticator) { + this.registerAccount(accountId); + this.#accounts.get(accountId).add(authenticator.credentialId); + } + + /** Requirement 2: "The Server recognizes the account." */ + validateAccount(accountId, credentialId) { + const credentials = this.#accounts.get(accountId); + if (!credentials) throw new Error(`server: unknown account ${accountId}`); + if (!credentials.has(credentialId)) { + throw new Error(`server: credential ${credentialId} not registered to ${accountId}`); + } + } +} + +/** + * A compromised coordinating Server: vouches for anyone. Used to prove the + * Host denies access even when the Server-side checks are attacker-controlled. + */ +export class CompromisedServer extends SimServer { + validateAccount() {} +} + +/** The Host (Dormouse Terminal): ACL + challenges + pairing + final decision. */ +export class SimHost { + constructor({ hostId, rpId, origin, clock = new FakeClock(), policy = {}, ttlMs } = {}) { + this.hostId = hostId; + this.clock = clock; + this.policy = { rpId, origin, ...policy }; + this.acl = new HostAcl(hostId, { now: clock.now }); + this.challenges = new HostChallengeIssuer({ now: clock.now, ttlMs }); + this.ceremony = new PairingCeremony(this.acl, { now: clock.now }); + } + + issueChallenge() { + return this.challenges.issue(); + } + + beginPairing(request) { + return this.ceremony.begin(request); + } + + approvePairing(pairingId, { approvedBy = 'host-user', label } = {}) { + return this.ceremony.approve(pairingId, { approvedBy, label }); + } + + denyPairing(pairingId) { + this.ceremony.deny(pairingId); + } + + handleConnect(request) { + return authorizeConnection( + { hostId: this.hostId, acl: this.acl, challenges: this.challenges, policy: this.policy }, + request, + ); + } +} + +/** A Client (Dormouse Pocket): one browser profile holding one device key. */ +export class SimClient { + static async create({ label = 'Test Client', origin } = {}) { + const client = new SimClient({ label, origin }); + client.deviceKey = await generateDeviceKeyPair(); + return client; + } + + constructor({ label, origin }) { + this.label = label; + this.origin = origin; + } + + /** Simulate browser-data loss: the old key is gone, a fresh one replaces it. */ + async loseDeviceKey() { + const previous = this.deviceKey.devicePublicKey; + this.deviceKey = await generateDeviceKeyPair(); + return previous; + } + + /** Run the pairing ceremony against a host; resolves to the ACL record. */ + async pair(host, { accountId, authenticator, approvedBy = 'host-user', label } = {}) { + const ticket = host.beginPairing({ + accountId, + passkeyCredentialId: authenticator.credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(authenticator.publicKey), + devicePublicKey: this.deviceKey.devicePublicKey, + requestedLabel: this.label, + }); + return host.approvePairing(ticket.pairingId, { approvedBy, label }); + } + + /** + * Build the connection request a real client would send: fetch a Host + * challenge, assert with the passkey over it, sign it with the device key. + * `tamper.request` overrides request fields; `tamper.assertion` is passed + * through to the authenticator. + */ + async buildConnectRequest(host, { accountId, authenticator, tamper = {} }) { + const { challenge } = host.issueChallenge(); + const assertion = await authenticator.assert({ + challenge, + origin: this.origin, + rpId: host.policy.rpId, + tamper: tamper.assertion ?? {}, + }); + const deviceSignature = await signDeviceChallenge(this.deviceKey.privateKey, { + hostId: tamper.signForHostId ?? host.hostId, + challenge, + devicePublicKey: this.deviceKey.devicePublicKey, + }); + return { + accountId, + devicePublicKey: this.deviceKey.devicePublicKey, + challenge, + deviceSignature, + passkey: { publicKey: authenticator.publicKey, assertion }, + ...(tamper.request ?? {}), + }; + } + + /** Full connection flow: server account check, then the Host's decision. */ + async connect(host, { server, accountId, authenticator, tamper = {} }) { + server.validateAccount(accountId, authenticator.credentialId); + const request = await this.buildConnectRequest(host, { accountId, authenticator, tamper }); + return host.handleConnect(request); + } +} diff --git a/server-lib-common/test/passkey.test.mjs b/server-lib-common/test/passkey.test.mjs new file mode 100644 index 00000000..f97aeae8 --- /dev/null +++ b/server-lib-common/test/passkey.test.mjs @@ -0,0 +1,156 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + hashPasskeyPublicKey, + toBase64Url, + utf8Encode, + verifyPasskeyAssertion, +} from '../dist/index.js'; +import { SimAuthenticator } from './harness/actors.mjs'; + +const RP_ID = 'dormouse.dev'; +const ORIGIN = 'https://dormouse.dev'; +const CHALLENGE = toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(32))); + +const EXPECTED = { challenge: CHALLENGE, origin: ORIGIN, rpId: RP_ID }; + +async function makeAssertion(tamper = {}, authenticatorOpts = {}) { + const authenticator = await SimAuthenticator.create({ rpId: RP_ID, ...authenticatorOpts }); + const assertion = await authenticator.assert({ challenge: CHALLENGE, origin: ORIGIN, tamper }); + return { authenticator, assertion }; +} + +test('a well-formed assertion verifies', async () => { + const { authenticator, assertion } = await makeAssertion(); + const result = await verifyPasskeyAssertion(assertion, authenticator.publicKey, EXPECTED); + assert.deepEqual(result, { ok: true, userPresent: true, userVerified: true, signCount: 1 }); +}); + +test('signCount reflects successive assertions', async () => { + const authenticator = await SimAuthenticator.create({ rpId: RP_ID }); + await authenticator.assert({ challenge: CHALLENGE, origin: ORIGIN }); + const second = await authenticator.assert({ challenge: CHALLENGE, origin: ORIGIN }); + const result = await verifyPasskeyAssertion(second, authenticator.publicKey, EXPECTED); + assert.equal(result.ok, true); + assert.equal(result.signCount, 2); +}); + +test('accepts a padded challenge encoding in clientDataJSON', async () => { + const { authenticator, assertion } = await makeAssertion({ challenge: `${CHALLENGE}==` }); + const result = await verifyPasskeyAssertion(assertion, authenticator.publicKey, EXPECTED); + assert.equal(result.ok, true); +}); + +test('rejects the wrong challenge', async () => { + const other = toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(32))); + const { authenticator, assertion } = await makeAssertion({ challenge: other }); + const result = await verifyPasskeyAssertion(assertion, authenticator.publicKey, EXPECTED); + assert.deepEqual(result, { ok: false, reason: 'challenge-mismatch' }); +}); + +test('rejects the wrong origin', async () => { + const { authenticator, assertion } = await makeAssertion({ origin: 'https://evil.example' }); + const result = await verifyPasskeyAssertion(assertion, authenticator.publicKey, EXPECTED); + assert.deepEqual(result, { ok: false, reason: 'origin-mismatch' }); +}); + +test('accepts any origin from the allowlist', async () => { + const { authenticator, assertion } = await makeAssertion({ origin: 'https://beta.dormouse.dev' }); + const result = await verifyPasskeyAssertion(assertion, authenticator.publicKey, { + ...EXPECTED, + origin: [ORIGIN, 'https://beta.dormouse.dev'], + }); + assert.equal(result.ok, true); +}); + +test('rejects a registration (webauthn.create) assertion', async () => { + const { authenticator, assertion } = await makeAssertion({ type: 'webauthn.create' }); + const result = await verifyPasskeyAssertion(assertion, authenticator.publicKey, EXPECTED); + assert.deepEqual(result, { ok: false, reason: 'client-data-type' }); +}); + +test('rejects the wrong relying party', async () => { + const { authenticator, assertion } = await makeAssertion({ rpId: 'evil.example' }); + const result = await verifyPasskeyAssertion(assertion, authenticator.publicKey, EXPECTED); + assert.deepEqual(result, { ok: false, reason: 'rp-id-mismatch' }); +}); + +test('rejects when user presence is missing', async () => { + const { authenticator, assertion } = await makeAssertion({ userPresent: false }); + const result = await verifyPasskeyAssertion(assertion, authenticator.publicKey, EXPECTED); + assert.deepEqual(result, { ok: false, reason: 'user-presence-missing' }); +}); + +test('requireUserVerification demands the UV flag', async () => { + const { authenticator, assertion } = await makeAssertion({ userVerified: false }); + const relaxed = await verifyPasskeyAssertion(assertion, authenticator.publicKey, EXPECTED); + assert.equal(relaxed.ok, true); + assert.equal(relaxed.userVerified, false); + const strict = await verifyPasskeyAssertion(assertion, authenticator.publicKey, { + ...EXPECTED, + requireUserVerification: true, + }); + assert.deepEqual(strict, { ok: false, reason: 'user-verification-missing' }); +}); + +test('rejects a signature from a different key', async () => { + const foreign = await SimAuthenticator.foreignSigningKey(); + const { authenticator, assertion } = await makeAssertion({ signWith: foreign }); + const result = await verifyPasskeyAssertion(assertion, authenticator.publicKey, EXPECTED); + assert.deepEqual(result, { ok: false, reason: 'signature-invalid' }); +}); + +test('rejects a verification against the wrong public key', async () => { + const { assertion } = await makeAssertion(); + const other = await SimAuthenticator.create({ rpId: RP_ID }); + const result = await verifyPasskeyAssertion(assertion, other.publicKey, EXPECTED); + assert.deepEqual(result, { ok: false, reason: 'signature-invalid' }); +}); + +test('rejects a tampered signature without throwing', async () => { + const { authenticator, assertion } = await makeAssertion(); + const result = await verifyPasskeyAssertion( + { ...assertion, signature: 'AAAA' }, + authenticator.publicKey, + EXPECTED, + ); + assert.deepEqual(result, { ok: false, reason: 'signature-invalid' }); +}); + +test('rejects malformed clientDataJSON', async () => { + const { authenticator, assertion } = await makeAssertion(); + for (const clientDataJSON of ['!!!', toBase64Url(utf8Encode('not json')), toBase64Url(utf8Encode('"str"'))]) { + const result = await verifyPasskeyAssertion( + { ...assertion, clientDataJSON }, + authenticator.publicKey, + EXPECTED, + ); + assert.deepEqual(result, { ok: false, reason: 'client-data-malformed' }); + } +}); + +test('rejects truncated or malformed authenticatorData', async () => { + const { authenticator, assertion } = await makeAssertion(); + for (const authenticatorData of ['!!!', toBase64Url(new Uint8Array(36))]) { + const result = await verifyPasskeyAssertion( + { ...assertion, authenticatorData }, + authenticator.publicKey, + EXPECTED, + ); + assert.deepEqual(result, { ok: false, reason: 'authenticator-data-malformed' }); + } +}); + +test('rejects an unparseable public key', async () => { + const { assertion } = await makeAssertion(); + const result = await verifyPasskeyAssertion(assertion, toBase64Url(Uint8Array.of(1, 2, 3)), EXPECTED); + assert.deepEqual(result, { ok: false, reason: 'public-key-invalid' }); +}); + +test('hashPasskeyPublicKey is deterministic and key-specific', async () => { + const a = await SimAuthenticator.create({ rpId: RP_ID }); + const b = await SimAuthenticator.create({ rpId: RP_ID }); + assert.equal(await hashPasskeyPublicKey(a.publicKey), await hashPasskeyPublicKey(a.publicKey)); + assert.notEqual(await hashPasskeyPublicKey(a.publicKey), await hashPasskeyPublicKey(b.publicKey)); +}); From cccd2ffd4fe48e7b01c972038bdcc24d12891c40 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 08:49:04 -0700 Subject: [PATCH 3/5] test(server-lib-common): cover host ACL, pairing ceremony, and connection authorization The connection suite exercises the full deny matrix: replayed/expired/ forged challenges, tampered assertions, unpaired or mismatched passkey and device pairs, substituted passkey keys, cross-host and cross-account requests, forged device signatures, and revocation. Co-Authored-By: Claude Fable 5 --- server-lib-common/test/acl.test.mjs | 122 +++++++++++ server-lib-common/test/connection.test.mjs | 240 +++++++++++++++++++++ server-lib-common/test/pairing.test.mjs | 115 ++++++++++ 3 files changed, 477 insertions(+) create mode 100644 server-lib-common/test/acl.test.mjs create mode 100644 server-lib-common/test/connection.test.mjs create mode 100644 server-lib-common/test/pairing.test.mjs diff --git a/server-lib-common/test/acl.test.mjs b/server-lib-common/test/acl.test.mjs new file mode 100644 index 00000000..c14c264d --- /dev/null +++ b/server-lib-common/test/acl.test.mjs @@ -0,0 +1,122 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { HostAcl } from '../dist/index.js'; +import { FakeClock } from './harness/actors.mjs'; + +const CLIENT = { + accountId: 'account-1', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + approvedBy: 'host-user', + label: 'iPhone Safari', +}; + +function makeAcl() { + const clock = new FakeClock(); + return { clock, acl: new HostAcl('host-1', { now: clock.now }) }; +} + +test('approve stores the full record', () => { + const { clock, acl } = makeAcl(); + const record = acl.approve(CLIENT); + assert.deepEqual(record, { + ...CLIENT, + hostId: 'host-1', + approvedAt: clock.now(), + revokedAt: null, + }); + assert.deepEqual(acl.records(), [record]); +}); + +test('findActive requires passkey AND device key on the same record', () => { + const { acl } = makeAcl(); + acl.approve(CLIENT); + acl.approve({ ...CLIENT, passkeyCredentialId: 'cred-2', devicePublicKey: 'device-2' }); + assert.ok(acl.findActive({ passkeyCredentialId: 'cred-1', devicePublicKey: 'device-1' })); + assert.ok(acl.findActive({ passkeyCredentialId: 'cred-2', devicePublicKey: 'device-2' })); + // Each half exists on some record, but never together. + assert.equal(acl.findActive({ passkeyCredentialId: 'cred-1', devicePublicKey: 'device-2' }), undefined); + assert.equal(acl.findActive({ passkeyCredentialId: 'cred-2', devicePublicKey: 'device-1' }), undefined); +}); + +test('hasActivePasskey / hasActiveDevice track each half individually', () => { + const { acl } = makeAcl(); + acl.approve(CLIENT); + assert.equal(acl.hasActivePasskey('cred-1'), true); + assert.equal(acl.hasActivePasskey('cred-2'), false); + assert.equal(acl.hasActiveDevice('device-1'), true); + assert.equal(acl.hasActiveDevice('device-2'), false); +}); + +test('revokeDevice revokes every active record for that device', () => { + const { clock, acl } = makeAcl(); + acl.approve(CLIENT); + acl.approve({ ...CLIENT, passkeyCredentialId: 'cred-2' }); // same device, second passkey + acl.approve({ ...CLIENT, devicePublicKey: 'device-2' }); + clock.advance(1000); + assert.equal(acl.revokeDevice('device-1'), 2); + assert.equal(acl.hasActiveDevice('device-1'), false); + assert.equal(acl.hasActiveDevice('device-2'), true); + const revoked = acl.records().filter((record) => record.revokedAt !== null); + assert.equal(revoked.length, 2); + for (const record of revoked) assert.equal(record.revokedAt, clock.now()); +}); + +test('revokePasskey revokes every active record for that credential', () => { + const { acl } = makeAcl(); + acl.approve(CLIENT); + acl.approve({ ...CLIENT, devicePublicKey: 'device-2' }); + acl.approve({ ...CLIENT, passkeyCredentialId: 'cred-2' }); + assert.equal(acl.revokePasskey('cred-1'), 2); + assert.equal(acl.hasActivePasskey('cred-1'), false); + assert.equal(acl.hasActivePasskey('cred-2'), true); +}); + +test('revoked records no longer authorize', () => { + const { acl } = makeAcl(); + acl.approve(CLIENT); + acl.revokeDevice('device-1'); + assert.equal(acl.findActive({ passkeyCredentialId: 'cred-1', devicePublicKey: 'device-1' }), undefined); + assert.equal(acl.revokeDevice('device-1'), 0, 'already-revoked records are not re-revoked'); +}); + +test('re-approving the same pair supersedes the old record', () => { + const { clock, acl } = makeAcl(); + acl.approve(CLIENT); + clock.advance(5000); + const fresh = acl.approve({ ...CLIENT, label: 'iPhone Safari (repaired)' }); + const active = acl.activeRecords(); + assert.equal(active.length, 1); + assert.deepEqual(active[0], fresh); + const all = acl.records(); + assert.equal(all.length, 2); + assert.equal(all[0].revokedAt, clock.now()); +}); + +test('records round-trip through fromRecords (persistence)', () => { + const { clock, acl } = makeAcl(); + acl.approve(CLIENT); + clock.advance(1000); + acl.approve({ ...CLIENT, devicePublicKey: 'device-2' }); + acl.revokeDevice('device-1'); + const restored = HostAcl.fromRecords('host-1', acl.records(), { now: clock.now }); + assert.deepEqual(restored.records(), acl.records()); + assert.equal(restored.hasActiveDevice('device-1'), false); + assert.equal(restored.hasActiveDevice('device-2'), true); +}); + +test('fromRecords refuses records from another host', () => { + const { acl } = makeAcl(); + acl.approve(CLIENT); + assert.throws(() => HostAcl.fromRecords('host-2', acl.records()), /cannot be loaded/); +}); + +test('returned records are copies — mutating them cannot alter the ACL', () => { + const { acl } = makeAcl(); + acl.approve(CLIENT); + acl.records()[0].revokedAt = 123; + acl.findActive({ passkeyCredentialId: 'cred-1', devicePublicKey: 'device-1' }).revokedAt = 123; + assert.equal(acl.activeRecords().length, 1); +}); diff --git a/server-lib-common/test/connection.test.mjs b/server-lib-common/test/connection.test.mjs new file mode 100644 index 00000000..e4a6fea7 --- /dev/null +++ b/server-lib-common/test/connection.test.mjs @@ -0,0 +1,240 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { generateDeviceKeyPair, signDeviceChallenge, toBase64Url } from '../dist/index.js'; +import { FakeClock, SimAuthenticator, SimClient, SimHost } from './harness/actors.mjs'; + +const RP_ID = 'dormouse.dev'; +const ORIGIN = 'https://dormouse.dev'; +const ACCOUNT = 'account-1'; + +/** A host with one fully paired client, ready to connect. */ +async function pairedSetup(options = {}) { + const clock = new FakeClock(); + const host = new SimHost({ hostId: 'host-1', rpId: RP_ID, origin: ORIGIN, clock, ...options }); + const authenticator = await SimAuthenticator.create({ rpId: RP_ID }); + const client = await SimClient.create({ label: 'iPhone Safari', origin: ORIGIN }); + await client.pair(host, { accountId: ACCOUNT, authenticator }); + return { clock, host, authenticator, client }; +} + +async function connect(setup, tamper = {}) { + const { host, client, authenticator } = setup; + const request = await client.buildConnectRequest(host, { + accountId: ACCOUNT, + authenticator, + tamper, + }); + return host.handleConnect(request); +} + +test('a fully valid request is allowed', async () => { + const setup = await pairedSetup(); + const decision = await connect(setup); + assert.deepEqual(decision.failures, []); + assert.equal(decision.allowed, true); + assert.equal(decision.record.devicePublicKey, setup.client.deviceKey.devicePublicKey); + assert.equal(decision.record.label, 'iPhone Safari'); + assert.equal(decision.passkey.ok, true); +}); + +test('each connection needs its own challenge — replaying a request is denied', async () => { + const setup = await pairedSetup(); + const request = await setup.client.buildConnectRequest(setup.host, { + accountId: ACCOUNT, + authenticator: setup.authenticator, + }); + assert.equal((await setup.host.handleConnect(request)).allowed, true); + const replay = await setup.host.handleConnect(request); + assert.equal(replay.allowed, false); + assert.ok(replay.failures.includes('challenge-invalid')); +}); + +test('an expired challenge is denied', async () => { + const setup = await pairedSetup({ ttlMs: 1000 }); + const request = await setup.client.buildConnectRequest(setup.host, { + accountId: ACCOUNT, + authenticator: setup.authenticator, + }); + setup.clock.advance(1000); + const decision = await setup.host.handleConnect(request); + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('challenge-invalid')); +}); + +test('a made-up challenge is denied even if everything else is consistent', async () => { + const setup = await pairedSetup(); + const forged = toBase64Url(globalThis.crypto.getRandomValues(new Uint8Array(32))); + const { host, client, authenticator } = setup; + const assertion = await authenticator.assert({ challenge: forged, origin: ORIGIN }); + const deviceSignature = await signDeviceChallenge(client.deviceKey.privateKey, { + hostId: host.hostId, + challenge: forged, + devicePublicKey: client.deviceKey.devicePublicKey, + }); + const decision = await host.handleConnect({ + accountId: ACCOUNT, + devicePublicKey: client.deviceKey.devicePublicKey, + challenge: forged, + deviceSignature, + passkey: { publicKey: authenticator.publicKey, assertion }, + }); + assert.deepEqual(decision.failures, ['challenge-invalid']); +}); + +test('a tampered passkey assertion is denied', async () => { + const setup = await pairedSetup(); + const decision = await connect(setup, { assertion: { origin: 'https://evil.example' } }); + assert.equal(decision.allowed, false); + assert.deepEqual(decision.failures, ['passkey-assertion-invalid']); + assert.deepEqual(decision.passkey, { ok: false, reason: 'origin-mismatch' }); +}); + +test('an unpaired passkey is denied even from a paired device', async () => { + const setup = await pairedSetup(); + const newPasskey = await SimAuthenticator.create({ rpId: RP_ID }); + const request = await setup.client.buildConnectRequest(setup.host, { + accountId: ACCOUNT, + authenticator: newPasskey, + }); + const decision = await setup.host.handleConnect(request); + assert.equal(decision.allowed, false); + assert.deepEqual(decision.failures, ['passkey-not-paired']); +}); + +test('an unpaired device is denied even with a paired passkey', async () => { + const setup = await pairedSetup(); + const stranger = await SimClient.create({ label: 'Attacker', origin: ORIGIN }); + const request = await stranger.buildConnectRequest(setup.host, { + accountId: ACCOUNT, + authenticator: setup.authenticator, + }); + const decision = await setup.host.handleConnect(request); + assert.equal(decision.allowed, false); + assert.deepEqual(decision.failures, ['device-not-paired']); +}); + +test('passkey and device paired separately — but never together — is denied', async () => { + const setup = await pairedSetup(); + // A second client pairs with a second passkey; both halves are now known + // to the host, but on different records. + const otherAuthenticator = await SimAuthenticator.create({ rpId: RP_ID }); + const otherClient = await SimClient.create({ label: 'MacBook Chrome', origin: ORIGIN }); + await otherClient.pair(setup.host, { accountId: ACCOUNT, authenticator: otherAuthenticator }); + const request = await otherClient.buildConnectRequest(setup.host, { + accountId: ACCOUNT, + authenticator: setup.authenticator, // first client's passkey + }); + const decision = await setup.host.handleConnect(request); + assert.equal(decision.allowed, false); + assert.deepEqual(decision.failures, ['pairing-mismatch']); +}); + +test('a substituted passkey public key is denied', async () => { + const setup = await pairedSetup(); + // Same credential id in the assertion, but a different key pair — as if a + // compromised server re-registered the credential with an attacker key. + const impostor = await SimAuthenticator.create({ rpId: RP_ID }); + impostor.credentialId = setup.authenticator.credentialId; + const request = await setup.client.buildConnectRequest(setup.host, { + accountId: ACCOUNT, + authenticator: impostor, + }); + const decision = await setup.host.handleConnect(request); + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('passkey-key-mismatch')); +}); + +test('a request for a different account than the pairing is denied', async () => { + const setup = await pairedSetup(); + const decision = await connect(setup, { request: { accountId: 'account-2' } }); + assert.equal(decision.allowed, false); + assert.deepEqual(decision.failures, ['account-mismatch']); +}); + +test('a bad device signature is denied', async () => { + const setup = await pairedSetup(); + const decision = await connect(setup, { request: { deviceSignature: 'AAAA' } }); + assert.equal(decision.allowed, false); + assert.deepEqual(decision.failures, ['device-signature-invalid']); +}); + +test('a device signature scoped to another host is denied', async () => { + const setup = await pairedSetup(); + const decision = await connect(setup, { signForHostId: 'host-2' }); + assert.equal(decision.allowed, false); + assert.deepEqual(decision.failures, ['device-signature-invalid']); +}); + +test('a signature from a different device key is denied', async () => { + const setup = await pairedSetup(); + const foreignKey = await generateDeviceKeyPair(); + const { challenge } = setup.host.issueChallenge(); + const assertion = await setup.authenticator.assert({ challenge, origin: ORIGIN }); + const deviceSignature = await signDeviceChallenge(foreignKey.privateKey, { + hostId: setup.host.hostId, + challenge, + devicePublicKey: setup.client.deviceKey.devicePublicKey, // claims the paired identity + }); + const decision = await setup.host.handleConnect({ + accountId: ACCOUNT, + devicePublicKey: setup.client.deviceKey.devicePublicKey, + challenge, + deviceSignature, + passkey: { publicKey: setup.authenticator.publicKey, assertion }, + }); + assert.equal(decision.allowed, false); + assert.deepEqual(decision.failures, ['device-signature-invalid']); +}); + +test('a revoked device is denied', async () => { + const setup = await pairedSetup(); + setup.host.acl.revokeDevice(setup.client.deviceKey.devicePublicKey); + const decision = await connect(setup); + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('device-not-paired')); +}); + +test('every failing layer is reported, not just the first', async () => { + const setup = await pairedSetup(); + const stranger = await SimClient.create({ label: 'Attacker', origin: 'https://evil.example' }); + const strangerPasskey = await SimAuthenticator.create({ rpId: RP_ID }); + const request = await stranger.buildConnectRequest(setup.host, { + accountId: ACCOUNT, + authenticator: strangerPasskey, + tamper: { request: { challenge: 'AAAA', deviceSignature: 'AAAA' } }, + }); + const decision = await setup.host.handleConnect(request); + assert.equal(decision.allowed, false); + assert.deepEqual( + [...decision.failures].sort(), + [ + 'challenge-invalid', + 'device-not-paired', + 'device-signature-invalid', + 'passkey-assertion-invalid', + 'passkey-not-paired', + ], + ); +}); + +test('a denied decision never carries the ACL record', async () => { + const setup = await pairedSetup(); + const decision = await connect(setup, { request: { accountId: 'account-2' } }); + assert.equal(decision.record, null); +}); + +test('userVerified is surfaced for policy decisions and logging', async () => { + const setup = await pairedSetup(); + const decision = await connect(setup, { assertion: { userVerified: false } }); + assert.equal(decision.allowed, true); + assert.equal(decision.passkey.userVerified, false); +}); + +test('requireUserVerification policy rejects presence-only assertions', async () => { + const setup = await pairedSetup({ policy: { requireUserVerification: true } }); + const decision = await connect(setup, { assertion: { userVerified: false } }); + assert.equal(decision.allowed, false); + assert.deepEqual(decision.failures, ['passkey-assertion-invalid']); + assert.deepEqual(decision.passkey, { ok: false, reason: 'user-verification-missing' }); +}); diff --git a/server-lib-common/test/pairing.test.mjs b/server-lib-common/test/pairing.test.mjs new file mode 100644 index 00000000..4a77529b --- /dev/null +++ b/server-lib-common/test/pairing.test.mjs @@ -0,0 +1,115 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { DEFAULT_PAIRING_TTL_MS, HostAcl, PairingCeremony, PairingError } from '../dist/index.js'; +import { FakeClock } from './harness/actors.mjs'; + +const REQUEST = { + accountId: 'account-1', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + requestedLabel: 'iPhone Safari', +}; + +function makeCeremony(options = {}) { + const clock = new FakeClock(); + const acl = new HostAcl('host-1', { now: clock.now }); + const ceremony = new PairingCeremony(acl, { now: clock.now, ...options }); + return { clock, acl, ceremony }; +} + +function assertPairingError(fn, code) { + assert.throws(fn, (error) => error instanceof PairingError && error.code === code); +} + +test('begin creates a pending ticket with a unique id', () => { + const { clock, ceremony } = makeCeremony(); + const a = ceremony.begin(REQUEST); + const b = ceremony.begin(REQUEST); + assert.notEqual(a.pairingId, b.pairingId); + assert.equal(a.state, 'pending'); + assert.deepEqual(a.request, REQUEST); + assert.equal(a.requestedAt, clock.now()); + assert.equal(a.expiresAt, clock.now() + DEFAULT_PAIRING_TTL_MS); +}); + +test('begin does not touch the ACL — only approve does', () => { + const { acl, ceremony } = makeCeremony(); + ceremony.begin(REQUEST); + assert.equal(acl.records().length, 0); +}); + +test('approve writes the ACL record with approver metadata', () => { + const { clock, acl, ceremony } = makeCeremony(); + const ticket = ceremony.begin(REQUEST); + const record = ceremony.approve(ticket.pairingId, { approvedBy: 'ned@host' }); + assert.deepEqual(record, { + hostId: 'host-1', + accountId: 'account-1', + passkeyCredentialId: 'cred-1', + passkeyPublicKeyHash: 'hash-1', + devicePublicKey: 'device-1', + approvedAt: clock.now(), + approvedBy: 'ned@host', + label: 'iPhone Safari', + revokedAt: null, + }); + assert.deepEqual(acl.records(), [record]); + assert.equal(ceremony.get(ticket.pairingId).state, 'approved'); +}); + +test('the approver can override the requested label', () => { + const { ceremony } = makeCeremony(); + const ticket = ceremony.begin(REQUEST); + const record = ceremony.approve(ticket.pairingId, { approvedBy: 'ned', label: 'Ned iPhone' }); + assert.equal(record.label, 'Ned iPhone'); +}); + +test('deny leaves the ACL untouched', () => { + const { acl, ceremony } = makeCeremony(); + const ticket = ceremony.begin(REQUEST); + ceremony.deny(ticket.pairingId); + assert.equal(acl.records().length, 0); + assert.equal(ceremony.get(ticket.pairingId).state, 'denied'); +}); + +test('approve after deny fails', () => { + const { ceremony } = makeCeremony(); + const ticket = ceremony.begin(REQUEST); + ceremony.deny(ticket.pairingId); + assertPairingError(() => ceremony.approve(ticket.pairingId, { approvedBy: 'ned' }), 'not-pending'); +}); + +test('double approve fails', () => { + const { acl, ceremony } = makeCeremony(); + const ticket = ceremony.begin(REQUEST); + ceremony.approve(ticket.pairingId, { approvedBy: 'ned' }); + assertPairingError(() => ceremony.approve(ticket.pairingId, { approvedBy: 'ned' }), 'not-pending'); + assert.equal(acl.records().length, 1); +}); + +test('an expired pairing cannot be approved or denied', () => { + const { clock, acl, ceremony } = makeCeremony({ ttlMs: 1000 }); + const ticket = ceremony.begin(REQUEST); + clock.advance(1000); + assert.equal(ceremony.get(ticket.pairingId).state, 'expired'); + assertPairingError(() => ceremony.approve(ticket.pairingId, { approvedBy: 'ned' }), 'expired'); + assertPairingError(() => ceremony.deny(ticket.pairingId), 'expired'); + assert.equal(acl.records().length, 0); +}); + +test('a pairing approved just before expiry succeeds', () => { + const { clock, ceremony } = makeCeremony({ ttlMs: 1000 }); + const ticket = ceremony.begin(REQUEST); + clock.advance(999); + const record = ceremony.approve(ticket.pairingId, { approvedBy: 'ned' }); + assert.equal(record.devicePublicKey, 'device-1'); +}); + +test('unknown pairing ids are rejected', () => { + const { ceremony } = makeCeremony(); + assertPairingError(() => ceremony.approve('nope', { approvedBy: 'ned' }), 'unknown-pairing'); + assertPairingError(() => ceremony.deny('nope'), 'unknown-pairing'); + assert.equal(ceremony.get('nope'), undefined); +}); From a4038ced684a05996fc3a2ce2156332121f36a0e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 08:50:27 -0700 Subject: [PATCH 4/5] test(server-lib-common): end-to-end scenarios for every spec security guarantee Simulated Client/Server/Host actors run the full pairing and connection flows to prove each guarantee from the spec: new passkeys grant nothing, a compromised server grants nothing, synced passkeys are not trusted clients, pairing is per-host, presence must be fresh (no replay or splice), revocation is immediate, device-key loss recovers by re-pairing, and only local approval on the host writes the ACL. Co-Authored-By: Claude Fable 5 --- .../test/security-guarantees.test.mjs | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 server-lib-common/test/security-guarantees.test.mjs diff --git a/server-lib-common/test/security-guarantees.test.mjs b/server-lib-common/test/security-guarantees.test.mjs new file mode 100644 index 00000000..106ad446 --- /dev/null +++ b/server-lib-common/test/security-guarantees.test.mjs @@ -0,0 +1,264 @@ +/** + * End-to-end scenarios for each guarantee in + * docs/specs/remote-security-model.md § Security Guarantees, driven through + * the full Client / Server / Host flow of the harness actors. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { hashPasskeyPublicKey } from '../dist/index.js'; +import { + CompromisedServer, + FakeClock, + SimAuthenticator, + SimClient, + SimHost, + SimServer, +} from './harness/actors.mjs'; + +const RP_ID = 'dormouse.dev'; +const ORIGIN = 'https://dormouse.dev'; +const ACCOUNT = 'ned@dormouse.dev'; + +async function world() { + const clock = new FakeClock(); + const server = new SimServer(); + const host = new SimHost({ hostId: 'dormouse-terminal', rpId: RP_ID, origin: ORIGIN, clock }); + const authenticator = await SimAuthenticator.create({ rpId: RP_ID }); + const client = await SimClient.create({ label: 'iPhone Safari', origin: ORIGIN }); + server.registerPasskey(ACCOUNT, authenticator); + return { clock, server, host, authenticator, client }; +} + +test('pairing then connecting succeeds end to end', async () => { + const { server, host, authenticator, client } = await world(); + const record = await client.pair(host, { accountId: ACCOUNT, authenticator }); + assert.equal(record.label, 'iPhone Safari'); + const decision = await client.connect(host, { server, accountId: ACCOUNT, authenticator }); + assert.equal(decision.allowed, true); + assert.deepEqual(decision.failures, []); +}); + +test('adding a new passkey does not grant host access', async () => { + const { server, host, authenticator, client } = await world(); + await client.pair(host, { accountId: ACCOUNT, authenticator }); + + // The account gains a second passkey — entirely legitimate on the server — + // and an attacker (or new browser) holds it on an unpaired device. + const newPasskey = await SimAuthenticator.create({ rpId: RP_ID }); + server.registerPasskey(ACCOUNT, newPasskey); + const newDevice = await SimClient.create({ label: 'New Browser', origin: ORIGIN }); + + const decision = await newDevice.connect(host, { + server, + accountId: ACCOUNT, + authenticator: newPasskey, + }); + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('passkey-not-paired')); + assert.ok(decision.failures.includes('device-not-paired')); + + // Even from the already-paired device, the new passkey alone is refused. + const fromPairedDevice = await client.connect(host, { + server, + accountId: ACCOUNT, + authenticator: newPasskey, + }); + assert.equal(fromPairedDevice.allowed, false); + assert.deepEqual(fromPairedDevice.failures, ['passkey-not-paired']); +}); + +test('compromising the server does not grant host access', async () => { + const { host, authenticator, client } = await world(); + await client.pair(host, { accountId: ACCOUNT, authenticator }); + + // The attacker controls the coordinating server: it vouches for any + // account and any credential. They hold a passkey and device of their own. + const evilServer = new CompromisedServer(); + const attackerPasskey = await SimAuthenticator.create({ rpId: RP_ID }); + const attacker = await SimClient.create({ label: 'Attacker', origin: ORIGIN }); + + const decision = await attacker.connect(host, { + server: evilServer, + accountId: ACCOUNT, + authenticator: attackerPasskey, + }); + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('passkey-not-paired')); + assert.ok(decision.failures.includes('device-not-paired')); + + // The server also has no write path into the host's ACL: nothing about the + // attack changed what the host trusts. + assert.equal(host.acl.records().length, 1); + assert.equal(host.acl.records()[0].devicePublicKey, client.deviceKey.devicePublicKey); +}); + +test('passkey synchronization does not automatically create trusted clients', async () => { + const { server, host, authenticator, client } = await world(); + await client.pair(host, { accountId: ACCOUNT, authenticator }); + + // The same passkey syncs to a second device (same SimAuthenticator, new + // SimClient) — exactly what iCloud Keychain does. + const syncedDevice = await SimClient.create({ label: 'iPad Safari', origin: ORIGIN }); + const beforePairing = await syncedDevice.connect(host, { + server, + accountId: ACCOUNT, + authenticator, + }); + assert.equal(beforePairing.allowed, false); + assert.deepEqual(beforePairing.failures, ['device-not-paired']); + + // After its own explicit pairing ceremony, the synced device is trusted — + // and the original keeps working. + await syncedDevice.pair(host, { accountId: ACCOUNT, authenticator }); + const afterPairing = await syncedDevice.connect(host, { + server, + accountId: ACCOUNT, + authenticator, + }); + assert.equal(afterPairing.allowed, true); + const original = await client.connect(host, { server, accountId: ACCOUNT, authenticator }); + assert.equal(original.allowed, true); +}); + +test('every trusted client must be explicitly paired with every host', async () => { + const { clock, server, host, authenticator, client } = await world(); + const otherHost = new SimHost({ + hostId: 'dormouse-terminal-2', + rpId: RP_ID, + origin: ORIGIN, + clock, + }); + await client.pair(host, { accountId: ACCOUNT, authenticator }); + + const decision = await client.connect(otherHost, { server, accountId: ACCOUNT, authenticator }); + assert.equal(decision.allowed, false); + assert.ok(decision.failures.includes('passkey-not-paired')); + assert.ok(decision.failures.includes('device-not-paired')); + + await client.pair(otherHost, { accountId: ACCOUNT, authenticator }); + const afterPairing = await client.connect(otherHost, { + server, + accountId: ACCOUNT, + authenticator, + }); + assert.equal(afterPairing.allowed, true); +}); + +test('every connection requires fresh user presence', async () => { + const { server, host, authenticator, client } = await world(); + await client.pair(host, { accountId: ACCOUNT, authenticator }); + + // First connection: fine. Reusing any part of it: refused. The passkey + // assertion and device signature are bound to a consumed challenge, so a + // network eavesdropper (or the server itself) can replay nothing. + const request = await client.buildConnectRequest(host, { + accountId: ACCOUNT, + authenticator, + }); + assert.equal((await host.handleConnect(request)).allowed, true); + + const replayed = await host.handleConnect(request); + assert.equal(replayed.allowed, false); + assert.ok(replayed.failures.includes('challenge-invalid')); + + // Splicing the old (valid, signed) assertion into a fresh challenge fails + // both signature layers: nothing signed over the old challenge transfers. + const fresh = host.issueChallenge(); + const spliced = await host.handleConnect({ ...request, challenge: fresh.challenge }); + assert.equal(spliced.allowed, false); + assert.ok(spliced.failures.includes('passkey-assertion-invalid')); + assert.ok(spliced.failures.includes('device-signature-invalid')); +}); + +test('revoking a client cuts off access immediately', async () => { + const { server, host, authenticator, client } = await world(); + await client.pair(host, { accountId: ACCOUNT, authenticator }); + assert.equal( + (await client.connect(host, { server, accountId: ACCOUNT, authenticator })).allowed, + true, + ); + + host.acl.revokeDevice(client.deviceKey.devicePublicKey); + const afterRevocation = await client.connect(host, { + server, + accountId: ACCOUNT, + authenticator, + }); + assert.equal(afterRevocation.allowed, false); + assert.ok(afterRevocation.failures.includes('device-not-paired')); + + // Re-pairing (a fresh ceremony) restores access. + await client.pair(host, { accountId: ACCOUNT, authenticator }); + assert.equal( + (await client.connect(host, { server, accountId: ACCOUNT, authenticator })).allowed, + true, + ); +}); + +test('device key loss is recoverable without weakening the model', async () => { + const { server, host, authenticator, client } = await world(); + await client.pair(host, { accountId: ACCOUNT, authenticator }); + + // Browser data cleared: the device key is gone and a new one is generated. + const lostKey = await client.loseDeviceKey(); + + // The passkey still works, but the host no longer recognizes the device. + const beforeRepairing = await client.connect(host, { + server, + accountId: ACCOUNT, + authenticator, + }); + assert.equal(beforeRepairing.allowed, false); + assert.deepEqual(beforeRepairing.failures, ['device-not-paired']); + + // Recovery: authenticate with the passkey, re-pair, optionally revoke the + // old key. + await client.pair(host, { accountId: ACCOUNT, authenticator }); + host.acl.revokeDevice(lostKey); + const afterRepairing = await client.connect(host, { + server, + accountId: ACCOUNT, + authenticator, + }); + assert.equal(afterRepairing.allowed, true); + assert.equal(host.acl.hasActiveDevice(lostKey), false); +}); + +test('the host is the final authority: a denied pairing never grants access', async () => { + const { server, host, authenticator, client } = await world(); + + // The request reaches the host, but the local user denies it. + const ticket = host.beginPairing({ + accountId: ACCOUNT, + passkeyCredentialId: authenticator.credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(authenticator.publicKey), + devicePublicKey: client.deviceKey.devicePublicKey, + requestedLabel: client.label, + }); + host.denyPairing(ticket.pairingId); + + const decision = await client.connect(host, { server, accountId: ACCOUNT, authenticator }); + assert.equal(decision.allowed, false); + assert.equal(host.acl.records().length, 0); +}); + +test('the server cannot pair on the user\'s behalf: pending pairings grant nothing', async () => { + const { server, host, authenticator, client } = await world(); + + // A malicious server floods the host with pairing requests; none are + // approved locally, so none authorize anything. + for (let i = 0; i < 3; i++) { + host.beginPairing({ + accountId: ACCOUNT, + passkeyCredentialId: authenticator.credentialId, + passkeyPublicKeyHash: await hashPasskeyPublicKey(authenticator.publicKey), + devicePublicKey: client.deviceKey.devicePublicKey, + requestedLabel: 'Totally Legit Device', + }); + } + const decision = await client.connect(host, { server, accountId: ACCOUNT, authenticator }); + assert.equal(decision.allowed, false); + assert.equal(host.acl.records().length, 0); +}); From 7a304ff60908e2d312fffffb6b028e8840cccdfa Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 2 Jul 2026 10:08:25 -0700 Subject: [PATCH 5/5] refactor(server-lib-common): move pairing-miss reasons into HostAcl.authorize, parallelize connection checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HostAcl.authorize now owns the 'a record is passkey AND device' rule and explains misses itself; authorizeConnection runs the independent crypto checks concurrently and assembles failures in spec order. Hardened while landing it: - attach the rejection handler to the passkey key hash at creation — a malformed (non-base64url) publicKey from a paired client previously rejected the promise before it was awaited, crashing the process via unhandledRejection; it now denies with passkey-key-mismatch - build assertion expectations as { ...policy, challenge } so nothing on a policy object can override the challenge binding Adds direct unit tests for HostAcl.authorize and a regression test for the malformed-publicKey deny. Co-Authored-By: Claude Fable 5 --- server-lib-common/src/security/acl.ts | 42 +++++++++- server-lib-common/src/security/connection.ts | 81 ++++++++++---------- server-lib-common/test/acl.test.mjs | 43 +++++++++++ server-lib-common/test/connection.test.mjs | 17 ++++ 4 files changed, 141 insertions(+), 42 deletions(-) diff --git a/server-lib-common/src/security/acl.ts b/server-lib-common/src/security/acl.ts index 2527b46c..d8bf2a43 100644 --- a/server-lib-common/src/security/acl.ts +++ b/server-lib-common/src/security/acl.ts @@ -41,11 +41,28 @@ export interface HostAclOptions { readonly now?: () => number; } +/** Why {@link HostAcl.authorize} found no active record for a (passkey, device) pair. */ +export type AclAuthorizationMiss = 'passkey-not-paired' | 'device-not-paired' | 'pairing-mismatch'; + +/** + * The result of {@link HostAcl.authorize}: either the single active record that + * matches both identities, or the reason(s) none does. Because a record is the + * conjunction of a passkey and a device, a miss is explained entirely by which + * half (if either) is paired — knowledge that belongs here with the record + * model rather than reconstructed by every caller. + */ +export type AclAuthorization = + | { readonly record: HostAclRecord } + | { readonly record: null; readonly reasons: readonly AclAuthorizationMiss[] }; + +/** A stored record whose `revokedAt` is writable; every other field stays readonly. */ +type MutableAclRecord = HostAclRecord & { revokedAt: number | null }; + export class HostAcl { readonly hostId: string; readonly #now: () => number; /** Mutable record objects stay private; every public API returns copies. */ - #records: Array = []; + #records: MutableAclRecord[] = []; constructor(hostId: string, options: HostAclOptions = {}) { this.hostId = hostId; @@ -111,6 +128,27 @@ export class HostAcl { return found ? { ...found } : undefined; } + /** + * The connection-time authorization lookup: the active record matching BOTH + * identities, or — when none does — exactly why, derived from whether each + * identity is independently paired. Keeps the "a record is passkey ∧ device" + * rule next to the record model instead of in the connection layer. + */ + authorize(query: { + readonly passkeyCredentialId: string; + readonly devicePublicKey: string; + }): AclAuthorization { + const found = this.#findActive(query.passkeyCredentialId, query.devicePublicKey); + if (found) return { record: { ...found } }; + const reasons: AclAuthorizationMiss[] = []; + const passkeyPaired = this.hasActivePasskey(query.passkeyCredentialId); + const devicePaired = this.hasActiveDevice(query.devicePublicKey); + if (!passkeyPaired) reasons.push('passkey-not-paired'); + if (!devicePaired) reasons.push('device-not-paired'); + if (passkeyPaired && devicePaired) reasons.push('pairing-mismatch'); + return { record: null, reasons }; + } + hasActivePasskey(passkeyCredentialId: string): boolean { return this.#records.some( (record) => record.revokedAt === null && record.passkeyCredentialId === passkeyCredentialId, @@ -136,7 +174,7 @@ export class HostAcl { #findActive( passkeyCredentialId: string, devicePublicKey: string, - ): (HostAclRecord & { revokedAt: number | null }) | undefined { + ): MutableAclRecord | undefined { return this.#records.find( (record) => record.revokedAt === null && diff --git a/server-lib-common/src/security/connection.ts b/server-lib-common/src/security/connection.ts index 223d53bc..077767a2 100644 --- a/server-lib-common/src/security/connection.ts +++ b/server-lib-common/src/security/connection.ts @@ -98,65 +98,66 @@ export async function authorizeConnection( request: ConnectionRequest, crypto: WebCryptoLike = getWebCrypto(), ): Promise { - const failures: ConnectionFailure[] = []; + // 5 (freshness half): burn the challenge before any other work, so it can + // never be presented twice whatever the rest of the decision does. + const challengeValid = host.challenges.consume(request.challenge); - // 5 (freshness half): burn the challenge before any other work. - if (!host.challenges.consume(request.challenge)) { - failures.push('challenge-invalid'); - } + // 3 + 4: both identities must sit on the same active ACL record. + const auth = host.acl.authorize({ + passkeyCredentialId: request.passkey.assertion.credentialId, + devicePublicKey: request.devicePublicKey, + }); - // 1: fresh user presence — a WebAuthn assertion over this same challenge. - const passkey = await verifyPasskeyAssertion( + // The remaining checks are independent and every layer is always evaluated + // (we never short-circuit on the first failure), so run the crypto + // concurrently rather than in series. Hashing the presented passkey key only + // means anything against a matched record, so it is skipped otherwise. + // The challenge must come after the spread so nothing on the policy object + // can ever override the freshness binding. + const passkeyPromise = verifyPasskeyAssertion( request.passkey.assertion, request.passkey.publicKey, - { - challenge: request.challenge, - origin: host.policy.origin, - rpId: host.policy.rpId, - requireUserVerification: host.policy.requireUserVerification, - }, + { ...host.policy, challenge: request.challenge }, crypto, ); - if (!passkey.ok) failures.push('passkey-assertion-invalid'); + const signaturePromise = verifyDeviceChallengeSignature( + { hostId: host.hostId, challenge: request.challenge, devicePublicKey: request.devicePublicKey }, + request.deviceSignature, + crypto, + ); + // The catch is attached at creation: this promise is attacker-rejectable (a + // malformed publicKey is not base64url), and it must never sit rejected and + // unhandled while the earlier awaits run. null can't equal the stored hash, + // so a malformed key denies as a mismatch. + const keyHashPromise = auth.record + ? hashPasskeyPublicKey(request.passkey.publicKey, crypto).catch(() => null) + : undefined; - // 3 + 4: both identities must sit on the same active ACL record. - const passkeyCredentialId = request.passkey.assertion.credentialId; - const record = host.acl.findActive({ - passkeyCredentialId, - devicePublicKey: request.devicePublicKey, - }); - if (!record) { - const passkeyPaired = host.acl.hasActivePasskey(passkeyCredentialId); - const devicePaired = host.acl.hasActiveDevice(request.devicePublicKey); - if (!passkeyPaired) failures.push('passkey-not-paired'); - if (!devicePaired) failures.push('device-not-paired'); - if (passkeyPaired && devicePaired) failures.push('pairing-mismatch'); + const passkey = await passkeyPromise; + const signatureValid = await signaturePromise; + const presentedKeyHash = await keyHashPromise; + + // Assemble every failure, in spec order. + const failures: ConnectionFailure[] = []; + if (!challengeValid) failures.push('challenge-invalid'); + if (!passkey.ok) failures.push('passkey-assertion-invalid'); + if (auth.record === null) { + failures.push(...auth.reasons); } else { - if ((await hashPasskeyPublicKey(request.passkey.publicKey, crypto)) !== record.passkeyPublicKeyHash) { + if (presentedKeyHash !== auth.record.passkeyPublicKeyHash) { failures.push('passkey-key-mismatch'); } - if (record.accountId !== request.accountId) { + if (auth.record.accountId !== request.accountId) { failures.push('account-mismatch'); } } - - // 5 (identity half): the paired device key must have signed this challenge. - const signatureValid = await verifyDeviceChallengeSignature( - { - hostId: host.hostId, - challenge: request.challenge, - devicePublicKey: request.devicePublicKey, - }, - request.deviceSignature, - crypto, - ); if (!signatureValid) failures.push('device-signature-invalid'); const allowed = failures.length === 0; return { allowed, failures, - record: allowed ? (record ?? null) : null, + record: allowed ? auth.record : null, passkey, }; } diff --git a/server-lib-common/test/acl.test.mjs b/server-lib-common/test/acl.test.mjs index c14c264d..4a219e74 100644 --- a/server-lib-common/test/acl.test.mjs +++ b/server-lib-common/test/acl.test.mjs @@ -41,6 +41,49 @@ test('findActive requires passkey AND device key on the same record', () => { assert.equal(acl.findActive({ passkeyCredentialId: 'cred-2', devicePublicKey: 'device-1' }), undefined); }); +test('authorize returns the record when both halves match', () => { + const { acl } = makeAcl(); + const record = acl.approve(CLIENT); + const auth = acl.authorize({ passkeyCredentialId: 'cred-1', devicePublicKey: 'device-1' }); + assert.deepEqual(auth, { record }); +}); + +test('authorize explains a miss by which half is unpaired', () => { + const { acl } = makeAcl(); + acl.approve(CLIENT); + assert.deepEqual( + acl.authorize({ passkeyCredentialId: 'cred-2', devicePublicKey: 'device-1' }), + { record: null, reasons: ['passkey-not-paired'] }, + ); + assert.deepEqual( + acl.authorize({ passkeyCredentialId: 'cred-1', devicePublicKey: 'device-2' }), + { record: null, reasons: ['device-not-paired'] }, + ); + assert.deepEqual( + acl.authorize({ passkeyCredentialId: 'cred-2', devicePublicKey: 'device-2' }), + { record: null, reasons: ['passkey-not-paired', 'device-not-paired'] }, + ); +}); + +test('authorize reports a pairing mismatch when both halves are paired separately', () => { + const { acl } = makeAcl(); + acl.approve(CLIENT); + acl.approve({ ...CLIENT, passkeyCredentialId: 'cred-2', devicePublicKey: 'device-2' }); + assert.deepEqual( + acl.authorize({ passkeyCredentialId: 'cred-1', devicePublicKey: 'device-2' }), + { record: null, reasons: ['pairing-mismatch'] }, + ); +}); + +test('authorize does not match revoked records', () => { + const { acl } = makeAcl(); + acl.approve(CLIENT); + acl.revokeDevice('device-1'); + const auth = acl.authorize({ passkeyCredentialId: 'cred-1', devicePublicKey: 'device-1' }); + assert.equal(auth.record, null); + assert.deepEqual(auth.reasons, ['passkey-not-paired', 'device-not-paired']); +}); + test('hasActivePasskey / hasActiveDevice track each half individually', () => { const { acl } = makeAcl(); acl.approve(CLIENT); diff --git a/server-lib-common/test/connection.test.mjs b/server-lib-common/test/connection.test.mjs index e4a6fea7..f94f0dd5 100644 --- a/server-lib-common/test/connection.test.mjs +++ b/server-lib-common/test/connection.test.mjs @@ -145,6 +145,23 @@ test('a substituted passkey public key is denied', async () => { assert.ok(decision.failures.includes('passkey-key-mismatch')); }); +test('a malformed passkey public key denies cleanly instead of throwing', async () => { + const setup = await pairedSetup(); + // Paired credential + paired device (a compromised server knows both), but + // garbage where the SPKI key belongs. Must be a decision, never a crash. + const request = await setup.client.buildConnectRequest(setup.host, { + accountId: ACCOUNT, + authenticator: setup.authenticator, + }); + const decision = await setup.host.handleConnect({ + ...request, + passkey: { ...request.passkey, publicKey: '!!!not-base64url!!!' }, + }); + assert.equal(decision.allowed, false); + assert.deepEqual(decision.failures, ['passkey-assertion-invalid', 'passkey-key-mismatch']); + assert.deepEqual(decision.passkey, { ok: false, reason: 'public-key-invalid' }); +}); + test('a request for a different account than the pairing is denied', async () => { const setup = await pairedSetup(); const decision = await connect(setup, { request: { accountId: 'account-2' } });