@cero-base/core 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cero-base/core",
3
- "version": "1.5.2",
3
+ "version": "1.7.0",
4
4
  "description": "cero p2p primitives — identity, storage, network, database, blobs, rpc, pairing.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -53,6 +53,10 @@
53
53
  "types": "./types/database/index.d.ts",
54
54
  "default": "./src/database/index.js"
55
55
  },
56
+ "./database/encryption": {
57
+ "types": "./types/database/encryption.d.ts",
58
+ "default": "./src/database/encryption.js"
59
+ },
56
60
  "./blobs": {
57
61
  "types": "./types/blobs/index.d.ts",
58
62
  "default": "./src/blobs/index.js"
@@ -140,7 +144,8 @@
140
144
  },
141
145
  "dependencies": {
142
146
  "@hyperswarm/secret-stream": "^6.9.1",
143
- "autobee": "^1.0.10",
147
+ "autobee": "1.0.10",
148
+ "autobee-encryption": "0.1.3",
144
149
  "b4a": "^1.8.1",
145
150
  "bare-crypto": "^1.15.3",
146
151
  "bare-fs": "^4.7.4",
@@ -179,7 +184,8 @@
179
184
  "bare-url": "^2.4.6",
180
185
  "blind-peer": "^3.12.3",
181
186
  "brittle": "^4.1.0",
182
- "typescript": "^5.9.3"
187
+ "typescript": "^5.9.3",
188
+ "which-runtime": "^1.4.0"
183
189
  },
184
190
  "license": "Apache-2.0"
185
191
  }
@@ -23,7 +23,7 @@ const NS = `${NAMESPACE}/blobs`
23
23
  /** Thin wrapper over a single Hyperblobs core; deals only in raw blobIds. */
