Simplified layers, authenticated sync, composite storage and hash-keyed events #2

Open
Harvmaster wants to merge 11 commits from sync into main
6 changed files with 128 additions and 65 deletions
Showing only changes of commit cb9ff514a0 - Show all commits
+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
+5 -4
View File
@@ -239,10 +239,11 @@ key a value is stored under is authenticated too, so the server can't move value
### Signed requests
Every request is signed with the user's key: `X-Public-Key`, `X-Timestamp`, `X-Signature` and
`X-Signature-Algorithm` headers, over `${timestamp}:${path}:${body}`. The server checks the
signature and a 60 s timestamp window (with replay protection), then that every key is the public
key or under it. Ed25519 is built in (WebCrypto); other algorithms plug in by implementing
Every request is signed with the user's key: `X-Public-Key`, `X-Timestamp`, `X-Nonce` (random
per request), `X-Signature` and `X-Signature-Algorithm` headers, over
`${timestamp}:${nonce}:${path}:${body}`. The server checks the signature, a 60 s timestamp
window and that the nonce wasn't used before (replay protection), then that every key is the
public key or under it. Ed25519 is built in (WebCrypto); other algorithms plug in by implementing
`RequestSigner` / `SignatureVerifier`.
### Live updates without the composite
+43 -14
View File
@@ -51,7 +51,8 @@ export interface KVStorageComposite<S extends Spec = AnySpec> {
* client's own writes therefore changes nothing.
* - A write the source can't take (`StorageUnavailableError`) is applied locally and stored in
* the `queue` store; later writes queue behind it, so order is kept.
* - `sync()` replays the queue, then copies what changed on the source into the replicas. It runs
* - `sync()` replays the queue, then copies what changed on the source into the replicas, except
* keys with local writes the source hasn't confirmed yet (in flight or queued). It runs
* on start, when the source reconnects (`resync`), and after a retry delay while offline.
* - Reads wait for the first sync (until one succeeds, they go to the source). Whether a sync has
* completed is kept in the `queue` store, so with a persistent one a restart reads offline.
@@ -77,9 +78,11 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
queued: number
/** Remote changes are applied one after another. */
applying: Promise<unknown>
/** Keys with local writes the source hasn't confirmed yet (in flight or queued) → count. */
pending: Map<string, number>
retry?: ReturnType<typeof setTimeout>
offs: OffCallback[]
} = { synced: false, queued: 0, applying: Promise.resolve(), offs: [] }
} = { synced: false, queued: 0, applying: Promise.resolve(), pending: new Map(), offs: [] }
constructor(options: KVStorageCompositeOptions) {
super()
@@ -140,20 +143,25 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
const keys = changes.map(({ key }) => key)
this.checkRange(keys)
await this.start()
// Applied locally first, so the source relaying this write back finds nothing new.
// Applied locally first, so the source relaying this write back finds nothing new. Until the
// source confirms it, a sync must not replace it with the source's (older) value.
const previous = await this.replicas[0]!.readMany(keys)
this.markPending(keys, 1)
await this.apply(changes)
// While syncing or with writes queued, queue this one too so the source sees writes in order.
if (this.state.queued === 0 && !this.state.syncing) {
try {
return await this.source.writeMany(changes)
} catch (error) {
if (!(error instanceof StorageUnavailableError)) {
await this.apply(keys.map((key, i) => ({ key, value: previous[i] })))
throw error
}
}
if (this.state.queued > 0 || this.state.syncing) return this.enqueue(changes)
try {
await this.source.writeMany(changes)
} catch (error) {
if (error instanceof StorageUnavailableError) return this.enqueue(changes)
this.markPending(keys, -1)
await this.apply(keys.map((key, i) => ({ key, value: previous[i] })))
throw error
}
this.markPending(keys, -1)
}
private async enqueue(changes: KVChange[]): Promise<void> {
await this.writes.set(this.ids.generate(), changes)
this.state.queued++
this.retryLater()
@@ -167,7 +175,13 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
private async open(): Promise<void> {
this.state.synced = (await this.queue.get('synced')) === true
for await (const _ of this.writes.keys()) this.state.queued++
for await (const [, changes] of this.writes.entries()) {
this.state.queued++
this.markPending(
(changes as KVChange[]).map(({ key }) => key),
1,
)
}
await this.seed()
this.state.offs.push(
this.source.on('change', (event) => {
@@ -192,7 +206,10 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
try {
await this.replay()
let batch: KVChange[] = []
for await (const change of this.diff()) if (batch.push(change) >= 500) await this.apply(batch.splice(0), true)
for await (const change of this.diff()) {
if (this.state.pending.has(change.key)) continue // a newer local write, not yet sent
if (batch.push(change) >= 500) await this.apply(batch.splice(0), true)
}
await this.apply(batch, true)
await this.replay() // writes made during the diff: send them, and re-apply them over it
await this.queue.set('synced', true)
@@ -224,6 +241,10 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
}
await this.writes.delete(next[0])
this.state.queued--
this.markPending(
changes.map(({ key }) => key),
-1,
)
}
}
@@ -275,6 +296,14 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
await flush()
}
private markPending(keys: string[], delta: 1 | -1): void {
for (const key of keys) {
const count = (this.state.pending.get(key) ?? 0) + delta
if (count > 0) this.state.pending.set(key, count)
else this.state.pending.delete(key)
}
}
private retryLater(): void {
clearTimeout(this.state.retry)
this.state.retry = setTimeout(() => void this.sync(), this.retryMs)
+24 -27
View File
@@ -19,6 +19,7 @@ export interface SignatureVerifier {
export const AUTH_HEADERS = {
publicKey: 'X-Public-Key',
timestamp: 'X-Timestamp',
nonce: 'X-Nonce',
signature: 'X-Signature',
algorithm: 'X-Signature-Algorithm',
} as const
@@ -30,17 +31,18 @@ export interface RequestAuthOptions {
}
/**
* Signed-request authentication. The client signs `${timestamp}:${path}:${body}` (path = URL
* pathname, body = the exact request body) and sends the public key, timestamp, signature (hex)
* and algorithm as headers. The server checks the timestamp is recent, verifies the signature
* with the verifier for that algorithm, and rejects a signature it has already seen within the
* timestamp window (replay protection).
* Signed-request authentication. The client signs `${timestamp}:${nonce}:${path}:${body}` (nonce =
* random per request, path = URL pathname, body = the exact request body) and sends the public
* key, timestamp, nonce, signature (hex) and algorithm as headers. The server checks the
* timestamp is recent, verifies the signature with the verifier for that algorithm, and rejects a
* nonce the same key already used within the timestamp window (replay protection). The nonce also
* keeps identical requests made at the same moment distinct (Ed25519 signatures are deterministic).
*/
export class RequestAuth {
private readonly verifiers: Map<string, SignatureVerifier>
private readonly maxSkewMs: number
private readonly now: () => number
/** Signatures accepted within the window → when they expire; insertion order ≈ expiry order. */
/** `<publicKey>:<nonce>` accepted within the window → when it expires; insertion order ≈ expiry order. */
private readonly seen = new Map<string, number>()
constructor(verifiers: SignatureVerifier[], options: RequestAuthOptions = {}) {
@@ -50,27 +52,19 @@ export class RequestAuth {
this.now = options.now ?? Date.now
}
static payload(timestamp: string, path: string, body: string): Uint8Array {
return new TextEncoder().encode(`${timestamp}:${path}:${body}`)
static payload(timestamp: string, nonce: string, path: string, body: string): Uint8Array {
return new TextEncoder().encode(`${timestamp}:${nonce}:${path}:${body}`)
}
/** Last timestamp used per public key in this process (see `headers`). */
private static readonly lastTimestamps = new Map<string, number>()
/**
* Auth headers for a request to `path` with `body`. Timestamps per public key strictly increase
* within a process: Ed25519 signatures are deterministic, so two identical requests in the same
* millisecond would otherwise carry the same signature, and the second would be rejected as a
* replay.
*/
/** Auth headers for a request to `path` with `body`. */
static async headers(signer: RequestSigner, path: string, body: string, now: number = Date.now()): Promise<Record<string, string>> {
const time = Math.max(now, (RequestAuth.lastTimestamps.get(signer.publicKey) ?? 0) + 1)
RequestAuth.lastTimestamps.set(signer.publicKey, time)
const timestamp = String(time)
const signature = await signer.sign(RequestAuth.payload(timestamp, path, body))
const timestamp = String(now)
const nonce = Hex.encode(crypto.getRandomValues(new Uint8Array(16)))
const signature = await signer.sign(RequestAuth.payload(timestamp, nonce, path, body))
return {
[AUTH_HEADERS.publicKey]: signer.publicKey,
[AUTH_HEADERS.timestamp]: timestamp,
[AUTH_HEADERS.nonce]: nonce,
[AUTH_HEADERS.signature]: Hex.encode(signature),
[AUTH_HEADERS.algorithm]: signer.algorithm,
}
@@ -80,32 +74,35 @@ export class RequestAuth {
async verify(headers: Headers, path: string, body: string): Promise<string> {
const publicKey = headers.get(AUTH_HEADERS.publicKey)
const timestamp = headers.get(AUTH_HEADERS.timestamp)
const nonce = headers.get(AUTH_HEADERS.nonce)
const signature = headers.get(AUTH_HEADERS.signature)
const algorithm = headers.get(AUTH_HEADERS.algorithm)
if (!publicKey || !timestamp || !signature || !algorithm) throw new AuthError(401, 'Missing authentication headers')
if (!publicKey || !timestamp || !nonce || !signature || !algorithm) throw new AuthError(401, 'Missing authentication headers')
const verifier = this.verifiers.get(algorithm)
if (!verifier) throw new AuthError(401, `Unsupported signature algorithm "${algorithm}"`)
if (!/^[0-9a-f]+$/.test(publicKey)) throw new AuthError(401, 'Public key must be lowercase hex')
if (!/^[0-9A-Za-z_-]{16,128}$/.test(nonce)) throw new AuthError(401, 'Nonce must be 16-128 characters of [0-9A-Za-z_-]')
const now = this.now()
const time = Number(timestamp)
if (!Number.isInteger(time) || Math.abs(now - time) > this.maxSkewMs) throw new AuthError(401, 'Timestamp outside the accepted window')
this.forgetExpired(now)
if (this.seen.has(signature)) throw new AuthError(401, 'Replayed request')
const used = `${publicKey}:${nonce}`
if (this.seen.has(used)) throw new AuthError(401, 'Replayed request')
let valid = false
try {
valid = await verifier.verify(publicKey, RequestAuth.payload(timestamp, path, body), Hex.decode(signature))
valid = await verifier.verify(publicKey, RequestAuth.payload(timestamp, nonce, path, body), Hex.decode(signature))
} catch {
valid = false
}
if (!valid) throw new AuthError(401, 'Invalid signature')
this.seen.set(signature, time + this.maxSkewMs)
this.seen.set(used, time + this.maxSkewMs)
return publicKey
}
private forgetExpired(now: number): void {
for (const [signature, expires] of this.seen) {
for (const [used, expires] of this.seen) {
if (expires >= now) break
this.seen.delete(signature)
this.seen.delete(used)
}
}
}
@@ -257,4 +257,29 @@ describe('KVStorageComposite sync', () => {
['b', 2],
])
})
it('a sync started while a write is on its way to the source does not undo it', async () => {
let release!: () => void
const gate = new Promise<void>((r) => (release = r))
const inner = new KVStorageInMemory()
const source = new KVStorageCounting(inner)
const writeMany = source.writeMany.bind(source)
source.writeMany = async (changes) => {
await gate // a slow request
return writeMany(changes)
}
const replica = new KVStorageInMemory()
const db = new KVStorageComposite({ source, replicas: [replica] })
composites.push(db)
await db.sync()
const writing = db.set('a', 1)
await vi.waitFor(async () => expect(await replica.readMany(['a'])).toEqual([1])) // applied locally, not yet on the source
const syncing = db.sync()
await new Promise((r) => setTimeout(r, 10))
expect(await replica.readMany(['a'])).toEqual([1]) // the sync skips the unconfirmed key
release()
await Promise.all([writing, syncing])
expect(await replica.readMany(['a'])).toEqual([1])
expect(await inner.readMany(['a'])).toEqual([1])
})
})
+16 -6
View File
@@ -18,38 +18,48 @@ describe('RequestAuth', () => {
expect(await auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey)
})
it('rejects tampered bodies, paths and timestamps', async () => {
it('rejects tampered bodies, paths, timestamps and nonces', async () => {
const signer = await Ed25519Signer.generate()
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
await expect(auth.verify(await signed(signer), '/kv/readMany', '{"keys":["b"]}')).rejects.toThrow(/Invalid signature/)
await expect(auth.verify(await signed(signer), '/kv/writeMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const headers = await signed(signer)
headers.set(AUTH_HEADERS.timestamp, String(Number(headers.get(AUTH_HEADERS.timestamp)) + 1))
headers.set(AUTH_HEADERS.timestamp, String(NOW + 1))
await expect(auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const renonced = await signed(signer)
renonced.set(AUTH_HEADERS.nonce, 'f'.repeat(32))
await expect(auth.verify(renonced, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
const other = await Ed25519Signer.generate()
const swapped = await signed(signer)
swapped.set(AUTH_HEADERS.publicKey, other.publicKey)
await expect(auth.verify(swapped, '/kv/readMany', '{"keys":["a"]}')).rejects.toThrow(/Invalid signature/)
})
it('accepts identical requests made in the same millisecond (timestamps per key increase)', async () => {
it('accepts identical requests made at the same moment (each has its own nonce)', async () => {
const signer = await Ed25519Signer.generate()
const sameKey = await Ed25519Signer.fromPrivateKey(await signer.exportPrivateKey())
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW })
const requests = await Promise.all([signed(signer), signed(signer), signed(sameKey)])
expect(new Set(requests.map((h) => h.get(AUTH_HEADERS.signature))).size).toBe(3)
expect(new Set(requests.map((h) => h.get(AUTH_HEADERS.timestamp)))).toEqual(new Set([String(NOW)]))
expect(new Set(requests.map((h) => h.get(AUTH_HEADERS.nonce))).size).toBe(3)
for (const headers of requests) expect(await auth.verify(headers, '/kv/readMany', '{"keys":["a"]}')).toBe(signer.publicKey)
})
it('rejects old or future timestamps, replays, missing headers and unknown algorithms', async () => {
const signer = await Ed25519Signer.generate()
const auth = new RequestAuth([new Ed25519Verifier()], { now: () => NOW, maxSkewMs: 1000 })
await expect(auth.verify(await signed(await Ed25519Signer.generate(), '/p', 'b', NOW - 5000), '/p', 'b')).rejects.toThrow(/window/)
await expect(auth.verify(await signed(await Ed25519Signer.generate(), '/p', 'b', NOW + 5000), '/p', 'b')).rejects.toThrow(/window/)
await expect(auth.verify(await signed(signer, '/p', 'b', NOW - 5000), '/p', 'b')).rejects.toThrow(/window/)
await expect(auth.verify(await signed(signer, '/p', 'b', NOW + 5000), '/p', 'b')).rejects.toThrow(/window/)
const once = await signed(signer, '/p', 'b')
await auth.verify(once, '/p', 'b')
await expect(auth.verify(once, '/p', 'b')).rejects.toThrow(/Replayed/)
await expect(auth.verify(new Headers(), '/p', 'b')).rejects.toBeInstanceOf(AuthError)
const noNonce = await signed(signer, '/p', 'd')
noNonce.delete(AUTH_HEADERS.nonce)
await expect(auth.verify(noNonce, '/p', 'd')).rejects.toThrow(/Missing/)
const shortNonce = await signed(signer, '/p', 'e')
shortNonce.set(AUTH_HEADERS.nonce, 'abc')
await expect(auth.verify(shortNonce, '/p', 'e')).rejects.toThrow(/Nonce/)
const unknown = await signed(signer, '/p', 'c')
unknown.set(AUTH_HEADERS.algorithm, 'rot13')
await expect(auth.verify(unknown, '/p', 'c')).rejects.toThrow(/Unsupported signature algorithm "rot13"/)