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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user