@onlyne/lib 3.0.0 → 3.1.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/src/keys.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
2
- import { canonical, fail, key, OnlyneError } from './core.js';
3
- import type { Context } from './core.js';
4
- import type { Auth, ErrorCode, KeyIdentity, KeyMaterial, KeyExchange, NodeId, NotifyEvent, TrustEntry, TrustStatement } from './types.js';
5
- import { trustPhase } from './types.js';
2
+ import { canonical, fail, key, OnlyneError } from './core.ts';
3
+ import type { Context } from './core.ts';
4
+ import type { Auth, ErrorCode, KeyIdentity, KeyMaterial, KeyExchange, NodeId, NotifyEvent, TrustEntry, TrustStatement } from './types.ts';
5
+ import { trustPhase } from './types.ts';
6
6
 
7
7
  export const SIGNATURE_HEADER = 'Onlyne-Sig';
8
8
  export const CLOCK_SKEW_MS = 300_000;
@@ -41,14 +41,18 @@ export function generateKeyMaterial(): KeyMaterial {
41
41
 
42
42
  export class KeyService {
43
43
  #material?: Promise<KeyMaterial>;
44
- constructor(private readonly ctx: Context) {}
45
- get enabled(): boolean { return this.ctx.options.plugins.keys !== undefined; }
46
- keyId(): NodeId { return this.ctx.node; }
44
+ readonly #ctx: Context;
45
+
46
+ constructor(ctx: Context) {
47
+ this.#ctx = ctx;
48
+ }
49
+ get enabled(): boolean { return this.#ctx.options.plugins.keys !== undefined; }
50
+ keyId(): NodeId { return this.#ctx.node; }
47
51
  freshTimestamp(): { timestamp: string; nonce: string } {
48
- return { timestamp: String(this.ctx.now()), nonce: this.ctx.id() };
52
+ return { timestamp: String(this.#ctx.now()), nonce: this.#ctx.id() };
49
53
  }
50
54
  async material(): Promise<KeyMaterial> {
51
- const hook = this.ctx.options.plugins.keys;
55
+ const hook = this.#ctx.options.plugins.keys;
52
56
  if (hook === undefined) fail('unsupported_delivery', 'The host supplied no identity key.');
53
57
  this.#material ??= hook().then(material => {
54
58
  const derived = Buffer.from(createPublicKey(privateKey(material)).export({ format: 'jwk' }).x!, 'base64url');
@@ -60,22 +64,22 @@ export class KeyService {
60
64
  async describe(): Promise<KeyIdentity | undefined> {
61
65
  if (!this.enabled) return undefined;
62
66
  const material = await this.material();
63
- return { node_id: this.ctx.node, public_key: Buffer.from(material.public_key).toString('base64'), fingerprint: await keyFingerprint(material.public_key) };
67
+ return { node_id: this.#ctx.node, public_key: Buffer.from(material.public_key).toString('base64'), fingerprint: await keyFingerprint(material.public_key) };
64
68
  }
65
69
  async sign(payload: string): Promise<string> {
66
70
  return sign(null, Buffer.from(payload), privateKey(await this.material())).toString('base64');
67
71
  }
68
72
  async generate(auth: Auth): Promise<KeyIdentity> {
69
- this.ctx.owner(auth.actor);
70
- const hook = this.ctx.options.plugins.keys;
73
+ this.#ctx.owner(auth.actor);
74
+ const hook = this.#ctx.options.plugins.keys;
71
75
  if (hook?.replace === undefined) fail('unsupported_delivery', 'The host does not support key replacement.');
72
- if ((await this.ctx.storage.transaction(tx => tx.scan<TrustEntry>('trust/'))).length > 0) fail('conflict', 'Use trust.rotate to replace an established identity.');
76
+ if ((await this.#ctx.storage.transaction(tx => tx.scan<TrustEntry>('trust/'))).length > 0) fail('conflict', 'Use trust.rotate to replace an established identity.');
73
77
  await hook.replace(generateKeyMaterial());
74
78
  this.#material = undefined;
75
79
  return (await this.describe())!;
76
80
  }
77
81
  async replace(material: KeyMaterial): Promise<void> {
78
- const hook = this.ctx.options.plugins.keys;
82
+ const hook = this.#ctx.options.plugins.keys;
79
83
  if (hook?.replace === undefined) fail('unsupported_delivery', 'The host does not support key replacement.');
80
84
  try {
81
85
  await hook.replace(material);
@@ -163,17 +167,23 @@ function responsePayload(target: NodeId, nonce: string, status: number, digest:
163
167
  export function bodyDigest(body: Uint8Array | string): string { return hash(body); }
164
168
 
165
169
  export class TrustService {
166
- constructor(private readonly ctx: Context, private readonly keys: KeyService) {}
170
+ readonly #ctx: Context;
171
+ readonly #keys: KeyService;
172
+
173
+ constructor(ctx: Context, keys: KeyService) {
174
+ this.#ctx = ctx;
175
+ this.#keys = keys;
176
+ }
167
177
  /** The client's loop, so a committed trust change re-arms the cadence; set by the node. */
168
178
  #waker: { wakeLoop(): void } | undefined;
169
179
  /** @internal Called by OnlyneNode so trust commits reach the maintenance loop. */
170
180
  bindLoop(waker: { wakeLoop(): void }): void { this.#waker = waker; }
171
- private owner(auth: Auth): void { this.ctx.owner(auth.actor); }
181
+ private owner(auth: Auth): void { this.#ctx.owner(auth.actor); }
172
182
  async list(auth: Auth): Promise<TrustEntry[]> {
173
183
  this.owner(auth);
174
- return (await this.ctx.storage.transaction(tx => tx.scan<TrustEntry>('trust/'))).map(row => row.value).sort((a, b) => a.node_id.localeCompare(b.node_id));
184
+ return (await this.#ctx.storage.transaction(tx => tx.scan<TrustEntry>('trust/'))).map(row => row.value).sort((a, b) => a.node_id.localeCompare(b.node_id));
175
185
  }
176
- async entry(node: NodeId): Promise<TrustEntry | undefined> { return this.ctx.storage.transaction(tx => tx.get<TrustEntry>(key('trust', node))); }
186
+ async entry(node: NodeId): Promise<TrustEntry | undefined> { return this.#ctx.storage.transaction(tx => tx.get<TrustEntry>(key('trust', node))); }
177
187
  /**
178
188
  * RFC 0017 §3: stage one of one exchange. Reads the peer's live `/keys`, so the
179
189
  * fingerprint the host shows is one the peer proved. Committing is a separate
@@ -195,7 +205,7 @@ export class TrustService {
195
205
  };
196
206
  }
197
207
  private async identity(): Promise<KeyIdentity> {
198
- const identity = await this.keys.describe();
208
+ const identity = await this.#keys.describe();
199
209
  if (identity === undefined) fail('unsupported_delivery', 'Key exchange requires a local identity key.');
200
210
  return identity;
201
211
  }
@@ -209,7 +219,7 @@ export class TrustService {
209
219
  }
210
220
  async inspect(node: NodeId): Promise<KeyIdentity> {
211
221
  const base = this.address(await this.peerBase(node));
212
- const response = await fetch(new URL('./keys', base.href.replace(/\/?$/, '/')), { redirect: 'error', signal: AbortSignal.timeout(this.ctx.options.wait_ms ?? 30_000) });
222
+ const response = await fetch(new URL('./keys', base.href.replace(/\/?$/, '/')), { redirect: 'error', signal: AbortSignal.timeout(this.#ctx.options.wait_ms ?? 30_000) });
213
223
  if (!response.ok) fail('unavailable', `Key lookup answered HTTP ${response.status}.`);
214
224
  const identity = await response.json() as KeyIdentity;
215
225
  if (typeof identity.public_key !== 'string') fail('unauthenticated', 'The peer supplied no public key.');
@@ -226,7 +236,7 @@ export class TrustService {
226
236
  */
227
237
  private async peerBase(node: NodeId): Promise<string> {
228
238
  try {
229
- const target = await this.ctx.options.plugins.resolve(node);
239
+ const target = await this.#ctx.options.plugins.resolve(node);
230
240
  if (target.node_id !== node) fail('unauthenticated', 'Resolver identity mismatch.');
231
241
  return target.base_url;
232
242
  } catch (error) {
@@ -237,11 +247,11 @@ export class TrustService {
237
247
  }
238
248
  private async statement(peer: KeyIdentity, approved: boolean, nonce: string, replyTo?: string, old?: Uint8Array): Promise<TrustStatement> {
239
249
  const own = await this.identity();
240
- const value: TrustStatement = { node_id: this.ctx.node, target: peer.node_id, public_key: own.public_key,
241
- target_fingerprint: peer.fingerprint, base_url: this.ctx.baseUrl, approved,
242
- nonce, timestamp: this.ctx.now(), signature: '' };
250
+ const value: TrustStatement = { node_id: this.#ctx.node, target: peer.node_id, public_key: own.public_key,
251
+ target_fingerprint: peer.fingerprint, base_url: this.#ctx.baseUrl, approved,
252
+ nonce, timestamp: this.#ctx.now(), signature: '' };
243
253
  if (replyTo !== undefined) value.reply_nonce = replyTo;
244
- value.signature = await this.keys.sign(statementPayload(value));
254
+ value.signature = await this.#keys.sign(statementPayload(value));
245
255
  if (old !== undefined) {
246
256
  const key = createPrivateKey({ key: Buffer.from(old), format: 'der', type: 'pkcs8' });
247
257
  value.previous_signature = sign(null, Buffer.from(statementPayload(value)), key).toString('base64');
@@ -258,7 +268,7 @@ export class TrustService {
258
268
  const statement = await this.statement(peer, approved, attempt.nonce, undefined, attempt.old);
259
269
  const response = await fetch(new URL('./introductions', base.href.replace(/\/?$/, '/')), {
260
270
  method: 'POST', headers: { 'content-type': 'application/json' }, redirect: 'error',
261
- body: JSON.stringify(statement), signal: AbortSignal.timeout(this.ctx.options.wait_ms ?? 30_000),
271
+ body: JSON.stringify(statement), signal: AbortSignal.timeout(this.#ctx.options.wait_ms ?? 30_000),
262
272
  });
263
273
  const result = await response.json() as TrustStatement & { error?: { code: ErrorCode; message: string; retryable?: boolean } };
264
274
  if (!response.ok) {
@@ -276,7 +286,7 @@ export class TrustService {
276
286
  }
277
287
  /** Clears the obligation an answered attempt created; a newer attempt keeps its own. */
278
288
  private settle(node: NodeId, nonce: string): Promise<void> {
279
- return this.ctx.storage.transaction(async tx => {
289
+ return this.#ctx.storage.transaction(async tx => {
280
290
  const slot = key('trust-outbound', node);
281
291
  const owed = await tx.get<TrustOutbound>(slot);
282
292
  if (owed === undefined || owed.nonce !== nonce) return;
@@ -285,7 +295,7 @@ export class TrustService {
285
295
  });
286
296
  }
287
297
  /** The rotation window closes when its last statement is gone. */
288
- private async closeWindow(tx: import('./types.js').Transaction): Promise<void> {
298
+ private async closeWindow(tx: import('./types.ts').Transaction): Promise<void> {
289
299
  const remaining = await tx.scan<TrustOutbound>(key('trust-outbound') + '/');
290
300
  if (remaining.every(row => row.value.kind !== 'rotation')) await tx.delete(key('security', 'rotation'));
291
301
  }
@@ -295,7 +305,7 @@ export class TrustService {
295
305
  * write-back from the stale snapshot would drop the old-key proof it just stored.
296
306
  */
297
307
  private async compareObligation(node: NodeId, expected: TrustOutbound, next: TrustOutbound | null): Promise<boolean> {
298
- return this.ctx.storage.transaction(async tx => {
308
+ return this.#ctx.storage.transaction(async tx => {
299
309
  const slot = key('trust-outbound', node);
300
310
  const current = await tx.get<TrustOutbound>(slot);
301
311
  if (current === undefined || current.nonce !== expected.nonce || current.kind !== expected.kind) return false;
@@ -305,19 +315,19 @@ export class TrustService {
305
315
  });
306
316
  }
307
317
  private rotationWindow(): Promise<RotationWindow | undefined> {
308
- return this.ctx.storage.transaction(tx => tx.get<RotationWindow>(key('security', 'rotation')));
318
+ return this.#ctx.storage.transaction(tx => tx.get<RotationWindow>(key('security', 'rotation')));
309
319
  }
310
320
  /** Backs off an obligation after a refusal this node cannot authenticate. */
311
321
  private park(node: NodeId, nonce: string): Promise<void> {
312
- return this.ctx.storage.transaction(async tx => {
322
+ return this.#ctx.storage.transaction(async tx => {
313
323
  const slot = key('trust-outbound', node);
314
324
  const owed = await tx.get<TrustOutbound>(slot);
315
- if (owed !== undefined && owed.nonce === nonce) await tx.put(slot, { ...owed, blocked: this.ctx.now() + REFUSED_RETRY_MS });
325
+ if (owed !== undefined && owed.nonce === nonce) await tx.put(slot, { ...owed, blocked: this.#ctx.now() + REFUSED_RETRY_MS });
316
326
  });
317
327
  }
318
328
  /** The swap never completed: drop the window and every statement prepared under it. */
319
329
  private abandonRotation(): Promise<void> {
320
- return this.ctx.storage.transaction(async tx => {
330
+ return this.#ctx.storage.transaction(async tx => {
321
331
  await tx.delete(key('security', 'rotation'));
322
332
  for (const row of await tx.scan<TrustOutbound>(key('trust-outbound') + '/')) {
323
333
  if (row.value.kind === 'rotation') await tx.delete(row.key);
@@ -329,8 +339,8 @@ export class TrustService {
329
339
  const peer = await this.inspect(node);
330
340
  if (peer.fingerprint !== fingerprint) fail('unauthenticated', 'The peer key changed after confirmation.', { fingerprint: peer.fingerprint });
331
341
  const own = await this.identity();
332
- const nonce = this.ctx.id();
333
- const committed = await this.ctx.storage.transaction(async tx => {
342
+ const nonce = this.#ctx.id();
343
+ const committed = await this.#ctx.storage.transaction(async tx => {
334
344
  const slot = key('trust', node);
335
345
  const before = await tx.get<TrustEntry>(slot);
336
346
  if (before !== undefined && before.public_key !== peer.public_key && before.state !== 'revoked') fail('id_conflict', 'A different peer key is already pinned.');
@@ -341,7 +351,7 @@ export class TrustService {
341
351
  const mutual = before?.public_key === peer.public_key && (before.state === 'trusted' || (before.state === 'pending' && before.remote_approved));
342
352
  const state: TrustEntry['state'] = mutual ? 'trusted' : 'pending';
343
353
  const entry: TrustEntry = { ...peer, state, base_url: before?.base_url ?? '', local_approved: true, remote_approved: before?.remote_approved ?? false,
344
- first_seen: before?.first_seen ?? new Date(this.ctx.now()).toISOString(), last_checked: new Date(this.ctx.now()).toISOString() };
354
+ first_seen: before?.first_seen ?? new Date(this.#ctx.now()).toISOString(), last_checked: new Date(this.#ctx.now()).toISOString() };
345
355
  await tx.put(slot, entry);
346
356
  // A repeat confirmation of an established row is a refresh: timestamps move, nothing is
347
357
  // owed and nothing is sent, so a host that renders twice cannot spam the peer. While the
@@ -356,7 +366,7 @@ export class TrustService {
356
366
  if (fresh && !rotating) {
357
367
  // Committed with the row, so a crash between commit and send still owes the delivery;
358
368
  // only the peer's answering reply settles it.
359
- await tx.put(key('trust-outbound', node), { node_id: node, kind: 'approval', nonce, fingerprint: own.fingerprint, at: this.ctx.now() } satisfies TrustOutbound);
369
+ await tx.put(key('trust-outbound', node), { node_id: node, kind: 'approval', nonce, fingerprint: own.fingerprint, at: this.#ctx.now() } satisfies TrustOutbound);
360
370
  }
361
371
  return { before, entry, fresh, rotating };
362
372
  });
@@ -371,23 +381,23 @@ export class TrustService {
371
381
  }
372
382
  private notify(cause: NotifyEvent extends infer T ? T extends { cause: infer C } ? C : never : never, entry: TrustEntry, previous?: TrustEntry): void {
373
383
  try { this.#waker?.wakeLoop(); } catch { /* a loop fault never blocks the commit */ }
374
- this.ctx.options.plugins.runtime?.notify?.({ event: 'trust', cause, phase: trustPhase(entry), entry, ...(previous === undefined ? {} : { previous }) });
384
+ this.#ctx.options.plugins.runtime?.notify?.({ event: 'trust', cause, phase: trustPhase(entry), entry, ...(previous === undefined ? {} : { previous }) });
375
385
  }
376
386
  async accept(auth: Auth, node: NodeId, fingerprint: string): Promise<TrustEntry> { return this.request(auth, node, fingerprint); }
377
387
 
378
388
  async receive(value: TrustStatement): Promise<TrustEntry> {
379
389
  const own = await this.identity();
380
- if (value === null || typeof value !== 'object' || typeof value.node_id !== 'string' || value.node_id === this.ctx.node
381
- || value.target !== this.ctx.node || value.target_fingerprint !== own.fingerprint
390
+ if (value === null || typeof value !== 'object' || typeof value.node_id !== 'string' || value.node_id === this.#ctx.node
391
+ || value.target !== this.#ctx.node || value.target_fingerprint !== own.fingerprint
382
392
  || typeof value.nonce !== 'string' || value.nonce.length < 1 || value.nonce.length > 128
383
393
  || typeof value.signature !== 'string' || typeof value.public_key !== 'string' || typeof value.approved !== 'boolean'
384
- || !Number.isSafeInteger(value.timestamp) || Math.abs(this.ctx.now() - value.timestamp) > CLOCK_SKEW_MS) fail('unauthenticated', 'Invalid or expired key exchange transcript.');
394
+ || !Number.isSafeInteger(value.timestamp) || Math.abs(this.#ctx.now() - value.timestamp) > CLOCK_SKEW_MS) fail('unauthenticated', 'Invalid or expired key exchange transcript.');
385
395
  this.address(value.base_url);
386
396
  if (!signatureValid(value.public_key, value.signature, statementPayload(value))) fail('unauthenticated', 'Invalid key exchange signature.');
387
397
  const fingerprint = await keyFingerprint(Buffer.from(value.public_key, 'base64'));
388
- const seed = this.ctx.options.plugins.trust_seeds?.find(row => row.node_id === value.node_id);
398
+ const seed = this.#ctx.options.plugins.trust_seeds?.find(row => row.node_id === value.node_id);
389
399
  if (seed !== undefined && seed.fingerprint !== fingerprint) fail('unauthenticated', 'The peer differs from its pinned fingerprint.', { fingerprint });
390
- const settled = await this.ctx.storage.transaction(async tx => {
400
+ const settled = await this.#ctx.storage.transaction(async tx => {
391
401
  await this.consumeNonce(tx, value.node_id, `exchange:${value.nonce}`, value.timestamp);
392
402
  const slot = key('trust', value.node_id);
393
403
  const previous = await tx.get<TrustEntry>(slot);
@@ -408,7 +418,7 @@ export class TrustService {
408
418
  const local = seed !== undefined || previous?.local_approved === true;
409
419
  const entry: TrustEntry = { node_id: value.node_id, public_key: value.public_key, fingerprint,
410
420
  state: local && value.approved ? 'trusted' : 'pending', local_approved: local, remote_approved: value.approved,
411
- base_url: value.base_url, first_seen: previous?.first_seen ?? new Date(this.ctx.now()).toISOString(), last_checked: new Date(this.ctx.now()).toISOString(),
421
+ base_url: value.base_url, first_seen: previous?.first_seen ?? new Date(this.#ctx.now()).toISOString(), last_checked: new Date(this.#ctx.now()).toISOString(),
412
422
  ...(changed ? { history: [...(previous.history ?? []), previous.fingerprint] } : previous?.history === undefined ? {} : { history: previous.history }) };
413
423
  await tx.put(slot, entry);
414
424
  // The peer spoke: whatever parked this node's own obligation may no longer hold.
@@ -428,13 +438,13 @@ export class TrustService {
428
438
  // The reply echoes the request nonce: that is what lets the sender settle its obligation.
429
439
  // While this node's own rotation is unacknowledged, every statement carries the old-key
430
440
  // proof, so a peer still pinning the previous key can accept this reply (RFC 0017 §2).
431
- const owed = await this.ctx.storage.transaction(tx => tx.get<TrustOutbound>(key('trust-outbound', value.node_id)));
441
+ const owed = await this.#ctx.storage.transaction(tx => tx.get<TrustOutbound>(key('trust-outbound', value.node_id)));
432
442
  const old = owed?.previous === undefined ? undefined : new Uint8Array(owed.previous);
433
- return this.statement(entry, entry.local_approved, this.ctx.id(), value.nonce, old);
443
+ return this.statement(entry, entry.local_approved, this.#ctx.id(), value.nonce, old);
434
444
  }
435
445
  async revoke(auth: Auth, node: NodeId): Promise<void> {
436
446
  this.owner(auth);
437
- const previous = await this.ctx.storage.transaction(async tx => {
447
+ const previous = await this.#ctx.storage.transaction(async tx => {
438
448
  const slot = key('trust', node);
439
449
  const previous = await tx.get<TrustEntry>(slot);
440
450
  if (previous !== undefined) await tx.put(slot, { ...previous, state: 'revoked', public_key: '', local_approved: false, remote_approved: false });
@@ -458,8 +468,8 @@ export class TrustService {
458
468
  * a re-send for a replay. The maintenance loop calls this; the host never does.
459
469
  */
460
470
  async redrive(signal?: AbortSignal): Promise<void> {
461
- if (!this.keys.enabled) return;
462
- const owed = await this.ctx.storage.transaction(tx => tx.scan<TrustOutbound>(key('trust-outbound') + '/'));
471
+ if (!this.#keys.enabled) return;
472
+ const owed = await this.#ctx.storage.transaction(tx => tx.scan<TrustOutbound>(key('trust-outbound') + '/'));
463
473
  for (const row of owed) {
464
474
  if (signal?.aborted) return;
465
475
  const obligation = row.value;
@@ -468,7 +478,7 @@ export class TrustService {
468
478
  // thing that may drop it besides the peer's own answer.
469
479
  if (entry === undefined || entry.state === 'revoked') {
470
480
  if (await this.compareObligation(obligation.node_id, obligation, null) && obligation.kind === 'rotation') {
471
- await this.ctx.storage.transaction(tx => this.closeWindow(tx));
481
+ await this.#ctx.storage.transaction(tx => this.closeWindow(tx));
472
482
  }
473
483
  continue;
474
484
  }
@@ -498,13 +508,13 @@ export class TrustService {
498
508
  // An unverified refusal delays the next attempt; it never ends the obligation.
499
509
  let current = obligation;
500
510
  if (current.blocked !== undefined) {
501
- if (this.ctx.now() < current.blocked) continue;
511
+ if (this.#ctx.now() < current.blocked) continue;
502
512
  const { blocked: _blocked, ...rest } = current;
503
513
  if (!await this.compareObligation(current.node_id, current, rest)) continue;
504
514
  current = rest;
505
515
  }
506
- if (this.ctx.now() - current.at < EXCHANGE_RETRY_MS) continue;
507
- const nonce = this.ctx.id();
516
+ if (this.#ctx.now() - current.at < EXCHANGE_RETRY_MS) continue;
517
+ const nonce = this.#ctx.id();
508
518
  const old = current.previous === undefined ? undefined : new Uint8Array(current.previous);
509
519
  // Address the key the peer publishes now. A statement's `target_fingerprint` is the key
510
520
  // it is written for; reusing the stored one would make two nodes that rotated at the
@@ -515,7 +525,7 @@ export class TrustService {
515
525
  } catch {
516
526
  continue; // unreachable this pass; the obligation stays
517
527
  }
518
- if (!await this.compareObligation(current.node_id, current, { ...current, nonce, at: this.ctx.now() })) continue;
528
+ if (!await this.compareObligation(current.node_id, current, { ...current, nonce, at: this.#ctx.now() })) continue;
519
529
  try {
520
530
  await this.send(live, entry.local_approved, { nonce, ...(old === undefined ? {} : { old }) });
521
531
  } catch (error) {
@@ -539,7 +549,7 @@ export class TrustService {
539
549
  async #rotate(auth: Auth): Promise<KeyIdentity> {
540
550
  const window = await this.rotationWindow();
541
551
  if (window !== undefined) fail('conflict', 'A rotation is still unacknowledged; a second swap would skip the proof a peer needs.');
542
- const previous = await this.keys.material();
552
+ const previous = await this.#keys.material();
543
553
  // Every non-revoked relation where the peer may already hold this node's old key owes the
544
554
  // rotation statement: our own approval (requested) or the peer's recorded statement
545
555
  // (invited) both mean the old key is pinned somewhere.
@@ -549,7 +559,7 @@ export class TrustService {
549
559
  // The window and every obligation are written in one transaction, so the maintenance loop
550
560
  // can tell a swap in progress from a completed one. Each obligation carries the old private
551
561
  // key, so an unreachable peer is re-driven later and a restart keeps the proof.
552
- await this.ctx.storage.transaction(async tx => {
562
+ await this.#ctx.storage.transaction(async tx => {
553
563
  if (await tx.get(key('security', 'rotation')) !== undefined) {
554
564
  fail('conflict', 'A rotation is still unacknowledged; a second swap would skip the proof a peer needs.');
555
565
  }
@@ -557,20 +567,20 @@ export class TrustService {
557
567
  if (peers.length === 0) return;
558
568
  for (const peer of peers) {
559
569
  await tx.put(key('trust-outbound', peer.node_id), {
560
- node_id: peer.node_id, kind: 'rotation', nonce: this.ctx.id(), fingerprint, at: this.ctx.now(), previous: Array.from(previous.private_key),
570
+ node_id: peer.node_id, kind: 'rotation', nonce: this.#ctx.id(), fingerprint, at: this.#ctx.now(), previous: Array.from(previous.private_key),
561
571
  } satisfies TrustOutbound);
562
572
  }
563
- await tx.put(key('security', 'rotation'), { new_fingerprint: fingerprint, at: this.ctx.now() } satisfies RotationWindow);
573
+ await tx.put(key('security', 'rotation'), { new_fingerprint: fingerprint, at: this.#ctx.now() } satisfies RotationWindow);
564
574
  });
565
575
  this.#rotating = true;
566
576
  try {
567
- await this.keys.replace(next);
577
+ await this.#keys.replace(next);
568
578
  } finally {
569
579
  this.#rotating = false;
570
580
  }
571
581
  const old = previous.private_key;
572
582
  for (const peer of peers) {
573
- const obligation = await this.ctx.storage.transaction(tx => tx.get<TrustOutbound>(key('trust-outbound', peer.node_id)));
583
+ const obligation = await this.#ctx.storage.transaction(tx => tx.get<TrustOutbound>(key('trust-outbound', peer.node_id)));
574
584
  if (obligation === undefined) continue;
575
585
  let live: KeyIdentity;
576
586
  try {
@@ -590,17 +600,17 @@ export class TrustService {
590
600
  for (const peer of peers) this.notify('rotation', (await this.entry(peer.node_id)) ?? peer, peer);
591
601
  return identity;
592
602
  }
593
- private async consumeNonce(tx: import('./types.js').Transaction, actor: string, nonce: string, timestamp: number): Promise<void> {
603
+ private async consumeNonce(tx: import('./types.ts').Transaction, actor: string, nonce: string, timestamp: number): Promise<void> {
594
604
  const slot = key('nonce', actor, nonce);
595
605
  if (await tx.get(slot) !== undefined) fail('unauthenticated', 'This nonce was already used.');
596
- for (const row of await tx.scan<number>('nonce/')) if (row.value < this.ctx.now() - CLOCK_SKEW_MS) await tx.delete(row.key);
606
+ for (const row of await tx.scan<number>('nonce/')) if (row.value < this.#ctx.now() - CLOCK_SKEW_MS) await tx.delete(row.key);
597
607
  await tx.put(slot, timestamp);
598
608
  }
599
609
  async signRequest(request: Request, target: NodeId, raw: Uint8Array | string): Promise<void> {
600
- const { timestamp, nonce } = this.keys.freshTimestamp();
601
- request.headers.set('Onlyne-Actor', this.ctx.node);
602
- const signature = await this.keys.sign(requestPayload(request, target, hash(raw), timestamp, nonce));
603
- request.headers.set(SIGNATURE_HEADER, `v1 ${encodeURIComponent(this.ctx.node)} ${timestamp} ${nonce} ${signature}`);
610
+ const { timestamp, nonce } = this.#keys.freshTimestamp();
611
+ request.headers.set('Onlyne-Actor', this.#ctx.node);
612
+ const signature = await this.#keys.sign(requestPayload(request, target, hash(raw), timestamp, nonce));
613
+ request.headers.set(SIGNATURE_HEADER, `v1 ${encodeURIComponent(this.#ctx.node)} ${timestamp} ${nonce} ${signature}`);
604
614
  }
605
615
  async authenticate(request: Request, digest: string): Promise<Auth> {
606
616
  const identity = await this.identity();
@@ -610,11 +620,11 @@ export class TrustService {
610
620
  let actor: string;
611
621
  try { actor = decodeURIComponent(parts[1]!); } catch { return fail('unauthenticated', 'Invalid signing identity.', details); }
612
622
  const timestamp = parts[2]!, nonce = parts[3]!, signature = parts[4]!;
613
- if (!Number.isSafeInteger(Number(timestamp)) || Math.abs(this.ctx.now() - Number(timestamp)) > CLOCK_SKEW_MS || !/^[a-zA-Z0-9-]{1,128}$/.test(nonce)) fail('unauthenticated', 'Invalid signature timestamp or nonce.', details);
623
+ if (!Number.isSafeInteger(Number(timestamp)) || Math.abs(this.#ctx.now() - Number(timestamp)) > CLOCK_SKEW_MS || !/^[a-zA-Z0-9-]{1,128}$/.test(nonce)) fail('unauthenticated', 'Invalid signature timestamp or nonce.', details);
614
624
  const entry = await this.entry(actor);
615
- if (entry?.state !== 'trusted' || actor === this.ctx.node || request.headers.get('Onlyne-Actor') !== actor) fail('unauthenticated', 'The caller is not a trusted peer.', details);
616
- if (!signatureValid(entry.public_key, signature, requestPayload(request, this.ctx.node, digest, timestamp, nonce))) fail('unauthenticated', 'Invalid request signature.', details);
617
- await this.ctx.storage.transaction(async tx => {
625
+ if (entry?.state !== 'trusted' || actor === this.#ctx.node || request.headers.get('Onlyne-Actor') !== actor) fail('unauthenticated', 'The caller is not a trusted peer.', details);
626
+ if (!signatureValid(entry.public_key, signature, requestPayload(request, this.#ctx.node, digest, timestamp, nonce))) fail('unauthenticated', 'Invalid request signature.', details);
627
+ await this.#ctx.storage.transaction(async tx => {
618
628
  if ((await tx.get<TrustEntry>(key('trust', actor)))?.state !== 'trusted') fail('unauthenticated', 'The caller was revoked.', details);
619
629
  await this.consumeNonce(tx, actor, nonce, Number(timestamp));
620
630
  });
@@ -622,7 +632,7 @@ export class TrustService {
622
632
  }
623
633
  async signResponse(request: Request, response: Response, body: string): Promise<void> {
624
634
  const nonce = request.headers.get(SIGNATURE_HEADER)?.split(' ')[3] ?? '';
625
- response.headers.set('Onlyne-Response-Sig', await this.keys.sign(responsePayload(this.ctx.node, nonce, response.status, hash(body))));
635
+ response.headers.set('Onlyne-Response-Sig', await this.#keys.sign(responsePayload(this.#ctx.node, nonce, response.status, hash(body))));
626
636
  }
627
637
  async verifyResponse(target: NodeId, request: Request, response: Response, body: string): Promise<void> {
628
638
  const peer = await this.entry(target);
package/src/memory.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Storage, Transaction } from './types.js';
1
+ import type { Storage, Transaction } from './types.ts';
2
2
 
3
3
  /** Process-local storage for embedded ephemeral nodes. It does not claim durability. */
4
4
  export class MemoryStorage implements Storage {