nattermatic 0.0.1 → 0.2.1

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/src/roster.js ADDED
@@ -0,0 +1,517 @@
1
+ // Network membership: a set of signed records that members exchange and merge.
2
+ //
3
+ // add {host, fp, cert} signed by a current member ("by"); the founding machine signs its own
4
+ // remove {fp, seen} signed by a member that joined before the one removed; carries the
5
+ // next discovery key, and lists the removed machine's records the
6
+ // remover had (`seen`)
7
+ //
8
+ // Every member computes the same membership from the same set of records by replaying them in
9
+ // (at, sig) order: a record counts only if its signer is a member at that point.
10
+ //
11
+ // Seniority: machines are ranked in the order they joined (the founder first), and a machine can
12
+ // remove only machines ranked after it. So two machines can never remove each other.
13
+ //
14
+ // A removal is final. Timestamps are chosen by the signer, so a removed machine could sign records
15
+ // dated before its removal; instead, a removed machine's records count only if its remover listed
16
+ // them in `seen`. Anything else it signs is void, whatever its date. Between snapshots, a removed key
17
+ // stays removed (re-adding a machine means a new key) and the set of records only grows, so merging
18
+ // converges in any order and offline machines catch up on their next hello. A snapshot replaces all
19
+ // of that with one record: see replay.
20
+ import fs from 'node:fs'
21
+ import crypto from 'node:crypto'
22
+ import { signRecord, verifyRecord, newDiscoveryKey, recId, recKey } from './crypto.js'
23
+ import { fingerprint, certDer } from './cert.js'
24
+ import { normHost, machineName, writeAtomic } from './config.js'
25
+
26
+ // New records stop being taken in at this size, so that a roster always fits in one hello (8 MB):
27
+ // if it didn't, this node's removals would never reach anyone. The limit doesn't apply to loading our
28
+ // own file, so nothing already held is lost, or to a removal that must get in (see mustTake), so a
29
+ // full roster can always be cleaned up. There is at most one of those per machine, of at most
30
+ // MAX_TAKEN, plus a list entry for each machine its target added, plus what it clears. A machine's add
31
+ // takes at least about 400 bytes, so that's some 2600 machines at the very most, each with one adder,
32
+ // and what's cleared is at most what was held: 1 MB + 2600 * 2 KB + 2600 * 25 B + 1 MB is about
33
+ // 7 MB, whatever a member signs.
34
+ export const MAX_ROSTER_BYTES = 1 << 20
35
+ const MAX_TAKEN = 2 << 10 // a removal listing no one, with room for some 70 adds not held here
36
+ const SEEN_ENTRY = 25 // what listing one record in `seen` takes
37
+ const MAX_FORGED = 20_000 // bad signatures remembered, so a resent one costs nothing
38
+ const MAX_FUTURE_MS = 3600_000 // a record stamped further ahead than this is refused (bad clock)
39
+ const MAX_SEEN = 4000 // more than a full roster's adds, so no machine can make itself impossible to remove
40
+ const MAX_KEYS = 4000 // discovery keys a snapshot carries: every one still in its grace period, if it can
41
+ const MAX_DROPPED = 50 // adds that didn't count, kept per removed machine (so their keys stay known as dropped)
42
+ // a replaced discovery key still opens other members' gates for this long, so a machine that was
43
+ // offline during a removal can catch up; after that it needs a new invite
44
+ export const DK_GRACE_MS = 14 * 86400_000
45
+
46
+ /** @typedef {import('./types.js').RosterRecord} RosterRecord */
47
+ /** @typedef {import('./types.js').Member} Member */
48
+
49
+ const ASCII_KEY = /^[\w+/=-]+$/ // fingerprints and signatures: base64, in either alphabet
50
+
51
+ /** @param {any} r @returns {r is RosterRecord} */
52
+ function wellFormed(r) {
53
+ if (!r || typeof r !== 'object') return false
54
+ if (r.type !== 'add' && r.type !== 'remove' && r.type !== 'snapshot') return false
55
+ if (typeof r.fp !== 'string' || !ASCII_KEY.test(r.fp) || r.fp.length > 64 || typeof r.by !== 'string' || !ASCII_KEY.test(r.by) || r.by.length > 64) return false
56
+ if (typeof r.sig !== 'string' || !ASCII_KEY.test(r.sig) || r.sig.length > 200 || !Number.isSafeInteger(r.at) || r.at < 0) return false
57
+ if (r.dk !== undefined && (typeof r.dk !== 'string' || r.dk.length !== 44 || Buffer.from(r.dk, 'base64').length !== 32)) return false
58
+ if (r.type !== 'add' && (r.host !== undefined || r.cert !== undefined)) return false
59
+ if (r.type === 'snapshot' ? !snapshotShape(r) : r.list !== undefined || r.keys !== undefined) return false
60
+ if (r.seen !== undefined && (r.type !== 'remove' || !Array.isArray(r.seen) || r.seen.length > MAX_SEEN || !r.seen.every((/** @type {unknown} */ x) => typeof x === 'string' && /^[\w-]{22}$/.test(x)))) return false
61
+ // strict base64: the decoder skips anything else, so a good certificate could carry padding
62
+ if (r.type === 'add' && (typeof r.host !== 'string' || r.host.length > 253 || typeof r.cert !== 'string' || r.cert.length > 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(r.cert))) return false
63
+ return true
64
+ }
65
+
66
+ /** @param {RosterRecord} r */
67
+ function snapshotShape(r) {
68
+ const key = (/** @type {unknown} */ s) => typeof s === 'string' && s.length <= 64 && ASCII_KEY.test(s)
69
+ return r.fp === r.by && r.dk === undefined && r.seen === undefined &&
70
+ Array.isArray(r.list) && r.list.length > 0 && r.list.length <= MAX_SEEN &&
71
+ r.list.every(m => Array.isArray(m) && m.length === 5 && typeof m[0] === 'string' && m[0].length <= 253 && key(m[1]) &&
72
+ typeof m[2] === 'string' && m[2].length <= 1024 && /^[A-Za-z0-9+/]+={0,2}$/.test(m[2]) && Number.isSafeInteger(m[3]) && m[3] >= 0 && key(m[4])) &&
73
+ Array.isArray(r.keys) && r.keys.length > 0 && r.keys.length <= MAX_KEYS &&
74
+ r.keys.every(k => Array.isArray(k) && k.length === 2 && typeof k[0] === 'string' && k[0].length === 44 && Buffer.from(k[0], 'base64').length === 32 && Number.isSafeInteger(k[1]) && k[1] >= 0)
75
+ }
76
+
77
+ /** What a snapshot says about each member, in the shape of the add that made it. @param {RosterRecord} r */
78
+ const snapshotAdds = r => (r.list ?? []).map(([host, fp, cert, at, by]) => /** @type {RosterRecord} */ ({ type: 'add', host, fp, cert, at, by, sig: '' }))
79
+
80
+ export class Roster {
81
+ /**
82
+ * @param {{ net: string, records: RosterRecord[] }} data
83
+ * @param {string | null} [file] where save() writes
84
+ */
85
+ constructor(data, file = null) {
86
+ this.net = data.net
87
+ this.file = file
88
+ /** @type {RosterRecord[]} */
89
+ this.records = []
90
+ /** @type {Set<string>} ids of the records held (see recId) */
91
+ this.ids = new Set()
92
+ /** @type {Set<string>} records whose signature checked out (see recKey) */
93
+ this.verified = new Set()
94
+ /** @type {Set<string>} records whose signature didn't: that can't change, so they aren't checked twice */
95
+ this.forged = new Set()
96
+ /** @type {Map<string, Member>} fp -> member */
97
+ this.members = new Map()
98
+ /** @type {Map<string, Member>} host -> member */
99
+ this.byHost = new Map()
100
+ /** @type {Map<string, Member>} every machine that joined, removed or not */
101
+ this.ranked = new Map()
102
+ /** @type {Map<string, { at: number, by: string }>} */
103
+ this.removed = new Map()
104
+ /** @type {string[]} discovery keys, oldest first */
105
+ this.dks = []
106
+ /** @type {number[]} when each discovery key was introduced */
107
+ this.dkAt = []
108
+ /** @type {Set<string>} keys added by a machine that was removed before the add was seen: never members */
109
+ this.dropped = new Set()
110
+ /** @type {RosterRecord | null} the founder's newest snapshot, which everything now starts from */
111
+ this.snapshot = null
112
+ /** @type {Map<string, { host: string, pem: string, publicKey: crypto.KeyObject } | null>} parsed add records (see recKey) */
113
+ this.parsed = new Map()
114
+ this.merge(data.records, { trustClock: true })
115
+ }
116
+
117
+ /** @param {string} file */
118
+ static load(file) {
119
+ const d = JSON.parse(fs.readFileSync(file, 'utf8'))
120
+ if (!d || typeof d.net !== 'string' || !Array.isArray(d.records)) throw new Error(`bad roster ${file}`)
121
+ return new Roster(d, file)
122
+ }
123
+
124
+ save() {
125
+ if (!this.file) return
126
+ writeAtomic(this.file, JSON.stringify({ net: this.net, records: this.records }, null, 1) + '\n', 0o600)
127
+ }
128
+
129
+ /** The current discovery key. */
130
+ get dk() { return this.dks[this.dks.length - 1] }
131
+
132
+ /** Discovery keys that open this node's gate: the current one, and ones replaced recently. */
133
+ gateDks(now = Date.now()) {
134
+ return this.dks.filter((_, i) => i === this.dks.length - 1 || this.dkAt[i + 1] > now - DK_GRACE_MS)
135
+ }
136
+
137
+ /** May member `by` remove member `fp`? (only machines that joined after it) @param {string} by @param {string} fp */
138
+ canRemove(by, fp) {
139
+ const a = this.members.get(by), b = this.members.get(fp)
140
+ return !!a && !!b && a.rank < b.rank
141
+ }
142
+
143
+ /**
144
+ * Must this removal get in even when the roster is full? It takes a current member off the network,
145
+ * by a machine that joined before it, so refusing it would leave the machine on here and off
146
+ * elsewhere, and a flooder could keep itself on. It may be as big as a removal has any reason to be:
147
+ * a list entry for each machine its target really added, and otherwise no bigger than what it clears
148
+ * (the target's adds it doesn't list stop counting). So a list padded with made-up ids gets nothing
149
+ * for them, and a member can't stop its own removal by filling one node with adds no one else has.
150
+ * @param {RosterRecord} r @param {number} size its size in bytes
151
+ * @param {() => Map<string, { id: string, size: number, made: boolean }[]>} adds what each machine has signed that its removal would list or clear
152
+ */
153
+ mustTake(r, size, adds) {
154
+ const s = this.ranked.get(r.by), t = this.ranked.get(r.fp)
155
+ if (r.type !== 'remove' || !s || !t || s.rank >= t.rank || !this.members.has(r.fp)) return false
156
+ const listed = new Set(r.seen ?? [])
157
+ let room = MAX_TAKEN
158
+ for (const a of adds().get(r.fp) ?? []) room += !listed.has(a.id) ? a.size : a.made ? SEEN_ENTRY : 0
159
+ return size <= room
160
+ }
161
+
162
+ /** @param {string} key */
163
+ noteForged(key) {
164
+ if (this.forged.size >= MAX_FORGED) this.forged.clear()
165
+ this.forged.add(key)
166
+ }
167
+
168
+ /** Did the founder sign this snapshot? It carries the founder's own certificate. @param {RosterRecord} r */
169
+ signedSnapshot(r) {
170
+ try {
171
+ const x509 = new crypto.X509Certificate(Buffer.from(String(r.list?.[0]?.[2]), 'base64'))
172
+ return r.by === this.net && fingerprint(x509) === this.net && r.list?.[0]?.[1] === this.net && verifyRecord(x509.publicKey, r)
173
+ } catch { return false }
174
+ }
175
+
176
+ /** What a machine's removal would list or clear, by who signed it. */
177
+ addsBySigner() {
178
+ /** @type {Map<string, { id: string, size: number, made: boolean }[]>} */
179
+ const out = new Map()
180
+ for (const r of this.records) {
181
+ // a removal of a key no one has heard of goes when its signer does, so it counts as much as an add
182
+ if (r.type !== 'add' && this.ranked.has(r.fp)) continue
183
+ if (!out.has(r.by)) out.set(r.by, [])
184
+ out.get(r.by)?.push({ id: recId(r), size: Buffer.byteLength(JSON.stringify(r)), made: this.ranked.get(r.fp)?.by === r.by })
185
+ }
186
+ return out
187
+ }
188
+
189
+ /**
190
+ * Add records we were sent (or made). Returns true if the set of records changed.
191
+ * @param {unknown} list @param {{ trustClock?: boolean }} [opts]
192
+ * @returns {boolean}
193
+ */
194
+ merge(list, { trustClock = false } = {}) {
195
+ // a snapshot first: what it clears makes room for what came with it
196
+ const isSnapshot = (/** @type {any} */ r) => r?.type === 'snapshot'
197
+ if (Array.isArray(list) && list.some(isSnapshot) && !list.every(isSnapshot)) {
198
+ const a = this.merge(list.filter(isSnapshot), { trustClock })
199
+ return this.merge(list.filter(r => !isSnapshot(r)), { trustClock }) || a
200
+ }
201
+ let added = false
202
+ const before = this.ids
203
+ let bytes = trustClock ? 0 : Buffer.byteLength(JSON.stringify(this.records))
204
+ /** @type {Set<string>} machines taken off past the limit in this batch: once is enough */
205
+ const over = new Set()
206
+ /** @type {ReturnType<Roster['addsBySigner']> | undefined} */
207
+ let adds
208
+ for (const r of Array.isArray(list) ? list : []) {
209
+ if (!wellFormed(r) || this.ids.has(recId(r)) || this.forged.has(recKey(r))) continue
210
+ if (!trustClock && r.at > Date.now() + MAX_FUTURE_MS) continue
211
+ // what a snapshot has already settled, sent by a machine that hasn't heard of it yet
212
+ const held = this.snapshot
213
+ if (held && r.type === 'add' && r.at <= held.at) continue
214
+ if (held && r.type === 'snapshot' && (r.at < held.at || (r.at === held.at && recId(r) < recId(held)))) continue
215
+ const o = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (r))
216
+ const clean = /** @type {RosterRecord} */ (/** @type {unknown} */ (Object.fromEntries(['type', 'host', 'fp', 'cert', 'at', 'by', 'dk', 'seen', 'list', 'keys', 'sig'].filter(k => o[k] !== undefined).map(k => [k, o[k]]))))
217
+ const size = Buffer.byteLength(JSON.stringify(clean))
218
+ if (clean.type === 'snapshot') {
219
+ // it's what makes room, so it always gets in, and only the founder's can
220
+ if (!this.signedSnapshot(clean)) { this.noteForged(recKey(clean)); continue }
221
+ } else if (!trustClock && bytes + size > MAX_ROSTER_BYTES) {
222
+ if (over.has(clean.fp) || !this.mustTake(clean, size, () => adds ??= this.addsBySigner())) continue
223
+ over.add(clean.fp)
224
+ }
225
+ bytes += size
226
+ this.records.push(clean)
227
+ added = true
228
+ }
229
+ if (!added) return false
230
+ this.replay()
231
+ return this.ids.size !== before.size || [...this.ids].some(s => !before.has(s))
232
+ }
233
+
234
+ /**
235
+ * Recompute membership: a pure function of the set of records, so every node that holds the same
236
+ * records agrees, whatever order they arrived in.
237
+ *
238
+ * Machines are settled in joining order. Whether a machine is removed depends only on records
239
+ * signed by machines that joined before it (only they can remove it), and those are settled
240
+ * first. So nothing is ever decided twice, and a single pass suffices.
241
+ *
242
+ * - A removal by an earlier machine always stands, even if its signer is removed too: a removal
243
+ * can't be undone by removing the remover (concurrent removals all stand).
244
+ * - Any other record signed by a removed machine (an add) counts only if every removal of that
245
+ * machine lists it in `seen`. So a removed machine can't add machines, backdated or not.
246
+ * - The discovery key is the newest one introduced by a machine that is still a member, so a
247
+ * key made by a removed machine is never current.
248
+ *
249
+ * Records are kept if they could ever count: everything signed by current members, and what
250
+ * counts of what removed machines signed. Anything else can never count, whatever arrives later
251
+ * (since the last snapshot, removals only accumulate), so every node reaches the same result.
252
+ *
253
+ * A snapshot replaces all of that history. The founder's newest one is where replay starts: its
254
+ * members hold their places in joining order, and its keys are the discovery keys so far. After it:
255
+ * - An add counts only if it's dated later. So a removed machine can't come back on an old add,
256
+ * and an add the founder hadn't heard of needs a new invite.
257
+ * - A removal of a machine in the snapshot counts whatever its date, if its signer is in it too and
258
+ * joined first, and it's dated after that machine's add. So a removal made at the same moment
259
+ * as the snapshot isn't lost, and one from before a forgotten key was let back in says nothing
260
+ * about the machine there now, at this snapshot or any later one.
261
+ * - Nothing else from before it counts, so it's dropped, and the roster shrinks.
262
+ */
263
+ replay() {
264
+ // ties go by content: which encoding of a signature a node happened to get must not decide anything
265
+ /** @type {Map<RosterRecord, string>} */
266
+ const idOf = new Map(this.records.map(r => [r, recId(r)]))
267
+ const tie = (/** @type {RosterRecord} */ a, /** @type {RosterRecord} */ b) => { const x = /** @type {string} */ (idOf.get(a)), y = /** @type {string} */ (idOf.get(b)); return x < y ? -1 : x > y ? 1 : 0 }
268
+ this.records.sort((a, b) => a.at - b.at || tie(a, b))
269
+ /** @param {crypto.KeyObject} pub @param {RosterRecord} r */
270
+ const ok = (pub, r) => {
271
+ const k = recKey(r)
272
+ if (this.verified.has(k)) return true
273
+ if (!verifyRecord(pub, r)) { this.noteForged(k); return false }
274
+ this.verified.add(k)
275
+ return true
276
+ }
277
+ /** @param {RosterRecord} r */
278
+ const parse = r => {
279
+ const k = recKey(r)
280
+ let c = this.parsed.get(k)
281
+ if (c === undefined) {
282
+ c = null
283
+ try {
284
+ const x509 = new crypto.X509Certificate(Buffer.from(String(r.cert), 'base64'))
285
+ const host = normHost(String(r.host))
286
+ if (host === r.host && machineName(host) && fingerprint(x509) === r.fp) c = { host, pem: x509.toString(), publicKey: x509.publicKey }
287
+ } catch {}
288
+ this.parsed.set(k, c)
289
+ }
290
+ return c
291
+ }
292
+
293
+ // (signatures were checked on the way in)
294
+ const snapshot = [...this.records].reverse().find(r => r.type === 'snapshot' && snapshotAdds(r).every(parse)) ?? null
295
+ const since = snapshot?.at ?? -1
296
+ const base = snapshot ? snapshotAdds(snapshot) : []
297
+ const addedAt = new Map(base.map(r => [r.fp, r.at]))
298
+ /**
299
+ * Is this removal about a key a snapshot forgot, rather than the machine there now? An honest
300
+ * removal is dated after the add it answers, as its signer held that add.
301
+ * @param {RosterRecord} r
302
+ */
303
+ const stale = r => {
304
+ if (r.type !== 'remove' || r.at > since) return false
305
+ const added = addedAt.get(r.fp)
306
+ return added === undefined || r.at < added
307
+ }
308
+
309
+ /** @type {Map<string, RosterRecord[]>} target fp -> remove records naming it */
310
+ const removesOf = new Map()
311
+ for (const r of this.records) if (r.type === 'remove') { if (!removesOf.has(r.fp)) removesOf.set(r.fp, []); removesOf.get(r.fp)?.push(r) }
312
+
313
+ /** @type {Map<string, Member>} every machine that joined (ranked), removed or not */
314
+ const ranked = new Map()
315
+ /** @type {Map<string, { removed: false } | { removed: true, at: number, by: string, seen: Set<string>[] }>} */
316
+ const statusMemo = new Map()
317
+ /** The removals that stand against `fp` (by machines ranked before it). @param {string} fp */
318
+ const status = fp => {
319
+ const known = statusMemo.get(fp)
320
+ if (known) return known
321
+ const m = ranked.get(fp)
322
+ /** @type {RosterRecord[]} */
323
+ const stand = []
324
+ /** @type {Set<string>} */
325
+ const signers = new Set()
326
+ // one removal per signer counts: its earliest (so signing more can't change the outcome)
327
+ if (m) for (const r of removesOf.get(fp) || []) {
328
+ const s = ranked.get(r.by)
329
+ if (s && s.rank < m.rank && !stale(r) && !signers.has(r.by) && ok(s.publicKey, r)) { stand.push(r); signers.add(r.by) }
330
+ }
331
+ const st = stand.length
332
+ ? { removed: /** @type {const} */ (true), at: stand[0].at, by: stand[0].by, seen: stand.map(r => new Set(r.seen ?? [])) }
333
+ : { removed: /** @type {const} */ (false) }
334
+ statusMemo.set(fp, st)
335
+ return st
336
+ }
337
+ /** Does `r`, signed by ranked machine `by`, count? @param {string} by @param {RosterRecord} r */
338
+ const counts = (by, r) => {
339
+ const st = status(by)
340
+ return !st.removed || st.seen.every(seen => seen.has(recId(r)))
341
+ }
342
+
343
+ /** @type {Map<string, Member>} host -> the machine holding that name */
344
+ const nameHolder = new Map()
345
+ /** @type {Set<string>} keys whose add didn't count because their adder was removed first */
346
+ const dropped = new Set()
347
+ let rank = 0
348
+ for (const r of base) {
349
+ const c = parse(r)
350
+ if (!c || ranked.has(r.fp)) continue
351
+ const m = { host: c.host, fp: r.fp, pem: c.pem, publicKey: c.publicKey, at: r.at, by: r.by, rank: rank++ }
352
+ ranked.set(r.fp, m)
353
+ nameHolder.set(c.host, m)
354
+ }
355
+ for (const r of this.records) {
356
+ if (r.type !== 'add' || r.at <= since) continue
357
+ const c = parse(r)
358
+ if (!c) continue
359
+ const genesis = r.by === r.fp
360
+ if (genesis) {
361
+ if (r.fp !== this.net || ranked.size || !ok(c.publicKey, r)) continue
362
+ } else {
363
+ const s = ranked.get(r.by)
364
+ if (!s || !ok(s.publicKey, r)) continue
365
+ }
366
+ if (ranked.has(r.fp)) continue
367
+ if (!genesis && !counts(r.by, r)) { dropped.add(r.fp); continue }
368
+ const holder = nameHolder.get(c.host)
369
+ if (holder && !status(holder.fp).removed) continue // the name is taken
370
+ const m = { host: c.host, fp: r.fp, pem: c.pem, publicKey: c.publicKey, at: r.at, by: r.by, rank: rank++ }
371
+ ranked.set(r.fp, m)
372
+ nameHolder.set(c.host, m)
373
+ }
374
+
375
+ /** @type {Map<string, Member>} */
376
+ const members = new Map()
377
+ /** @type {Map<string, Member>} */
378
+ const byHost = new Map()
379
+ /** @type {Map<string, { at: number, by: string }>} */
380
+ const removed = new Map()
381
+ for (const m of ranked.values()) {
382
+ const st = status(m.fp)
383
+ if (st.removed) removed.set(m.fp, { at: st.at, by: st.by })
384
+ else { members.set(m.fp, m); byHost.set(m.host, m) }
385
+ }
386
+ for (const fp of ranked.keys()) dropped.delete(fp)
387
+
388
+ // Only records whose signature checks out are kept, whoever they claim to be from. Of those: what
389
+ // current members signed, and of what removed machines signed only what counts (adds every
390
+ // remover had seen). Copies of a record (the same content, signed differently) are kept once. A removal is kept if it is the first by its signer for a
391
+ // target it can remove. Nothing else from a removed or dropped key can ever count, whatever
392
+ // arrives later, so dropping it keeps every node's result the same, and a removed machine can't
393
+ // fill the roster. Adds that didn't count are kept too (at most MAX_DROPPED per removed machine),
394
+ // so their keys stay known as dropped: messages from them are refused instead of retried.
395
+ /** @type {Set<string>} */
396
+ const firstRemove = new Set()
397
+ /** @type {Map<string, number>} */
398
+ const droppedKept = new Map()
399
+ /** @type {Set<string>} */
400
+ const copies = new Set()
401
+ const once = (/** @type {RosterRecord} */ r) => { const id = recId(r); if (copies.has(id)) return false; copies.add(id); return true }
402
+ const kept = this.records.filter(r => {
403
+ if (r.type === 'snapshot') return r === snapshot && once(r)
404
+ if ((r.type === 'add' && r.at <= since) || stale(r)) return false
405
+ const s = ranked.get(r.by)
406
+ if (!s || !ok(s.publicKey, r) || !once(r)) return false
407
+ if (r.type === 'add') {
408
+ if (!parse(r)) return false // can never count
409
+ if (members.has(r.by)) return true
410
+ if (ranked.get(r.fp)?.by === r.by && counts(r.by, r)) return true
411
+ if (!dropped.has(r.fp)) return false
412
+ const n = (droppedKept.get(r.by) || 0) + 1
413
+ droppedKept.set(r.by, n)
414
+ return n <= MAX_DROPPED
415
+ }
416
+ // the removal that counts for this signer and target (the only one status() looks at)
417
+ const t = ranked.get(r.fp)
418
+ if (t && s.rank < t.rank) {
419
+ if (firstRemove.has(`${r.by} ${r.fp}`)) return false
420
+ firstRemove.add(`${r.by} ${r.fp}`)
421
+ return true
422
+ }
423
+ // a machine it can't remove stays that way; one we haven't heard of yet may turn out otherwise
424
+ return !t && members.has(r.by)
425
+ })
426
+
427
+ /** @type {string[]} */
428
+ const dks = (snapshot?.keys ?? []).map(k => k[0])
429
+ /** @type {number[]} */
430
+ const dkAt = (snapshot?.keys ?? []).map(k => k[1])
431
+ for (const r of kept) {
432
+ const signer = members.get(r.by)
433
+ if (!r.dk || !signer) continue // never a key made by a removed machine
434
+ const target = ranked.get(r.fp)
435
+ // the founder's first key, and the keys in removals that stand: no other record brings one in.
436
+ // (This is the only check of which removals may bring in a key, whatever the checks above imply.)
437
+ if (r.type === 'add' ? r.by === r.fp && r.fp === this.net : !!target && status(r.fp).removed && signer.rank < target.rank) {
438
+ dks.push(r.dk)
439
+ dkAt.push(Math.max(r.at, since)) // it replaced the snapshot's key no earlier than the snapshot
440
+ }
441
+ }
442
+ this.parsed = new Map([...base, ...kept.filter(r => r.type === 'add')].map(r => [recKey(r), this.parsed.get(recKey(r)) ?? null]))
443
+ if (this.verified.size > MAX_FORGED) this.verified = new Set(kept.map(recKey).filter(k => this.verified.has(k)))
444
+ Object.assign(this, { members, byHost, ranked, removed, dropped, dks, dkAt, snapshot, records: kept, ids: new Set(kept.map(recId)) })
445
+ }
446
+
447
+ /** A timestamp later than every record we hold, so our records sort after what we've seen
448
+ * even if our clock is behind the machine that added us. */
449
+ nextAt() {
450
+ return Math.max(Date.now(), ...this.records.map(r => r.at + 1))
451
+ }
452
+
453
+ /**
454
+ * Sign and merge a record adding `host` (whose certificate is `pem`).
455
+ * @param {import('./types.js').Identity} me @param {string} host @param {string} pem
456
+ */
457
+ add(me, host, pem) {
458
+ /** @type {Omit<RosterRecord, 'sig'>} */
459
+ const r = { type: 'add', host, fp: fingerprint(pem), cert: certDer(pem), at: this.nextAt(), by: me.fp }
460
+ const rec = { ...r, sig: signRecord(me.key, r) }
461
+ this.merge([rec])
462
+ if (!this.ids.has(recId(rec))) throw new Error('the network has reached its size limit: remove any machine that is flooding it, then run `natter compact` on the founding machine')
463
+ return rec
464
+ }
465
+
466
+ /**
467
+ * Sign and merge a record removing `fp` (which must have joined after us); rotates the discovery
468
+ * key, and lists the removed machine's records we have: nothing else it signs will count.
469
+ * @param {import('./types.js').Identity} me @param {string} fp
470
+ */
471
+ remove(me, fp) {
472
+ if (!this.canRemove(me.fp, fp)) throw new Error('only a machine that joined earlier can remove this one')
473
+ // the adds that made machines, once each (records are in replay order, so the first is the one that counted)
474
+ /** @type {Map<string, string>} */
475
+ const made = new Map()
476
+ for (const x of this.records) {
477
+ if (x.by === fp && x.type === 'add' && !made.has(x.fp) && this.ranked.get(x.fp)?.by === fp) made.set(x.fp, recId(x))
478
+ }
479
+ /** @type {Omit<RosterRecord, 'sig'>} */
480
+ const r = { type: 'remove', fp, at: this.nextAt(), by: me.fp, dk: newDiscoveryKey(), seen: [...made.values()] }
481
+ const rec = { ...r, sig: signRecord(me.key, r) }
482
+ this.merge([rec])
483
+ if (!this.ids.has(recId(rec)) || this.members.has(fp)) throw new Error('its removal could not be recorded')
484
+ return rec
485
+ }
486
+
487
+ /**
488
+ * Sign and merge a snapshot of the current members, which replaces everything before it. Only the
489
+ * founder's counts: it's the one machine every member trusts and no one can remove. Removed
490
+ * machines are forgotten, and a new discovery key comes in, so whatever a removed machine still
491
+ * held stops being current.
492
+ * @param {import('./types.js').Identity} me
493
+ */
494
+ compact(me) {
495
+ if (me.fp !== this.net) throw new Error('only the founding machine can do this')
496
+ const at = this.nextAt()
497
+ const live = this.gateDks()
498
+ const keys = this.dks.flatMap((dk, i) => live.includes(dk) ? [/** @type {[string, number]} */ ([dk, this.dkAt[i]])] : []).slice(1 - MAX_KEYS)
499
+ /** @type {Omit<RosterRecord, 'sig'>} */
500
+ const r = {
501
+ type: 'snapshot', fp: me.fp, by: me.fp, at,
502
+ list: [...this.members.values()].sort((a, b) => a.rank - b.rank).map(m => [m.host, m.fp, certDer(m.pem), m.at, m.by]),
503
+ keys: [...keys, [newDiscoveryKey(), at]],
504
+ }
505
+ const rec = { ...r, sig: signRecord(me.key, r) }
506
+ this.merge([rec])
507
+ if (this.snapshot === null || recId(this.snapshot) !== recId(rec)) throw new Error('the snapshot could not be recorded')
508
+ return rec
509
+ }
510
+
511
+ /** A new network whose only member is `me`, called `host`. @param {import('./types.js').Identity} me @param {string} host */
512
+ static genesis(me, host, /** @type {string | null} */ file = null) {
513
+ /** @type {Omit<RosterRecord, 'sig'>} */
514
+ const r = { type: 'add', host, fp: me.fp, cert: certDer(me.cert), at: Date.now(), by: me.fp, dk: newDiscoveryKey() }
515
+ return new Roster({ net: me.fp, records: [{ ...r, sig: signRecord(me.key, r) }] }, file)
516
+ }
517
+ }
package/src/self.js ADDED
@@ -0,0 +1,41 @@
1
+ // How nattermatic runs itself and finds the files it ships with. Two forms:
2
+ // - an npm install: `node bin/natter.js`, with the package directory beside it;
3
+ // - a single executable (the macOS app): Node's single-executable feature, where the executable is
4
+ // the whole program and the files it ships with (install.sh, the npm package it hands to
5
+ // machines that join through it) are embedded in it.
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+ import sea from 'node:sea'
10
+
11
+ /** Running as a single executable? */
12
+ export const SEA = (() => { try { return sea.isSea() } catch { return false } })()
13
+
14
+ // A single executable can't be given Node's --no-warnings flag; node:sqlite's experimental warning
15
+ // would otherwise be printed on every command.
16
+ if (SEA) process.removeAllListeners('warning')
17
+
18
+ /** The package directory (npm installs only). */
19
+ export const PKG_ROOT = SEA ? '' : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
20
+ /** The program's entry point: bin/natter.js, or the executable itself. */
21
+ export const BIN = SEA ? process.execPath : path.join(PKG_ROOT, 'bin', 'natter.js')
22
+
23
+ /**
24
+ * The app bundle this executable runs from (the macOS app on Apple silicon), or null. A machine
25
+ * running the app hands it to Macs that join through its invites.
26
+ * @returns {string | null}
27
+ */
28
+ export function appBundle() {
29
+ if (!SEA || process.platform !== 'darwin' || process.arch !== 'arm64') return null
30
+ const dir = path.resolve(process.execPath, '..', '..', '..')
31
+ return dir.endsWith('.app') && fs.existsSync(path.join(dir, 'Contents', 'Info.plist')) ? dir : null
32
+ }
33
+
34
+ /**
35
+ * A file nattermatic ships with (install.sh, the npm package). For an npm install, `name` is
36
+ * relative to the package directory; a single executable has it embedded under the same name.
37
+ * @param {string} name @returns {Buffer}
38
+ */
39
+ export function readAsset(name) {
40
+ return SEA ? Buffer.from(sea.getAsset(name)) : fs.readFileSync(path.join(PKG_ROOT, name))
41
+ }