@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 +9 -3
- package/src/blobs/index.js +3 -2
- package/src/database/bootstrap.js +2 -0
- package/src/database/dispatch.js +71 -2
- package/src/database/encryption.js +303 -0
- package/src/database/index.js +257 -8
- package/src/identity/index.js +36 -0
- package/src/lib/constants.js +2 -0
- package/src/lib/errors.js +9 -0
- package/types/blobs/index.d.ts +2 -1
- package/types/database/dispatch.d.ts +9 -1
- package/types/database/encryption.d.ts +89 -0
- package/types/database/index.d.ts +69 -2
- package/types/identity/index.d.ts +19 -0
- package/types/lib/constants.d.ts +1 -0
- package/types/lib/errors.d.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cero-base/core",
|
|
3
|
-
"version": "1.
|
|
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": "
|
|
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
|
}
|
package/src/blobs/index.js
CHANGED
|
@@ -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 =
|
|
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
|
package/src/database/dispatch.js
CHANGED
|
@@ -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(
|
|
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, {
|
|
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() {}
|
package/src/database/index.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 {
|
package/src/identity/index.js
CHANGED
|
@@ -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
|
*
|
package/src/lib/constants.js
CHANGED
|
@@ -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
|
}
|
package/types/blobs/index.d.ts
CHANGED
|
@@ -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?: () =>
|
|
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:
|
|
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:
|
|
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
|
*
|
package/types/lib/constants.d.ts
CHANGED
package/types/lib/errors.d.ts
CHANGED
|
@@ -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.
|