@onlyne/lib 3.1.0 → 3.2.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.
Files changed (121) hide show
  1. package/README.md +4 -3
  2. package/dist/awaited.d.ts +43 -0
  3. package/dist/awaited.d.ts.map +1 -0
  4. package/dist/awaited.js +82 -0
  5. package/dist/awaited.js.map +1 -0
  6. package/dist/client.d.ts +140 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +1772 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/core.d.ts +36 -0
  11. package/dist/core.d.ts.map +1 -0
  12. package/dist/core.js +122 -0
  13. package/dist/core.js.map +1 -0
  14. package/dist/delivery.d.ts +120 -0
  15. package/dist/delivery.d.ts.map +1 -0
  16. package/dist/delivery.js +117 -0
  17. package/dist/delivery.js.map +1 -0
  18. package/dist/discovery.d.ts +35 -0
  19. package/dist/discovery.d.ts.map +1 -0
  20. package/dist/discovery.js +306 -0
  21. package/dist/discovery.js.map +1 -0
  22. package/dist/domains.d.ts +29 -0
  23. package/dist/domains.d.ts.map +1 -0
  24. package/dist/domains.js +267 -0
  25. package/dist/domains.js.map +1 -0
  26. package/dist/history.d.ts +13 -0
  27. package/dist/history.d.ts.map +1 -0
  28. package/dist/history.js +143 -0
  29. package/dist/history.js.map +1 -0
  30. package/dist/http.d.ts +20 -0
  31. package/dist/http.d.ts.map +1 -0
  32. package/dist/http.js +88 -0
  33. package/dist/http.js.map +1 -0
  34. package/{src/index.ts → dist/index.d.ts} +2 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +15 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/keys.d.ts +97 -0
  39. package/dist/keys.d.ts.map +1 -0
  40. package/dist/keys.js +679 -0
  41. package/dist/keys.js.map +1 -0
  42. package/dist/memory.d.ts +9 -0
  43. package/dist/memory.d.ts.map +1 -0
  44. package/dist/memory.js +35 -0
  45. package/dist/memory.js.map +1 -0
  46. package/dist/messages.d.ts +64 -0
  47. package/dist/messages.d.ts.map +1 -0
  48. package/dist/messages.js +480 -0
  49. package/dist/messages.js.map +1 -0
  50. package/dist/node.d.ts +26 -0
  51. package/dist/node.d.ts.map +1 -0
  52. package/dist/node.js +59 -0
  53. package/dist/node.js.map +1 -0
  54. package/dist/resolve.d.ts +18 -0
  55. package/dist/resolve.d.ts.map +1 -0
  56. package/dist/resolve.js +37 -0
  57. package/dist/resolve.js.map +1 -0
  58. package/dist/resources.d.ts +25 -0
  59. package/dist/resources.d.ts.map +1 -0
  60. package/dist/resources.js +782 -0
  61. package/dist/resources.js.map +1 -0
  62. package/dist/routes/domains.d.ts +4 -0
  63. package/dist/routes/domains.d.ts.map +1 -0
  64. package/dist/routes/domains.js +42 -0
  65. package/dist/routes/domains.js.map +1 -0
  66. package/dist/routes/grants.d.ts +8 -0
  67. package/dist/routes/grants.d.ts.map +1 -0
  68. package/dist/routes/grants.js +32 -0
  69. package/dist/routes/grants.js.map +1 -0
  70. package/dist/routes/public.d.ts +4 -0
  71. package/dist/routes/public.d.ts.map +1 -0
  72. package/dist/routes/public.js +62 -0
  73. package/dist/routes/public.js.map +1 -0
  74. package/dist/routes/resources.d.ts +8 -0
  75. package/dist/routes/resources.d.ts.map +1 -0
  76. package/dist/routes/resources.js +87 -0
  77. package/dist/routes/resources.js.map +1 -0
  78. package/dist/routes/shared.d.ts +77 -0
  79. package/dist/routes/shared.d.ts.map +1 -0
  80. package/dist/routes/shared.js +349 -0
  81. package/dist/routes/shared.js.map +1 -0
  82. package/dist/routes/threads.d.ts +8 -0
  83. package/dist/routes/threads.d.ts.map +1 -0
  84. package/dist/routes/threads.js +113 -0
  85. package/dist/routes/threads.js.map +1 -0
  86. package/dist/routes/trust.d.ts +4 -0
  87. package/dist/routes/trust.d.ts.map +1 -0
  88. package/dist/routes/trust.js +36 -0
  89. package/dist/routes/trust.js.map +1 -0
  90. package/dist/stream.d.ts +20 -0
  91. package/dist/stream.d.ts.map +1 -0
  92. package/dist/stream.js +141 -0
  93. package/dist/stream.js.map +1 -0
  94. package/dist/threads.d.ts +79 -0
  95. package/dist/threads.d.ts.map +1 -0
  96. package/dist/threads.js +480 -0
  97. package/dist/threads.js.map +1 -0
  98. package/dist/transport.d.ts +45 -0
  99. package/dist/transport.d.ts.map +1 -0
  100. package/dist/transport.js +175 -0
  101. package/dist/transport.js.map +1 -0
  102. package/dist/types.d.ts +809 -0
  103. package/dist/types.d.ts.map +1 -0
  104. package/dist/types.js +9 -0
  105. package/dist/types.js.map +1 -0
  106. package/package.json +16 -8
  107. package/src/client.ts +0 -1745
  108. package/src/core.ts +0 -114
  109. package/src/discovery.ts +0 -275
  110. package/src/domains.ts +0 -213
  111. package/src/history.ts +0 -139
  112. package/src/http.ts +0 -852
  113. package/src/keys.ts +0 -642
  114. package/src/memory.ts +0 -34
  115. package/src/messages.ts +0 -409
  116. package/src/node.ts +0 -63
  117. package/src/resolve.ts +0 -39
  118. package/src/resources.ts +0 -745
  119. package/src/threads.ts +0 -409
  120. package/src/transport.ts +0 -211
  121. package/src/types.ts +0 -428
