Remote security model primitives + shared server package#201
Conversation
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) <noreply@anthropic.com>
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 <noreply@anthropic.com>
…tion 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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
…uthorize, parallelize connection checks
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 <noreply@anthropic.com>
Deploying mouseterm with
|
| Latest commit: |
7a304ff
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://87749d07.mouseterm.pages.dev |
| Branch Preview URL: | https://security-model.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
Reviewed the remote security model end to end against the spec — the primitives are well-built and the guarantees are backed by genuinely adversarial tests. Replay protection (challenge burned up front, before any other work), the passkey∧device same-record conjunction, the passkey-key→stored-hash→assertion-signature binding chain, device-signature domain separation, and the WebAuthn checks (rpIdHash, origin allowlist, type, UP/UV flags, decoded-value challenge compare, ES256-only) all trace correctly, and verifyPasskeyAssertion / verifyDeviceChallengeSignature are carefully non-throwing.
One robustness observation inline on authorizeConnection, plus a minor packaging note below. Neither blocks; flagging for your judgment.
Minor: lib/package.json now depends on server-lib-common (workspace:*) and builds it in pretest, but nothing under lib/src imports it yet. If that's forward-wiring for the frontend to consume HELLO_ROUTE/the shared primitives, fine to leave; otherwise it's a currently-unused runtime dependency.
|
|
||
| // 3 + 4: both identities must sit on the same active ACL record. | ||
| const auth = host.acl.authorize({ | ||
| passkeyCredentialId: request.passkey.assertion.credentialId, |
There was a problem hiding this comment.
The module hardens malformed field content — a non-base64url passkey.publicKey denies as passkey-key-mismatch rather than crashing (the deliberate .catch(() => null) below), matching the "never throws on attacker-controlled input" guarantee in the PR description. But the structural access here isn't guarded: if request.passkey (or request.passkey.assertion) is missing or non-object, request.passkey.assertion.credentialId throws synchronously, and since authorizeConnection is async that surfaces as a rejected promise rather than an { allowed: false } decision.
There's no caller that hits this today (the server package is still hello-world), so it's forward-looking — but the natural HTTP handler is const request = await c.req.json(); return authorizeConnection(host, request), at which point request is unvalidated attacker JSON and a missing passkey becomes a 500 / unhandled rejection instead of a deny. Worth either a shape guard at the top of authorizeConnection or an explicit note that callers must validate structure before this point. Judgment call on where that boundary lives — flagging, not prescribing.
There was a problem hiding this comment.
we'll do validation like this in the handler
There was a problem hiding this comment.
Sounds good — validating request shape in the handler is exactly the caller-side boundary I had in mind as the alternative to a guard inside authorizeConnection. That keeps this function's contract as "well-formed ConnectionRequest in, decision out" and puts untrusted-JSON rejection at the trust boundary where the 400 belongs. Resolved from my side.
What
Introduces the remote security model — the primitives that let a Client browser prove to a Host that it is an approved device operated by an authenticated user — plus the shared
server-lib-commonpackage and a minimal Honoserverthat will host them.Design spec:
docs/specs/remote-security-model.md.Packages
server-lib-common— the server-side counterpart todor-lib-common. Runtime-agnostic (compiles into both a browser bundle and a Node process), zero runtime dependencies,"types": []. All crypto goes throughglobalThis.crypto(WebCrypto), so the same code runs on the Client and the Host.server— a Hono webserver. The app is built separately from theserve()entrypoint so routes can be tested viaapp.request()without binding a port; the route path and response shape live inserver-lib-commonso frontend and backend can't drift.Security primitives (
server-lib-common/src/security/)deviceKeychallengepasskeyaclauthorize()owns that conjunction rule.pairingapprovestep models the local user approval on the Host — the Server can relay a request but can't grant access.connectionauthorizeConnection— the Host's final decision. Evaluates every layer (never short-circuits), consumes the challenge up front, and allows only when all pass.ecdsa/bytes/webcryptoKey guarantees (enforced by tests)
authorizeConnectionnever throws on attacker-controlled input — a malformed passkey key denies aspasskey-key-mismatchrather than crashing.Testing
node --testacross every module, includingsecurity-guarantees.test.mjs— end-to-end scenarios covering each guarantee in the spec — driven by a simulated Client / Authenticator / Host harness. 111 tests, all passing, strict TypeScript build green.🤖 Generated with Claude Code