@serfab/cadre-core 1.13.0 → 1.14.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/dist/cadre-invite-protocol.d.ts +348 -0
- package/dist/cadre-invite-protocol.js +605 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- package/dist/cadre-node.d.ts +523 -180
- package/dist/cadre-node.js +921 -293
- 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 +514 -97
- package/dist/control-database.js +1033 -195
- 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 +801 -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 +15 -0
- package/dist/ed25519-key.js +28 -1
- package/dist/ed25519-key.js.map +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.js +27 -9
- package/dist/index.js.map +1 -1
- package/dist/link-budget.d.ts +16 -29
- package/dist/link-budget.js +16 -31
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +190 -151
- package/dist/membership-connection-gater.js +235 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +19 -0
- package/dist/node-claim-payload.js +120 -0
- package/dist/node-claim-payload.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 +19 -0
- package/dist/peer-dial.js +22 -1
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +54 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +13 -9
- package/dist/peer-record.js +16 -12
- 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/push-fanout.d.ts +8 -2
- package/dist/push-fanout.js +8 -2
- package/dist/push-fanout.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +147 -122
- package/dist/seed-bootstrap.js +284 -269
- 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-formation-protocol.js +1 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- 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 +13 -20
- 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 +302 -87
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- package/src/cadre-invite-protocol.ts +864 -0
- package/src/cadre-node.ts +9381 -8686
- 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 +1216 -218
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +812 -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 +28 -1
- package/src/index.ts +84 -10
- package/src/link-budget.ts +16 -32
- package/src/membership-connection-gater.ts +637 -570
- package/src/node-claim-payload.ts +125 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +22 -1
- package/src/peer-join-backfill.ts +56 -11
- package/src/peer-record.ts +16 -11
- package/src/pending-join-runner.ts +729 -735
- package/src/push-fanout.ts +8 -2
- package/src/seed-bootstrap.ts +344 -321
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- package/src/strand-formation-protocol.ts +1 -1
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +13 -23
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2471 -2242
package/dist/seed-bootstrap.js
CHANGED
|
@@ -4,11 +4,13 @@ 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, } from './peer-dial.js';
|
|
8
|
+
import { withTrailingPeerId } from './peer-record.js';
|
|
8
9
|
import { relayedRequestBudgetMs } from './link-budget.js';
|
|
9
10
|
import { generateStampId } from './control-database.js';
|
|
10
11
|
import { canonicalJson } from './canonical-json.js';
|
|
11
12
|
import { deviceTokenAddDigest } from './peer-authorization.js';
|
|
13
|
+
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
12
14
|
import { anchoredTrustPolicy, } from './seed-trust-policy.js';
|
|
13
15
|
const log = debug('sereus:cadre:seed-bootstrap');
|
|
14
16
|
/** Protocol ID for seed delivery */
|
|
@@ -48,28 +50,6 @@ export function decodeLengthPrefixedFrame(data, maxLength = MAX_SEED_SIZE) {
|
|
|
48
50
|
}
|
|
49
51
|
return data.subarray(4, 4 + length);
|
|
50
52
|
}
|
|
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
53
|
/**
|
|
74
54
|
* Canonical byte representation of the authenticated seed fields.
|
|
75
55
|
*
|
|
@@ -82,12 +62,28 @@ export function ed25519PublicKeyB64FromPeerId(peerId) {
|
|
|
82
62
|
export function canonicalSeedPayload(seed) {
|
|
83
63
|
return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
|
|
84
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* The digest a seed's signature covers: sha256 over {@link canonicalSeedPayload},
|
|
67
|
+
* base64url. One function so the signer (`createSeed`), the verifier
|
|
68
|
+
* (`validateSeedSignature`) and the claim proof (`claim-proof.ts`, which binds a
|
|
69
|
+
* proof to one seed through this value) all name the same bytes.
|
|
70
|
+
*/
|
|
71
|
+
export function seedDigest(seed) {
|
|
72
|
+
return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url');
|
|
73
|
+
}
|
|
85
74
|
/**
|
|
86
75
|
* An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
|
|
87
76
|
* so the dial counters read zero rather than being absent.
|
|
88
77
|
*/
|
|
89
|
-
function seedRejected(error) {
|
|
90
|
-
return {
|
|
78
|
+
function seedRejected(error, code) {
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
peersAdded: 0,
|
|
82
|
+
error,
|
|
83
|
+
...(code !== undefined ? { code } : {}),
|
|
84
|
+
ownerDialsAttempted: 0,
|
|
85
|
+
ownerDialsFailed: 0,
|
|
86
|
+
};
|
|
91
87
|
}
|
|
92
88
|
/**
|
|
93
89
|
* Parse each address, dropping (and logging) any that is malformed, so one bad
|
|
@@ -105,6 +101,55 @@ function parseDialAddrs(addrs) {
|
|
|
105
101
|
}
|
|
106
102
|
return parsed;
|
|
107
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Merge a peer list's addresses into `node`'s peer store, so the node can dial them: the
|
|
106
|
+
* seed's peers after its signature and trust checks, the dial hints a member returns on
|
|
107
|
+
* a cadre invitation redemption (`cadre-invite-protocol.ts`), or the node a seed is being
|
|
108
|
+
* delivered to ({@link SeedBootstrapService.deliverSeed}). Best-effort per peer — one
|
|
109
|
+
* unparsable entry costs that peer, not the rest — and a peer with no address is skipped,
|
|
110
|
+
* since there is nothing to dial. Returns how many peers were merged.
|
|
111
|
+
*/
|
|
112
|
+
export async function mergeSeedPeers(node, peers) {
|
|
113
|
+
let peersAdded = 0;
|
|
114
|
+
for (const peer of peers) {
|
|
115
|
+
if (peer.multiaddrs.length === 0) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const peerId = peerIdFromString(peer.peerId);
|
|
120
|
+
const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
|
|
121
|
+
await node.peerStore.merge(peerId, { multiaddrs: addrs });
|
|
122
|
+
peersAdded++;
|
|
123
|
+
log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
log('Failed to add peer %s: %o', peer.peerId, error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return peersAdded;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* A delivery target's addresses, each bound to its peer id (`withTrailingPeerId`) so the dial
|
|
133
|
+
* authenticates the node it aims at rather than trusting whoever answers. An address that does
|
|
134
|
+
* not parse, or that names another peer, throws: that is the caller's mistake, not the node
|
|
135
|
+
* being unreachable.
|
|
136
|
+
*/
|
|
137
|
+
function deliveryDialAddrs(peerId, addrs) {
|
|
138
|
+
return addrs.map((text) => {
|
|
139
|
+
const bound = withTrailingPeerId(multiaddr(text), peerId);
|
|
140
|
+
if (!bound) {
|
|
141
|
+
throw new Error(`Seed delivery to ${peerId}: address ${text} names another peer`);
|
|
142
|
+
}
|
|
143
|
+
return bound;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A connection to `peerId` that `node` already holds and a seed stream can use: open and without
|
|
148
|
+
* relay limits, the rule libp2p applies when `dialProtocol` reuses a connection.
|
|
149
|
+
*/
|
|
150
|
+
function openUnlimitedConnection(node, peerId) {
|
|
151
|
+
return node.getConnections(peerId).find((c) => c.status === 'open' && c.limits === undefined);
|
|
152
|
+
}
|
|
108
153
|
/**
|
|
109
154
|
* SeedBootstrapService handles control network seed generation and delivery.
|
|
110
155
|
*
|
|
@@ -158,10 +203,10 @@ export class SeedBootstrapService {
|
|
|
158
203
|
* `registerHandler` (default true) gates registration of the shared inbound
|
|
159
204
|
* `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
|
|
160
205
|
* (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
|
|
161
|
-
* it on. The throwaway temp
|
|
162
|
-
* `
|
|
163
|
-
*
|
|
164
|
-
*
|
|
206
|
+
* it on. The throwaway temp service CadreNode builds in `applySeed` passes
|
|
207
|
+
* `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
|
|
208
|
+
* dialing and known-key lookup, and must NOT bind a discarded closure to the
|
|
209
|
+
* shared node (a handler leak, and a second
|
|
165
210
|
* `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
|
|
166
211
|
*
|
|
167
212
|
* Rejects when libp2p refuses the registration; the node and database are
|
|
@@ -268,7 +313,7 @@ export class SeedBootstrapService {
|
|
|
268
313
|
*/
|
|
269
314
|
async insertSelfDeviceToken(record) {
|
|
270
315
|
const stampId = generateStampId(record.peerId);
|
|
271
|
-
const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
|
|
316
|
+
const signature = this.signDigest(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }));
|
|
272
317
|
if (!this.controlDatabase) {
|
|
273
318
|
throw new Error('Control database not initialized');
|
|
274
319
|
}
|
|
@@ -363,7 +408,7 @@ export class SeedBootstrapService {
|
|
|
363
408
|
*
|
|
364
409
|
* The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
|
|
365
410
|
* signature over the DISTINCT 'remove'-tagged digest
|
|
366
|
-
* `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
|
|
411
|
+
* `digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId)` by an owner
|
|
367
412
|
* key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
|
|
368
413
|
* never be replayed to delete.
|
|
369
414
|
*
|
|
@@ -406,6 +451,37 @@ export class SeedBootstrapService {
|
|
|
406
451
|
await this.controlDatabase.deleteCadrePeer(peerId, ownerKey, message => this.signMessageBytes(message));
|
|
407
452
|
log('Peer %s removed successfully (stamp retired)', peerId);
|
|
408
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
|
|
456
|
+
* liveness check, the stamp, the signature and the membership notify are
|
|
457
|
+
* {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
|
|
458
|
+
* precondition, checked before any read (see {@link removePeer}).
|
|
459
|
+
*
|
|
460
|
+
* @returns `true` when the row was seated, `false` when the key is already a live owner.
|
|
461
|
+
*/
|
|
462
|
+
async addOwner(ownerPublicKey) {
|
|
463
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
464
|
+
if (!this.controlDatabase) {
|
|
465
|
+
throw new Error('Control database not initialized');
|
|
466
|
+
}
|
|
467
|
+
return await this.controlDatabase.insertOwnerKeyVouched(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
|
|
471
|
+
* and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
|
|
472
|
+
* the last owner), the digests and the membership notify are
|
|
473
|
+
* {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
|
|
474
|
+
* checked before any read (see {@link removePeer}).
|
|
475
|
+
*
|
|
476
|
+
* @returns `true` when the row was removed, `false` when the key is not a live owner here.
|
|
477
|
+
*/
|
|
478
|
+
async removeOwner(ownerPublicKey) {
|
|
479
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
480
|
+
if (!this.controlDatabase) {
|
|
481
|
+
throw new Error('Control database not initialized');
|
|
482
|
+
}
|
|
483
|
+
return await this.controlDatabase.deleteOwnerKey(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
|
|
484
|
+
}
|
|
409
485
|
/**
|
|
410
486
|
* Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
|
|
411
487
|
* re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
|
|
@@ -483,6 +559,40 @@ export class SeedBootstrapService {
|
|
|
483
559
|
}
|
|
484
560
|
return this.controlDatabase.openRevocationLedger(ownerKey, message => this.signMessageBytes(message));
|
|
485
561
|
}
|
|
562
|
+
/**
|
|
563
|
+
* Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
|
|
564
|
+
* on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
|
|
565
|
+
* expiry and use-count defaults, and the bundle the holder receives are
|
|
566
|
+
* `CadreNode.createCadreInvitation`'s; what stays here is the owner-key precondition and
|
|
567
|
+
* the signature, as {@link insertSelfDeviceToken} keeps them for device tokens.
|
|
568
|
+
*
|
|
569
|
+
* @returns the row as stored, which the invitation bundle carries verbatim.
|
|
570
|
+
* @throws if no owner private key is configured or the control database is not initialized.
|
|
571
|
+
*/
|
|
572
|
+
async insertCadreInvite(invite) {
|
|
573
|
+
// Fail fast on a keyless service before any DB work (see removePeer).
|
|
574
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
575
|
+
if (!this.controlDatabase) {
|
|
576
|
+
throw new Error('Control database not initialized');
|
|
577
|
+
}
|
|
578
|
+
return await this.controlDatabase.insertCadreInvite(invite, ownerKey, message => this.signMessageBytes(message));
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
|
|
582
|
+
* the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
|
|
583
|
+
* owner-key precondition.
|
|
584
|
+
*
|
|
585
|
+
* @returns `true` when this call filed the tombstone, `false` when the row is absent here or
|
|
586
|
+
* already withdrawn.
|
|
587
|
+
* @throws if no owner private key is configured or the control database is not initialized.
|
|
588
|
+
*/
|
|
589
|
+
async withdrawCadreInvite(key) {
|
|
590
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
591
|
+
if (!this.controlDatabase) {
|
|
592
|
+
throw new Error('Control database not initialized');
|
|
593
|
+
}
|
|
594
|
+
return await this.controlDatabase.withdrawCadreInvite(key, ownerKey, message => this.signMessageBytes(message));
|
|
595
|
+
}
|
|
486
596
|
/**
|
|
487
597
|
* Create a seed from the current control network state.
|
|
488
598
|
* The seed contains peer information and is signed by an owner.
|
|
@@ -503,9 +613,7 @@ export class SeedBootstrapService {
|
|
|
503
613
|
peers,
|
|
504
614
|
};
|
|
505
615
|
// 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');
|
|
616
|
+
const signature = sign(seedDigest(seedData), this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
|
|
509
617
|
const seed = {
|
|
510
618
|
...seedData,
|
|
511
619
|
signature,
|
|
@@ -527,11 +635,11 @@ export class SeedBootstrapService {
|
|
|
527
635
|
*
|
|
528
636
|
* A key accepted via a pin/TOFU is persisted into the anchor (the policy says
|
|
529
637
|
* so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
|
|
530
|
-
* anchored without re-supplying the
|
|
638
|
+
* anchored without re-supplying the pin.
|
|
531
639
|
*
|
|
532
640
|
* @param seed - The seed to apply (already transport-decoded).
|
|
533
641
|
* @param options.trustPolicy - Per-call policy override (e.g. a
|
|
534
|
-
* `pinnedKeyTrustPolicy`
|
|
642
|
+
* `pinnedKeyTrustPolicy` built from an operator pin) used instead of the
|
|
535
643
|
* service-configured default for this seed only.
|
|
536
644
|
*/
|
|
537
645
|
async applySeed(seed, options) {
|
|
@@ -550,15 +658,20 @@ export class SeedBootstrapService {
|
|
|
550
658
|
if (!this.libp2pNode) {
|
|
551
659
|
return seedRejected('Service not initialized');
|
|
552
660
|
}
|
|
553
|
-
// NOTE: `seed.partyId` is never checked against `config.partyId`.
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
661
|
+
// NOTE: `seed.partyId` is never checked against `config.partyId`. Trust is keyed on
|
|
662
|
+
// `signerKey` vs the anchor, and the anchor a stray-party seed could write into
|
|
663
|
+
// belongs to THIS party, which only a caller-supplied pin for that signer can reach.
|
|
664
|
+
// The one reader of the seed's party is the claim policy, which records it as the
|
|
665
|
+
// party the node now serves (`CadreNodeConfig.claim.record`): a mismatch is still not
|
|
666
|
+
// rejected there because an unclaimed node's own party is a placeholder, and the
|
|
667
|
+
// claim seed is the first thing to name the real one. If any other path ever
|
|
668
|
+
// branches on the seed's partyId (or the anchor becomes multi-party), reject a
|
|
558
669
|
// mismatch here instead.
|
|
559
670
|
log('Applying seed for party: %s', seed.partyId);
|
|
560
|
-
//
|
|
561
|
-
|
|
671
|
+
// One digest serves the signature check and the trust context: a claim proof is
|
|
672
|
+
// bound to the seed through the same value the signature covers.
|
|
673
|
+
const digestB64 = seedDigest(seed);
|
|
674
|
+
if (!this.validateSeedSignature(seed, digestB64)) {
|
|
562
675
|
return seedRejected('Invalid seed signature');
|
|
563
676
|
}
|
|
564
677
|
// Evaluate the trust anchor for the signer key. The known-owner set comes
|
|
@@ -573,30 +686,19 @@ export class SeedBootstrapService {
|
|
|
573
686
|
partyId: seed.partyId,
|
|
574
687
|
signerKey: seed.signerKey,
|
|
575
688
|
knownOwnerKeys,
|
|
689
|
+
// Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
|
|
690
|
+
// omit `peerId`. A real node always has one, and a proof bound to a real peer id can
|
|
691
|
+
// only fail against '' — the safe direction.
|
|
692
|
+
localPeerId: this.libp2pNode.peerId?.toString() ?? '',
|
|
693
|
+
seedDigest: digestB64,
|
|
694
|
+
claimProof: options?.claimProof,
|
|
695
|
+
remotePeerId: options?.remotePeerId,
|
|
576
696
|
});
|
|
577
697
|
if (!decision.trusted) {
|
|
578
|
-
return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
|
|
698
|
+
return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy', decision.code);
|
|
579
699
|
}
|
|
580
700
|
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
|
-
}
|
|
701
|
+
const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
|
|
600
702
|
log('Merged seed: %d peers added', peersAdded);
|
|
601
703
|
return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
602
704
|
}
|
|
@@ -657,7 +759,7 @@ export class SeedBootstrapService {
|
|
|
657
759
|
}
|
|
658
760
|
/**
|
|
659
761
|
* 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
|
|
762
|
+
* later seed from the same owner is anchored without re-supplying the pin
|
|
661
763
|
* or re-prompting. Only the policy decides this happens (`anchorAs` is unset
|
|
662
764
|
* when the key was already anchored, so a plain re-apply writes nothing) and
|
|
663
765
|
* `trust()` is idempotent, keeping the original provenance for a known key.
|
|
@@ -665,14 +767,19 @@ export class SeedBootstrapService {
|
|
|
665
767
|
* Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
|
|
666
768
|
* in-memory anchor synchronously, so this seed and the rest of the session are
|
|
667
769
|
* unaffected — only durability across a restart is lost, and that is logged.
|
|
770
|
+
* That holds for pins and TOFU, which are re-supplied at the next start; a claim
|
|
771
|
+
* is the opposite case, so `claimSecretTrustPolicy` anchors its signer itself,
|
|
772
|
+
* awaits durability, and never sets `anchorAs` (the type excludes `'claim'`).
|
|
668
773
|
*
|
|
669
774
|
* NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
|
|
670
775
|
* 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
|
-
*
|
|
776
|
+
* (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. The anchor
|
|
777
|
+
* is mutated from two places: here, on seed application, where both seed paths
|
|
778
|
+
* refresh the gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`);
|
|
779
|
+
* and by the anchor sync (`CadreNode.syncOwnerAnchor`), which runs INSIDE the
|
|
780
|
+
* membership refresh, ahead of the membership read, so its changes are judged by
|
|
781
|
+
* the same pass. If some future path anchors an owner outside both, it owes the
|
|
782
|
+
* same `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
|
|
676
783
|
*/
|
|
677
784
|
async anchorAcceptedSigner(signerKey, decision) {
|
|
678
785
|
if (!decision.anchorAs || !this.config.trustedOwners) {
|
|
@@ -704,43 +811,103 @@ export class SeedBootstrapService {
|
|
|
704
811
|
/**
|
|
705
812
|
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
706
813
|
*
|
|
707
|
-
*
|
|
708
|
-
* {@link
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
814
|
+
* `target` is a multiaddr string, dialed as given, or a peer id with its addresses
|
|
815
|
+
* ({@link SeedDeliveryTarget}). For the second, the connection is formed first and on
|
|
816
|
+
* its own limits ({@link connectForDelivery}): an open one is reused, otherwise each
|
|
817
|
+
* address is dialed in turn, so one that never answers cannot use up the time a later
|
|
818
|
+
* one needed. Its addresses are also merged into the peer store, so identify and later
|
|
819
|
+
* dials see them. A peer id with no addresses is dialed by id, which reuses an open
|
|
820
|
+
* connection or tries the peer store's addresses. `options.claimProof` rides beside
|
|
821
|
+
* the seed in the message when the target is a brand-new node being claimed
|
|
822
|
+
* (`claim-proof.ts`).
|
|
823
|
+
*
|
|
824
|
+
* Sender hardening: the request — opening the stream (with its dial, for the forms
|
|
825
|
+
* that dial by `dialProtocol`), write, ack read — is bounded by {@link seedDeliverTimeoutMs},
|
|
826
|
+
* and the ack is capped at {@link MAX_SEED_SIZE}. The target is a NOT-YET-TRUSTED node the
|
|
827
|
+
* instigator chose to dial during onboarding, so an unbounded read here is strictly more
|
|
828
|
+
* exposed than the membership-gated receiver paths: without the bound a target that accepts
|
|
829
|
+
* the stream and never replies parks this call forever, and one that streams arbitrary bytes
|
|
830
|
+
* as a fake ack exhausts memory. A peer id with addresses can therefore take up to
|
|
831
|
+
* `dialBudget.totalMs` plus {@link seedDeliverTimeoutMs} (114.5 s at the default declared
|
|
832
|
+
* link) when none of its addresses answers.
|
|
833
|
+
*
|
|
834
|
+
* @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it
|
|
835
|
+
* formed; nothing was sent. Anything else thrown after the connection formed means the
|
|
836
|
+
* node was reached and the exchange failed.
|
|
714
837
|
*/
|
|
715
|
-
async deliverSeed(
|
|
838
|
+
async deliverSeed(target, seed, options) {
|
|
716
839
|
if (!this.libp2pNode) {
|
|
717
840
|
throw new Error('Service not initialized');
|
|
718
841
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
842
|
+
const label = typeof target === 'string' ? target : target.peerId;
|
|
843
|
+
log('Delivering seed to: %s', label);
|
|
844
|
+
const openStream = await this.seedStreamOpener(this.libp2pNode, target);
|
|
845
|
+
return await withDeadline(this.seedDeliverTimeoutMs, `Seed delivery to ${label}`, (signal) => this.sendSeed(openStream, seed, options?.claimProof, signal));
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* The part of {@link deliverSeed} that runs before its deadline: how the delivery's stream
|
|
849
|
+
* will be opened, with any connection it needs already formed.
|
|
850
|
+
*/
|
|
851
|
+
async seedStreamOpener(node, target) {
|
|
852
|
+
if (typeof target === 'string') {
|
|
853
|
+
const addr = multiaddr(target);
|
|
854
|
+
return (signal) => node.dialProtocol(addr, SEED_PROTOCOL, { signal });
|
|
855
|
+
}
|
|
856
|
+
const peerId = peerIdFromString(target.peerId);
|
|
857
|
+
const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
|
|
858
|
+
await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
|
|
859
|
+
if (addrs.length === 0) {
|
|
860
|
+
return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
|
|
861
|
+
}
|
|
862
|
+
const connection = await this.connectForDelivery(node, peerId, addrs);
|
|
863
|
+
return (signal) => connection.newStream(SEED_PROTOCOL, { signal });
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* A connection to `peerId` for {@link deliverSeed}: one already open, or one dialed from
|
|
867
|
+
* `addrs` by {@link dialPeerAddrs} — each address on `dialBudget.perAddressMs`, the whole
|
|
868
|
+
* peer within `dialBudget.totalMs`, direct addresses before relayed ones and the given
|
|
869
|
+
* order otherwise.
|
|
870
|
+
*
|
|
871
|
+
* @throws {PeerUnreachableError} when no connection formed, with the dial's error as `cause`.
|
|
872
|
+
*/
|
|
873
|
+
async connectForDelivery(node, peerId, addrs) {
|
|
874
|
+
const open = openUnlimitedConnection(node, peerId);
|
|
875
|
+
if (open) {
|
|
876
|
+
log('Seed delivery to %s reuses an open connection', peerId);
|
|
877
|
+
return open;
|
|
878
|
+
}
|
|
879
|
+
// NOTE: a relayed address can return a limited connection, on which `sendSeed`'s stream
|
|
880
|
+
// open then fails as a plain error rather than `PeerUnreachableError`, though nothing was
|
|
881
|
+
// sent. Claim codes carry no relay addresses today; if a delivery target ever may, count a
|
|
882
|
+
// limited connection as unreachable here.
|
|
883
|
+
try {
|
|
884
|
+
return await dialPeerAddrs(node, addrs, this.dialBudget, `Seed delivery dial of ${peerId}`);
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
throw new PeerUnreachableError(peerId.toString(), error);
|
|
888
|
+
}
|
|
724
889
|
}
|
|
725
890
|
/**
|
|
726
891
|
* Open one stream to the target, send the seed frame, half-close, and read the ack.
|
|
727
892
|
*
|
|
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
|
|
893
|
+
* `signal` is the deadline from {@link deliverSeed}: it goes to `openStream` so a
|
|
894
|
+
* timeout while dialing or opening the stream aborts it, and into {@link exchangeFrame}
|
|
895
|
+
* so a timeout after the stream is open resets it — releasing the otherwise unbounded
|
|
731
896
|
* ack-read.
|
|
732
897
|
*
|
|
733
898
|
* 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
|
-
*
|
|
899
|
+
* tiny frame over a relay, whereas a seed is up to 1MB, so a relayed address that
|
|
900
|
+
* forms only a limited connection fails here when the stream is opened. Changing
|
|
901
|
+
* that is a separate decision.
|
|
736
902
|
*/
|
|
737
|
-
async sendSeed(
|
|
738
|
-
const rawStream = await
|
|
903
|
+
async sendSeed(openStream, seed, claimProof, signal) {
|
|
904
|
+
const rawStream = await openStream(signal);
|
|
739
905
|
const message = {
|
|
740
906
|
partyId: seed.partyId,
|
|
741
907
|
peers: seed.peers,
|
|
742
908
|
signature: seed.signature,
|
|
743
909
|
signerKey: seed.signerKey,
|
|
910
|
+
...(claimProof !== undefined ? { claimProof } : {}),
|
|
744
911
|
};
|
|
745
912
|
const ack = await exchangeFrame(rawStream, signal, message, (stream) => this.readSeedAck(stream), 'Seed delivery aborted by timeout');
|
|
746
913
|
log('Seed delivery response: accepted=%s', ack.accepted);
|
|
@@ -777,15 +944,16 @@ export class SeedBootstrapService {
|
|
|
777
944
|
}
|
|
778
945
|
/**
|
|
779
946
|
* Validate a seed's signature.
|
|
947
|
+
*
|
|
948
|
+
* @param digestB64 - The seed's {@link seedDigest}, when the caller already computed it
|
|
949
|
+
* (`verifyAndMergeSeed` shares one digest between this check and the trust context).
|
|
780
950
|
*/
|
|
781
|
-
validateSeedSignature(seed) {
|
|
951
|
+
validateSeedSignature(seed, digestB64 = seedDigest(seed)) {
|
|
782
952
|
try {
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
|
|
787
|
-
const seedDigest = digest([seedJson], 'sha256', 'base64url');
|
|
788
|
-
return verify(seedDigest, seed.signature, seed.signerKey, 'ed25519', 'base64url', 'base64url', 'base64url');
|
|
953
|
+
// The digest is over the shared canonical payload, so verification is
|
|
954
|
+
// independent of key order; the payload is the fixed `{ partyId, peers }`
|
|
955
|
+
// the producer emits.
|
|
956
|
+
return verify(digestB64, seed.signature, seed.signerKey, 'ed25519', 'base64url', 'base64url', 'base64url');
|
|
789
957
|
}
|
|
790
958
|
catch (error) {
|
|
791
959
|
log('Seed signature validation failed: %o', error);
|
|
@@ -793,49 +961,16 @@ export class SeedBootstrapService {
|
|
|
793
961
|
}
|
|
794
962
|
}
|
|
795
963
|
/**
|
|
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.
|
|
964
|
+
* The peers a seed carries: the control database's own projection
|
|
965
|
+
* ({@link ControlDatabase.querySeedPeers}, shared with the cadre invitation redemption
|
|
966
|
+
* reply), which reads through the revocation filter and flags owners from the replicated
|
|
967
|
+
* `OwnerKey` table — a dial hint, not a trust decision (the NOTE at `projectSeedPeers`).
|
|
819
968
|
*/
|
|
820
969
|
async queryPeers() {
|
|
821
970
|
if (!this.controlDatabase) {
|
|
822
971
|
return [];
|
|
823
972
|
}
|
|
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
|
-
});
|
|
973
|
+
return await this.controlDatabase.querySeedPeers();
|
|
839
974
|
}
|
|
840
975
|
/**
|
|
841
976
|
* Register the seed protocol handler. The inbound closure just delegates to
|
|
@@ -881,11 +1016,11 @@ export class SeedBootstrapService {
|
|
|
881
1016
|
this.activeStreams++;
|
|
882
1017
|
let acked = false;
|
|
883
1018
|
try {
|
|
884
|
-
const seed = await this.readSeedFrame(stream);
|
|
1019
|
+
const { seed, claimProof } = await this.readSeedFrame(stream);
|
|
885
1020
|
this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
|
|
886
|
-
const merged = await this.verifyAndMergeSeed(seed);
|
|
1021
|
+
const merged = await this.verifyAndMergeSeed(seed, { claimProof, remotePeerId });
|
|
887
1022
|
acked = true;
|
|
888
|
-
await replyAndClose(stream, { accepted: merged.success, reason: merged.error }, 'Seed');
|
|
1023
|
+
await replyAndClose(stream, { accepted: merged.success, reason: merged.error, code: merged.code }, 'Seed');
|
|
889
1024
|
if (merged.success) {
|
|
890
1025
|
await this.dialSeedOwners(seed);
|
|
891
1026
|
this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
|
|
@@ -907,7 +1042,11 @@ export class SeedBootstrapService {
|
|
|
907
1042
|
this.activeStreams--;
|
|
908
1043
|
}
|
|
909
1044
|
}
|
|
910
|
-
/**
|
|
1045
|
+
/**
|
|
1046
|
+
* Read the inbound seed frame to EOF (bounded and size-capped) and decode it. The
|
|
1047
|
+
* claim proof comes back beside the seed: it is not part of what the owner signed, so
|
|
1048
|
+
* `ControlNetworkSeed` does not carry it.
|
|
1049
|
+
*/
|
|
911
1050
|
async readSeedFrame(stream) {
|
|
912
1051
|
const data = await readStreamToEnd(stream, {
|
|
913
1052
|
maxBytes: MAX_SEED_SIZE,
|
|
@@ -916,10 +1055,13 @@ export class SeedBootstrapService {
|
|
|
916
1055
|
});
|
|
917
1056
|
const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data)));
|
|
918
1057
|
return {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1058
|
+
seed: {
|
|
1059
|
+
partyId: message.partyId,
|
|
1060
|
+
peers: message.peers,
|
|
1061
|
+
signature: message.signature,
|
|
1062
|
+
signerKey: message.signerKey,
|
|
1063
|
+
},
|
|
1064
|
+
claimProof: message.claimProof,
|
|
923
1065
|
};
|
|
924
1066
|
}
|
|
925
1067
|
/**
|
|
@@ -967,93 +1109,6 @@ export class SeedBootstrapService {
|
|
|
967
1109
|
log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
|
|
968
1110
|
return { seed, encodedSeed };
|
|
969
1111
|
}
|
|
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
1112
|
/**
|
|
1058
1113
|
* Add a phone to the cadre with relay support.
|
|
1059
1114
|
*
|
|
@@ -1084,45 +1139,5 @@ export class SeedBootstrapService {
|
|
|
1084
1139
|
log('Phone %s added with relay, seed created', phonePeerId);
|
|
1085
1140
|
return { seed, encodedSeed };
|
|
1086
1141
|
}
|
|
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
1142
|
}
|
|
1128
1143
|
//# sourceMappingURL=seed-bootstrap.js.map
|