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