Skip to content

Remote security model primitives + shared server package#201

Merged
nedtwigg merged 5 commits into
mainfrom
security-model
Jul 2, 2026
Merged

Remote security model primitives + shared server package#201
nedtwigg merged 5 commits into
mainfrom
security-model

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Jul 2, 2026

Copy link
Copy Markdown
Member

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-common package and a minimal Hono server that will host them.

Design spec: docs/specs/remote-security-model.md.

Packages

  • server-lib-common — the server-side counterpart to dor-lib-common. Runtime-agnostic (compiles into both a browser bundle and a Node process), zero runtime dependencies, "types": []. All crypto goes through globalThis.crypto (WebCrypto), so the same code runs on the Client and the Host.
  • server — a Hono webserver. The app is built separately from the serve() entrypoint so routes can be tested via app.request() without binding a port; the route path and response shape live in server-lib-common so frontend and backend can't drift.

Security primitives (server-lib-common/src/security/)

Module Role
deviceKey Long-lived Client identity — a non-extractable ECDSA P-256 keypair; its raw public key is the Client's id everywhere in the model.
challenge Freshness — unguessable (256-bit), fast-expiring, single-use Host challenges; consuming one removes it whether or not the rest of the attempt succeeds.
passkey Fresh user presence — verifies a WebAuthn ES256 assertion; the Host stores only a hash of each paired passkey key, so a compromised Server can't substitute one.
acl Authorization — each Host's own list of approved Clients; an approved Client is the pair of (passkey credential, device key), and authorize() owns that conjunction rule.
pairing The only path into the ACL; its approve step models the local user approval on the Host — the Server can relay a request but can't grant access.
connection authorizeConnection — the Host's final decision. Evaluates every layer (never short-circuits), consumes the challenge up front, and allows only when all pass.
ecdsa / bytes / webcrypto Leaf helpers: DER↔raw signature conversion, base64url/UTF-8/constant-time byte ops, and minimal structural WebCrypto typings.

Key guarantees (enforced by tests)

  • The Host is the final authority: a denied/pending pairing grants nothing; the Server can't pair on the user's behalf.
  • A captured request can't be replayed — challenges are single-use and expire.
  • Passkey and device key must sit on the same active record; each paired separately is not authorization.
  • A compromised Server can't substitute a passkey (hash check) or connect for a different account (the Host re-checks the account binding itself).
  • authorizeConnection never throws on attacker-controlled input — a malformed passkey key denies as passkey-key-mismatch rather than crashing.

Testing

node --test across every module, including security-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

nedtwigg and others added 5 commits July 2, 2026 08:29
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>
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7a304ff
Status: ✅  Deploy successful!
Preview URL: https://87749d07.mouseterm.pages.dev
Branch Preview URL: https://security-model.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

we'll do validation like this in the handler

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@nedtwigg nedtwigg merged commit 82bdb16 into main Jul 2, 2026
13 checks passed
@nedtwigg nedtwigg deleted the security-model branch July 2, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants