@cero-base/cero 1.5.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/package.json +2 -2
- package/src/build/builtins.js +14 -3
- package/src/build/index.js +2 -0
- package/src/build/schemas.js +12 -1
- package/src/handle/index.js +83 -24
- package/src/index.js +3 -0
- package/src/lib/constants.js +1 -0
- package/src/lib/operators.js +44 -9
- package/src/rpc/client.js +25 -2
- package/src/rpc/server.js +13 -4
- package/types/build/builtins.d.ts +4 -0
- package/types/build/schemas.d.ts +11 -0
- package/types/handle/index.d.ts +17 -3
- package/types/index.d.ts +3 -1
- package/types/lib/constants.d.ts +1 -0
- package/types/lib/operators.d.ts +3 -0
- package/types/rpc/client.d.ts +3 -1
package/README.md
CHANGED
|
@@ -216,6 +216,75 @@ await cero.open(me.room, { id: existingRoomId }) // load an existing room by id
|
|
|
216
216
|
|
|
217
217
|
Returns the child handle, ready to operate on (`cero.put(joined.messages, …)` etc.).
|
|
218
218
|
|
|
219
|
+
### `cero.rotate(handle)`
|
|
220
|
+
|
|
221
|
+
Rotate the handle's encryption epoch. A fresh secret is sealed to every current
|
|
222
|
+
member and announced through the log — members removed **before** the rotation
|
|
223
|
+
cannot decrypt anything written after it (rows **and** files). Requires the
|
|
224
|
+
`remove` permission (admin or owner). Returns `{ epoch }`.
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
await cero.del(room.members, memberId) // revoke write access
|
|
228
|
+
await cero.rotate(room) // revoke read access for everything that follows
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Rotation is opt-in per room: without it, `del` alone is a soft removal (write
|
|
232
|
+
revocation only). Once a room has rotated at least once, later removals
|
|
233
|
+
self-heal — a `del` without an explicit `rotate` triggers an automatic re-key
|
|
234
|
+
from any online admin device. Standalone rotations (no removal) are valid too,
|
|
235
|
+
as periodic key hygiene. Full design: [`docs/key-rotation.md`](../../docs/key-rotation.md).
|
|
236
|
+
|
|
237
|
+
### Watching removals
|
|
238
|
+
|
|
239
|
+
There is no separate removal event — `members` is a collection, so the normal
|
|
240
|
+
streams already are the membership feed. A removal arrives as an ordinary
|
|
241
|
+
delete (`next: null`), carrying the row that was removed:
|
|
242
|
+
|
|
243
|
+
```js
|
|
244
|
+
for await (const { changes } of cero.changes(room.members)) {
|
|
245
|
+
for (const { prev, next } of changes) {
|
|
246
|
+
if (next === null) console.log('removed:', prev.name)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
To detect **your own** removal — the signal an app renders as "you were removed
|
|
252
|
+
from this room" — listen for the store losing writability:
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
room.store.on('unwritable', () => onRemoved()) // freeze the UI, close the room
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This works even when the removal is followed by a rotation: the removal lands
|
|
259
|
+
before the new key, so a removed member always receives it. After that their
|
|
260
|
+
streams stay open but go silent — reads freeze at the moment of removal and
|
|
261
|
+
writes reject with `NOT_WRITABLE`.
|
|
262
|
+
|
|
263
|
+
### Store events
|
|
264
|
+
|
|
265
|
+
Beyond the ref streams, the store reports its own lifecycle:
|
|
266
|
+
|
|
267
|
+
```js
|
|
268
|
+
room.store.on('writable', () => {}) // admitted — this device can write
|
|
269
|
+
room.store.on('unwritable', () => {}) // access ended (removed)
|
|
270
|
+
room.store.on('update', () => {}) // an apply batch committed
|
|
271
|
+
room.store.on('behind', (v) => {}) // ops from a newer app version — see below
|
|
272
|
+
room.store.on('rebuild', () => {}) // view replayed after catching up
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`before`/`after` hooks only fire on the device performing the write. To observe
|
|
276
|
+
**every** applied op — local _and_ replicated — use `onApply`, which returns an
|
|
277
|
+
unsubscribe fn:
|
|
278
|
+
|
|
279
|
+
```js
|
|
280
|
+
const off = room.store.onApply(({ op, name, row, writerKey, seq }) => {
|
|
281
|
+
if (op === 'del' && name === 'member') auditLog(row)
|
|
282
|
+
})
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The callback runs synchronously inside apply, so keep it cheap — enqueue and
|
|
286
|
+
return. It costs nothing when nobody is subscribed.
|
|
287
|
+
|
|
219
288
|
### `cero.before(ref, fn, opts?)` / `cero.after(ref, fn, opts?)`
|
|
220
289
|
|
|
221
290
|
Hook into writes to a ref. `before` runs **in-path** before the write commits — return `false` to cancel, or mutate `ctx.row`. `after` is a non-blocking **event** that fires once the write has committed. Both return an unsubscribe fn — call it to stop early, ignore it for a subscription that lives as long as the handle, or pass `{ signal }` to unsubscribe when an `AbortSignal` fires (e.g. `{ signal: me.signal }` to stop on close).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/cero",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "The ideal p2p API — everything is a handle, handles contain refs, refs contain rows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"test:node": "ls test/*.test.js | xargs -P1 -n1 brittle-node"
|
|
96
96
|
},
|
|
97
97
|
"dependencies": {
|
|
98
|
-
"@cero-base/core": "^1.
|
|
98
|
+
"@cero-base/core": "^1.7.0",
|
|
99
99
|
"b4a": "^1.8.1",
|
|
100
100
|
"bare-abort-controller": "^1.1.2",
|
|
101
101
|
"bare-crypto": "^1.15.3",
|
package/src/build/builtins.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// maps into the type / collection / dispatch / command descriptors the builder
|
|
3
3
|
// registers. Every cero schema includes these; app refs are added on top.
|
|
4
4
|
import { CeroError } from '@cero-base/core/errors'
|
|
5
|
-
import { COUNTERS, DB_TYPE } from '../lib/constants.js'
|
|
5
|
+
import { COUNTERS, EPOCHS, DB_TYPE } from '../lib/constants.js'
|
|
6
6
|
import * as schemas from './schemas.js'
|
|
7
7
|
|
|
8
8
|
// Builtin collections by scope — ref name → { type, kind? }. kind defaults to
|
|
@@ -79,7 +79,10 @@ export const builtinCollections = (ns, scope) => {
|
|
|
79
79
|
schema: at(ns, def.type),
|
|
80
80
|
key: keyOf(def)
|
|
81
81
|
}))
|
|
82
|
-
if (scope === 'main')
|
|
82
|
+
if (scope === 'main') {
|
|
83
|
+
out.push({ name: COUNTERS, schema: at(ns, 'counter'), key: ['name'] })
|
|
84
|
+
out.push({ name: EPOCHS, schema: at(ns, 'epoch'), key: ['epoch'] })
|
|
85
|
+
}
|
|
83
86
|
return out
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -95,6 +98,13 @@ export const builtinDispatches = (ns) => [
|
|
|
95
98
|
])
|
|
96
99
|
]
|
|
97
100
|
|
|
101
|
+
// Registered AFTER the app's own dispatches (see build/index.js): hyperdispatch
|
|
102
|
+
// numbers routes positionally and persists them, so a spec built before
|
|
103
|
+
// rotation must see rotate-key appended at the end — inserting it into the
|
|
104
|
+
// builtin group would collide with the app's persisted route ids on an
|
|
105
|
+
// incremental rebuild.
|
|
106
|
+
export const rotateDispatch = (ns) => ({ name: 'rotate-key', requestType: at(ns, 'epoch') })
|
|
107
|
+
|
|
98
108
|
export const rpcCommands = (ns) => {
|
|
99
109
|
const ref = (n) => at(ns, n)
|
|
100
110
|
return [
|
|
@@ -145,6 +155,7 @@ export const rpcCommands = (ns) => {
|
|
|
145
155
|
name: 'changes',
|
|
146
156
|
request: { name: ref('req-query') },
|
|
147
157
|
response: { name: ref('res-changes'), stream: true }
|
|
148
|
-
}
|
|
158
|
+
},
|
|
159
|
+
{ name: 'rotate', request: { name: ref('req-handle') }, response: { name: ref('res-epoch') } }
|
|
149
160
|
]
|
|
150
161
|
}
|
package/src/build/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
builtinTypes,
|
|
17
17
|
builtinCollections,
|
|
18
18
|
builtinDispatches,
|
|
19
|
+
rotateDispatch,
|
|
19
20
|
rpcTypes,
|
|
20
21
|
rpcCommands,
|
|
21
22
|
getHyperdbType
|
|
@@ -272,6 +273,7 @@ function emitMain(dir, ns, { types, collections, dispatches, indexes = [] }, { r
|
|
|
272
273
|
const xns = d.namespace(ns)
|
|
273
274
|
for (const desc of builtinDispatches(ns)) xns.register(desc)
|
|
274
275
|
for (const desc of dispatches) xns.register(desc)
|
|
276
|
+
xns.register(rotateDispatch(ns))
|
|
275
277
|
Hyperdispatch.toDisk(d, dispatchDir, { esm: true })
|
|
276
278
|
|
|
277
279
|
if (rpc) {
|
package/src/build/schemas.js
CHANGED
|
@@ -66,13 +66,21 @@ export const main = {
|
|
|
66
66
|
name: string,
|
|
67
67
|
createdAt: int,
|
|
68
68
|
updatedAt: int,
|
|
69
|
-
index: uint
|
|
69
|
+
index: uint,
|
|
70
|
+
stamp: uint
|
|
70
71
|
},
|
|
71
72
|
claim: {
|
|
72
73
|
identity: required(bytes),
|
|
73
74
|
writer: required(bytes),
|
|
74
75
|
sig: required(bytes),
|
|
75
76
|
ts: int
|
|
77
|
+
},
|
|
78
|
+
epoch: {
|
|
79
|
+
epoch: required(uint),
|
|
80
|
+
wrapped: required(bytes),
|
|
81
|
+
createdAt: int,
|
|
82
|
+
commit: bytes,
|
|
83
|
+
stamp: uint
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
|
|
@@ -188,5 +196,8 @@ export const rpc = {
|
|
|
188
196
|
},
|
|
189
197
|
'res-ok': {
|
|
190
198
|
ok: bool
|
|
199
|
+
},
|
|
200
|
+
'res-epoch': {
|
|
201
|
+
epoch: required(uint)
|
|
191
202
|
}
|
|
192
203
|
}
|
package/src/handle/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import AbortController from 'bare-abort-controller'
|
|
2
2
|
import b4a from 'b4a'
|
|
3
|
+
import c from 'compact-encoding'
|
|
3
4
|
import Hypercore from 'hypercore'
|
|
4
5
|
import { discoveryKey } from 'hypercore-crypto'
|
|
5
6
|
import ReadyResource from 'ready-resource'
|
|
@@ -8,6 +9,7 @@ import z32 from 'z32'
|
|
|
8
9
|
|
|
9
10
|
import { Identity } from '@cero-base/core/identity'
|
|
10
11
|
import { Database } from '@cero-base/core/database'
|
|
12
|
+
import { epochEntries, blobEpochKey } from '@cero-base/core/database/encryption'
|
|
11
13
|
import { Pairing } from '@cero-base/core/pairing'
|
|
12
14
|
import { toId, grants, addWriterPayload } from '@cero-base/core/utils'
|
|
13
15
|
import { CeroError } from '@cero-base/core/errors'
|
|
@@ -40,6 +42,7 @@ export { Ref } from '../lib/utils.js'
|
|
|
40
42
|
* @property {Record<string, Function>} [routes]
|
|
41
43
|
* @property {Uint8Array} [key] Existing database key.
|
|
42
44
|
* @property {Uint8Array} [encryptionKey] Existing encryption key.
|
|
45
|
+
* @property {Array<{ epoch: number, entropy: Uint8Array }>} [epochs] Rotation epochs delivered at join.
|
|
43
46
|
* @property {string} [namespace] Corestore namespace.
|
|
44
47
|
* @property {KeyPair} [keyPair] Writer keypair.
|
|
45
48
|
* @property {boolean} [passive] Join discovery server-only; flip later with `setActive`.
|
|
@@ -121,6 +124,7 @@ export class Handle extends ReadyResource {
|
|
|
121
124
|
this._coreKeys = parent ? null : new Map()
|
|
122
125
|
this._fileServer = null
|
|
123
126
|
this._blobs = null
|
|
127
|
+
this._epochBlobs = null
|
|
124
128
|
this._owned = new Set()
|
|
125
129
|
|
|
126
130
|
this.store = new Database({
|
|
@@ -131,6 +135,7 @@ export class Handle extends ReadyResource {
|
|
|
131
135
|
routes: opts.routes,
|
|
132
136
|
key: opts.key,
|
|
133
137
|
encryptionKey: opts.encryptionKey,
|
|
138
|
+
epochs: opts.epochs,
|
|
134
139
|
namespace: opts.namespace,
|
|
135
140
|
keyPair: opts.keyPair,
|
|
136
141
|
passive: opts.passive,
|
|
@@ -159,10 +164,15 @@ export class Handle extends ReadyResource {
|
|
|
159
164
|
async _close() {
|
|
160
165
|
this.root._coreKeys.delete(b4a.toString(this.store.key, 'hex'))
|
|
161
166
|
if (this._blobs?.key) this.root._coreKeys.delete(b4a.toString(this._blobs.key, 'hex'))
|
|
167
|
+
for (const b of this._epochBlobs?.values() || []) {
|
|
168
|
+
if (b.key) this.root._coreKeys.delete(b4a.toString(b.key, 'hex'))
|
|
169
|
+
}
|
|
162
170
|
for (const hex of this._blobKeys || []) this.root._coreKeys.delete(hex)
|
|
163
171
|
for (const r of [...this._owned]) r.destroy?.()
|
|
164
172
|
this._owned.clear()
|
|
165
173
|
if (this._blobs) await this._blobs.close()
|
|
174
|
+
for (const b of this._epochBlobs?.values() || []) await b.close().catch(safetyCatch)
|
|
175
|
+
this._epochBlobs = null
|
|
166
176
|
if (this.children) {
|
|
167
177
|
for (const c of [...this.children]) await c.close()
|
|
168
178
|
this.children.clear()
|
|
@@ -286,31 +296,49 @@ export class Handle extends ReadyResource {
|
|
|
286
296
|
}
|
|
287
297
|
|
|
288
298
|
/**
|
|
289
|
-
* Lazily-built blob store for THIS handle's writing device
|
|
290
|
-
*
|
|
291
|
-
*
|
|
299
|
+
* Lazily-built blob store for THIS handle's writing device, at the current
|
|
300
|
+
* rotation epoch. One core per writer per epoch that writes files: the base
|
|
301
|
+
* era uses the handle's encryptionKey, rotated epochs use a key derived
|
|
302
|
+
* from the epoch entropy — so a removed member cannot decrypt files added
|
|
303
|
+
* after the rotation. Instances carry `.stamp` so the file row records
|
|
304
|
+
* which era its core belongs to.
|
|
292
305
|
*
|
|
293
306
|
* @returns {Blobs}
|
|
294
307
|
*/
|
|
295
308
|
get blobs() {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
})
|
|
302
|
-
this.
|
|
303
|
-
.ready()
|
|
304
|
-
.then(() => {
|
|
305
|
-
if (this._blobs && this._blobs.key) {
|
|
306
|
-
this.root._coreKeys.set(b4a.toString(this._blobs.key, 'hex'), this.store.encryptionKey)
|
|
307
|
-
}
|
|
308
|
-
})
|
|
309
|
-
.catch(this._onerror)
|
|
309
|
+
const stamp = this.store.keyring.current
|
|
310
|
+
if (!stamp) return this._baseBlobs()
|
|
311
|
+
let blobs = this._epochBlobs?.get(stamp)
|
|
312
|
+
if (!blobs) {
|
|
313
|
+
const entropy = this.store.keyring.entropy(stamp)
|
|
314
|
+
blobs = this._makeBlobs(`blobs-${stamp}`, blobEpochKey(entropy), stamp)
|
|
315
|
+
;(this._epochBlobs ??= new Map()).set(stamp, blobs)
|
|
310
316
|
}
|
|
317
|
+
return blobs
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
_baseBlobs() {
|
|
321
|
+
if (!this._blobs) this._blobs = this._makeBlobs('blobs', this.store.encryptionKey, 0)
|
|
311
322
|
return this._blobs
|
|
312
323
|
}
|
|
313
324
|
|
|
325
|
+
_makeBlobs(name, encryptionKey, stamp) {
|
|
326
|
+
const blobs = new Blobs({
|
|
327
|
+
store: this.store.store,
|
|
328
|
+
network: this.network,
|
|
329
|
+
encryptionKey,
|
|
330
|
+
name
|
|
331
|
+
})
|
|
332
|
+
blobs.stamp = stamp
|
|
333
|
+
blobs
|
|
334
|
+
.ready()
|
|
335
|
+
.then(() => {
|
|
336
|
+
if (blobs.key) this.root._coreKeys.set(b4a.toString(blobs.key, 'hex'), encryptionKey)
|
|
337
|
+
})
|
|
338
|
+
.catch(this._onerror)
|
|
339
|
+
return blobs
|
|
340
|
+
}
|
|
341
|
+
|
|
314
342
|
/** Canonical id — identity id for the root handle, store key for children. */
|
|
315
343
|
get id() {
|
|
316
344
|
if (!this.parent) return this.identity.id
|
|
@@ -426,7 +454,13 @@ export class Handle extends ReadyResource {
|
|
|
426
454
|
// joiner times out. So the key is revealed before the membership writes
|
|
427
455
|
// land; if they fail the joiner holds the key un-admitted, which is
|
|
428
456
|
// recoverable (re-pair) and surfaced via onerror in _wireAccept.
|
|
429
|
-
|
|
457
|
+
// Epoch secrets ride along so a post-rotation joiner reads full history.
|
|
458
|
+
const epochs = this.store.keyring.all()
|
|
459
|
+
await candidate.confirm({
|
|
460
|
+
key: this.store.key,
|
|
461
|
+
encryptionKey: this.store.encryptionKey,
|
|
462
|
+
additional: epochs.length ? c.encode(epochEntries, epochs) : null
|
|
463
|
+
})
|
|
430
464
|
|
|
431
465
|
const ts = Date.now()
|
|
432
466
|
const writerKey = Hypercore.key({ version: 2, signers: [{ publicKey: data.subarray(32, 64) }] })
|
|
@@ -487,7 +521,10 @@ export class Handle extends ReadyResource {
|
|
|
487
521
|
spec: pickHandle(this.spec, type),
|
|
488
522
|
namespace: `${NS}/handle/${type}/${writer.id}`,
|
|
489
523
|
routes,
|
|
490
|
-
keyPair: writer
|
|
524
|
+
keyPair: writer,
|
|
525
|
+
// each room gets its own key — inheriting identity.encryptionKey would let
|
|
526
|
+
// any member decrypt every room this identity ever created, plus its root db
|
|
527
|
+
encryptionKey: Identity.randomBytes(32)
|
|
491
528
|
})
|
|
492
529
|
)
|
|
493
530
|
// a failure after the child opens must close it — else it leaks its Database,
|
|
@@ -551,15 +588,24 @@ export class Handle extends ReadyResource {
|
|
|
551
588
|
async _join(invite, type, { routes, timeout } = {}) {
|
|
552
589
|
const deadline = timeout || TIMEOUT
|
|
553
590
|
|
|
554
|
-
// Idempotent: if the invite targets a handle we already have
|
|
555
|
-
// without pairing, so there's no
|
|
591
|
+
// Idempotent: if the invite targets a handle we already have AND we are
|
|
592
|
+
// still a member of it, return it — without pairing, so there's no
|
|
593
|
+
// waiting on a peer to confirm. If our member row is gone (we were
|
|
594
|
+
// removed), the stored writer is revoked and the old session can never
|
|
595
|
+
// become writable again — holding a fresh invite is exactly the
|
|
596
|
+
// re-admission path, so fall through to a real pairing instead.
|
|
556
597
|
const target = Pairing.inviteTopic(invite)
|
|
557
598
|
if (target) {
|
|
558
599
|
const { data: joined } = await this.store.get('handles')
|
|
559
600
|
const existing = joined.find(
|
|
560
601
|
(h) => h.type === type && b4a.equals(discoveryKey(h.key), target)
|
|
561
602
|
)
|
|
562
|
-
if (existing)
|
|
603
|
+
if (existing) {
|
|
604
|
+
const known = await this._load(type, existing.id)
|
|
605
|
+
const { data: me } = await known.store.get('members', this.identity.id)
|
|
606
|
+
if (me) return known
|
|
607
|
+
await known.close().catch(safetyCatch)
|
|
608
|
+
}
|
|
563
609
|
}
|
|
564
610
|
|
|
565
611
|
// offline join: with nearby sync on, also rendezvous on the invite-derived
|
|
@@ -724,14 +770,26 @@ export class Handle extends ReadyResource {
|
|
|
724
770
|
const pair = new Pairing({ network: net, identity: id })
|
|
725
771
|
await pair.ready()
|
|
726
772
|
|
|
727
|
-
let key, encryptionKey
|
|
773
|
+
let key, encryptionKey, additional
|
|
728
774
|
try {
|
|
729
775
|
const userData = b4a.concat([id.publicKey, writer.publicKey])
|
|
730
|
-
;({ key, encryptionKey } = await pair.join(invite, { userData, timeout }))
|
|
776
|
+
;({ key, encryptionKey, additional } = await pair.join(invite, { userData, timeout }))
|
|
731
777
|
} finally {
|
|
732
778
|
await pair.close()
|
|
733
779
|
}
|
|
734
780
|
|
|
781
|
+
let epochs = null
|
|
782
|
+
if (additional?.byteLength) {
|
|
783
|
+
// the epoch set is load-bearing for a rotated room — a malformed
|
|
784
|
+
// delivery must fail the join loudly, not produce a silently inert
|
|
785
|
+
// member (hosts predating rotation send no additional payload at all)
|
|
786
|
+
try {
|
|
787
|
+
epochs = c.decode(epochEntries, additional)
|
|
788
|
+
} catch {
|
|
789
|
+
throw CeroError.INVALID('malformed epoch delivery in pairing confirm')
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
735
793
|
return new Handle({
|
|
736
794
|
parent,
|
|
737
795
|
store,
|
|
@@ -742,6 +800,7 @@ export class Handle extends ReadyResource {
|
|
|
742
800
|
routes,
|
|
743
801
|
key,
|
|
744
802
|
encryptionKey,
|
|
803
|
+
epochs,
|
|
745
804
|
keyPair: writer
|
|
746
805
|
})
|
|
747
806
|
}
|
package/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
changes,
|
|
21
21
|
call,
|
|
22
22
|
open,
|
|
23
|
+
rotate,
|
|
23
24
|
before,
|
|
24
25
|
after,
|
|
25
26
|
bind,
|
|
@@ -41,6 +42,7 @@ export {
|
|
|
41
42
|
changes,
|
|
42
43
|
call,
|
|
43
44
|
open,
|
|
45
|
+
rotate,
|
|
44
46
|
before,
|
|
45
47
|
after,
|
|
46
48
|
bind,
|
|
@@ -285,6 +287,7 @@ cero.watch = watch
|
|
|
285
287
|
cero.changes = changes
|
|
286
288
|
cero.call = call
|
|
287
289
|
cero.open = open
|
|
290
|
+
cero.rotate = rotate
|
|
288
291
|
cero.before = before
|
|
289
292
|
cero.after = after
|
|
290
293
|
cero.peek = peek
|
package/src/lib/constants.js
CHANGED
package/src/lib/operators.js
CHANGED
|
@@ -2,6 +2,7 @@ import { Readable } from 'streamx'
|
|
|
2
2
|
import b4a from 'b4a'
|
|
3
3
|
|
|
4
4
|
import { encodeId, decodeId } from '@cero-base/core/blobs/codec'
|
|
5
|
+
import { blobEpochKey } from '@cero-base/core/database/encryption'
|
|
5
6
|
import { CeroError } from '@cero-base/core/errors'
|
|
6
7
|
import { onAbort } from './utils.js'
|
|
7
8
|
|
|
@@ -45,10 +46,11 @@ async function putFile(ref, row) {
|
|
|
45
46
|
const handle = ref.handle
|
|
46
47
|
if (handle.rpc) return handle.put('files', row)
|
|
47
48
|
const { data, type, name = null } = row
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
49
|
+
const blobs = handle.blobs // captured once — the instance carries its epoch stamp
|
|
50
|
+
await blobs.ready()
|
|
51
|
+
const blobId = await blobs.put(data)
|
|
52
|
+
const id = encodeId(blobs.key, blobId, type)
|
|
53
|
+
await handle.store.call('add-file', { id, name, stamp: blobs.stamp || 0 })
|
|
52
54
|
return { data: resolveFile(handle, id, name) }
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -166,12 +168,12 @@ function resolveRow(ref, row) {
|
|
|
166
168
|
if (!row || typeof row !== 'object') return row
|
|
167
169
|
if (ref.handle.rpc) return ref.handle._resolveRow(ref.name, ref.handle._refInfo(ref.name), row)
|
|
168
170
|
const handle = ref.handle
|
|
169
|
-
const resolve = (id, name) => {
|
|
170
|
-
registerBlobCore(handle, id)
|
|
171
|
+
const resolve = (id, name, stamp) => {
|
|
172
|
+
registerBlobCore(handle, id, stamp)
|
|
171
173
|
return resolveFile(handle, id, name)
|
|
172
174
|
}
|
|
173
175
|
if (ref.name === 'files') {
|
|
174
|
-
return { ...row, ...resolve(row.id, row.name) }
|
|
176
|
+
return { ...row, ...resolve(row.id, row.name, row.stamp) }
|
|
175
177
|
}
|
|
176
178
|
const fields = handle.store.refs?.[ref.name]?.files
|
|
177
179
|
if (!fields || !fields.length) return row
|
|
@@ -184,13 +186,24 @@ function resolveRow(ref, row) {
|
|
|
184
186
|
return out
|
|
185
187
|
}
|
|
186
188
|
|
|
187
|
-
function registerBlobCore(handle, id) {
|
|
189
|
+
function registerBlobCore(handle, id, stamp) {
|
|
188
190
|
if (!id || !handle.root?._coreKeys) return
|
|
189
191
|
try {
|
|
190
192
|
const { coreKey } = decodeId(id)
|
|
191
193
|
const hex = b4a.toString(coreKey, 'hex')
|
|
192
194
|
if (!handle.root._coreKeys.has(hex)) {
|
|
193
|
-
|
|
195
|
+
// a file-field value carries no stamp — look it up from the files row
|
|
196
|
+
// (fire-and-forget: idempotent, resolution happens again per request)
|
|
197
|
+
if (stamp === undefined) {
|
|
198
|
+
handle.store
|
|
199
|
+
.get('files', id)
|
|
200
|
+
.then(({ data }) => data && registerBlobCore(handle, id, data.stamp || 0))
|
|
201
|
+
.catch(() => {})
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
const key = blobCoreKey(handle, stamp)
|
|
205
|
+
if (!key) return // unknown epoch — this device is not entitled to the core
|
|
206
|
+
handle.root._coreKeys.set(hex, key)
|
|
194
207
|
}
|
|
195
208
|
// remember which handle read it, so close prunes the entry (re-registered
|
|
196
209
|
// on the next read if another handle still serves the same core)
|
|
@@ -200,6 +213,14 @@ function registerBlobCore(handle, id) {
|
|
|
200
213
|
}
|
|
201
214
|
}
|
|
202
215
|
|
|
216
|
+
// Base-era blob cores use the OWNING handle's key (not the root's — rooms have
|
|
217
|
+
// their own keys); rotated-era cores derive from the epoch entropy.
|
|
218
|
+
function blobCoreKey(handle, stamp) {
|
|
219
|
+
if (!stamp) return handle.store.encryptionKey
|
|
220
|
+
const entropy = handle.store.keyring.entropy(stamp)
|
|
221
|
+
return entropy ? blobEpochKey(entropy) : null
|
|
222
|
+
}
|
|
223
|
+
|
|
203
224
|
/**
|
|
204
225
|
* Read from `ref`. For data refs, dispatches to the underlying store. For
|
|
205
226
|
* `handle`-kind refs, lists existing child handles of that type from the
|
|
@@ -360,6 +381,20 @@ export const open = (ref, arg) => {
|
|
|
360
381
|
return ref.handle._create(ref.name, arg)
|
|
361
382
|
}
|
|
362
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Rotate a handle's encryption epoch. A fresh secret is sealed to every
|
|
386
|
+
* current member and announced through the log — members removed before the
|
|
387
|
+
* rotation cannot decrypt anything written after it. Requires the remove
|
|
388
|
+
* permission (admin or owner). Compose with removal:
|
|
389
|
+
*
|
|
390
|
+
* await cero.del(room.members, memberId)
|
|
391
|
+
* await cero.rotate(room)
|
|
392
|
+
*
|
|
393
|
+
* @param {any} handle
|
|
394
|
+
* @returns {Promise<{ epoch: number }>}
|
|
395
|
+
*/
|
|
396
|
+
export const rotate = (handle) => handle.store.rotate()
|
|
397
|
+
|
|
363
398
|
// ─── custom operators ──────────────────────────────────────────────────────
|
|
364
399
|
// App business logic lives as custom operators: pure functions whose first arg
|
|
365
400
|
// is the handle they act on, composed from the operators above. `define`
|
package/src/rpc/client.js
CHANGED
|
@@ -5,10 +5,22 @@ import z32 from 'z32'
|
|
|
5
5
|
import { decodeId } from '@cero-base/core/blobs/codec'
|
|
6
6
|
|
|
7
7
|
import { attachRefs } from '../lib/utils.js'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
put,
|
|
10
|
+
set,
|
|
11
|
+
get,
|
|
12
|
+
del,
|
|
13
|
+
count,
|
|
14
|
+
watch,
|
|
15
|
+
call,
|
|
16
|
+
open,
|
|
17
|
+
rotate,
|
|
18
|
+
bind,
|
|
19
|
+
define
|
|
20
|
+
} from '../lib/operators.js'
|
|
9
21
|
import { t, schema } from '../lib/spec.js'
|
|
10
22
|
|
|
11
|
-
export { put, set, get, del, count, watch, call, open, bind, define, t, schema }
|
|
23
|
+
export { put, set, get, del, count, watch, call, open, rotate, bind, define, t, schema }
|
|
12
24
|
|
|
13
25
|
/**
|
|
14
26
|
* @typedef {import('@cero-base/core/rpc').RPCClient} BaseRPCClient
|
|
@@ -392,6 +404,16 @@ const operators = {
|
|
|
392
404
|
async revoke(invite) {
|
|
393
405
|
const { ok } = await this.rpc.revoke({ handle: this.id, invite })
|
|
394
406
|
return ok
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Rotate this handle's encryption epoch on the server.
|
|
411
|
+
*
|
|
412
|
+
* @returns {Promise<{ epoch: number }>}
|
|
413
|
+
*/
|
|
414
|
+
async rotate() {
|
|
415
|
+
const { epoch } = await this.rpc.rotate({ handle: this.id })
|
|
416
|
+
return { epoch }
|
|
395
417
|
}
|
|
396
418
|
}
|
|
397
419
|
|
|
@@ -598,6 +620,7 @@ cero.count = count
|
|
|
598
620
|
cero.watch = watch
|
|
599
621
|
cero.call = call
|
|
600
622
|
cero.open = open
|
|
623
|
+
cero.rotate = rotate
|
|
601
624
|
cero.bind = bind
|
|
602
625
|
cero.define = define
|
|
603
626
|
cero.schema = schema
|
package/src/rpc/server.js
CHANGED
|
@@ -117,11 +117,12 @@ export class Server extends RPCServer {
|
|
|
117
117
|
_wireData() {
|
|
118
118
|
this.rpc.onAddFile(async ({ handle, data, name, type }) => {
|
|
119
119
|
const h = this._resolve(handle)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
120
|
+
const blobs = h.blobs // captured once — the instance carries its epoch stamp
|
|
121
|
+
await blobs.ready()
|
|
122
|
+
const blobId = await blobs.put(data)
|
|
123
|
+
const id = encodeId(blobs.key, blobId, type || '')
|
|
123
124
|
const codec = h.spec.codec
|
|
124
|
-
await h.store.call('add-file', { id, name: name || null })
|
|
125
|
+
await h.store.call('add-file', { id, name: name || null, stamp: blobs.stamp || 0 })
|
|
125
126
|
const { data: row } = await get(h.files, id)
|
|
126
127
|
return { data: codec.encodeRow(h.files.schema, row) }
|
|
127
128
|
})
|
|
@@ -281,6 +282,14 @@ export class Server extends RPCServer {
|
|
|
281
282
|
return { ok }
|
|
282
283
|
})
|
|
283
284
|
|
|
285
|
+
// specs built before rotation have no rotate command
|
|
286
|
+
if (typeof this.rpc.onRotate === 'function') {
|
|
287
|
+
this.rpc.onRotate(async ({ handle }) => {
|
|
288
|
+
const { epoch } = await this._resolve(handle).store.rotate()
|
|
289
|
+
return { epoch }
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
284
293
|
this.rpc.onJoin(async ({ parent, ref, invite }) => {
|
|
285
294
|
if (this._resolve(parent) !== this.me) throw CeroError.UNSUPPORTED('nested handles')
|
|
286
295
|
const child = await this.me._join(invite, ref)
|
|
@@ -75,6 +75,10 @@ export function builtinDispatches(ns: any): {
|
|
|
75
75
|
name: string;
|
|
76
76
|
requestType: string;
|
|
77
77
|
}[];
|
|
78
|
+
export function rotateDispatch(ns: any): {
|
|
79
|
+
name: string;
|
|
80
|
+
requestType: string;
|
|
81
|
+
};
|
|
78
82
|
export function rpcCommands(ns: any): ({
|
|
79
83
|
name: string;
|
|
80
84
|
request: {
|
package/types/build/schemas.d.ts
CHANGED
|
@@ -60,6 +60,7 @@ export const main: {
|
|
|
60
60
|
createdAt: import("@cero-base/core").Prim;
|
|
61
61
|
updatedAt: import("@cero-base/core").Prim;
|
|
62
62
|
index: import("@cero-base/core").Prim;
|
|
63
|
+
stamp: import("@cero-base/core").Prim;
|
|
63
64
|
};
|
|
64
65
|
claim: {
|
|
65
66
|
identity: import("@cero-base/core").Prim;
|
|
@@ -67,6 +68,13 @@ export const main: {
|
|
|
67
68
|
sig: import("@cero-base/core").Prim;
|
|
68
69
|
ts: import("@cero-base/core").Prim;
|
|
69
70
|
};
|
|
71
|
+
epoch: {
|
|
72
|
+
epoch: import("@cero-base/core").Prim;
|
|
73
|
+
wrapped: import("@cero-base/core").Prim;
|
|
74
|
+
createdAt: import("@cero-base/core").Prim;
|
|
75
|
+
commit: import("@cero-base/core").Prim;
|
|
76
|
+
stamp: import("@cero-base/core").Prim;
|
|
77
|
+
};
|
|
70
78
|
};
|
|
71
79
|
export const local: {
|
|
72
80
|
master: {
|
|
@@ -180,4 +188,7 @@ export const rpc: {
|
|
|
180
188
|
'res-ok': {
|
|
181
189
|
ok: import("@cero-base/core").Prim;
|
|
182
190
|
};
|
|
191
|
+
'res-epoch': {
|
|
192
|
+
epoch: import("@cero-base/core").Prim;
|
|
193
|
+
};
|
|
183
194
|
};
|
package/types/handle/index.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export { Ref } from "../lib/utils.js";
|
|
|
18
18
|
* @property {Record<string, Function>} [routes]
|
|
19
19
|
* @property {Uint8Array} [key] Existing database key.
|
|
20
20
|
* @property {Uint8Array} [encryptionKey] Existing encryption key.
|
|
21
|
+
* @property {Array<{ epoch: number, entropy: Uint8Array }>} [epochs] Rotation epochs delivered at join.
|
|
21
22
|
* @property {string} [namespace] Corestore namespace.
|
|
22
23
|
* @property {KeyPair} [keyPair] Writer keypair.
|
|
23
24
|
* @property {boolean} [passive] Join discovery server-only; flip later with `setActive`.
|
|
@@ -93,6 +94,7 @@ export class Handle extends ReadyResource {
|
|
|
93
94
|
_coreKeys: Map<any, any>;
|
|
94
95
|
_fileServer: FileServer;
|
|
95
96
|
_blobs: Blobs;
|
|
97
|
+
_epochBlobs: any;
|
|
96
98
|
_owned: Set<any>;
|
|
97
99
|
store: Database;
|
|
98
100
|
pair: Pairing;
|
|
@@ -158,13 +160,18 @@ export class Handle extends ReadyResource {
|
|
|
158
160
|
*/
|
|
159
161
|
getLink(id: string): string;
|
|
160
162
|
/**
|
|
161
|
-
* Lazily-built blob store for THIS handle's writing device
|
|
162
|
-
*
|
|
163
|
-
*
|
|
163
|
+
* Lazily-built blob store for THIS handle's writing device, at the current
|
|
164
|
+
* rotation epoch. One core per writer per epoch that writes files: the base
|
|
165
|
+
* era uses the handle's encryptionKey, rotated epochs use a key derived
|
|
166
|
+
* from the epoch entropy — so a removed member cannot decrypt files added
|
|
167
|
+
* after the rotation. Instances carry `.stamp` so the file row records
|
|
168
|
+
* which era its core belongs to.
|
|
164
169
|
*
|
|
165
170
|
* @returns {Blobs}
|
|
166
171
|
*/
|
|
167
172
|
get blobs(): Blobs;
|
|
173
|
+
_baseBlobs(): Blobs;
|
|
174
|
+
_makeBlobs(name: any, encryptionKey: any, stamp: any): Blobs;
|
|
168
175
|
/** Canonical id — identity id for the root handle, store key for children. */
|
|
169
176
|
get id(): any;
|
|
170
177
|
/** This device's id + name. `null` on child handles. */
|
|
@@ -370,6 +377,13 @@ export type HandleOpts = {
|
|
|
370
377
|
* Existing encryption key.
|
|
371
378
|
*/
|
|
372
379
|
encryptionKey?: Uint8Array;
|
|
380
|
+
/**
|
|
381
|
+
* Rotation epochs delivered at join.
|
|
382
|
+
*/
|
|
383
|
+
epochs?: Array<{
|
|
384
|
+
epoch: number;
|
|
385
|
+
entropy: Uint8Array;
|
|
386
|
+
}>;
|
|
373
387
|
/**
|
|
374
388
|
* Corestore namespace.
|
|
375
389
|
*/
|
package/types/index.d.ts
CHANGED
|
@@ -44,6 +44,7 @@ export namespace cero {
|
|
|
44
44
|
export { changes };
|
|
45
45
|
export { call };
|
|
46
46
|
export { open };
|
|
47
|
+
export { rotate };
|
|
47
48
|
export { before };
|
|
48
49
|
export { after };
|
|
49
50
|
export { peek };
|
|
@@ -158,6 +159,7 @@ import { watch } from './lib/operators.js';
|
|
|
158
159
|
import { changes } from './lib/operators.js';
|
|
159
160
|
import { call } from './lib/operators.js';
|
|
160
161
|
import { open } from './lib/operators.js';
|
|
162
|
+
import { rotate } from './lib/operators.js';
|
|
161
163
|
import { before } from './lib/operators.js';
|
|
162
164
|
import { after } from './lib/operators.js';
|
|
163
165
|
import { peek } from './lib/peek.js';
|
|
@@ -170,5 +172,5 @@ import { Ref } from './handle/index.js';
|
|
|
170
172
|
import { Local } from './local/index.js';
|
|
171
173
|
import { Identity } from '@cero-base/core/identity';
|
|
172
174
|
export { Handle, Ref, Local };
|
|
173
|
-
export { put, set, get, del, count, watch, changes, call, open, before, after, bind, define } from "./lib/operators.js";
|
|
175
|
+
export { put, set, get, del, count, watch, changes, call, open, rotate, before, after, bind, define } from "./lib/operators.js";
|
|
174
176
|
export { t, schema } from "./lib/spec.js";
|
package/types/lib/constants.d.ts
CHANGED
package/types/lib/operators.d.ts
CHANGED
|
@@ -81,6 +81,9 @@ export function open(ref: Ref, arg?: string | {
|
|
|
81
81
|
role?: string;
|
|
82
82
|
accept?: boolean;
|
|
83
83
|
} | undefined): Promise<CeroHandle>;
|
|
84
|
+
export function rotate(handle: any): Promise<{
|
|
85
|
+
epoch: number;
|
|
86
|
+
}>;
|
|
84
87
|
export type Ref = import("./utils.js").Ref;
|
|
85
88
|
export type CeroHandle = import("../handle/index.js").CeroHandle;
|
|
86
89
|
export type SingleResult = {
|
package/types/rpc/client.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export namespace cero {
|
|
|
41
41
|
export { watch };
|
|
42
42
|
export { call };
|
|
43
43
|
export { open };
|
|
44
|
+
export { rotate };
|
|
44
45
|
export { bind };
|
|
45
46
|
export { define };
|
|
46
47
|
export { schema };
|
|
@@ -141,6 +142,7 @@ import { count } from '../lib/operators.js';
|
|
|
141
142
|
import { watch } from '../lib/operators.js';
|
|
142
143
|
import { call } from '../lib/operators.js';
|
|
143
144
|
import { open } from '../lib/operators.js';
|
|
145
|
+
import { rotate } from '../lib/operators.js';
|
|
144
146
|
import { bind } from '../lib/operators.js';
|
|
145
147
|
import { define } from '../lib/operators.js';
|
|
146
148
|
import { schema } from '../lib/spec.js';
|
|
@@ -189,4 +191,4 @@ declare class Handle {
|
|
|
189
191
|
/** Tear down the remote handle and drop membership. */
|
|
190
192
|
leave(): any;
|
|
191
193
|
}
|
|
192
|
-
export { put, set, get, del, count, watch, call, open, bind, define, t, schema };
|
|
194
|
+
export { put, set, get, del, count, watch, call, open, rotate, bind, define, t, schema };
|