@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/README.md +2 -0
- package/package.json +1 -1
- package/src/client.ts +255 -59
- package/src/core.ts +2 -2
- package/src/discovery.ts +26 -18
- package/src/domains.ts +29 -26
- package/src/history.ts +21 -11
- package/src/http.ts +25 -14
- package/src/index.ts +18 -18
- package/src/keys.ts +81 -71
- package/src/memory.ts +1 -1
- package/src/messages.ts +63 -52
- package/src/node.ts +23 -16
- package/src/resolve.ts +39 -0
- package/src/resources.ts +3 -3
- package/src/threads.ts +98 -39
- package/src/transport.ts +18 -11
- package/src/types.ts +54 -9
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.
|
|
3
|
-
import type { Context } from './core.
|
|
4
|
-
import type { Auth, ErrorCode, KeyIdentity, KeyMaterial, KeyExchange, NodeId, NotifyEvent, TrustEntry, TrustStatement } from './types.
|
|
5
|
-
import { trustPhase } from './types.
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
52
|
+
return { timestamp: String(this.#ctx.now()), nonce: this.#ctx.id() };
|
|
49
53
|
}
|
|
50
54
|
async material(): Promise<KeyMaterial> {
|
|
51
|
-
const hook = this
|
|
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
|
|
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
|
|
70
|
-
const hook = this
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
241
|
-
target_fingerprint: peer.fingerprint, base_url: this
|
|
242
|
-
nonce, timestamp: this
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
333
|
-
const committed = await this
|
|
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
|
|
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
|
|
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
|
|
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
|
|
381
|
-
|| value.target !== this
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
462
|
-
const owed = await this
|
|
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
|
|
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
|
|
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
|
|
507
|
-
const nonce = this
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
601
|
-
request.headers.set('Onlyne-Actor', this
|
|
602
|
-
const signature = await this
|
|
603
|
-
request.headers.set(SIGNATURE_HEADER, `v1 ${encodeURIComponent(this
|
|
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
|
|
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
|
|
616
|
-
if (!signatureValid(entry.public_key, signature, requestPayload(request, this
|
|
617
|
-
await this
|
|
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
|
|
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