package/dist/keys.js ADDED
@@ -0,0 +1,679 @@
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
2
+ import { canonical, fail, key, OnlyneError } from "./core.js";
3
+ import { trustPhase } from "./types.js";
4
+ export const SIGNATURE_HEADER = 'Onlyne-Sig';
5
+ export const CLOCK_SKEW_MS = 300_000;
6
+ /** How long a failed exchange send waits before the maintenance loop tries it again. */
7
+ export const EXCHANGE_RETRY_MS = 60_000;
8
+ /** How long a refused statement waits before the loop tries again (unverified refusal). */
9
+ export const REFUSED_RETRY_MS = 600_000;
10
+ function hash(input) { return createHash('sha256').update(input).digest('hex'); }
11
+ function privateKey(material) {
12
+ return createPrivateKey({ key: Buffer.from(material.private_key), format: 'der', type: 'pkcs8' });
13
+ }
14
+ function publicKey(encoded) {
15
+ try {
16
+ const raw = Buffer.from(encoded, 'base64');
17
+ if (raw.length !== 32 || raw.toString('base64') !== encoded)
18
+ fail('unauthenticated', 'Invalid Ed25519 public key.');
19
+ return createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: raw.toString('base64url') }, format: 'jwk' });
20
+ }
21
+ catch (error) {
22
+ if (error instanceof OnlyneError)
23
+ throw error;
24
+ return fail('unauthenticated', 'Invalid Ed25519 public key.');
25
+ }
26
+ }
27
+ function signatureValid(encoded, signature, payload) {
28
+ try {
29
+ return verify(null, Buffer.from(payload), publicKey(encoded), Buffer.from(signature, 'base64'));
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
35
+ export async function keyFingerprint(raw) { return hash(raw).slice(0, 16); }
36
+ export function generateKeyMaterial() {
37
+ const pair = generateKeyPairSync('ed25519');
38
+ return {
39
+ private_key: new Uint8Array(pair.privateKey.export({ format: 'der', type: 'pkcs8' })),
40
+ public_key: new Uint8Array(Buffer.from(pair.publicKey.export({ format: 'jwk' }).x, 'base64url')),
41
+ };
42
+ }
43
+ export class KeyService {
44
+ #material;
45
+ #ctx;
46
+ constructor(ctx) {
47
+ this.#ctx = ctx;
48
+ }
49
+ get enabled() { return this.#ctx.options.plugins.keys !== undefined; }
50
+ keyId() { return this.#ctx.node; }
51
+ freshTimestamp() {
52
+ return { timestamp: String(this.#ctx.now()), nonce: this.#ctx.id() };
53
+ }
54
+ async material() {
55
+ const hook = this.#ctx.options.plugins.keys;
56
+ if (hook === undefined)
57
+ fail('unsupported_delivery', 'The host supplied no identity key.');
58
+ this.#material ??= hook().then(material => {
59
+ const derived = Buffer.from(createPublicKey(privateKey(material)).export({ format: 'jwk' }).x, 'base64url');
60
+ if (!derived.equals(Buffer.from(material.public_key)))
61
+ fail('unauthenticated', 'The private and public keys do not match.');
62
+ return material;
63
+ });
64
+ return this.#material;
65
+ }
66
+ async describe() {
67
+ if (!this.enabled)
68
+ return undefined;
69
+ const material = await this.material();
70
+ return { node_id: this.#ctx.node, public_key: Buffer.from(material.public_key).toString('base64'), fingerprint: await keyFingerprint(material.public_key) };
71
+ }
72
+ async sign(payload) {
73
+ return sign(null, Buffer.from(payload), privateKey(await this.material())).toString('base64');
74
+ }
75
+ async generate(auth) {
76
+ this.#ctx.owner(auth.actor);
77
+ const hook = this.#ctx.options.plugins.keys;
78
+ if (hook?.replace === undefined)
79
+ fail('unsupported_delivery', 'The host does not support key replacement.');
80
+ if ((await this.#ctx.storage.transaction(tx => tx.scan('trust/'))).length > 0)
81
+ fail('conflict', 'Use trust.rotate to replace an established identity.');
82
+ await hook.replace(generateKeyMaterial());
83
+ this.#material = undefined;
84
+ return (await this.describe());
85
+ }
86
+ async replace(material) {
87
+ const hook = this.#ctx.options.plugins.keys;
88
+ if (hook?.replace === undefined)
89
+ fail('unsupported_delivery', 'The host does not support key replacement.');
90
+ try {
91
+ await hook.replace(material);
92
+ }
93
+ finally {
94
+ // The host may have swapped the stored key and then failed. Re-read it: a rotation
95
+ // window is only abandoned when the host still reports the old key.
96
+ this.#material = undefined;
97
+ }
98
+ }
99
+ }
100
+ function statementPayload(statement) {
101
+ const { signature: _signature, previous_signature: _previous, ...body } = statement;
102
+ return `onlyne-trust-v1\n${canonical(body)}`;
103
+ }
104
+ /**
105
+ * A refusal that ends the delivery obligation: the peer revoked this node, or its key
106
+ * changed without proof, so the same statement can never succeed. Every other failure
107
+ * keeps the obligation — a lost response and a failed verification are both retried by
108
+ * the next window (RFC 0017 §3.5).
109
+ */
110
+ function terminalRefusal(error) {
111
+ return error instanceof OnlyneError && (error.error.code === 'forbidden' || error.error.code === 'id_conflict');
112
+ }
113
+ /** Serializes key rotations inside one process; cross-process callers fail on the stored window. */
114
+ const RotationMutex = new class {
115
+ #tail = Promise.resolve();
116
+ locked(work) {
117
+ const run = () => work();
118
+ const result = this.#tail.then(run, run);
119
+ this.#tail = result.then(() => undefined, () => undefined);
120
+ return result;
121
+ }
122
+ }();
123
+ export function requestPayload(request, target, digest, timestamp, nonce) {
124
+ const url = new URL(request.url);
125
+ // Reverse proxies preserve the service path and query. The stable recipient replaces the external authority.
126
+ return canonical({ version: 'onlyne-request-v1', actor: request.headers.get('Onlyne-Actor'), target,
127
+ method: request.method, path: url.pathname + url.search, digest, timestamp, nonce,
128
+ content_type: request.headers.get('content-type'), idempotency_key: request.headers.get('idempotency-key'),
129
+ retry_until: request.headers.get('x-onlyne-retry-until'), last_event_id: request.headers.get('last-event-id') });
130
+ }
131
+ function responsePayload(target, nonce, status, digest) {
132
+ return canonical({ version: 'onlyne-response-v1', target, nonce, status, digest });
133
+ }
134
+ export function bodyDigest(body) { return hash(body); }
135
+ export class TrustService {
136
+ #ctx;
137
+ #keys;
138
+ constructor(ctx, keys) {
139
+ this.#ctx = ctx;
140
+ this.#keys = keys;
141
+ }
142
+ /** The client's loop, so a committed trust change re-arms the cadence; set by the node. */
143
+ #waker;
144
+ /** @internal Called by OnlyneNode so trust commits reach the maintenance loop. */
145
+ bindLoop(waker) { this.#waker = waker; }
146
+ owner(auth) { this.#ctx.owner(auth.actor); }
147
+ async list(auth) {
148
+ this.owner(auth);
149
+ return (await this.#ctx.storage.transaction(tx => tx.scan('trust/'))).map(row => row.value).sort((a, b) => a.node_id.localeCompare(b.node_id));
150
+ }
151
+ async entry(node) { return this.#ctx.storage.transaction(tx => tx.get(key('trust', node))); }
152
+ /**
153
+ * RFC 0017 §3: stage one of one exchange. Reads the peer's live `/keys`, so the
154
+ * fingerprint the host shows is one the peer proved. Committing is a separate
155
+ * explicit step on the returned session; nothing is recorded or sent here.
156
+ */
157
+ async begin(auth, node) {
158
+ this.owner(auth);
159
+ const peer = await this.inspect(node);
160
+ const observed = peer.fingerprint;
161
+ return {
162
+ node_id: peer.node_id, fingerprint: peer.fingerprint, public_key: peer.public_key,
163
+ base_url: await this.peerBase(node),
164
+ // The confirmation is sealed to the fingerprint this session displayed: a host that
165
+ // confirms a value it never showed, or a key that moved since, never reaches the wire.
166
+ confirm: async (fingerprint) => {
167
+ if (fingerprint !== observed)
168
+ fail('unauthenticated', 'The confirmed fingerprint is not the one begin displayed.', { fingerprint: observed });
169
+ return this.request(auth, node, fingerprint);
170
+ },
171
+ };
172
+ }
173
+ async identity() {
174
+ const identity = await this.#keys.describe();
175
+ if (identity === undefined)
176
+ fail('unsupported_delivery', 'Key exchange requires a local identity key.');
177
+ return identity;
178
+ }
179
+ address(value) {
180
+ let url;
181
+ try {
182
+ url = new URL(value);
183
+ }
184
+ catch {
185
+ return fail('invalid_request', 'Expected an absolute node service URL.');
186
+ }
187
+ if (url.username || url.password || url.search || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)))) {
188
+ fail('invalid_request', 'Node addresses require HTTPS; HTTP is limited to loopback.');
189
+ }
190
+ return url;
191
+ }
192
+ async inspect(node) {
193
+ const base = this.address(await this.peerBase(node));
194
+ const response = await fetch(new URL('./keys', base.href.replace(/\/?$/, '/')), { redirect: 'error', signal: AbortSignal.timeout(this.#ctx.options.wait_ms ?? 30_000) });
195
+ if (!response.ok)
196
+ fail('unavailable', `Key lookup answered HTTP ${response.status}.`);
197
+ const identity = await response.json();
198
+ if (typeof identity.public_key !== 'string')
199
+ fail('unauthenticated', 'The peer supplied no public key.');
200
+ publicKey(identity.public_key);
201
+ if (identity.node_id !== node || identity.fingerprint !== await keyFingerprint(Buffer.from(identity.public_key, 'base64')))
202
+ fail('unauthenticated', 'The peer identity or fingerprint is inconsistent.');
203
+ return identity;
204
+ }
205
+ /**
206
+ * The peer's address. Discovery answers for every peer this node has observed; a peer that
207
+ * introduced itself before discovery ever saw it has no record at all, and the address its own
208
+ * signed statement carried is then the only one there is. Without that fallback an inbound
209
+ * invitation can never be accepted: the confirmation resolves the peer before it may answer,
210
+ * so the row would sit at `invited` forever while both holders had approved.
211
+ */
212
+ async peerBase(node) {
213
+ try {
214
+ const target = await this.#ctx.options.plugins.resolve(node);
215
+ if (target.node_id !== node)
216
+ fail('unauthenticated', 'Resolver identity mismatch.');
217
+ return target.base_url;
218
+ }
219
+ catch (error) {
220
+ const recorded = (await this.entry(node))?.base_url;
221
+ if (!(error instanceof OnlyneError) || error.error.code !== 'not_found' || recorded === undefined || recorded === '')
222
+ throw error;
223
+ return recorded;
224
+ }
225
+ }
226
+ async statement(peer, approved, nonce, replyTo, old) {
227
+ const own = await this.identity();
228
+ const value = { node_id: this.#ctx.node, target: peer.node_id, public_key: own.public_key,
229
+ target_fingerprint: peer.fingerprint, base_url: this.#ctx.baseUrl, approved,
230
+ nonce, timestamp: this.#ctx.now(), signature: '' };
231
+ if (replyTo !== undefined)
232
+ value.reply_nonce = replyTo;
233
+ value.signature = await this.#keys.sign(statementPayload(value));
234
+ if (old !== undefined) {
235
+ const key = createPrivateKey({ key: Buffer.from(old), format: 'der', type: 'pkcs8' });
236
+ value.previous_signature = sign(null, Buffer.from(statementPayload(value)), key).toString('base64');
237
+ }
238
+ return value;
239
+ }
240
+ /**
241
+ * Sends one statement and settles the matching obligation only on a reply that proves
242
+ * it answered this attempt (RFC 0017 §3.5). A missing, unsigned or mismatched reply
243
+ * leaves the obligation owed, so the maintenance loop re-sends with a fresh nonce.
244
+ */
245
+ async send(peer, approved, attempt) {
246
+ const base = this.address(await this.peerBase(peer.node_id));
247
+ const statement = await this.statement(peer, approved, attempt.nonce, undefined, attempt.old);
248
+ const response = await fetch(new URL('./introductions', base.href.replace(/\/?$/, '/')), {
249
+ method: 'POST', headers: { 'content-type': 'application/json' }, redirect: 'error',
250
+ body: JSON.stringify(statement), signal: AbortSignal.timeout(this.#ctx.options.wait_ms ?? 30_000),
251
+ });
252
+ const result = await response.json();
253
+ if (!response.ok) {
254
+ // Keep the peer's own code: a revoked peer answers `forbidden` and a failed
255
+ // verification answers `unauthenticated`, and only the first ends the obligation.
256
+ const rejection = new OnlyneError(result.error?.code ?? 'unauthenticated', result.error?.message ?? 'The peer rejected the exchange.');
257
+ if (typeof result.error?.retryable === 'boolean')
258
+ rejection.error.retryable = result.error.retryable;
259
+ throw rejection;
260
+ }
261
+ if (result.node_id !== peer.node_id || result.public_key !== peer.public_key)
262
+ fail('unauthenticated', 'The exchange answered with a different identity.');
263
+ if (result.reply_nonce !== statement.nonce)
264
+ fail('unauthenticated', 'The exchange reply does not answer this introduction.');
265
+ const entry = await this.receive(result);
266
+ await this.settle(peer.node_id, statement.nonce);
267
+ return entry;
268
+ }
269
+ /** Clears the obligation an answered attempt created; a newer attempt keeps its own. */
270
+ settle(node, nonce) {
271
+ return this.#ctx.storage.transaction(async (tx) => {
272
+ const slot = key('trust-outbound', node);
273
+ const owed = await tx.get(slot);
274
+ if (owed === undefined || owed.nonce !== nonce)
275
+ return;
276
+ await tx.delete(slot);
277
+ if (owed.kind === 'rotation')
278
+ await this.closeWindow(tx);
279
+ });
280
+ }
281
+ /** The rotation window closes when its last statement is gone. */
282
+ async closeWindow(tx) {
283
+ const remaining = await tx.scan(key('trust-outbound') + '/');
284
+ if (remaining.every(row => row.value.kind !== 'rotation'))
285
+ await tx.delete(key('security', 'rotation'));
286
+ }
287
+ /**
288
+ * Rewrites or deletes one obligation only if the row is still the attempt this pass read.
289
+ * `rotate` can replace an approval obligation with a rotation one mid-pass; a whole-row
290
+ * write-back from the stale snapshot would drop the old-key proof it just stored.
291
+ */
292
+ async compareObligation(node, expected, next) {
293
+ return this.#ctx.storage.transaction(async (tx) => {
294
+ const slot = key('trust-outbound', node);
295
+ const current = await tx.get(slot);
296
+ if (current === undefined || current.nonce !== expected.nonce || current.kind !== expected.kind)
297
+ return false;
298
+ if (next === null)
299
+ await tx.delete(slot);
300
+ else
301
+ await tx.put(slot, next);
302
+ return true;
303
+ });
304
+ }
305
+ rotationWindow() {
306
+ return this.#ctx.storage.transaction(tx => tx.get(key('security', 'rotation')));
307
+ }
308
+ /** Backs off an obligation after a refusal this node cannot authenticate. */
309
+ park(node, nonce) {
310
+ return this.#ctx.storage.transaction(async (tx) => {
311
+ const slot = key('trust-outbound', node);
312
+ const owed = await tx.get(slot);
313
+ if (owed !== undefined && owed.nonce === nonce)
314
+ await tx.put(slot, { ...owed, blocked: this.#ctx.now() + REFUSED_RETRY_MS });
315
+ });
316
+ }
317
+ /** The swap never completed: drop the window and every statement prepared under it. */
318
+ abandonRotation() {
319
+ return this.#ctx.storage.transaction(async (tx) => {
320
+ await tx.delete(key('security', 'rotation'));
321
+ for (const row of await tx.scan(key('trust-outbound') + '/')) {
322
+ if (row.value.kind === 'rotation')
323
+ await tx.delete(row.key);
324
+ }
325
+ });
326
+ }
327
+ async request(auth, node, fingerprint) {
328
+ this.owner(auth);
329
+ const peer = await this.inspect(node);
330
+ if (peer.fingerprint !== fingerprint)
331
+ fail('unauthenticated', 'The peer key changed after confirmation.', { fingerprint: peer.fingerprint });
332
+ const own = await this.identity();
333
+ const nonce = this.#ctx.id();
334
+ const committed = await this.#ctx.storage.transaction(async (tx) => {
335
+ const slot = key('trust', node);
336
+ const before = await tx.get(slot);
337
+ if (before !== undefined && before.public_key !== peer.public_key && before.state !== 'revoked')
338
+ fail('id_conflict', 'A different peer key is already pinned.');
339
+ // Mutual approval is already known locally — our approval now, theirs from the stored
340
+ // statement — so the row is trusted at once. The send only confirms the transcript;
341
+ // a failed re-send must not demote a working signature relation either. Revocation
342
+ // cleared remote approval, so only a row that still carries it counts as mutual.
343
+ const mutual = before?.public_key === peer.public_key && (before.state === 'trusted' || (before.state === 'pending' && before.remote_approved));
344
+ const state = mutual ? 'trusted' : 'pending';
345
+ const entry = { ...peer, state, base_url: before?.base_url ?? '', local_approved: true, remote_approved: before?.remote_approved ?? false,
346
+ first_seen: before?.first_seen ?? new Date(this.#ctx.now()).toISOString(), last_checked: new Date(this.#ctx.now()).toISOString() };
347
+ await tx.put(slot, entry);
348
+ // A repeat confirmation of an established row is a refresh: timestamps move, nothing is
349
+ // owed and nothing is sent, so a host that renders twice cannot spam the peer. While the
350
+ // relation is still one-sided, the same call means "try again" and does owe a statement
351
+ // (RFC 0017 §3.3).
352
+ const fresh = before === undefined || before.state !== 'trusted' || !before.local_approved || before.public_key !== peer.public_key;
353
+ // An unacknowledged rotation owns this peer's delivery: its statement carries the old-key
354
+ // proof, and overwriting it with a plain approval would drop that proof for good. The
355
+ // confirmation still refreshes the row; the rotation statement also carries the approval.
356
+ const existing = await tx.get(key('trust-outbound', node));
357
+ const rotating = existing?.kind === 'rotation';
358
+ if (fresh && !rotating) {
359
+ // Committed with the row, so a crash between commit and send still owes the delivery;
360
+ // only the peer's answering reply settles it.
361
+ await tx.put(key('trust-outbound', node), { node_id: node, kind: 'approval', nonce, fingerprint: own.fingerprint, at: this.#ctx.now() });
362
+ }
363
+ return { before, entry, fresh, rotating };
364
+ });
365
+ // Accepting an invitation and initiating an exchange are different host events.
366
+ const cause = committed.before?.remote_approved === true && committed.before.local_approved === false ? 'local-accept' : 'local-request';
367
+ this.notify(cause, committed.entry, committed.before);
368
+ if (!committed.fresh || committed.rotating)
369
+ return committed.entry;
370
+ return this.send(peer, true, { nonce }).catch(async (error) => {
371
+ if (terminalRefusal(error))
372
+ await this.park(node, nonce);
373
+ throw error;
374
+ });
375
+ }
376
+ notify(cause, entry, previous) {
377
+ try {
378
+ this.#waker?.wakeLoop();
379
+ }
380
+ catch { /* a loop fault never blocks the commit */ }
381
+ this.#ctx.options.plugins.runtime?.notify?.({ event: 'trust', cause, phase: trustPhase(entry), entry, ...(previous === undefined ? {} : { previous }) });
382
+ }
383
+ async accept(auth, node, fingerprint) { return this.request(auth, node, fingerprint); }
384
+ async receive(value) {
385
+ const own = await this.identity();
386
+ if (value === null || typeof value !== 'object' || typeof value.node_id !== 'string' || value.node_id === this.#ctx.node
387
+ || value.target !== this.#ctx.node || value.target_fingerprint !== own.fingerprint
388
+ || typeof value.nonce !== 'string' || value.nonce.length < 1 || value.nonce.length > 128
389
+ || typeof value.signature !== 'string' || typeof value.public_key !== 'string' || typeof value.approved !== 'boolean'
390
+ || !Number.isSafeInteger(value.timestamp) || Math.abs(this.#ctx.now() - value.timestamp) > CLOCK_SKEW_MS)
391
+ fail('unauthenticated', 'Invalid or expired key exchange transcript.');
392
+ this.address(value.base_url);
393
+ if (!signatureValid(value.public_key, value.signature, statementPayload(value)))
394
+ fail('unauthenticated', 'Invalid key exchange signature.');
395
+ const fingerprint = await keyFingerprint(Buffer.from(value.public_key, 'base64'));
396
+ const seed = this.#ctx.options.plugins.trust_seeds?.find(row => row.node_id === value.node_id);
397
+ if (seed !== undefined && seed.fingerprint !== fingerprint)
398
+ fail('unauthenticated', 'The peer differs from its pinned fingerprint.', { fingerprint });
399
+ const settled = await this.#ctx.storage.transaction(async (tx) => {
400
+ await this.consumeNonce(tx, value.node_id, `exchange:${value.nonce}`, value.timestamp);
401
+ const slot = key('trust', value.node_id);
402
+ const previous = await tx.get(slot);
403
+ if (previous?.state === 'revoked')
404
+ fail('forbidden', 'This peer was revoked. Local approval is required.');
405
+ // RFC 0017 §3.3: with no recorded relationship there is nothing this statement could
406
+ // answer, so a first-contact refusal is not a fact about either side.
407
+ if (previous === undefined && !value.approved)
408
+ fail('invalid_request', 'A first statement must carry the peer approval it claims.');
409
+ const changed = previous !== undefined && previous.public_key !== value.public_key;
410
+ if (changed && (value.previous_signature === undefined || !signatureValid(previous.public_key, value.previous_signature, statementPayload(value)))) {
411
+ // No local repair exists: this node cannot learn which key the peer still pins, and the
412
+ // old key is gone once its rotation window closed. Only revoke + re-confirm recovers
413
+ // (RFC 0017 §2).
414
+ fail('id_conflict', 'The peer key changed without proof from the previous key; revoke this peer and confirm again.', { fingerprint });
415
+ }
416
+ // A proven rotation keeps this node's approval: the old-key proof says the identity is
417
+ // continuous, so the user does not re-approve (RFC 0017 §3.3). An unproven key change
418
+ // never reaches this line — it fails as `id_conflict` above.
419
+ const local = seed !== undefined || previous?.local_approved === true;
420
+ const entry = { node_id: value.node_id, public_key: value.public_key, fingerprint,
421
+ state: local && value.approved ? 'trusted' : 'pending', local_approved: local, remote_approved: value.approved,
422
+ base_url: value.base_url, first_seen: previous?.first_seen ?? new Date(this.#ctx.now()).toISOString(), last_checked: new Date(this.#ctx.now()).toISOString(),
423
+ ...(changed ? { history: [...(previous.history ?? []), previous.fingerprint] } : previous?.history === undefined ? {} : { history: previous.history }) };
424
+ await tx.put(slot, entry);
425
+ // The peer spoke: whatever parked this node's own obligation may no longer hold.
426
+ const owedSlot = key('trust-outbound', value.node_id);
427
+ const owed = await tx.get(owedSlot);
428
+ if (owed?.blocked !== undefined) {
429
+ const { blocked: _blocked, ...rest } = owed;
430
+ await tx.put(owedSlot, rest);
431
+ }
432
+ return { entry, previous };
433
+ });
434
+ this.notify('peer-statement', settled.entry, settled.previous);
435
+ return settled.entry;
436
+ }
437
+ async introduce(value) {
438
+ const entry = await this.receive(value);
439
+ // The reply echoes the request nonce: that is what lets the sender settle its obligation.
440
+ // While this node's own rotation is unacknowledged, every statement carries the old-key
441
+ // proof, so a peer still pinning the previous key can accept this reply (RFC 0017 §2).
442
+ const owed = await this.#ctx.storage.transaction(tx => tx.get(key('trust-outbound', value.node_id)));
443
+ const old = owed?.previous === undefined ? undefined : new Uint8Array(owed.previous);
444
+ return this.statement(entry, entry.local_approved, this.#ctx.id(), value.nonce, old);
445
+ }
446
+ async revoke(auth, node) {
447
+ this.owner(auth);
448
+ const previous = await this.#ctx.storage.transaction(async (tx) => {
449
+ const slot = key('trust', node);
450
+ const previous = await tx.get(slot);
451
+ if (previous !== undefined)
452
+ await tx.put(slot, { ...previous, state: 'revoked', public_key: '', local_approved: false, remote_approved: false });
453
+ // A revoked peer owes nothing: drop the statement, and close a rotation window it was
454
+ // the last member of, or every later rotation would be refused.
455
+ const owedSlot = key('trust-outbound', node);
456
+ const owed = await tx.get(owedSlot);
457
+ if (owed !== undefined) {
458
+ await tx.delete(owedSlot);
459
+ if (owed.kind === 'rotation')
460
+ await this.closeWindow(tx);
461
+ }
462
+ return previous;
463
+ });
464
+ if (previous !== undefined)
465
+ this.notify('local-revoke', { ...previous, state: 'revoked', public_key: '', local_approved: false, remote_approved: false }, previous);
466
+ }
467
+ /**
468
+ * RFC 0017 §3.5: re-drives every owed statement whose reply has not arrived. The stage
469
+ * of the row is irrelevant — an `exchanged` row still owes the delivery when its reply
470
+ * was lost. Each attempt uses a fresh nonce, so the peer's nonce cache cannot mistake
471
+ * a re-send for a replay. The maintenance loop calls this; the host never does.
472
+ */
473
+ async redrive(signal) {
474
+ if (!this.#keys.enabled)
475
+ return;
476
+ const owed = await this.#ctx.storage.transaction(tx => tx.scan(key('trust-outbound') + '/'));
477
+ for (const row of owed) {
478
+ if (signal?.aborted)
479
+ return;
480
+ const obligation = row.value;
481
+ const entry = await this.entry(obligation.node_id);
482
+ // A revoked peer or a deleted row ends the obligation; a local revoke is the only
483
+ // thing that may drop it besides the peer's own answer.
484
+ if (entry === undefined || entry.state === 'revoked') {
485
+ if (await this.compareObligation(obligation.node_id, obligation, null) && obligation.kind === 'rotation') {
486
+ await this.#ctx.storage.transaction(tx => this.closeWindow(tx));
487
+ }
488
+ continue;
489
+ }
490
+ const own = await this.identity();
491
+ if (obligation.kind === 'rotation') {
492
+ const window = await this.rotationWindow();
493
+ if (window === undefined) {
494
+ // A rotation statement without its window is an orphan: nothing will ever prove it.
495
+ await this.compareObligation(obligation.node_id, obligation, null);
496
+ continue;
497
+ }
498
+ if (own.fingerprint !== window.new_fingerprint) {
499
+ // The host still reports the old key. If this process is the one swapping, the host
500
+ // hook is simply slow: wait for it, however long it takes. Otherwise the process
501
+ // restarted mid-swap and the swap never completed, so the preparation is dead weight
502
+ // and the peers still hold a key this node still uses.
503
+ if (this.#rotating)
504
+ continue;
505
+ await this.abandonRotation();
506
+ continue;
507
+ }
508
+ }
509
+ else if (obligation.fingerprint !== own.fingerprint) {
510
+ // An approval statement signed by a key this node no longer uses; the rotation
511
+ // statement that replaced it carries the same approval.
512
+ await this.compareObligation(obligation.node_id, obligation, null);
513
+ continue;
514
+ }
515
+ // An unverified refusal delays the next attempt; it never ends the obligation.
516
+ let current = obligation;
517
+ if (current.blocked !== undefined) {
518
+ if (this.#ctx.now() < current.blocked)
519
+ continue;
520
+ const { blocked: _blocked, ...rest } = current;
521
+ if (!await this.compareObligation(current.node_id, current, rest))
522
+ continue;
523
+ current = rest;
524
+ }
525
+ if (this.#ctx.now() - current.at < EXCHANGE_RETRY_MS)
526
+ continue;
527
+ const nonce = this.#ctx.id();
528
+ const old = current.previous === undefined ? undefined : new Uint8Array(current.previous);
529
+ // Address the key the peer publishes now. A statement's `target_fingerprint` is the key
530
+ // it is written for; reusing the stored one would make two nodes that rotated at the
531
+ // same time reject each other forever (RFC 0017 §3.5).
532
+ let live;
533
+ try {
534
+ live = await this.inspect(entry.node_id);
535
+ }
536
+ catch {
537
+ continue; // unreachable this pass; the obligation stays
538
+ }
539
+ if (!await this.compareObligation(current.node_id, current, { ...current, nonce, at: this.#ctx.now() }))
540
+ continue;
541
+ try {
542
+ await this.send(live, entry.local_approved, { nonce, ...(old === undefined ? {} : { old }) });
543
+ }
544
+ catch (error) {
545
+ // An unverified error never destroys the obligation: it is parked, so a refusal the
546
+ // peer did not actually send cannot lose the approval. A transport failure or a failed
547
+ // verification simply keeps it for the next window.
548
+ if (terminalRefusal(error))
549
+ await this.park(entry.node_id, nonce);
550
+ }
551
+ }
552
+ }
553
+ async rotate(auth) {
554
+ this.owner(auth);
555
+ // The whole operation runs under one process-wide lock. The guard in the transaction
556
+ // alone cannot prevent a second rotation that starts while the first is still awaiting
557
+ // `keys.replace`: it would pass a window-free guard and swap again with a proof from a
558
+ // key generation the peers have already moved past.
559
+ return RotationMutex.locked(() => this.#rotate(auth));
560
+ }
561
+ /** True while this process is inside `rotate`; tells a slow hook from a crash leftover. */
562
+ #rotating = false;
563
+ async #rotate(auth) {
564
+ const window = await this.rotationWindow();
565
+ if (window !== undefined)
566
+ fail('conflict', 'A rotation is still unacknowledged; a second swap would skip the proof a peer needs.');
567
+ const previous = await this.#keys.material();
568
+ // Every non-revoked relation where the peer may already hold this node's old key owes the
569
+ // rotation statement: our own approval (requested) or the peer's recorded statement
570
+ // (invited) both mean the old key is pinned somewhere.
571
+ const peers = (await this.list(auth)).filter(row => row.state !== 'revoked' && (row.local_approved || row.remote_approved));
572
+ const next = generateKeyMaterial();
573
+ const fingerprint = await keyFingerprint(next.public_key);
574
+ // The window and every obligation are written in one transaction, so the maintenance loop
575
+ // can tell a swap in progress from a completed one. Each obligation carries the old private
576
+ // key, so an unreachable peer is re-driven later and a restart keeps the proof.
577
+ await this.#ctx.storage.transaction(async (tx) => {
578
+ if (await tx.get(key('security', 'rotation')) !== undefined) {
579
+ fail('conflict', 'A rotation is still unacknowledged; a second swap would skip the proof a peer needs.');
580
+ }
581
+ // No peer to tell: swap the key without opening a window that nothing could close.
582
+ if (peers.length === 0)
583
+ return;
584
+ for (const peer of peers) {
585
+ await tx.put(key('trust-outbound', peer.node_id), {
586
+ node_id: peer.node_id, kind: 'rotation', nonce: this.#ctx.id(), fingerprint, at: this.#ctx.now(), previous: Array.from(previous.private_key),
587
+ });
588
+ }
589
+ await tx.put(key('security', 'rotation'), { new_fingerprint: fingerprint, at: this.#ctx.now() });
590
+ });
591
+ this.#rotating = true;
592
+ try {
593
+ await this.#keys.replace(next);
594
+ }
595
+ finally {
596
+ this.#rotating = false;
597
+ }
598
+ const old = previous.private_key;
599
+ for (const peer of peers) {
600
+ const obligation = await this.#ctx.storage.transaction(tx => tx.get(key('trust-outbound', peer.node_id)));
601
+ if (obligation === undefined)
602
+ continue;
603
+ let live;
604
+ try {
605
+ live = await this.inspect(peer.node_id);
606
+ }
607
+ catch {
608
+ continue; // the obligation carries the old key and is re-driven later
609
+ }
610
+ try {
611
+ // The statement asserts what is true: a peer this node has not approved yet receives
612
+ // `approved=false` with the rotation proof (RFC 0017 §3.3).
613
+ await this.send(live, peer.local_approved, { nonce: obligation.nonce, old });
614
+ }
615
+ catch (error) {
616
+ if (terminalRefusal(error))
617
+ await this.park(peer.node_id, obligation.nonce);
618
+ }
619
+ }
620
+ const identity = await this.identity();
621
+ for (const peer of peers)
622
+ this.notify('rotation', (await this.entry(peer.node_id)) ?? peer, peer);
623
+ return identity;
624
+ }
625
+ async consumeNonce(tx, actor, nonce, timestamp) {
626
+ const slot = key('nonce', actor, nonce);
627
+ if (await tx.get(slot) !== undefined)
628
+ fail('unauthenticated', 'This nonce was already used.');
629
+ for (const row of await tx.scan('nonce/'))
630
+ if (row.value < this.#ctx.now() - CLOCK_SKEW_MS)
631
+ await tx.delete(row.key);
632
+ await tx.put(slot, timestamp);
633
+ }
634
+ async signRequest(request, target, raw) {
635
+ const { timestamp, nonce } = this.#keys.freshTimestamp();
636
+ request.headers.set('Onlyne-Actor', this.#ctx.node);
637
+ const signature = await this.#keys.sign(requestPayload(request, target, hash(raw), timestamp, nonce));
638
+ request.headers.set(SIGNATURE_HEADER, `v1 ${encodeURIComponent(this.#ctx.node)} ${timestamp} ${nonce} ${signature}`);
639
+ }
640
+ async authenticate(request, digest) {
641
+ const identity = await this.identity();
642
+ const details = { ...identity };
643
+ const parts = request.headers.get(SIGNATURE_HEADER)?.split(' ');
644
+ if (parts?.length !== 5 || parts[0] !== 'v1')
645
+ fail('unauthenticated', 'A node signature is required.', details);
646
+ let actor;
647
+ try {
648
+ actor = decodeURIComponent(parts[1]);
649
+ }
650
+ catch {
651
+ return fail('unauthenticated', 'Invalid signing identity.', details);
652
+ }
653
+ const timestamp = parts[2], nonce = parts[3], signature = parts[4];
654
+ if (!Number.isSafeInteger(Number(timestamp)) || Math.abs(this.#ctx.now() - Number(timestamp)) > CLOCK_SKEW_MS || !/^[a-zA-Z0-9-]{1,128}$/.test(nonce))
655
+ fail('unauthenticated', 'Invalid signature timestamp or nonce.', details);
656
+ const entry = await this.entry(actor);
657
+ if (entry?.state !== 'trusted' || actor === this.#ctx.node || request.headers.get('Onlyne-Actor') !== actor)
658
+ fail('unauthenticated', 'The caller is not a trusted peer.', details);
659
+ if (!signatureValid(entry.public_key, signature, requestPayload(request, this.#ctx.node, digest, timestamp, nonce)))
660
+ fail('unauthenticated', 'Invalid request signature.', details);
661
+ await this.#ctx.storage.transaction(async (tx) => {
662
+ if ((await tx.get(key('trust', actor)))?.state !== 'trusted')
663
+ fail('unauthenticated', 'The caller was revoked.', details);
664
+ await this.consumeNonce(tx, actor, nonce, Number(timestamp));
665
+ });
666
+ return { actor };
667
+ }
668
+ async signResponse(request, response, body) {
669
+ const nonce = request.headers.get(SIGNATURE_HEADER)?.split(' ')[3] ?? '';
670
+ response.headers.set('Onlyne-Response-Sig', await this.#keys.sign(responsePayload(this.#ctx.node, nonce, response.status, hash(body))));
671
+ }
672
+ async verifyResponse(target, request, response, body) {
673
+ const peer = await this.entry(target);
674
+ const nonce = request.headers.get(SIGNATURE_HEADER)?.split(' ')[3] ?? '';
675
+ if (peer?.state !== 'trusted' || !signatureValid(peer.public_key, response.headers.get('Onlyne-Response-Sig') ?? '', responsePayload(target, nonce, response.status, hash(body))))
676
+ fail('unauthenticated', 'The response does not prove the expected node identity.');
677
+ }
678
+ }
679
+ //# sourceMappingURL=keys.js.map