Signed requests: X-Nonce header; composite: sync skips unconfirmed writes
CI / Node 22 (push) Successful in 13s
CI / Node 24 (push) Successful in 13s
CI / Node 26 (push) Successful in 14s
CI / Node 22 (pull_request) Successful in 12s
CI / Node 24 (pull_request) Successful in 13s
CI / Node 26 (pull_request) Successful in 14s
CI / Chromium (push) Successful in 46s
CI / Publish to staging registry (push) Successful in 9s
CI / Chromium (pull_request) Successful in 45s
CI / Publish to staging registry (pull_request) Skipped

- Requests carry a random X-Nonce, signed as ${timestamp}:${nonce}:${path}:${body}. The
  server rejects a nonce the same key already used within the timestamp window. Identical
  requests at the same moment (two tabs, parallel reads) are now distinct, replacing the
  per-process increasing-timestamp workaround.
- Composite: a sync no longer reverts keys with local writes the source hasn't confirmed
  yet (in flight or queued); they briefly disappeared when a sync's scan ran before the
  write reached the source. Pending keys are reloaded from a persistent queue on start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
Claude
2026-10-23 14:50:16 +00:00
parent 7d755cecae
commit cb9ff514a0
6 changed files with 128 additions and 65 deletions
+15 -14
View File
@@ -128,20 +128,19 @@ no own value. A node's own value and its children are independent keys.
The server requires `auth`: either `{ verifiers, maxSkewMs?, now? }`, or `'none'` (open, for local
development and tests). It can't be left open by omission.
- **Signed requests.** The client's `RequestSigner` signs `${timestamp}:${path}:${body}` (path =
URL pathname, body = the exact request body; same scheme as Harvmaster/sync-server-example).
Headers: `X-Public-Key` (lowercase hex), `X-Timestamp` (ms), `X-Signature` (hex) and
`X-Signature-Algorithm`. Every request is signed, and so is each SSE (re)connect, with a fresh
timestamp.
- **Signed requests.** The client's `RequestSigner` signs `${timestamp}:${nonce}:${path}:${body}`
(nonce = 16 random bytes as hex, fresh per request; path = URL pathname; body = the exact request
body). Based on Harvmaster/sync-server-example, plus the nonce. Headers: `X-Public-Key`
(lowercase hex), `X-Timestamp` (ms), `X-Nonce`, `X-Signature` (hex) and `X-Signature-Algorithm`.
Every request is signed, and so is each SSE (re)connect, with a fresh timestamp and nonce.
- **Verification (401).** The server picks the `SignatureVerifier` registered for the algorithm
(unknown → 401) and checks the timestamp is within `maxSkewMs` (default 60 s). It then rejects a
signature already accepted within that window (replay protection), and verifies the signature
over the received path and raw body. The body is parsed only afterwards.
- **Unique timestamps.** Ed25519 signatures are deterministic, so identical requests in the same
millisecond would carry the same signature and the second would be rejected as a replay.
`RequestAuth.headers` makes timestamps strictly increase per public key within a process. Two
processes (e.g. browser tabs) with the same key can still collide, rarely; a nonce in the
payload would fix that but changes the signing scheme.
(unknown → 401), checks the nonce format (16–128 characters of `[0-9A-Za-z_-]`) and that the
timestamp is within `maxSkewMs` (default 60 s). It then rejects a nonce the same public key
already used within that window (replay protection), and verifies the signature over the
received timestamp, nonce, path and raw body. The body is parsed only afterwards.
- **Why a nonce.** Ed25519 signatures are deterministic, so without it two identical requests
from one key at the same millisecond (two tabs or devices subscribing to the same path, parallel
identical reads) would be indistinguishable, and the second rejected as a replay.
- **Algorithms are pluggable.** Built in: `Ed25519Signer` / `Ed25519Verifier` (WebCrypto, so no
dependencies; Node 22+ and current browsers; keys persist as PKCS #8). Others, e.g. secp256k1 via
libauth, implement `RequestSigner` (`algorithm`, `publicKey`, `sign`) and `SignatureVerifier`
@@ -178,7 +177,9 @@ is only appropriate when the client may read all of it.
only where they differ from what the first replica holds. No write tagging or echo tracking is
needed: echoes of this client's writes change nothing.
- **`sync()`**: replay the queue in order (send, then re-apply locally) → merge-scan the source
against the last replica (both key-ordered), applying the difference in batches of 500 →
against the last replica (both key-ordered), applying the difference in batches of 500, except
for keys with local writes the source hasn't confirmed yet (in flight or queued; counted per
key, and reloaded from a persistent queue on start), which would otherwise briefly revert →
replay again (writes made meanwhile) → mark `synced`. It runs on start, on `resync` (the stream
reconnected), and `retryMs` after a failure while offline. Called while a sync is running, it
schedules one more after it (shared by all callers meanwhile), so changes after the running