@serfab/cadre-core 1.13.0 → 1.15.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 +293 -274
- package/dist/cadre-invite-protocol.d.ts +383 -0
- package/dist/cadre-invite-protocol.js +774 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- package/dist/cadre-join-request.d.ts +42 -0
- package/dist/cadre-join-request.js +144 -0
- package/dist/cadre-join-request.js.map +1 -0
- package/dist/cadre-node.d.ts +849 -210
- package/dist/cadre-node.js +1574 -374
- package/dist/cadre-node.js.map +1 -1
- package/dist/claim-proof.d.ts +45 -0
- package/dist/claim-proof.js +132 -0
- package/dist/claim-proof.js.map +1 -0
- package/dist/control-authorization.d.ts +80 -19
- package/dist/control-authorization.js +55 -13
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +605 -108
- package/dist/control-database.js +1299 -280
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +142 -0
- package/dist/control-protocol-guard.js +293 -0
- package/dist/control-protocol-guard.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +817 -1067
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +4 -0
- package/dist/control-stream.js +22 -7
- package/dist/control-stream.js.map +1 -1
- package/dist/delegate-admission.d.ts +1 -1
- package/dist/delegate-admission.js +1 -1
- package/dist/device-token.d.ts +11 -9
- package/dist/device-token.js +14 -12
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +22 -9
- package/dist/ed25519-key.js +47 -10
- package/dist/ed25519-key.js.map +1 -1
- package/dist/fetch-impl.d.ts +12 -0
- package/dist/fetch-impl.js +23 -0
- package/dist/fetch-impl.js.map +1 -0
- package/dist/file-durable-slot.js +2 -1
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/file-safe-name.d.ts +25 -0
- package/dist/file-safe-name.js +29 -0
- package/dist/file-safe-name.js.map +1 -0
- package/dist/formation-approval.js +5 -11
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +0 -19
- package/dist/fs-atomic.js +3 -24
- package/dist/fs-atomic.js.map +1 -1
- package/dist/index.d.ts +22 -10
- package/dist/index.js +54 -11
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +50 -12
- package/dist/invitation-bootstrap.js +76 -13
- package/dist/invitation-bootstrap.js.map +1 -1
- package/dist/joined-strand-store.d.ts +7 -0
- package/dist/joined-strand-store.js +9 -0
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store-file.js +2 -1
- package/dist/key-store-file.js.map +1 -1
- package/dist/link-budget.d.ts +25 -33
- package/dist/link-budget.js +25 -35
- package/dist/link-budget.js.map +1 -1
- package/dist/machine-removal.d.ts +78 -0
- package/dist/machine-removal.js +166 -0
- package/dist/machine-removal.js.map +1 -0
- package/dist/membership-connection-gater.d.ts +191 -151
- package/dist/membership-connection-gater.js +236 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +74 -0
- package/dist/node-claim-payload.js +224 -0
- package/dist/node-claim-payload.js.map +1 -0
- package/dist/node-default-transports.d.ts +27 -0
- package/dist/node-default-transports.js +35 -0
- package/dist/node-default-transports.js.map +1 -0
- package/dist/node-transports.d.ts +30 -0
- package/dist/node-transports.js +40 -0
- package/dist/node-transports.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +22 -0
- package/dist/owner-anchor-sync.js +143 -0
- package/dist/owner-anchor-sync.js.map +1 -0
- package/dist/peer-authorization.d.ts +113 -18
- package/dist/peer-authorization.js +225 -29
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -27
- package/dist/peer-dial.js +276 -31
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +60 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +17 -52
- package/dist/peer-record.js +20 -58
- package/dist/peer-record.js.map +1 -1
- package/dist/pending-join-runner.d.ts +66 -58
- package/dist/pending-join-runner.js +136 -145
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/primary-lan-address.d.ts +8 -0
- package/dist/primary-lan-address.js +46 -0
- package/dist/primary-lan-address.js.map +1 -0
- package/dist/provider/client.d.ts +131 -0
- package/dist/provider/client.js +226 -0
- package/dist/provider/client.js.map +1 -0
- package/dist/provider/descriptor.d.ts +41 -0
- package/dist/provider/descriptor.js +103 -0
- package/dist/provider/descriptor.js.map +1 -0
- package/dist/provider/index.d.ts +9 -0
- package/dist/provider/index.js +10 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/provider/oauth.d.ts +125 -0
- package/dist/provider/oauth.js +199 -0
- package/dist/provider/oauth.js.map +1 -0
- package/dist/provider/rent-node.d.ts +101 -0
- package/dist/provider/rent-node.js +220 -0
- package/dist/provider/rent-node.js.map +1 -0
- package/dist/push-fanout.d.ts +8 -2
- package/dist/push-fanout.js +8 -2
- package/dist/push-fanout.js.map +1 -1
- package/dist/relay-addrs.d.ts +9 -4
- package/dist/relay-addrs.js +9 -4
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +51 -16
- package/dist/relay-reservation.js +107 -24
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +165 -123
- package/dist/seed-bootstrap.js +313 -275
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +98 -37
- package/dist/seed-trust-policy.js +188 -2
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +24 -26
- package/dist/strand-addr-protocol.js +19 -26
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-commit-durability.d.ts +54 -0
- package/dist/strand-commit-durability.js +74 -0
- package/dist/strand-commit-durability.js.map +1 -0
- package/dist/strand-database.d.ts +23 -8
- package/dist/strand-database.js +41 -13
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +7 -1
- package/dist/strand-first-sync-gate.js +7 -1
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-protocol.js +5 -2
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-fret-addrs.d.ts +19 -1
- package/dist/strand-fret-addrs.js +27 -9
- package/dist/strand-fret-addrs.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +81 -19
- package/dist/strand-instance-manager.js +216 -105
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-move.d.ts +124 -0
- package/dist/strand-move.js +438 -0
- package/dist/strand-move.js.map +1 -0
- package/dist/strand-network-config.js +7 -6
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-peer-redial.d.ts +113 -0
- package/dist/strand-peer-redial.js +236 -0
- package/dist/strand-peer-redial.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +11 -11
- package/dist/strand-revocation-enforcer.js +11 -11
- package/dist/strand-wake-protocol.d.ts +13 -16
- package/dist/strand-wake-protocol.js +23 -27
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/trusted-owner-store.d.ts +35 -5
- package/dist/trusted-owner-store.js +50 -8
- package/dist/trusted-owner-store.js.map +1 -1
- package/dist/types.d.ts +512 -89
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -1
- package/package.json +19 -10
- package/src/cadre-invite-protocol.ts +1052 -0
- package/src/cadre-join-request.ts +167 -0
- package/src/cadre-node.ts +1703 -386
- package/src/claim-proof.ts +147 -0
- package/src/control-authorization.ts +203 -117
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +4663 -3453
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +828 -1078
- package/src/control-stream.ts +26 -7
- package/src/delegate-admission.ts +1 -1
- package/src/device-token.ts +14 -10
- package/src/ed25519-key.ts +47 -10
- package/src/fetch-impl.ts +23 -0
- package/src/file-durable-slot.ts +64 -63
- package/src/file-safe-name.ts +32 -0
- package/src/formation-approval.ts +604 -610
- package/src/fs-atomic.ts +92 -117
- package/src/index.ts +789 -648
- package/src/invitation-bootstrap.ts +91 -13
- package/src/joined-strand-store.ts +508 -498
- package/src/key-store-file.ts +142 -141
- package/src/link-budget.ts +25 -36
- package/src/machine-removal.ts +216 -0
- package/src/membership-connection-gater.ts +259 -191
- package/src/node-claim-payload.ts +259 -0
- package/src/node-default-transports.ts +38 -0
- package/src/node-transports.ts +49 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +604 -315
- package/src/peer-join-backfill.ts +62 -11
- package/src/peer-record.ts +159 -197
- package/src/pending-join-runner.ts +729 -735
- package/src/primary-lan-address.ts +47 -0
- package/src/provider/client.ts +326 -0
- package/src/provider/descriptor.ts +129 -0
- package/src/provider/index.ts +59 -0
- package/src/provider/oauth.ts +285 -0
- package/src/provider/rent-node.ts +275 -0
- package/src/push-fanout.ts +8 -2
- package/src/relay-addrs.ts +9 -4
- package/src/relay-reservation.ts +1211 -1121
- package/src/seed-bootstrap.ts +1502 -1448
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- package/src/strand-commit-durability.ts +116 -0
- package/src/strand-database.ts +46 -14
- package/src/strand-first-sync-gate.ts +7 -1
- package/src/strand-formation-protocol.ts +1039 -1036
- package/src/strand-fret-addrs.ts +179 -145
- package/src/strand-instance-manager.ts +2026 -1883
- package/src/strand-move.ts +585 -0
- package/src/strand-network-config.ts +7 -6
- package/src/strand-peer-redial.ts +307 -0
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +23 -30
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2687 -2242
package/dist/seed-bootstrap.js
CHANGED
|
@@ -4,11 +4,12 @@ import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-c
|
|
|
4
4
|
import { multiaddr } from '@multiformats/multiaddr';
|
|
5
5
|
import { peerIdFromString } from '@libp2p/peer-id';
|
|
6
6
|
import { withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
|
|
7
|
-
import { dialPeerAddrs, SelfRelayOnlyError, DEFAULT_PEER_DIAL_BUDGET } from './peer-dial.js';
|
|
7
|
+
import { dialPeerAddrs, SelfRelayOnlyError, PeerUnreachableError, DEFAULT_PEER_DIAL_BUDGET, withTrailingPeerId, } from './peer-dial.js';
|
|
8
8
|
import { relayedRequestBudgetMs } from './link-budget.js';
|
|
9
9
|
import { generateStampId } from './control-database.js';
|
|
10
10
|
import { canonicalJson } from './canonical-json.js';
|
|
11
11
|
import { deviceTokenAddDigest } from './peer-authorization.js';
|
|
12
|
+
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
12
13
|
import { anchoredTrustPolicy, } from './seed-trust-policy.js';
|
|
13
14
|
const log = debug('sereus:cadre:seed-bootstrap');
|
|
14
15
|
/** Protocol ID for seed delivery */
|
|
@@ -48,28 +49,6 @@ export function decodeLengthPrefixedFrame(data, maxLength = MAX_SEED_SIZE) {
|
|
|
48
49
|
}
|
|
49
50
|
return data.subarray(4, 4 + length);
|
|
50
51
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
|
|
53
|
-
*
|
|
54
|
-
* An Ed25519 PeerId is an identity multihash of the public key, so
|
|
55
|
-
* `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
|
|
56
|
-
* base64url form matches the `OwnerKey.Key` representation (and
|
|
57
|
-
* `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
|
|
58
|
-
* id, a missing embedded key, or any parse failure — callers treat null as
|
|
59
|
-
* "not an owner" rather than throwing.
|
|
60
|
-
*/
|
|
61
|
-
export function ed25519PublicKeyB64FromPeerId(peerId) {
|
|
62
|
-
try {
|
|
63
|
-
const parsed = peerIdFromString(peerId);
|
|
64
|
-
if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
52
|
/**
|
|
74
53
|
* Canonical byte representation of the authenticated seed fields.
|
|
75
54
|
*
|
|
@@ -82,12 +61,28 @@ export function ed25519PublicKeyB64FromPeerId(peerId) {
|
|
|
82
61
|
export function canonicalSeedPayload(seed) {
|
|
83
62
|
return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
|
|
84
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The digest a seed's signature covers: sha256 over {@link canonicalSeedPayload},
|
|
66
|
+
* base64url. One function so the signer (`createSeed`), the verifier
|
|
67
|
+
* (`validateSeedSignature`) and the claim proof (`claim-proof.ts`, which binds a
|
|
68
|
+
* proof to one seed through this value) all name the same bytes.
|
|
69
|
+
*/
|
|
70
|
+
export function seedDigest(seed) {
|
|
71
|
+
return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url');
|
|
72
|
+
}
|
|
85
73
|
/**
|
|
86
74
|
* An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
|
|
87
75
|
* so the dial counters read zero rather than being absent.
|
|
88
76
|
*/
|
|
89
|
-
function seedRejected(error) {
|
|
90
|
-
return {
|
|
77
|
+
function seedRejected(error, code) {
|
|
78
|
+
return {
|
|
79
|
+
success: false,
|
|
80
|
+
peersAdded: 0,
|
|
81
|
+
error,
|
|
82
|
+
...(code !== undefined ? { code } : {}),
|
|
83
|
+
ownerDialsAttempted: 0,
|
|
84
|
+
ownerDialsFailed: 0,
|
|
85
|
+
};
|
|
91
86
|
}
|
|
92
87
|
/**
|
|
93
88
|
* Parse each address, dropping (and logging) any that is malformed, so one bad
|
|
@@ -105,6 +100,48 @@ function parseDialAddrs(addrs) {
|
|
|
105
100
|
}
|
|
106
101
|
return parsed;
|
|
107
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* Merge a peer list's addresses into `node`'s peer store, so the node can dial them: the
|
|
105
|
+
* seed's peers after its signature and trust checks, the dial hints a member returns on
|
|
106
|
+
* a cadre invitation redemption (`cadre-invite-protocol.ts`), or the node a seed is being
|
|
107
|
+
* delivered to ({@link SeedBootstrapService.deliverSeed}). Best-effort per peer — one
|
|
108
|
+
* unparsable entry costs that peer, not the rest — and a peer with no address is skipped,
|
|
109
|
+
* since there is nothing to dial. Returns how many peers were merged.
|
|
110
|
+
*/
|
|
111
|
+
export async function mergeSeedPeers(node, peers) {
|
|
112
|
+
let peersAdded = 0;
|
|
113
|
+
for (const peer of peers) {
|
|
114
|
+
if (peer.multiaddrs.length === 0) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
const peerId = peerIdFromString(peer.peerId);
|
|
119
|
+
const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
|
|
120
|
+
await node.peerStore.merge(peerId, { multiaddrs: addrs });
|
|
121
|
+
peersAdded++;
|
|
122
|
+
log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
log('Failed to add peer %s: %o', peer.peerId, error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return peersAdded;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A delivery target's addresses, each bound to its peer id (`withTrailingPeerId`) so the dial
|
|
132
|
+
* authenticates the node it aims at rather than trusting whoever answers. An address that does
|
|
133
|
+
* not parse, or that names another peer, throws: that is the caller's mistake, not the node
|
|
134
|
+
* being unreachable.
|
|
135
|
+
*/
|
|
136
|
+
function deliveryDialAddrs(peerId, addrs) {
|
|
137
|
+
return addrs.map((text) => {
|
|
138
|
+
const bound = withTrailingPeerId(multiaddr(text), peerId);
|
|
139
|
+
if (!bound) {
|
|
140
|
+
throw new Error(`Seed delivery to ${peerId}: address ${text} names another peer`);
|
|
141
|
+
}
|
|
142
|
+
return bound;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
108
145
|
/**
|
|
109
146
|
* SeedBootstrapService handles control network seed generation and delivery.
|
|
110
147
|
*
|
|
@@ -158,10 +195,10 @@ export class SeedBootstrapService {
|
|
|
158
195
|
* `registerHandler` (default true) gates registration of the shared inbound
|
|
159
196
|
* `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
|
|
160
197
|
* (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
|
|
161
|
-
* it on. The throwaway temp
|
|
162
|
-
* `
|
|
163
|
-
*
|
|
164
|
-
*
|
|
198
|
+
* it on. The throwaway temp service CadreNode builds in `applySeed` passes
|
|
199
|
+
* `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
|
|
200
|
+
* dialing and known-key lookup, and must NOT bind a discarded closure to the
|
|
201
|
+
* shared node (a handler leak, and a second
|
|
165
202
|
* `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
|
|
166
203
|
*
|
|
167
204
|
* Rejects when libp2p refuses the registration; the node and database are
|
|
@@ -201,6 +238,7 @@ export class SeedBootstrapService {
|
|
|
201
238
|
multiaddr: multiaddrStr,
|
|
202
239
|
updatedAt: Date.now(),
|
|
203
240
|
sig: null,
|
|
241
|
+
profile: null,
|
|
204
242
|
});
|
|
205
243
|
log('Peer %s authorized successfully', peerId);
|
|
206
244
|
}
|
|
@@ -224,6 +262,7 @@ export class SeedBootstrapService {
|
|
|
224
262
|
multiaddr: record.addrs.join(','),
|
|
225
263
|
updatedAt: record.updatedAt,
|
|
226
264
|
sig: record.sig,
|
|
265
|
+
profile: record.profile,
|
|
227
266
|
});
|
|
228
267
|
}
|
|
229
268
|
/**
|
|
@@ -268,14 +307,14 @@ export class SeedBootstrapService {
|
|
|
268
307
|
*/
|
|
269
308
|
async insertSelfDeviceToken(record) {
|
|
270
309
|
const stampId = generateStampId(record.peerId);
|
|
271
|
-
const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
|
|
310
|
+
const signature = this.signDigest(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }));
|
|
272
311
|
if (!this.controlDatabase) {
|
|
273
312
|
throw new Error('Control database not initialized');
|
|
274
313
|
}
|
|
275
|
-
await this.controlDatabase.execWrite(`
|
|
276
|
-
insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
|
|
277
|
-
with context OwnerKey = ?, Signature = ?
|
|
278
|
-
values (?, ?, ?, ?, ?, ?)
|
|
314
|
+
await this.controlDatabase.execWrite(`
|
|
315
|
+
insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
|
|
316
|
+
with context OwnerKey = ?, Signature = ?
|
|
317
|
+
values (?, ?, ?, ?, ?, ?)
|
|
279
318
|
`, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
|
|
280
319
|
log('Device token inserted (owner-signed): %s', record.peerId);
|
|
281
320
|
}
|
|
@@ -363,7 +402,7 @@ export class SeedBootstrapService {
|
|
|
363
402
|
*
|
|
364
403
|
* The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
|
|
365
404
|
* signature over the DISTINCT 'remove'-tagged digest
|
|
366
|
-
* `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
|
|
405
|
+
* `digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId)` by an owner
|
|
367
406
|
* key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
|
|
368
407
|
* never be replayed to delete.
|
|
369
408
|
*
|
|
@@ -406,6 +445,66 @@ export class SeedBootstrapService {
|
|
|
406
445
|
await this.controlDatabase.deleteCadrePeer(peerId, ownerKey, message => this.signMessageBytes(message));
|
|
407
446
|
log('Peer %s removed successfully (stamp retired)', peerId);
|
|
408
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
|
|
450
|
+
* liveness check, the stamp, the signature and the membership notify are
|
|
451
|
+
* {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
|
|
452
|
+
* precondition, checked before any read (see {@link removePeer}).
|
|
453
|
+
*
|
|
454
|
+
* @returns `true` when the row was seated, `false` when the key is already a live owner.
|
|
455
|
+
*/
|
|
456
|
+
async addOwner(ownerPublicKey) {
|
|
457
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
458
|
+
if (!this.controlDatabase) {
|
|
459
|
+
throw new Error('Control database not initialized');
|
|
460
|
+
}
|
|
461
|
+
return await this.controlDatabase.insertOwnerKeyVouched(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
|
|
465
|
+
* and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
|
|
466
|
+
* the last owner), the digests and the membership notify are
|
|
467
|
+
* {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
|
|
468
|
+
* checked before any read (see {@link removePeer}).
|
|
469
|
+
*
|
|
470
|
+
* @returns `true` when the row was removed, `false` when the key is not a live owner here.
|
|
471
|
+
*/
|
|
472
|
+
async removeOwner(ownerPublicKey) {
|
|
473
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
474
|
+
if (!this.controlDatabase) {
|
|
475
|
+
throw new Error('Control database not initialized');
|
|
476
|
+
}
|
|
477
|
+
return await this.controlDatabase.deleteOwnerKey(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Re-vouch another owner's row under this service's owner key, so it stands on a remaining
|
|
481
|
+
* owner's signature before the owner that added it is removed
|
|
482
|
+
* ({@link ControlDatabase.revouchOwnerKey}; for `CadreNode.removeMachine` only). What stays
|
|
483
|
+
* here is the owner-key precondition, checked before any read (see {@link removePeer}).
|
|
484
|
+
*
|
|
485
|
+
* @returns `true` when the row was re-vouched, `false` when the key is not a live owner here.
|
|
486
|
+
*/
|
|
487
|
+
async revouchOwner(ownerPublicKey) {
|
|
488
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
489
|
+
if (!this.controlDatabase) {
|
|
490
|
+
throw new Error('Control database not initialized');
|
|
491
|
+
}
|
|
492
|
+
return await this.controlDatabase.revouchOwnerKey(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Remove a machine completely, signed by this service's owner key: its `CadrePeer` row, its
|
|
496
|
+
* `OwnerKey` row when `target.ownerKey` names one, its `DeviceToken` row, and the
|
|
497
|
+
* withdrawal of every invitation in `target.inviteKeys`, all in one transaction
|
|
498
|
+
* ({@link ControlDatabase.removeMachine}). What stays here is the owner-key precondition,
|
|
499
|
+
* checked before any read (see {@link removePeer}).
|
|
500
|
+
*/
|
|
501
|
+
async removeMachine(target) {
|
|
502
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
503
|
+
if (!this.controlDatabase) {
|
|
504
|
+
throw new Error('Control database not initialized');
|
|
505
|
+
}
|
|
506
|
+
return await this.controlDatabase.removeMachine(target, ownerKey, message => this.signMessageBytes(message));
|
|
507
|
+
}
|
|
409
508
|
/**
|
|
410
509
|
* Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
|
|
411
510
|
* re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
|
|
@@ -483,6 +582,40 @@ export class SeedBootstrapService {
|
|
|
483
582
|
}
|
|
484
583
|
return this.controlDatabase.openRevocationLedger(ownerKey, message => this.signMessageBytes(message));
|
|
485
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
|
|
587
|
+
* on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
|
|
588
|
+
* expiry and use-count defaults, and the bundle the holder receives are
|
|
589
|
+
* `CadreNode.createCadreInvitation`'s; what stays here is the owner-key precondition and
|
|
590
|
+
* the signature, as {@link insertSelfDeviceToken} keeps them for device tokens.
|
|
591
|
+
*
|
|
592
|
+
* @returns the row as stored, which the invitation bundle carries verbatim.
|
|
593
|
+
* @throws if no owner private key is configured or the control database is not initialized.
|
|
594
|
+
*/
|
|
595
|
+
async insertCadreInvite(invite) {
|
|
596
|
+
// Fail fast on a keyless service before any DB work (see removePeer).
|
|
597
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
598
|
+
if (!this.controlDatabase) {
|
|
599
|
+
throw new Error('Control database not initialized');
|
|
600
|
+
}
|
|
601
|
+
return await this.controlDatabase.insertCadreInvite(invite, ownerKey, message => this.signMessageBytes(message));
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
|
|
605
|
+
* the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
|
|
606
|
+
* owner-key precondition.
|
|
607
|
+
*
|
|
608
|
+
* @returns `true` when this call filed the tombstone, `false` when the row is absent here or
|
|
609
|
+
* already withdrawn.
|
|
610
|
+
* @throws if no owner private key is configured or the control database is not initialized.
|
|
611
|
+
*/
|
|
612
|
+
async withdrawCadreInvite(key) {
|
|
613
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
614
|
+
if (!this.controlDatabase) {
|
|
615
|
+
throw new Error('Control database not initialized');
|
|
616
|
+
}
|
|
617
|
+
return await this.controlDatabase.withdrawCadreInvite(key, ownerKey, message => this.signMessageBytes(message));
|
|
618
|
+
}
|
|
486
619
|
/**
|
|
487
620
|
* Create a seed from the current control network state.
|
|
488
621
|
* The seed contains peer information and is signed by an owner.
|
|
@@ -503,9 +636,7 @@ export class SeedBootstrapService {
|
|
|
503
636
|
peers,
|
|
504
637
|
};
|
|
505
638
|
// Sign the seed over its canonical byte representation
|
|
506
|
-
const
|
|
507
|
-
const seedDigest = digest([seedJson], 'sha256', 'base64url');
|
|
508
|
-
const signature = sign(seedDigest, this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
|
|
639
|
+
const signature = sign(seedDigest(seedData), this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
|
|
509
640
|
const seed = {
|
|
510
641
|
...seedData,
|
|
511
642
|
signature,
|
|
@@ -527,11 +658,11 @@ export class SeedBootstrapService {
|
|
|
527
658
|
*
|
|
528
659
|
* A key accepted via a pin/TOFU is persisted into the anchor (the policy says
|
|
529
660
|
* so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
|
|
530
|
-
* anchored without re-supplying the
|
|
661
|
+
* anchored without re-supplying the pin.
|
|
531
662
|
*
|
|
532
663
|
* @param seed - The seed to apply (already transport-decoded).
|
|
533
664
|
* @param options.trustPolicy - Per-call policy override (e.g. a
|
|
534
|
-
* `pinnedKeyTrustPolicy`
|
|
665
|
+
* `pinnedKeyTrustPolicy` built from an operator pin) used instead of the
|
|
535
666
|
* service-configured default for this seed only.
|
|
536
667
|
*/
|
|
537
668
|
async applySeed(seed, options) {
|
|
@@ -550,15 +681,20 @@ export class SeedBootstrapService {
|
|
|
550
681
|
if (!this.libp2pNode) {
|
|
551
682
|
return seedRejected('Service not initialized');
|
|
552
683
|
}
|
|
553
|
-
// NOTE: `seed.partyId` is never checked against `config.partyId`.
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
684
|
+
// NOTE: `seed.partyId` is never checked against `config.partyId`. Trust is keyed on
|
|
685
|
+
// `signerKey` vs the anchor, and the anchor a stray-party seed could write into
|
|
686
|
+
// belongs to THIS party, which only a caller-supplied pin for that signer can reach.
|
|
687
|
+
// The one reader of the seed's party is the claim policy, which records it as the
|
|
688
|
+
// party the node now serves (`CadreNodeConfig.claim.record`): a mismatch is still not
|
|
689
|
+
// rejected there because an unclaimed node's own party is a placeholder, and the
|
|
690
|
+
// claim seed is the first thing to name the real one. If any other path ever
|
|
691
|
+
// branches on the seed's partyId (or the anchor becomes multi-party), reject a
|
|
558
692
|
// mismatch here instead.
|
|
559
693
|
log('Applying seed for party: %s', seed.partyId);
|
|
560
|
-
//
|
|
561
|
-
|
|
694
|
+
// One digest serves the signature check and the trust context: a claim proof is
|
|
695
|
+
// bound to the seed through the same value the signature covers.
|
|
696
|
+
const digestB64 = seedDigest(seed);
|
|
697
|
+
if (!this.validateSeedSignature(seed, digestB64)) {
|
|
562
698
|
return seedRejected('Invalid seed signature');
|
|
563
699
|
}
|
|
564
700
|
// Evaluate the trust anchor for the signer key. The known-owner set comes
|
|
@@ -573,30 +709,19 @@ export class SeedBootstrapService {
|
|
|
573
709
|
partyId: seed.partyId,
|
|
574
710
|
signerKey: seed.signerKey,
|
|
575
711
|
knownOwnerKeys,
|
|
712
|
+
// Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
|
|
713
|
+
// omit `peerId`. A real node always has one, and a proof bound to a real peer id can
|
|
714
|
+
// only fail against '' — the safe direction.
|
|
715
|
+
localPeerId: this.libp2pNode.peerId?.toString() ?? '',
|
|
716
|
+
seedDigest: digestB64,
|
|
717
|
+
claimProof: options?.claimProof,
|
|
718
|
+
remotePeerId: options?.remotePeerId,
|
|
576
719
|
});
|
|
577
720
|
if (!decision.trusted) {
|
|
578
|
-
return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
|
|
721
|
+
return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy', decision.code);
|
|
579
722
|
}
|
|
580
723
|
await this.anchorAcceptedSigner(seed.signerKey, decision);
|
|
581
|
-
|
|
582
|
-
// Add peers to the peer store
|
|
583
|
-
for (const peer of seed.peers) {
|
|
584
|
-
try {
|
|
585
|
-
// Import peer multiaddrs into the peer store
|
|
586
|
-
if (peer.multiaddrs.length > 0) {
|
|
587
|
-
const peerId = peerIdFromString(peer.peerId);
|
|
588
|
-
const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
|
|
589
|
-
await this.libp2pNode.peerStore.merge(peerId, {
|
|
590
|
-
multiaddrs: addrs
|
|
591
|
-
});
|
|
592
|
-
peersAdded++;
|
|
593
|
-
log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
catch (error) {
|
|
597
|
-
log('Failed to add peer %s: %o', peer.peerId, error);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
724
|
+
const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
|
|
600
725
|
log('Merged seed: %d peers added', peersAdded);
|
|
601
726
|
return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
602
727
|
}
|
|
@@ -625,7 +750,8 @@ export class SeedBootstrapService {
|
|
|
625
750
|
// and an undefined self simply matches nothing.
|
|
626
751
|
// Every one of an owner's addresses is a candidate, each on its own time limit
|
|
627
752
|
// (`dialPeerAddrs`), so an owner whose first address never answers neither
|
|
628
|
-
// stalls seed application nor goes undialed at its other addresses.
|
|
753
|
+
// stalls seed application nor goes undialed at its other addresses. The seed's
|
|
754
|
+
// addresses carry no peer id; the dial binds each to the owner's.
|
|
629
755
|
const selfPeerId = node.peerId?.toString();
|
|
630
756
|
let ownerDialsAttempted = 0;
|
|
631
757
|
let ownerDialsFailed = 0;
|
|
@@ -637,7 +763,7 @@ export class SeedBootstrapService {
|
|
|
637
763
|
try {
|
|
638
764
|
const addrs = parseDialAddrs(peer.multiaddrs);
|
|
639
765
|
log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
|
|
640
|
-
await dialPeerAddrs(node, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
|
|
766
|
+
await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
|
|
641
767
|
}
|
|
642
768
|
catch (error) {
|
|
643
769
|
// Counted even when the owner reaches us only through our own relay: this node is still
|
|
@@ -657,7 +783,7 @@ export class SeedBootstrapService {
|
|
|
657
783
|
}
|
|
658
784
|
/**
|
|
659
785
|
* Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
|
|
660
|
-
* later seed from the same owner is anchored without re-supplying the
|
|
786
|
+
* later seed from the same owner is anchored without re-supplying the pin
|
|
661
787
|
* or re-prompting. Only the policy decides this happens (`anchorAs` is unset
|
|
662
788
|
* when the key was already anchored, so a plain re-apply writes nothing) and
|
|
663
789
|
* `trust()` is idempotent, keeping the original provenance for a known key.
|
|
@@ -665,14 +791,19 @@ export class SeedBootstrapService {
|
|
|
665
791
|
* Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
|
|
666
792
|
* in-memory anchor synchronously, so this seed and the rest of the session are
|
|
667
793
|
* unaffected — only durability across a restart is lost, and that is logged.
|
|
794
|
+
* That holds for pins and TOFU, which are re-supplied at the next start; a claim
|
|
795
|
+
* is the opposite case, so `claimSecretTrustPolicy` anchors its signer itself,
|
|
796
|
+
* awaits durability, and never sets `anchorAs` (the type excludes `'claim'`).
|
|
668
797
|
*
|
|
669
798
|
* NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
|
|
670
799
|
* unauthorized to authorized, which the write-driven membership-gate refresh
|
|
671
|
-
* (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written.
|
|
672
|
-
*
|
|
673
|
-
* gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`)
|
|
674
|
-
*
|
|
675
|
-
*
|
|
800
|
+
* (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. The anchor
|
|
801
|
+
* is mutated from two places: here, on seed application, where both seed paths
|
|
802
|
+
* refresh the gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`);
|
|
803
|
+
* and by the anchor sync (`CadreNode.syncOwnerAnchor`), which runs INSIDE the
|
|
804
|
+
* membership refresh, ahead of the membership read, so its changes are judged by
|
|
805
|
+
* the same pass. If some future path anchors an owner outside both, it owes the
|
|
806
|
+
* same `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
|
|
676
807
|
*/
|
|
677
808
|
async anchorAcceptedSigner(signerKey, decision) {
|
|
678
809
|
if (!decision.anchorAs || !this.config.trustedOwners) {
|
|
@@ -704,43 +835,102 @@ export class SeedBootstrapService {
|
|
|
704
835
|
/**
|
|
705
836
|
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
706
837
|
*
|
|
707
|
-
*
|
|
708
|
-
* {@link
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
838
|
+
* `target` is a multiaddr string, dialed as given, or a peer id with its addresses
|
|
839
|
+
* ({@link SeedDeliveryTarget}). For the second, the connection is formed first and on
|
|
840
|
+
* its own limits ({@link connectForDelivery}): an open one is reused, otherwise each
|
|
841
|
+
* address is dialed in turn, so one that never answers cannot use up the time a later
|
|
842
|
+
* one needed. Its addresses are also merged into the peer store, so identify and later
|
|
843
|
+
* dials see them. A peer id with no addresses is dialed by id, which reuses an open
|
|
844
|
+
* connection or tries the peer store's addresses. `options.claimProof` rides beside
|
|
845
|
+
* the seed in the message when the target is a brand-new node being claimed
|
|
846
|
+
* (`claim-proof.ts`).
|
|
847
|
+
*
|
|
848
|
+
* Sender hardening: the request — opening the stream (with its dial, for the forms
|
|
849
|
+
* that dial by `dialProtocol`), write, ack read — is bounded by {@link seedDeliverTimeoutMs},
|
|
850
|
+
* and the ack is capped at {@link MAX_SEED_SIZE}. The target is a NOT-YET-TRUSTED node the
|
|
851
|
+
* instigator chose to dial during onboarding, so an unbounded read here is strictly more
|
|
852
|
+
* exposed than the membership-gated receiver paths: without the bound a target that accepts
|
|
853
|
+
* the stream and never replies parks this call forever, and one that streams arbitrary bytes
|
|
854
|
+
* as a fake ack exhausts memory. A peer id with addresses can therefore take up to
|
|
855
|
+
* `dialBudget.totalMs` plus {@link seedDeliverTimeoutMs} (114.5 s at the default declared
|
|
856
|
+
* link) when none of its addresses answers.
|
|
857
|
+
*
|
|
858
|
+
* @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it
|
|
859
|
+
* formed; nothing was sent. Anything else thrown after the connection formed means the
|
|
860
|
+
* node was reached and the exchange failed.
|
|
714
861
|
*/
|
|
715
|
-
async deliverSeed(
|
|
862
|
+
async deliverSeed(target, seed, options) {
|
|
716
863
|
if (!this.libp2pNode) {
|
|
717
864
|
throw new Error('Service not initialized');
|
|
718
865
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
866
|
+
const label = typeof target === 'string' ? target : target.peerId;
|
|
867
|
+
log('Delivering seed to: %s', label);
|
|
868
|
+
const openStream = await this.seedStreamOpener(this.libp2pNode, target);
|
|
869
|
+
return await withDeadline(this.seedDeliverTimeoutMs, `Seed delivery to ${label}`, (signal) => this.sendSeed(openStream, seed, options?.claimProof, signal));
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* The part of {@link deliverSeed} that runs before its deadline: how the delivery's stream
|
|
873
|
+
* will be opened, with any connection it needs already formed.
|
|
874
|
+
*/
|
|
875
|
+
async seedStreamOpener(node, target) {
|
|
876
|
+
if (typeof target === 'string') {
|
|
877
|
+
const addr = multiaddr(target);
|
|
878
|
+
// NOTE: this one-address form goes through libp2p's dial queue, which joins it to any dial
|
|
879
|
+
// of the same peer in flight and then waits on that job's address, not this one. Nothing is
|
|
880
|
+
// skipped (there is one address), only delayed; if a seed delivered to a bare multiaddr is
|
|
881
|
+
// ever seen timing out while something else dials that peer, open it with `dialAddr`.
|
|
882
|
+
return (signal) => node.dialProtocol(addr, SEED_PROTOCOL, { signal });
|
|
883
|
+
}
|
|
884
|
+
const peerId = peerIdFromString(target.peerId);
|
|
885
|
+
const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
|
|
886
|
+
await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
|
|
887
|
+
if (addrs.length === 0) {
|
|
888
|
+
return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
|
|
889
|
+
}
|
|
890
|
+
const connection = await this.connectForDelivery(node, peerId, addrs);
|
|
891
|
+
return (signal) => connection.newStream(SEED_PROTOCOL, { signal });
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* A connection to `peerId` for {@link deliverSeed}, from {@link dialPeerAddrs}: one already
|
|
895
|
+
* open without relay limits, or one dialed from `addrs` — each address on
|
|
896
|
+
* `dialBudget.perAddressMs`, the whole peer within `dialBudget.totalMs`, direct addresses
|
|
897
|
+
* before relayed ones and the given order otherwise.
|
|
898
|
+
*
|
|
899
|
+
* @throws {PeerUnreachableError} when no connection formed, with the dial's error as `cause`.
|
|
900
|
+
*/
|
|
901
|
+
async connectForDelivery(node, peerId, addrs) {
|
|
902
|
+
// NOTE: a relayed address can return a limited connection, on which `sendSeed`'s stream
|
|
903
|
+
// open then fails as a plain error rather than `PeerUnreachableError`, though nothing was
|
|
904
|
+
// sent. Claim codes carry no relay addresses today; if a delivery target ever may, count a
|
|
905
|
+
// limited connection as unreachable here.
|
|
906
|
+
try {
|
|
907
|
+
return await dialPeerAddrs(node, peerId.toString(), addrs, this.dialBudget, `Seed delivery dial of ${peerId}`);
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
throw new PeerUnreachableError(peerId.toString(), error);
|
|
911
|
+
}
|
|
724
912
|
}
|
|
725
913
|
/**
|
|
726
914
|
* Open one stream to the target, send the seed frame, half-close, and read the ack.
|
|
727
915
|
*
|
|
728
|
-
* `signal` is the deadline from {@link deliverSeed}: it goes to `
|
|
729
|
-
*
|
|
730
|
-
* timeout after the stream is open resets it — releasing the otherwise unbounded
|
|
916
|
+
* `signal` is the deadline from {@link deliverSeed}: it goes to `openStream` so a
|
|
917
|
+
* timeout while dialing or opening the stream aborts it, and into {@link exchangeFrame}
|
|
918
|
+
* so a timeout after the stream is open resets it — releasing the otherwise unbounded
|
|
731
919
|
* ack-read.
|
|
732
920
|
*
|
|
733
921
|
* Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
|
|
734
|
-
* tiny frame over a relay, whereas a seed is up to 1MB
|
|
735
|
-
*
|
|
922
|
+
* tiny frame over a relay, whereas a seed is up to 1MB, so a relayed address that
|
|
923
|
+
* forms only a limited connection fails here when the stream is opened. Changing
|
|
924
|
+
* that is a separate decision.
|
|
736
925
|
*/
|
|
737
|
-
async sendSeed(
|
|
738
|
-
const rawStream = await
|
|
926
|
+
async sendSeed(openStream, seed, claimProof, signal) {
|
|
927
|
+
const rawStream = await openStream(signal);
|
|
739
928
|
const message = {
|
|
740
929
|
partyId: seed.partyId,
|
|
741
930
|
peers: seed.peers,
|
|
742
931
|
signature: seed.signature,
|
|
743
932
|
signerKey: seed.signerKey,
|
|
933
|
+
...(claimProof !== undefined ? { claimProof } : {}),
|
|
744
934
|
};
|
|
745
935
|
const ack = await exchangeFrame(rawStream, signal, message, (stream) => this.readSeedAck(stream), 'Seed delivery aborted by timeout');
|
|
746
936
|
log('Seed delivery response: accepted=%s', ack.accepted);
|
|
@@ -777,15 +967,16 @@ export class SeedBootstrapService {
|
|
|
777
967
|
}
|
|
778
968
|
/**
|
|
779
969
|
* Validate a seed's signature.
|
|
970
|
+
*
|
|
971
|
+
* @param digestB64 - The seed's {@link seedDigest}, when the caller already computed it
|
|
972
|
+
* (`verifyAndMergeSeed` shares one digest between this check and the trust context).
|
|
780
973
|
*/
|
|
781
|
-
validateSeedSignature(seed) {
|
|
974
|
+
validateSeedSignature(seed, digestB64 = seedDigest(seed)) {
|
|
782
975
|
try {
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
|
|
787
|
-
const seedDigest = digest([seedJson], 'sha256', 'base64url');
|
|
788
|
-
return verify(seedDigest, seed.signature, seed.signerKey, 'ed25519', 'base64url', 'base64url', 'base64url');
|
|
976
|
+
// The digest is over the shared canonical payload, so verification is
|
|
977
|
+
// independent of key order; the payload is the fixed `{ partyId, peers }`
|
|
978
|
+
// the producer emits.
|
|
979
|
+
return verify(digestB64, seed.signature, seed.signerKey, 'ed25519', 'base64url', 'base64url', 'base64url');
|
|
789
980
|
}
|
|
790
981
|
catch (error) {
|
|
791
982
|
log('Seed signature validation failed: %o', error);
|
|
@@ -793,49 +984,16 @@ export class SeedBootstrapService {
|
|
|
793
984
|
}
|
|
794
985
|
}
|
|
795
986
|
/**
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
800
|
-
* multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
|
|
801
|
-
* peer is an owner iff that derived key is in the `OwnerKey` set.
|
|
802
|
-
* This makes any owner node markable — not just the local one — and ties
|
|
803
|
-
* `isOwner` to the control table rather than to `peerId === self`.
|
|
804
|
-
*
|
|
805
|
-
* Read through {@link ControlDatabase.queryCadrePeers}, not a raw `CadrePeer`
|
|
806
|
-
* select: that reader drops any row whose `StampId` is retired in
|
|
807
|
-
* `CadreControl.Revocation`, so a removed member's addresses are never packed
|
|
808
|
-
* into a seed and pushed into a joiner's peerstore (`applySeed` adds every
|
|
809
|
-
* seed peer's addrs and dials the owner-flagged ones). A revoked peer is off
|
|
810
|
-
* the addressable surface everywhere, and this is one of its exits.
|
|
811
|
-
*
|
|
812
|
-
* NOTE: this is the one owner lookup deliberately left on the REPLICATED
|
|
813
|
-
* table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
|
|
814
|
-
* — the receiver dials owner-flagged peers first — not a trust decision, and
|
|
815
|
-
* the receiver re-derives real trust from its own anchor. So a polluted table
|
|
816
|
-
* costs at most a wasted dial, while anchoring here would silently drop
|
|
817
|
-
* legitimate co-owners this node never pinned. If `isOwner` ever gates
|
|
818
|
-
* anything the receiver TRUSTS, move it to the anchor.
|
|
987
|
+
* The peers a seed carries: the control database's own projection
|
|
988
|
+
* ({@link ControlDatabase.querySeedPeers}, shared with the cadre invitation redemption
|
|
989
|
+
* reply), which reads through the revocation filter and flags owners from the replicated
|
|
990
|
+
* `OwnerKey` table — a dial hint, not a trust decision (the NOTE at `projectSeedPeers`).
|
|
819
991
|
*/
|
|
820
992
|
async queryPeers() {
|
|
821
993
|
if (!this.controlDatabase) {
|
|
822
994
|
return [];
|
|
823
995
|
}
|
|
824
|
-
|
|
825
|
-
const rows = await this.controlDatabase.queryCadrePeers();
|
|
826
|
-
return rows.map(({ peerId, multiaddr }) => {
|
|
827
|
-
// Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
|
|
828
|
-
// unparsable id yields null and is treated as a non-owner rather than
|
|
829
|
-
// failing the whole seed creation.
|
|
830
|
-
const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
|
|
831
|
-
const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
|
|
832
|
-
return {
|
|
833
|
-
peerId,
|
|
834
|
-
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
835
|
-
isOwner,
|
|
836
|
-
...(isOwner ? { publicKey: pubKeyB64 } : {}),
|
|
837
|
-
};
|
|
838
|
-
});
|
|
996
|
+
return await this.controlDatabase.querySeedPeers();
|
|
839
997
|
}
|
|
840
998
|
/**
|
|
841
999
|
* Register the seed protocol handler. The inbound closure just delegates to
|
|
@@ -881,11 +1039,11 @@ export class SeedBootstrapService {
|
|
|
881
1039
|
this.activeStreams++;
|
|
882
1040
|
let acked = false;
|
|
883
1041
|
try {
|
|
884
|
-
const seed = await this.readSeedFrame(stream);
|
|
1042
|
+
const { seed, claimProof } = await this.readSeedFrame(stream);
|
|
885
1043
|
this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
|
|
886
|
-
const merged = await this.verifyAndMergeSeed(seed);
|
|
1044
|
+
const merged = await this.verifyAndMergeSeed(seed, { claimProof, remotePeerId });
|
|
887
1045
|
acked = true;
|
|
888
|
-
await replyAndClose(stream, { accepted: merged.success, reason: merged.error }, 'Seed');
|
|
1046
|
+
await replyAndClose(stream, { accepted: merged.success, reason: merged.error, code: merged.code }, 'Seed');
|
|
889
1047
|
if (merged.success) {
|
|
890
1048
|
await this.dialSeedOwners(seed);
|
|
891
1049
|
this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
|
|
@@ -907,7 +1065,11 @@ export class SeedBootstrapService {
|
|
|
907
1065
|
this.activeStreams--;
|
|
908
1066
|
}
|
|
909
1067
|
}
|
|
910
|
-
/**
|
|
1068
|
+
/**
|
|
1069
|
+
* Read the inbound seed frame to EOF (bounded and size-capped) and decode it. The
|
|
1070
|
+
* claim proof comes back beside the seed: it is not part of what the owner signed, so
|
|
1071
|
+
* `ControlNetworkSeed` does not carry it.
|
|
1072
|
+
*/
|
|
911
1073
|
async readSeedFrame(stream) {
|
|
912
1074
|
const data = await readStreamToEnd(stream, {
|
|
913
1075
|
maxBytes: MAX_SEED_SIZE,
|
|
@@ -916,10 +1078,13 @@ export class SeedBootstrapService {
|
|
|
916
1078
|
});
|
|
917
1079
|
const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data)));
|
|
918
1080
|
return {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1081
|
+
seed: {
|
|
1082
|
+
partyId: message.partyId,
|
|
1083
|
+
peers: message.peers,
|
|
1084
|
+
signature: message.signature,
|
|
1085
|
+
signerKey: message.signerKey,
|
|
1086
|
+
},
|
|
1087
|
+
claimProof: message.claimProof,
|
|
923
1088
|
};
|
|
924
1089
|
}
|
|
925
1090
|
/**
|
|
@@ -967,93 +1132,6 @@ export class SeedBootstrapService {
|
|
|
967
1132
|
log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
|
|
968
1133
|
return { seed, encodedSeed };
|
|
969
1134
|
}
|
|
970
|
-
/**
|
|
971
|
-
* Create an invite for a phone to join the cadre.
|
|
972
|
-
*
|
|
973
|
-
* Use this when a server (public IP) wants to invite a phone (NAT'd).
|
|
974
|
-
* The invite is shared out-of-band (QR code, link, etc.) and contains
|
|
975
|
-
* the server's address so the phone can dial in.
|
|
976
|
-
*
|
|
977
|
-
* @param token - Optional invite token for validation
|
|
978
|
-
* @param expiresIn - Optional expiration time in milliseconds
|
|
979
|
-
* @returns Invite and encoded invite for sharing
|
|
980
|
-
*/
|
|
981
|
-
async createInvite(token, expiresIn) {
|
|
982
|
-
if (!this.libp2pNode) {
|
|
983
|
-
throw new Error('Service not initialized');
|
|
984
|
-
}
|
|
985
|
-
log('Creating invite for phone');
|
|
986
|
-
// Get this node's dialable addresses. When an inviteAddressResolver is
|
|
987
|
-
// configured (typically by `@serfab/cadre-host`'s NatService), it takes
|
|
988
|
-
// priority — it may substitute a DDNS hostname and externally-mapped
|
|
989
|
-
// port for the raw LAN multiaddrs libp2p reports.
|
|
990
|
-
let ownerAddrs;
|
|
991
|
-
if (this.config.inviteAddressResolver) {
|
|
992
|
-
try {
|
|
993
|
-
ownerAddrs = await this.config.inviteAddressResolver();
|
|
994
|
-
}
|
|
995
|
-
catch (err) {
|
|
996
|
-
log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
|
|
997
|
-
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
else {
|
|
1001
|
-
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
1002
|
-
}
|
|
1003
|
-
// Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
|
|
1004
|
-
// the trusted owner set before applying any seed.
|
|
1005
|
-
//
|
|
1006
|
-
// Sourced ONLY from this node's own anchor, never from the replicated
|
|
1007
|
-
// OwnerKey table: the invitee anchors whatever arrives here
|
|
1008
|
-
// (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
|
|
1009
|
-
// pollutable table would let a stranger's genesis-inserted key ride an
|
|
1010
|
-
// otherwise-legitimate invite straight into the new node's anchor —
|
|
1011
|
-
// poisoning the very store this whole trust chain rests on. No anchor wired
|
|
1012
|
-
// (a directly-constructed service) means no pins to hand out: an invite
|
|
1013
|
-
// without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
|
|
1014
|
-
// table-sourced one silently hands it an unanchored key.
|
|
1015
|
-
const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
|
|
1016
|
-
const now = Date.now();
|
|
1017
|
-
const invite = {
|
|
1018
|
-
partyId: this.config.partyId,
|
|
1019
|
-
ownerAddrs,
|
|
1020
|
-
ownerKeys: ownerKeys.length ? ownerKeys : undefined,
|
|
1021
|
-
token,
|
|
1022
|
-
createdAt: now,
|
|
1023
|
-
expiresAt: expiresIn ? now + expiresIn : undefined,
|
|
1024
|
-
};
|
|
1025
|
-
const encodedInvite = this.encodeInvite(invite);
|
|
1026
|
-
log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
|
|
1027
|
-
return { invite, encodedInvite };
|
|
1028
|
-
}
|
|
1029
|
-
/**
|
|
1030
|
-
* Accept a phone connection using an invite.
|
|
1031
|
-
*
|
|
1032
|
-
* Use this when a phone dials in with an invite token. This method:
|
|
1033
|
-
* 1. Validates the token if provided
|
|
1034
|
-
* 2. Authorizes the phone peer
|
|
1035
|
-
*
|
|
1036
|
-
* After this, the phone can sync the control database normally.
|
|
1037
|
-
*
|
|
1038
|
-
* @param options - Phone peer info and invite token
|
|
1039
|
-
* @param issuedInvite - The original invite for validation
|
|
1040
|
-
*/
|
|
1041
|
-
async acceptPhone(options, issuedInvite) {
|
|
1042
|
-
const { phonePeerId, token } = options;
|
|
1043
|
-
log('Accepting phone: %s', phonePeerId);
|
|
1044
|
-
// Validate token if invite provided
|
|
1045
|
-
if (issuedInvite) {
|
|
1046
|
-
if (issuedInvite.token && issuedInvite.token !== token) {
|
|
1047
|
-
throw new Error('Invalid invite token');
|
|
1048
|
-
}
|
|
1049
|
-
if (issuedInvite.expiresAt && Date.now() > issuedInvite.expiresAt) {
|
|
1050
|
-
throw new Error('Invite has expired');
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
// Authorize the phone peer (no multiaddrs - phone is NAT'd)
|
|
1054
|
-
await this.authorizePeer({ peerId: phonePeerId });
|
|
1055
|
-
log('Phone %s accepted and authorized', phonePeerId);
|
|
1056
|
-
}
|
|
1057
1135
|
/**
|
|
1058
1136
|
* Add a phone to the cadre with relay support.
|
|
1059
1137
|
*
|
|
@@ -1084,45 +1162,5 @@ export class SeedBootstrapService {
|
|
|
1084
1162
|
log('Phone %s added with relay, seed created', phonePeerId);
|
|
1085
1163
|
return { seed, encodedSeed };
|
|
1086
1164
|
}
|
|
1087
|
-
/**
|
|
1088
|
-
* Encode an invite for out-of-band delivery.
|
|
1089
|
-
*/
|
|
1090
|
-
encodeInvite(invite) {
|
|
1091
|
-
const json = JSON.stringify(invite);
|
|
1092
|
-
return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
|
|
1093
|
-
}
|
|
1094
|
-
/**
|
|
1095
|
-
* Decode an invite from base64url encoding.
|
|
1096
|
-
*/
|
|
1097
|
-
decodeInvite(encoded) {
|
|
1098
|
-
const bytes = uint8ArrayFromString(encoded, 'base64url');
|
|
1099
|
-
const json = new TextDecoder().decode(bytes);
|
|
1100
|
-
return JSON.parse(json);
|
|
1101
|
-
}
|
|
1102
|
-
/**
|
|
1103
|
-
* Dial an owner from an invite.
|
|
1104
|
-
* Use this on a phone after receiving an invite to connect to the owner.
|
|
1105
|
-
*
|
|
1106
|
-
* @param invite - The invite received out-of-band
|
|
1107
|
-
* @returns Connection to the owner
|
|
1108
|
-
*/
|
|
1109
|
-
async dialInvite(invite) {
|
|
1110
|
-
if (!this.libp2pNode) {
|
|
1111
|
-
throw new Error('Service not initialized');
|
|
1112
|
-
}
|
|
1113
|
-
// Check expiration
|
|
1114
|
-
if (invite.expiresAt && Date.now() > invite.expiresAt) {
|
|
1115
|
-
throw new Error('Invite has expired');
|
|
1116
|
-
}
|
|
1117
|
-
log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
|
|
1118
|
-
const addrs = parseDialAddrs(invite.ownerAddrs);
|
|
1119
|
-
if (addrs.length === 0) {
|
|
1120
|
-
throw new Error('No owner addresses available');
|
|
1121
|
-
}
|
|
1122
|
-
// Each address on its own time limit, so one that never answers cannot hold
|
|
1123
|
-
// the invitee back from the rest (`dialPeerAddrs`).
|
|
1124
|
-
const connection = await dialPeerAddrs(this.libp2pNode, addrs, this.dialBudget, 'Invite owner dial');
|
|
1125
|
-
log('Connected to owner at: %s', connection.remoteAddr.toString());
|
|
1126
|
-
}
|
|
1127
1165
|
}
|
|
1128
1166
|
//# sourceMappingURL=seed-bootstrap.js.map
|