24
24
  export class Blobs extends ReadyResource {
25
25
  /** @param {BlobsOpts} [opts] */
26
- constructor({ store, identity, network, key, encryptionKey } = {}) {
26
+ constructor({ store, identity, network, key, encryptionKey, name } = {}) {
27
27
  super()
28
28
  if (!store) throw CeroError.REQUIRED('store')
29
29
  if (!identity && !encryptionKey) throw CeroError.REQUIRED('identity or encryptionKey')
@@ -32,6 +32,7 @@ export class Blobs extends ReadyResource {
32
32
  this.identity = identity || null
33
33
  this.network = network || null
34
34
  this.encryptionKey = encryptionKey || (identity && identity.encryptionKey) || null
35
+ this.name = name || 'blobs'
35
36
 
36
37
  this._coreKey = key || null
37
38
  this.core = null
@@ -72,7 +73,7 @@ export class Blobs extends ReadyResource {
72
73
 
73
74
  const opts = { encryptionKey: this.encryptionKey }
74
75
  if (this._coreKey) opts.key = this._coreKey
75
- else opts.name = 'blobs'
76
+ else opts.name = this.name
76
77
 
77
78
  const ns = this.store.namespace(NS)
78
79
  this.core = ns.get(opts)
@@ -133,6 +133,7 @@ async function saveWriter(db, writerKey, { name, isMobile }) {
133
133
  async function swapWriter(db, keyPair, manifest) {
134
134
  const head = await db.bee.local.getUserData('autobee/head')
135
135
  const enc = await db.bee.local.getUserData('autobee/encryption')
136
+ const epochs = await db.bee.local.getUserData('cero/epochs')
136
137
 
137
138
  if (db.network) db.network.detach(db.bee)
138
139
  await db.bee.close()
@@ -142,6 +143,7 @@ async function swapWriter(db, keyPair, manifest) {
142
143
  await deviceCore.ready()
143
144
  if (head) await deviceCore.setUserData('autobee/head', head)
144
145
  if (enc) await deviceCore.setUserData('autobee/encryption', enc)
146
+ if (epochs) await deviceCore.setUserData('cero/epochs', epochs)
145
147
  await deviceCore.close()
146
148
 
147
149
  db.keyPair = keyPair
@@ -6,6 +6,7 @@ import {
6
6
  COLLECTION,
7
7
  ACTION,
8
8
  COUNTERS,
9
+ EPOCHS,
9
10
  INVITE,
10
11
  REMOVE,
11
12
  ASSIGN,
@@ -45,9 +46,18 @@ const isSig = (b) => b?.byteLength === 64
45
46
  * @param {string} ns Namespace prefix for collection and op names.
46
47
  * @param {Record<string, Function>} routes Custom action handlers keyed by route name.
47
48
  * @param {(err: Error) => void} [onerror] Called when a malformed node is skipped.
49
+ * @param {() => Uint8Array | null} [getDbKey]
50
+ * @param {{ onEpoch?: (row: { epoch: number, wrapped: Uint8Array, createdAt: number }) => Promise<void> }} [hooks] Per-peer side effects fired after an op applies (skipped in dry runs).
48
51
  * @returns {{ dispatcher: object, apply: (nodes: Array<{ value: Buffer, key: Buffer }>, view: object, host: object) => Promise<void> }}
49
52
  */
50
- export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey = () => null) {
53
+ export function makeDispatcher(
54
+ spec,
55
+ ns,
56
+ routes,
57
+ onerror = safetyCatch,
58
+ getDbKey = () => null,
59
+ hooks = {}
60
+ ) {
51
61
  const dispatcher = new spec.dispatch.Router()
52
62
 
53
63
  const countersCol = `@${ns}/${COUNTERS}`
@@ -181,9 +191,63 @@ export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey
181
191
  if (!can(r, REMOVE) || !outranks(r, existing.role)) return
182
192
  }
183
193
  if (existing.key) await ctx.host.removeWriter(existing.key)
194
+ // every device of the member goes with them — a removed member must not
195
+ // leave writers admitted or stale device rows behind
196
+ for (const device of await ctx.view.find(`@${ns}/devices`, {}).toArray()) {
197
+ if (device.memberId !== op.id) continue
198
+ await ctx.host.removeWriter(toKey(device.id))
199
+ await ctx.view.delete(`@${ns}/devices`, { id: device.id })
200
+ }
184
201
  await ctx.view.delete(`@${ns}/members`, op)
185
202
  })
186
203
 
204
+ // specs built before rotation have no rotate-key route — Router.add throws
205
+ // NONEXISTENT_ROUTE, and the op stays unavailable until the app rebuilds
206
+ try {
207
+ add('rotate-key', async (op, ctx) => {
208
+ if (!op.wrapped?.byteLength) return
209
+ // the commitment lets every member verify the secret their envelope
210
+ // opened to — without it a rotator could seal different secrets to
211
+ // different members and split the room
212
+ if (op.commit?.byteLength !== 32) return
213
+ // the stamp is the content-addressed uint32 written into block headers.
214
+ // The rotator picks it before writing any block, so linearization can
215
+ // reorder epochs freely without ever changing which key a block header
216
+ // points at. Uniqueness is enforced here, deterministically.
217
+ if (!Number.isInteger(op.stamp) || op.stamp <= 0 || op.stamp > 0xffffffff) return
218
+ if (!can(await getSignerRole(ctx.view, ctx.key), REMOVE)) return
219
+ const rows = await ctx.view.find(`@${ns}/${EPOCHS}`, {}).toArray()
220
+ if (rows.some((r) => r.stamp === op.stamp)) return
221
+ // sequence numbers order epochs; concurrent rotations linearize as
222
+ // consecutive sequences with distinct stamps — both remain readable
223
+ const counter = (await ctx.view.get(countersCol, { name: EPOCHS })) ?? {
224
+ name: EPOCHS,
225
+ value: 0
226
+ }
227
+ const epoch = counter.value + 1
228
+ await ctx.view.insert(countersCol, { name: EPOCHS, value: epoch })
229
+ const row = {
230
+ epoch,
231
+ stamp: op.stamp,
232
+ wrapped: op.wrapped,
233
+ createdAt: op.createdAt || 0,
234
+ commit: op.commit
235
+ }
236
+ await ctx.view.insert(`@${ns}/${EPOCHS}`, row)
237
+ // per-peer hydration (unseal own envelope → keyring) — never in a dry
238
+ // run: the announcement itself must be appended under the OLD epoch
239
+ if (hooks.onEpoch && !ctx.dryRun) {
240
+ try {
241
+ await hooks.onEpoch(row)
242
+ } catch (err) {
243
+ onerror(err)
244
+ }
245
+ }
246
+ })
247
+ } catch {
248
+ // pre-rotation spec
249
+ }
250
+
187
251
  add('del-device', async (op, ctx) => {
188
252
  const existing = await getDevice(ctx.view, op.id)
189
253
  if (!existing) return
@@ -217,7 +281,12 @@ export function makeDispatcher(spec, ns, routes, onerror = safetyCatch, getDbKey
217
281
  add('add-file', async (op, ctx) => {
218
282
  if (!can(await getSignerRole(ctx.view, ctx.key), WRITE)) return
219
283
  const memberId = await getSignerMember(ctx.view, ctx.key)
220
- await insert(ctx.view, b.name, col, { id: op.id, name: op.name ?? null, memberId })
284
+ await insert(ctx.view, b.name, col, {
285
+ id: op.id,
286
+ name: op.name ?? null,
287
+ memberId,
288
+ stamp: op.stamp ?? 0
289
+ })
221
290
  })
222
291
  } else {
223
292
  add(`add-${b.verb}`, upsert(b.name, col, COLLECTION))
@@ -0,0 +1,303 @@
1
+ import Autobee from 'autobee'
2
+ import autobeeEncryption from 'autobee-encryption'
3
+ import crypto from 'hypercore-crypto'
4
+ import c from 'compact-encoding'
5
+ import b4a from 'b4a'
6
+
7
+ import { CeroError } from '../lib/errors.js'
8
+
9
+ const { AutobeeEncryption, WriterEncryption } = autobeeEncryption
10
+
11
+ // same derivation constant autobee-encryption uses internally (index 2 of the
12
+ // 'autobase' namespace) — epoch keys must hash with the identical hash-key ns
13
+ const NS_HASH_KEY = crypto.namespace('autobase', 4)[2]
14
+
15
+ const NS_BLOBS = crypto.namespace('cero/blobs', 1)[0]
16
+
17
+ /**
18
+ * Encryption key for a rotation epoch's blob cores. New files land in a core
19
+ * keyed by the epoch entropy, so file confidentiality rotates with the room:
20
+ * only holders of the epoch secret can derive the core key.
21
+ *
22
+ * @param {Uint8Array} entropy
23
+ * @returns {Uint8Array}
24
+ */
25
+ export function blobEpochKey(entropy) {
26
+ return crypto.hash([NS_BLOBS, entropy])
27
+ }
28
+
29
+ // autobee constructs WriterEncryption in four places, and one of them
30
+ // (ActiveWriters.add, lib/writers.js) news the class directly — no factory to
31
+ // override. The only injection point that covers every remote writer core is
32
+ // the provider base class itself, so the epoch awareness is patched onto the
33
+ // autobee-encryption prototype. The pin MUST stay on the version autobee's own
34
+ // range resolves to — pin them apart and npm installs two copies, autobee uses
35
+ // the unpatched one, and every rotation silently writes epoch-0 blocks that a
36
+ // removed member can still read. Epoch 0 keeps the upstream
37
+ // GENESIS_ENTROPY derivation byte-for-byte; epoch n derives from the
38
+ // keyring's entropy. Without a keyring (auto.keyring unset) behavior is
39
+ // identical to upstream. Removable once autobee accepts a provider factory.
40
+ const baseGetKeys = AutobeeEncryption.prototype.getKeys
41
+
42
+ AutobeeEncryption.prototype.getKeys = async function (id, ctx) {
43
+ if (!id) return baseGetKeys.call(this, id, ctx)
44
+
45
+ const keyring = this.auto?.keyring
46
+ if (!keyring) throw CeroError.UNKNOWN_EPOCH(id)
47
+
48
+ let entropy = keyring.entropy(id)
49
+ if (!entropy && !keyring.primed && this.auto.local) {
50
+ // first epoch miss of a session may happen inside autobee's own boot
51
+ // (reading back this device's post-rotation state) — prime lazily from
52
+ // the local core's userData, once
53
+ keyring.primed = true
54
+ await primeKeyring(keyring, this.auto.local)
55
+ entropy = keyring.entropy(id)
56
+ }
57
+ if (!entropy) throw CeroError.UNKNOWN_EPOCH(id)
58
+
59
+ const block = this.blockKey(entropy, ctx)
60
+ return { id, block, hash: crypto.hash([NS_HASH_KEY, block]) }
61
+ }
62
+
63
+ async function primeKeyring(keyring, local) {
64
+ const saved = await local.getUserData('cero/epochs').catch(() => null)
65
+ if (!saved) return
66
+ try {
67
+ for (const e of c.decode(epochEntries, saved)) keyring.add(e.stamp, e.entropy, e.epoch)
68
+ } catch {
69
+ // corrupt userData — epochs re-hydrate from announcements
70
+ }
71
+ }
72
+
73
+ AutobeeEncryption.prototype.update = async function (ctx) {
74
+ const current = this.auto?.keyring ? this.auto.keyring.current : 0
75
+ if (!this.keys || this.keys.id !== current) this.keys = await this.get(current, ctx)
76
+ }
77
+
78
+ /**
79
+ * Wire codec for a rotation announcement's envelope list — one sealed box
80
+ * per remaining member, addressed by member id.
81
+ */
82
+ export const wraps = c.array({
83
+ preencode(state, w) {
84
+ c.string.preencode(state, w.id)
85
+ c.buffer.preencode(state, w.box)
86
+ },
87
+ encode(state, w) {
88
+ c.string.encode(state, w.id)
89
+ c.buffer.encode(state, w.box)
90
+ },
91
+ decode(state) {
92
+ return { id: c.string.decode(state), box: c.buffer.decode(state) }
93
+ }
94
+ })
95
+
96
+ /**
97
+ * Wire codec for locally persisted / pairing-delivered epoch secrets.
98
+ * `epoch` is the apply-order sequence; `stamp` is the content-addressed
99
+ * uint32 written into block headers.
100
+ */
101
+ export const epochEntries = c.array({
102
+ preencode(state, e) {
103
+ c.uint.preencode(state, e.epoch)
104
+ c.uint.preencode(state, e.stamp)
105
+ c.fixed32.preencode(state, e.entropy)
106
+ },
107
+ encode(state, e) {
108
+ c.uint.encode(state, e.epoch)
109
+ c.uint.encode(state, e.stamp)
110
+ c.fixed32.encode(state, e.entropy)
111
+ },
112
+ decode(state) {
113
+ return {
114
+ epoch: c.uint.decode(state),
115
+ stamp: c.uint.decode(state),
116
+ entropy: c.fixed32.decode(state)
117
+ }
118
+ }
119
+ })
120
+
121
+ /**
122
+ * Per-database registry of rotation epochs. Stamp 0 is the base key era
123
+ * (no entry needed — it derives from the upstream GENESIS_ENTROPY path).
124
+ * Every rotation adds an entry keyed by its content-addressed `stamp` (the
125
+ * uint32 written into block headers, chosen randomly by the rotator and
126
+ * validated unique at apply) alongside its apply-order `seq`. Because the
127
+ * stamp is chosen before any block is written and can never be renumbered
128
+ * by linearization, a block's header always identifies its true key —
129
+ * concurrent rotations cannot make blocks undecryptable.
130
+ */
131
+ export class Keyring {
132
+ constructor() {
133
+ /** @type {Map<number, Uint8Array>} stamp → entropy */
134
+ this.entropies = new Map()
135
+ /** @type {Map<number, number>} stamp → apply-order sequence */
136
+ this.seqs = new Map()
137
+ /** stamp used for new blocks — the adopted epoch with the highest seq */
138
+ this.current = 0
139
+ /** highest adopted apply-order sequence (0 = base era) */
140
+ this.seq = 0
141
+ /** bumped on every add/remove — cheap change detection for retries */
142
+ this.version = 0
143
+ this.primed = false
144
+ }
145
+
146
+ /** @returns {Array<{ epoch: number, stamp: number, entropy: Uint8Array }>} ascending by seq */
147
+ all() {
148
+ return [...this.entropies]
149
+ .map(([stamp, entropy]) => ({ epoch: this.seqs.get(stamp) || 0, stamp, entropy }))
150
+ .sort((a, b) => a.epoch - b.epoch)
151
+ }
152
+
153
+ /**
154
+ * @param {number} stamp
155
+ * @param {Uint8Array} entropy
156
+ * @param {number} [seq] Apply-order sequence; drives `current` selection.
157
+ */
158
+ add(stamp, entropy, seq = 0) {
159
+ if (!Number.isInteger(stamp) || stamp <= 0 || stamp > 0xffffffff) {
160
+ throw CeroError.INVALID(`epoch stamp: ${stamp}`)
161
+ }
162
+ if (!b4a.isBuffer(entropy) || entropy.byteLength !== 32) {
163
+ throw CeroError.INVALID('epoch entropy must be 32 bytes')
164
+ }
165
+ this.entropies.set(stamp, entropy)
166
+ if (seq) this.seqs.set(stamp, seq)
167
+ const effective = this.seqs.get(stamp) || 0
168
+ if (this.current === 0 || (effective > 0 && effective >= this.seq)) {
169
+ this.seq = effective
170
+ this.current = stamp
171
+ }
172
+ this.version++
173
+ }
174
+
175
+ /**
176
+ * @param {number} stamp
177
+ * @returns {Uint8Array | null}
178
+ */
179
+ entropy(stamp) {
180
+ return this.entropies.get(stamp) || null
181
+ }
182
+
183
+ /**
184
+ * Forget an epoch (used to undo an add whose persistence failed).
185
+ *
186
+ * @param {number} stamp
187
+ */
188
+ remove(stamp) {
189
+ this.entropies.delete(stamp)
190
+ this.seqs.delete(stamp)
191
+ this.current = 0
192
+ this.seq = 0
193
+ for (const [s, q] of this.seqs) {
194
+ if (q >= this.seq) {
195
+ this.seq = q
196
+ this.current = s
197
+ }
198
+ }
199
+ this.version++
200
+ }
201
+ }
202
+
203
+ /**
204
+ * The epoch-aware provider — the class itself is upstream WriterEncryption;
205
+ * the epoch behavior lives on the (patched) base prototype above. Named for
206
+ * call sites and tests.
207
+ */
208
+ export class EpochEncryption extends WriterEncryption {}
209
+
210
+ /**
211
+ * Autobee with a rotation keyring. Every provider autobee constructs
212
+ * (view/system factory, foreign cores, ActiveWriters) picks the epochs up
213
+ * through the patched base class and this `keyring` property.
214
+ */
215
+ export class EpochAutobee extends Autobee {
216
+ constructor(store, key, handlers = {}) {
217
+ super(store, key, handlers)
218
+ this.keyring = handlers.keyring || null
219
+ this._epochRetry = null
220
+ this._epochRetryDelay = 1000
221
+ this._epochRetrySeen = 0
222
+ }
223
+
224
+ // Prime the keyring from local userData as soon as boot resolves the local
225
+ // core (in-boot epoch misses are covered by the provider's lazy prime).
226
+ async _bootState() {
227
+ await super._bootState()
228
+ if (!this.keyring || this.keyring.primed || !this.local) return
229
+ this.keyring.primed = true
230
+ await primeKeyring(this.keyring, this.local)
231
+ }
232
+
233
+ // Upstream's drain body with one change: a writer whose next block sits at
234
+ // an epoch we haven't learned yet parks (stays pending, so every later
235
+ // bump re-examines it) instead of aborting the whole drain — the
236
+ // announcement carrying that epoch lives in another writer's core and
237
+ // applies on this or a later bump. The catch wraps the whole per-writer
238
+ // step so an UNKNOWN_EPOCH surfacing from batch processing parks too
239
+ // instead of crashing the bee.
240
+ async _bumpPendingWriters() {
241
+ let updated = false
242
+
243
+ const pending = this.writers.pending.slice()
244
+
245
+ for (let i = pending.length - 1; i >= 0; i--) {
246
+ const w = pending[i]
247
+
248
+ try {
249
+ const batch = await w.next()
250
+ if (batch === null) continue
251
+
252
+ if (w.isAdded || (w.isRemoved && w.hasReferrals())) {
253
+ await this._processBatch(batch)
254
+ w.notify(batch)
255
+ updated = true
256
+ continue
257
+ }
258
+
259
+ if (this.optimistic && !w.isRemoved && batch[0].optimistic) {
260
+ if (!(await this._optimisticBatch(batch))) {
261
+ w.removePending()
262
+ continue
263
+ }
264
+ w.notify(batch)
265
+ updated = true
266
+ continue
267
+ }
268
+ } catch (err) {
269
+ if (err?.code !== 'UNKNOWN_EPOCH') throw err
270
+ this._scheduleEpochRetry()
271
+ continue
272
+ }
273
+ }
274
+
275
+ return updated
276
+ }
277
+
278
+ // a parked writer produces no wake-up of its own — poke the bee until the
279
+ // pending announcement applies and the parked blocks decrypt. Exponential
280
+ // backoff (reset when the keyring advances) so a removed member, who will
281
+ // never learn the epoch, settles into a slow idle poll instead of a hot loop
282
+ _scheduleEpochRetry() {
283
+ if (this._epochRetry || this.closing) return
284
+ const version = this.keyring ? this.keyring.version : 0
285
+ if (version !== this._epochRetrySeen) {
286
+ this._epochRetrySeen = version
287
+ this._epochRetryDelay = 1000
288
+ }
289
+ this._epochRetry = setTimeout(() => {
290
+ this._epochRetry = null
291
+ if (!this.closing) this.update().catch(noop)
292
+ }, this._epochRetryDelay)
293
+ this._epochRetryDelay = Math.min(this._epochRetryDelay * 2, 60000)
294
+ }
295
+
296
+ async _close() {
297
+ if (this._epochRetry) clearTimeout(this._epochRetry)
298
+ this._epochRetry = null
299
+ return super._close()
300
+ }
301
+ }
302
+
303
+ function noop() {}
@@ -1,14 +1,24 @@
1
- import Autobee from 'autobee'
2
1
  import HyperDB from 'hyperdb'
3
2
  import c from 'compact-encoding'
3
+ import crypto from 'hypercore-crypto'
4
4
  import Hypercore from 'hypercore'
5
5
  import ReadyResource from 'ready-resource'
6
6
  import safetyCatch from 'safety-catch'
7
7
  import b4a from 'b4a'
8
8
 
9
- import { NAMESPACE, SINGLE, COLLECTION, ACTION, ACTIVE, PASSIVE } from '../lib/constants.js'
10
- import { genId, subscribe, addWriterPayload, claimWriterPayload } from '../lib/utils.js'
9
+ import { NAMESPACE, SINGLE, COLLECTION, ACTION, ACTIVE, PASSIVE, REMOVE } from '../lib/constants.js'
10
+ import {
11
+ genId,
12
+ toId,
13
+ toKey,
14
+ can,
15
+ subscribe,
16
+ addWriterPayload,
17
+ claimWriterPayload
18
+ } from '../lib/utils.js'
11
19
  import { wrap, unwrap } from './envelope.js'
20
+ import { EpochAutobee, Keyring, wraps, epochEntries } from './encryption.js'
21
+ import { Identity } from '../identity/index.js'
12
22
  import { CeroError } from '../lib/errors.js'
13
23
  import { bootstrap } from './bootstrap.js'
14
24
  import { makeChanges } from './changes.js'
@@ -23,6 +33,7 @@ import { makeDispatcher } from './dispatch.js'
23
33
  * @property {Record<string, Function>} [routes] Custom action handlers keyed by route name.
24
34
  * @property {string} [namespace] Corestore namespace; defaults to `cero`.
25
35
  * @property {Uint8Array | null} [encryptionKey] Optional encryption key; falls back to identity's key.
36
+ * @property {Array<{ epoch: number, entropy: Uint8Array }> | null} [epochs] Rotation epochs to prime the keyring with (delivered at join).
26
37
  * @property {(nodes: any, view: any, host: any) => Promise<void>} [apply] Override the default apply function.
27
38
  * @property {Uint8Array | null} [key] Existing autobee key to reopen.
28
39
  * @property {boolean} [passive] Join discovery server-only (reachable but not searching). Flip at runtime with `setActive`.
@@ -65,6 +76,15 @@ export class Database extends ReadyResource {
65
76
  this.routes = opts.routes || {}
66
77
  this.namespace = opts.namespace || NAMESPACE
67
78
  this.encryptionKey = opts.encryptionKey || opts.identity.encryptionKey || null
79
+ this.keyring = new Keyring()
80
+ if (opts.epochs) for (const e of opts.epochs) this.keyring.add(e.stamp, e.entropy, e.epoch)
81
+ this._rotation = null
82
+ this._healTimer = null
83
+ this._healedFor = null
84
+ // membership and epochs both change through apply — after each update,
85
+ // REMOVE-capable devices verify the current epoch still matches the
86
+ // member set and re-key when it does not (see _healEpochs)
87
+ this.on('update', () => this._scheduleHeal())
68
88
  this.applyOverride = opts.apply || null
69
89
  this.key = opts.key || null
70
90
  this.passive = opts.passive === true
@@ -94,6 +114,10 @@ export class Database extends ReadyResource {
94
114
  }
95
115
 
96
116
  async _close() {
117
+ if (this._healTimer) {
118
+ clearTimeout(this._healTimer)
119
+ this._healTimer = null
120
+ }
97
121
  if (this._discovery) {
98
122
  await this._discovery.destroy()
99
123
  this._discovery = null
@@ -109,7 +133,14 @@ export class Database extends ReadyResource {
109
133
 
110
134
  /** Open the underlying autobee, wire dispatcher + apply, attach to network. */
111
135
  async openBee() {
112
- this.dispatcher = makeDispatcher(this.spec, this.ns, this.routes, this._onerror, () => this.key)
136
+ this.dispatcher = makeDispatcher(
137
+ this.spec,
138
+ this.ns,
139
+ this.routes,
140
+ this._onerror,
141
+ () => this.key,
142
+ { onEpoch: (row) => this._onEpoch(row) }
143
+ )
113
144
 
114
145
  // autobee-wakeup destroys whatever wakeup it is handed (its _owner guard is
115
146
  // computed but never consulted upstream) — closing one bee must not tear
@@ -122,9 +153,10 @@ export class Database extends ReadyResource {
122
153
  destroy: () => {}
123
154
  }
124
155
 
125
- const bee = new Autobee(this.store.namespace(this.namespace), this.key, {
156
+ const bee = new EpochAutobee(this.store.namespace(this.namespace), this.key, {
126
157
  keyPair: this.keyPair,
127
158
  encryptionKey: this.encryptionKey,
159
+ keyring: this.keyring,
128
160
  optimistic: true,
129
161
  wakeup: wakeup || undefined,
130
162
  open: (b) => HyperDB.bee2(b, this.spec.database, { autoUpdate: true }),
@@ -161,10 +193,11 @@ export class Database extends ReadyResource {
161
193
  })
162
194
 
163
195
  await bee.ready()
164
- await bee.update()
165
-
196
+ // assigned before the first update: apply-time hooks (_onEpoch's save,
197
+ // _onFuture) dereference this.bee and must never see it null mid-drain
166
198
  this.bee = bee
167
199
  this.key = bee.key
200
+ await bee.update()
168
201
 
169
202
  const behind = await bee.local.getUserData('cero/behind')
170
203
  this.behind = behind ? c.decode(c.uint, behind) : null
@@ -181,7 +214,15 @@ export class Database extends ReadyResource {
181
214
  return
182
215
  }
183
216
 
217
+ // prime the keyring from local userData before replication can deliver
218
+ // blocks at epochs this session hasn't learned yet
219
+ await this._loadEpochs()
220
+
184
221
  bee.on('writable', () => this.emit('writable'))
222
+ // the falling edge: this device's writer was removed (or its member was).
223
+ // Apps need it to freeze a UI the moment access ends, rather than
224
+ // discovering it from a failed write or by scanning `members`.
225
+ bee.on('unwritable', () => this.emit('unwritable'))
185
226
  // without a listener autobee escalates apply/view errors to a process crash
186
227
  bee.on('error', this._onerror)
187
228
 
@@ -486,6 +527,213 @@ export class Database extends ReadyResource {
486
527
  await this.write([[op, data]])
487
528
  }
488
529
 
530
+ /**
531
+ * Rotate the encryption epoch: generate a fresh 32-byte secret, seal it to
532
+ * every current member's identity key, and announce it through the log.
533
+ * The announcement is appended under the current epoch (so every member —
534
+ * even one offline for several rotations — can walk the chain forward),
535
+ * and blocks written after it use the new one. Members absent from the
536
+ * envelope set (removed before the rotation) never learn the new key.
537
+ *
538
+ * @returns {Promise<{ epoch: number }>}
539
+ */
540
+ async rotate(retried = false) {
541
+ this.guard()
542
+ if (!this.writable) throw CeroError.NOT_WRITABLE('Database')
543
+ // inside tx() the op would only be queued — the epoch could never be
544
+ // observed before returning, and batching a rekey with app writes would
545
+ // blur which epoch those writes land in
546
+ if (this.txQueue) throw CeroError.INVALID('rotate() cannot run inside tx()')
547
+ if (this._rotation) throw CeroError.INVALID('a rotation is already in progress')
548
+ // compat-path (manifest v1) blocks carry no key id — rotation would be a
549
+ // silent downgrade there, so refuse deterministically
550
+ if (this.bee.local.manifest?.version < 2) {
551
+ throw CeroError.INVALID('rotation requires manifest v2 cores')
552
+ }
553
+ // claimed synchronously — no await may separate the check from the claim
554
+ this._rotation = { entropy: null, stamp: 0, epoch: 0 }
555
+ try {
556
+ const { data: members } = await this.get('members')
557
+ if (!Array.isArray(members) || members.length === 0) {
558
+ throw CeroError.INVALID('cannot rotate a database with no members')
559
+ }
560
+ const entropy = Identity.randomBytes(32)
561
+ const stamp = this._randomStamp()
562
+ const wrapped = []
563
+ for (const m of members) {
564
+ let key
565
+ try {
566
+ key = toKey(m.id)
567
+ } catch {
568
+ throw CeroError.INVALID(`member id is not an identity key: ${m.id}`)
569
+ }
570
+ wrapped.push({ id: m.id, box: Identity.seal(key, entropy) })
571
+ }
572
+ // Defer our own hydration until the append has flushed: the local block
573
+ // is encrypted at drain time, AFTER our optimistic apply runs — advancing
574
+ // the keyring there would encrypt the announcement itself under the new
575
+ // epoch, which nobody else could read (the chain rule would break).
576
+ this._rotation.entropy = entropy
577
+ this._rotation.stamp = stamp
578
+ const appendedFrom = this.bee.local.length
579
+ await this.call('rotate-key', {
580
+ epoch: 0, // sequence assigned deterministically at apply time
581
+ stamp,
582
+ wrapped: c.encode(wraps, wrapped),
583
+ createdAt: Date.now(),
584
+ commit: crypto.hash(entropy)
585
+ })
586
+ await this.bee.update()
587
+ const { epoch } = this._rotation
588
+ if (!epoch) {
589
+ // distinguish a stamp collision (another epoch owns our random stamp
590
+ // — ~2^-32, or an adversarial pre-claim) from a permission rejection
591
+ const rows = await this.view.find(`@${this.ns}/epochs`, {}).toArray()
592
+ if (!retried && rows.some((r) => r.stamp === stamp)) {
593
+ this._rotation = null
594
+ return this.rotate(true)
595
+ }
596
+ throw CeroError.INVALID('rotation was not applied — requires the remove permission')
597
+ }
598
+ // Invariant check, not an assumption: no block appended during this
599
+ // rotation may carry the stamp it announces — that would make the
600
+ // announcement unreadable to every other member.
601
+ for (let i = appendedFrom; i < this.bee.local.length; i++) {
602
+ const raw = await this.bee.local.get(i, { raw: true })
603
+ const id = c.uint32.decode({ start: 4, end: 8, buffer: raw.subarray(0, 8) })
604
+ if (id === stamp) {
605
+ throw CeroError.INVALID('announcement encrypted under its own epoch — aborting rotation')
606
+ }
607
+ }
608
+ this.keyring.add(stamp, entropy, epoch)
609
+ await this._saveEpochs()
610
+ return { epoch }
611
+ } finally {
612
+ this._rotation = null
613
+ }
614
+ }
615
+
616
+ /** Fresh nonzero uint32 block-header stamp not already used by a known epoch. */
617
+ _randomStamp() {
618
+ for (;;) {
619
+ const buf = Identity.randomBytes(4)
620
+ const stamp = ((buf[0] << 24) | (buf[1] << 16) | (buf[2] << 8) | buf[3]) >>> 0
621
+ if (stamp !== 0 && !this.keyring.entropy(stamp)) return stamp
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Per-peer side of an applied rotation: open our envelope, learn the epoch
627
+ * secret, persist it locally. Peers without an envelope (removed members)
628
+ * simply stop here — every block after the announcement is noise to them.
629
+ * Our own in-flight rotation is deferred to `rotate()` (see above).
630
+ *
631
+ * @param {{ epoch: number, wrapped: Uint8Array, createdAt: number }} row
632
+ */
633
+ async _onEpoch(row) {
634
+ if (this.keyring.entropy(row.stamp)) {
635
+ // idempotent re-apply — but record the sequence if it was unknown
636
+ this.keyring.add(row.stamp, this.keyring.entropy(row.stamp), row.epoch)
637
+ this._scheduleHeal()
638
+ return
639
+ }
640
+ for (const w of c.decode(wraps, row.wrapped)) {
641
+ if (w.id !== this.identity.id) continue
642
+ const entropy = this.identity.unseal(w.box)
643
+ // skip malformed or mis-sealed envelopes and keep scanning — a bad
644
+ // envelope ahead of a good one must not lock this member out
645
+ if (entropy?.byteLength !== 32) continue
646
+ // the secret must match the row's public commitment — otherwise a
647
+ // rotator sealed different secrets to different members
648
+ if (!row.commit || !b4a.equals(crypto.hash(entropy), row.commit)) {
649
+ this._onerror(CeroError.INVALID(`epoch ${row.epoch} envelope fails its commitment`))
650
+ continue
651
+ }
652
+ if (this._rotation?.entropy && b4a.equals(entropy, this._rotation.entropy)) {
653
+ this._rotation.epoch = row.epoch
654
+ } else {
655
+ this.keyring.add(row.stamp, entropy, row.epoch)
656
+ try {
657
+ await this._saveEpochs()
658
+ } catch (err) {
659
+ // an epoch this device cannot reload after a restart must not be
660
+ // used for writes — undo the add and surface the storage failure
661
+ this.keyring.remove(row.stamp)
662
+ this._onerror(err)
663
+ }
664
+ }
665
+ break
666
+ }
667
+ this._scheduleHeal()
668
+ }
669
+
670
+ /**
671
+ * Safety net after boot (userData priming happens in EpochAutobee's
672
+ * `_bootState`): scan the epochs collection for anything applied but never
673
+ * hydrated — e.g. a crash between a rotation's append and its save.
674
+ */
675
+ async _loadEpochs() {
676
+ try {
677
+ const rows = await this.view.find(`@${this.ns}/epochs`, {}).toArray()
678
+ for (const row of rows) {
679
+ if (this.keyring.entropy(row.epoch)) continue
680
+ await this._onEpoch(row)
681
+ }
682
+ } catch {
683
+ // pre-rotation spec (no epochs collection), or rows beyond our newest
684
+ // known epoch — those hydrate through apply when announcements sync
685
+ }
686
+ }
687
+
688
+ /** Persist known epoch secrets to local userData (device-only, never replicates). */
689
+ _saveEpochs() {
690
+ return this.bee.local.setUserData('cero/epochs', c.encode(epochEntries, this.keyring.all()))
691
+ }
692
+
693
+ /** Debounced trigger for the epoch/membership consistency check. */
694
+ _scheduleHeal() {
695
+ if (this._healTimer || this.closing || this.closed) return
696
+ this._healTimer = setTimeout(() => {
697
+ this._healTimer = null
698
+ this._healEpochs().catch(this._onerror)
699
+ }, 500)
700
+ }
701
+
702
+ /**
703
+ * Convergence rotation: rotations author their envelope set against the
704
+ * rotator's possibly-stale local view, so after linearization the current
705
+ * epoch may include a since-removed member (who then still holds the
706
+ * current key) or miss a since-added one (who is locked out). Every
707
+ * REMOVE-capable writable device audits the current epoch's recipients
708
+ * against canonical membership and re-keys on mismatch. Only rooms that
709
+ * have rotated at least once are audited — rotation stays opt-in.
710
+ */
711
+ async _healEpochs() {
712
+ if (this.closing || this.closed || !this.writable || this._rotation) return
713
+ if (!this.keyring.current) return
714
+ const { data: device } = await this.get('devices', toId(this.writerKey))
715
+ const me = device?.memberId ? (await this.get('members', device.memberId)).data : null
716
+ if (!can(me?.role, REMOVE)) return
717
+
718
+ const rows = await this.view.find(`@${this.ns}/epochs`, {}).toArray()
719
+ if (!rows.length) return
720
+ const top = rows.reduce((a, b) => (b.epoch > a.epoch ? b : a))
721
+ const recipients = new Set(c.decode(wraps, top.wrapped).map((w) => w.id))
722
+ const { data: members } = await this.get('members')
723
+ const ids = new Set(members.map((m) => m.id))
724
+ const clean = recipients.size === ids.size && [...recipients].every((id) => ids.has(id))
725
+ if (clean) {
726
+ this._healedFor = null
727
+ return
728
+ }
729
+ // one attempt per (epoch, membership) state — a failing rotate (e.g. a
730
+ // permission race) must not loop; any state change re-arms the audit
731
+ const state = `${top.stamp}:${[...ids].sort().join(',')}`
732
+ if (this._healedFor === state) return
733
+ this._healedFor = state
734
+ await this.rotate()
735
+ }
736
+
489
737
  /**
490
738
  * Batch every write performed inside `fn` into a single autobee append.
491
739
  * Nested calls reuse the outer queue.
@@ -572,7 +820,8 @@ export class Database extends ReadyResource {
572
820
  view: tx,
573
821
  host,
574
822
  key: this.writerKey,
575
- dbKey: this.key
823
+ dbKey: this.key,
824
+ dryRun: true
576
825
  })
577
826
  }
578
827
  } finally {
@@ -89,6 +89,25 @@ export class Identity {
89
89
  return sodium.crypto_sign_verify_detached(signature, message, this.publicKey)
90
90
  }
91
91
 
92
+ /**
93
+ * Open a sealed box addressed to this identity (any device holding the
94
+ * seed-derived keypair can open it). Returns null when the box is not ours
95
+ * or is corrupt — callers treat that as "not addressed to me".
96
+ *
97
+ * @param {Uint8Array} sealed
98
+ * @returns {Uint8Array | null}
99
+ */
100
+ unseal(sealed) {
101
+ if (!b4a.isBuffer(sealed) || sealed.byteLength <= sodium.crypto_box_SEALBYTES) return null
102
+ const publicKey = b4a.alloc(sodium.crypto_box_PUBLICKEYBYTES)
103
+ const secretKey = b4a.alloc(sodium.crypto_box_SECRETKEYBYTES)
104
+ sodium.crypto_sign_ed25519_pk_to_curve25519(publicKey, this.publicKey)
105
+ sodium.crypto_sign_ed25519_sk_to_curve25519(secretKey, this.secretKey)
106
+ const message = b4a.alloc(sealed.byteLength - sodium.crypto_box_SEALBYTES)
107
+ const opened = sodium.crypto_box_seal_open(message, sealed, publicKey, secretKey)
108
+ return opened ? message : null
109
+ }
110
+
92
111
  /**
93
112
  * Render the underlying seed as a BIP-39 mnemonic phrase.
94
113
  *
@@ -203,6 +222,23 @@ export class Identity {
203
222
  return sodium.crypto_sign_verify_detached(signature, message, publicKey)
204
223
  }
205
224
 
225
+ /**
226
+ * Seal a message to an identity's Ed25519 public key (sealed box over the
227
+ * curve25519 conversion). Only the holder of the matching secret key —
228
+ * i.e. any of that identity's devices — can open it.
229
+ *
230
+ * @param {Uint8Array} publicKey
231
+ * @param {Uint8Array} message
232
+ * @returns {Uint8Array}
233
+ */
234
+ static seal(publicKey, message) {
235
+ const curve = b4a.alloc(sodium.crypto_box_PUBLICKEYBYTES)
236
+ sodium.crypto_sign_ed25519_pk_to_curve25519(curve, publicKey)
237
+ const sealed = b4a.alloc(message.byteLength + sodium.crypto_box_SEALBYTES)
238
+ sodium.crypto_box_seal(sealed, message, curve)
239
+ return sealed
240
+ }
241
+
206
242
  /**
207
243
  * Generate a fresh Ed25519 keypair (for devices, blind invites, etc.).
208
244
  *
@@ -42,3 +42,5 @@ export const NAMESPACE = 'cero'
42
42
 
43
43
  // Internal counter collection (assigns monotonic `index` to every row in every collection)
44
44
  export const COUNTERS = 'counters'
45
+ // Internal key-rotation epoch collection (rotation announcements with sealed envelopes)
46
+ export const EPOCHS = 'epochs'
package/src/lib/errors.js CHANGED
@@ -163,4 +163,13 @@ export class CeroError extends Error {
163
163
  static NETWORK_ERROR(msg = 'network error') {
164
164
  return new CeroError('NETWORK_ERROR', msg)
165
165
  }
166
+ /**
167
+ * A block references a rotation epoch this peer hasn't learned yet —
168
+ * resolves once the announcement carrying the epoch secret syncs in.
169
+ *
170
+ * @param {number} epoch
171
+ */
172
+ static UNKNOWN_EPOCH(epoch) {
173
+ return new CeroError('UNKNOWN_EPOCH', `unknown encryption epoch: ${epoch}`)
174
+ }
166
175
  }
@@ -11,11 +11,12 @@
11
11
  /** Thin wrapper over a single Hyperblobs core; deals only in raw blobIds. */
12
12
  export class Blobs extends ReadyResource {
13
13
  /** @param {BlobsOpts} [opts] */
14
- constructor({ store, identity, network, key, encryptionKey }?: BlobsOpts);
14
+ constructor({ store, identity, network, key, encryptionKey, name }?: BlobsOpts);
15
15
  store: any;
16
16
  identity: import("../index.js").Identity;
17
17
  network: import("../index.js").Network;
18
18
  encryptionKey: Uint8Array<ArrayBufferLike>;
19
+ name: any;
19
20
  _coreKey: Uint8Array<ArrayBufferLike>;
20
21
  core: any;
21
22
  hyperblobs: any;
@@ -6,6 +6,8 @@
6
6
  * @param {string} ns Namespace prefix for collection and op names.
7
7
  * @param {Record<string, Function>} routes Custom action handlers keyed by route name.
8
8
  * @param {(err: Error) => void} [onerror] Called when a malformed node is skipped.
9
+ * @param {() => Uint8Array | null} [getDbKey]
10
+ * @param {{ onEpoch?: (row: { epoch: number, wrapped: Uint8Array, createdAt: number }) => Promise<void> }} [hooks] Per-peer side effects fired after an op applies (skipped in dry runs).
9
11
  * @returns {{ dispatcher: object, apply: (nodes: Array<{ value: Buffer, key: Buffer }>, view: object, host: object) => Promise<void> }}
10
12
  */
11
13
  export function makeDispatcher(spec: {
@@ -19,7 +21,13 @@ export function makeDispatcher(spec: {
19
21
  verb?: string;
20
22
  }>;
21
23
  };
22
- }, ns: string, routes: Record<string, Function>, onerror?: (err: Error) => void, getDbKey?: () => any): {
24
+ }, ns: string, routes: Record<string, Function>, onerror?: (err: Error) => void, getDbKey?: () => Uint8Array | null, hooks?: {
25
+ onEpoch?: (row: {
26
+ epoch: number;
27
+ wrapped: Uint8Array;
28
+ createdAt: number;
29
+ }) => Promise<void>;
30
+ }): {
23
31
  dispatcher: object;
24
32
  apply: (nodes: Array<{
25
33
  value: Buffer;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Encryption key for a rotation epoch's blob cores. New files land in a core
3
+ * keyed by the epoch entropy, so file confidentiality rotates with the room:
4
+ * only holders of the epoch secret can derive the core key.
5
+ *
6
+ * @param {Uint8Array} entropy
7
+ * @returns {Uint8Array}
8
+ */
9
+ export function blobEpochKey(entropy: Uint8Array): Uint8Array;
10
+ /**
11
+ * Wire codec for a rotation announcement's envelope list — one sealed box
12
+ * per remaining member, addressed by member id.
13
+ */
14
+ export const wraps: any;
15
+ /**
16
+ * Wire codec for locally persisted / pairing-delivered epoch secrets.
17
+ * `epoch` is the apply-order sequence; `stamp` is the content-addressed
18
+ * uint32 written into block headers.
19
+ */
20
+ export const epochEntries: any;
21
+ /**
22
+ * Per-database registry of rotation epochs. Stamp 0 is the base key era
23
+ * (no entry needed — it derives from the upstream GENESIS_ENTROPY path).
24
+ * Every rotation adds an entry keyed by its content-addressed `stamp` (the
25
+ * uint32 written into block headers, chosen randomly by the rotator and
26
+ * validated unique at apply) alongside its apply-order `seq`. Because the
27
+ * stamp is chosen before any block is written and can never be renumbered
28
+ * by linearization, a block's header always identifies its true key —
29
+ * concurrent rotations cannot make blocks undecryptable.
30
+ */
31
+ export class Keyring {
32
+ /** @type {Map<number, Uint8Array>} stamp → entropy */
33
+ entropies: Map<number, Uint8Array>;
34
+ /** @type {Map<number, number>} stamp → apply-order sequence */
35
+ seqs: Map<number, number>;
36
+ /** stamp used for new blocks — the adopted epoch with the highest seq */
37
+ current: number;
38
+ /** highest adopted apply-order sequence (0 = base era) */
39
+ seq: number;
40
+ /** bumped on every add/remove — cheap change detection for retries */
41
+ version: number;
42
+ primed: boolean;
43
+ /** @returns {Array<{ epoch: number, stamp: number, entropy: Uint8Array }>} ascending by seq */
44
+ all(): Array<{
45
+ epoch: number;
46
+ stamp: number;
47
+ entropy: Uint8Array;
48
+ }>;
49
+ /**
50
+ * @param {number} stamp
51
+ * @param {Uint8Array} entropy
52
+ * @param {number} [seq] Apply-order sequence; drives `current` selection.
53
+ */
54
+ add(stamp: number, entropy: Uint8Array, seq?: number): void;
55
+ /**
56
+ * @param {number} stamp
57
+ * @returns {Uint8Array | null}
58
+ */
59
+ entropy(stamp: number): Uint8Array | null;
60
+ /**
61
+ * Forget an epoch (used to undo an add whose persistence failed).
62
+ *
63
+ * @param {number} stamp
64
+ */
65
+ remove(stamp: number): void;
66
+ }
67
+ /**
68
+ * The epoch-aware provider — the class itself is upstream WriterEncryption;
69
+ * the epoch behavior lives on the (patched) base prototype above. Named for
70
+ * call sites and tests.
71
+ */
72
+ export class EpochEncryption {
73
+ }
74
+ /**
75
+ * Autobee with a rotation keyring. Every provider autobee constructs
76
+ * (view/system factory, foreign cores, ActiveWriters) picks the epochs up
77
+ * through the patched base class and this `keyring` property.
78
+ */
79
+ export class EpochAutobee {
80
+ constructor(store: any, key: any, handlers?: {});
81
+ keyring: any;
82
+ _epochRetry: any;
83
+ _epochRetryDelay: number;
84
+ _epochRetrySeen: number;
85
+ _bootState(): Promise<void>;
86
+ _bumpPendingWriters(): Promise<boolean>;
87
+ _scheduleEpochRetry(): void;
88
+ _close(): Promise<any>;
89
+ }
@@ -7,6 +7,7 @@
7
7
  * @property {Record<string, Function>} [routes] Custom action handlers keyed by route name.
8
8
  * @property {string} [namespace] Corestore namespace; defaults to `cero`.
9
9
  * @property {Uint8Array | null} [encryptionKey] Optional encryption key; falls back to identity's key.
10
+ * @property {Array<{ epoch: number, entropy: Uint8Array }> | null} [epochs] Rotation epochs to prime the keyring with (delivered at join).
10
11
  * @property {(nodes: any, view: any, host: any) => Promise<void>} [apply] Override the default apply function.
11
12
  * @property {Uint8Array | null} [key] Existing autobee key to reopen.
12
13
  * @property {boolean} [passive] Join discovery server-only (reachable but not searching). Flip at runtime with `setActive`.
@@ -29,7 +30,7 @@ export class Database extends ReadyResource {
29
30
  /** @param {Partial<DatabaseOpts>} [opts] */
30
31
  constructor(opts?: Partial<DatabaseOpts>);
31
32
  store: any;
32
- identity: import("../index.js").Identity;
33
+ identity: Identity;
33
34
  network: import("../index.js").Network;
34
35
  spec: {
35
36
  database: any;
@@ -59,6 +60,14 @@ export class Database extends ReadyResource {
59
60
  routes: Record<string, Function>;
60
61
  namespace: string;
61
62
  encryptionKey: Uint8Array<ArrayBufferLike>;
63
+ keyring: Keyring;
64
+ _rotation: {
65
+ entropy: any;
66
+ stamp: number;
67
+ epoch: number;
68
+ };
69
+ _healTimer: any;
70
+ _healedFor: any;
62
71
  applyOverride: (nodes: any, view: any, host: any) => Promise<void>;
63
72
  key: Uint8Array<ArrayBufferLike>;
64
73
  passive: boolean;
@@ -67,7 +76,7 @@ export class Database extends ReadyResource {
67
76
  secretKey: Uint8Array<ArrayBufferLike>;
68
77
  };
69
78
  _onerror: any;
70
- bee: any;
79
+ bee: EpochAutobee;
71
80
  dispatcher: {
72
81
  dispatcher: object;
73
82
  apply: (nodes: Array<{
@@ -200,6 +209,54 @@ export class Database extends ReadyResource {
200
209
  * @returns {Promise<void>}
201
210
  */
202
211
  call(op: string, data?: Record<string, any>): Promise<void>;
212
+ /**
213
+ * Rotate the encryption epoch: generate a fresh 32-byte secret, seal it to
214
+ * every current member's identity key, and announce it through the log.
215
+ * The announcement is appended under the current epoch (so every member —
216
+ * even one offline for several rotations — can walk the chain forward),
217
+ * and blocks written after it use the new one. Members absent from the
218
+ * envelope set (removed before the rotation) never learn the new key.
219
+ *
220
+ * @returns {Promise<{ epoch: number }>}
221
+ */
222
+ rotate(retried?: boolean): Promise<{
223
+ epoch: number;
224
+ }>;
225
+ /** Fresh nonzero uint32 block-header stamp not already used by a known epoch. */
226
+ _randomStamp(): number;
227
+ /**
228
+ * Per-peer side of an applied rotation: open our envelope, learn the epoch
229
+ * secret, persist it locally. Peers without an envelope (removed members)
230
+ * simply stop here — every block after the announcement is noise to them.
231
+ * Our own in-flight rotation is deferred to `rotate()` (see above).
232
+ *
233
+ * @param {{ epoch: number, wrapped: Uint8Array, createdAt: number }} row
234
+ */
235
+ _onEpoch(row: {
236
+ epoch: number;
237
+ wrapped: Uint8Array;
238
+ createdAt: number;
239
+ }): Promise<void>;
240
+ /**
241
+ * Safety net after boot (userData priming happens in EpochAutobee's
242
+ * `_bootState`): scan the epochs collection for anything applied but never
243
+ * hydrated — e.g. a crash between a rotation's append and its save.
244
+ */
245
+ _loadEpochs(): Promise<void>;
246
+ /** Persist known epoch secrets to local userData (device-only, never replicates). */
247
+ _saveEpochs(): any;
248
+ /** Debounced trigger for the epoch/membership consistency check. */
249
+ _scheduleHeal(): void;
250
+ /**
251
+ * Convergence rotation: rotations author their envelope set against the
252
+ * rotator's possibly-stale local view, so after linearization the current
253
+ * epoch may include a since-removed member (who then still holds the
254
+ * current key) or miss a since-added one (who is locked out). Every
255
+ * REMOVE-capable writable device audits the current epoch's recipients
256
+ * against canonical membership and re-keys on mismatch. Only rooms that
257
+ * have rotated at least once are audited — rotation stays opt-in.
258
+ */
259
+ _healEpochs(): Promise<void>;
203
260
  /**
204
261
  * Batch every write performed inside `fn` into a single autobee append.
205
262
  * Nested calls reuse the outer queue.
@@ -401,6 +458,13 @@ export type DatabaseOpts = {
401
458
  * Optional encryption key; falls back to identity's key.
402
459
  */
403
460
  encryptionKey?: Uint8Array | null;
461
+ /**
462
+ * Rotation epochs to prime the keyring with (delivered at join).
463
+ */
464
+ epochs?: Array<{
465
+ epoch: number;
466
+ entropy: Uint8Array;
467
+ }> | null;
404
468
  /**
405
469
  * Override the default apply function.
406
470
  */
@@ -451,3 +515,6 @@ export type Ref = {
451
515
  };
452
516
  export type HookFn = (ctx: any) => any | Promise<any>;
453
517
  import ReadyResource from 'ready-resource';
518
+ import { Identity } from '../identity/index.js';
519
+ import { Keyring } from './encryption.js';
520
+ import { EpochAutobee } from './encryption.js';
@@ -75,6 +75,16 @@ export class Identity {
75
75
  * @returns {boolean}
76
76
  */
77
77
  static verify(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): boolean;
78
+ /**
79
+ * Seal a message to an identity's Ed25519 public key (sealed box over the
80
+ * curve25519 conversion). Only the holder of the matching secret key —
81
+ * i.e. any of that identity's devices — can open it.
82
+ *
83
+ * @param {Uint8Array} publicKey
84
+ * @param {Uint8Array} message
85
+ * @returns {Uint8Array}
86
+ */
87
+ static seal(publicKey: Uint8Array, message: Uint8Array): Uint8Array;
78
88
  /**
79
89
  * Generate a fresh Ed25519 keypair (for devices, blind invites, etc.).
80
90
  *
@@ -149,6 +159,15 @@ export class Identity {
149
159
  * @returns {boolean}
150
160
  */
151
161
  verify(message: Uint8Array, signature: Uint8Array): boolean;
162
+ /**
163
+ * Open a sealed box addressed to this identity (any device holding the
164
+ * seed-derived keypair can open it). Returns null when the box is not ours
165
+ * or is corrupt — callers treat that as "not addressed to me".
166
+ *
167
+ * @param {Uint8Array} sealed
168
+ * @returns {Uint8Array | null}
169
+ */
170
+ unseal(sealed: Uint8Array): Uint8Array | null;
152
171
  /**
153
172
  * Render the underlying seed as a BIP-39 mnemonic phrase.
154
173
  *
@@ -34,3 +34,4 @@ export namespace RANK {
34
34
  }
35
35
  export const NAMESPACE: "cero";
36
36
  export const COUNTERS: "counters";
37
+ export const EPOCHS: "epochs";
@@ -110,6 +110,13 @@ export class CeroError extends Error {
110
110
  * @param {string} [msg]
111
111
  */
112
112
  static NETWORK_ERROR(msg?: string): CeroError;
113
+ /**
114
+ * A block references a rotation epoch this peer hasn't learned yet —
115
+ * resolves once the announcement carrying the epoch secret syncs in.
116
+ *
117
+ * @param {number} epoch
118
+ */
119
+ static UNKNOWN_EPOCH(epoch: number): CeroError;
113
120
  /**
114
121
  * @param {string} code Stable identifier (e.g. `REQUIRED`, `EXPIRED`).
115
122
  * @param {string} [message] Human-readable detail; appended after the code.