@serfab/cadre-core 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/bootstrap-peer-store-file.d.ts +13 -0
- package/dist/bootstrap-peer-store-file.js +42 -0
- package/dist/bootstrap-peer-store-file.js.map +1 -0
- package/dist/bootstrap-peer-store.d.ts +73 -0
- package/dist/bootstrap-peer-store.js +128 -0
- package/dist/bootstrap-peer-store.js.map +1 -0
- package/dist/cadre-node.d.ts +1006 -101
- package/dist/cadre-node.js +1977 -283
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +93 -0
- package/dist/control-authorization.js +57 -0
- package/dist/control-authorization.js.map +1 -0
- package/dist/control-cohort.d.ts +15 -15
- package/dist/control-cohort.js +14 -14
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +1033 -76
- package/dist/control-database.js +1520 -153
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +91 -12
- package/dist/control-formation-recorder.js +131 -16
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +774 -186
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +38 -4
- package/dist/control-stream.js +70 -4
- package/dist/control-stream.js.map +1 -1
- package/dist/control-write-retry.d.ts +151 -0
- package/dist/control-write-retry.js +411 -0
- package/dist/control-write-retry.js.map +1 -0
- package/dist/delegate-admission.d.ts +134 -0
- package/dist/delegate-admission.js +244 -0
- package/dist/delegate-admission.js.map +1 -0
- package/dist/device-token.d.ts +12 -12
- package/dist/device-token.js +15 -14
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +68 -0
- package/dist/ed25519-key.js +106 -0
- package/dist/ed25519-key.js.map +1 -0
- package/dist/file-durable-slot.d.ts +20 -0
- package/dist/file-durable-slot.js +53 -0
- package/dist/file-durable-slot.js.map +1 -0
- package/dist/formation-approval.d.ts +157 -0
- package/dist/formation-approval.js +375 -0
- package/dist/formation-approval.js.map +1 -0
- package/dist/fs-atomic.d.ts +38 -0
- package/dist/fs-atomic.js +97 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/identity-key.d.ts +49 -0
- package/dist/identity-key.js +81 -0
- package/dist/identity-key.js.map +1 -0
- package/dist/index.d.ts +16 -6
- package/dist/index.js +44 -10
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +2 -24
- package/dist/key-store-file.js +10 -77
- package/dist/key-store-file.js.map +1 -1
- package/dist/key-store.d.ts +1 -1
- package/dist/key-store.js +1 -1
- package/dist/membership-connection-gater.d.ts +146 -0
- package/dist/membership-connection-gater.js +180 -0
- package/dist/membership-connection-gater.js.map +1 -0
- package/dist/node-local-snapshot.d.ts +109 -0
- package/dist/node-local-snapshot.js +182 -0
- package/dist/node-local-snapshot.js.map +1 -0
- package/dist/peer-authorization.d.ts +150 -11
- package/dist/peer-authorization.js +170 -13
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-record.d.ts +24 -11
- package/dist/peer-record.js +29 -13
- package/dist/peer-record.js.map +1 -1
- package/dist/push-node.d.ts +38 -0
- package/dist/push-node.js +48 -0
- package/dist/push-node.js.map +1 -0
- package/dist/push-notifier-apns.d.ts +10 -2
- package/dist/push-notifier-apns.js +14 -10
- package/dist/push-notifier-apns.js.map +1 -1
- package/dist/push-notifier-fcm.d.ts +7 -1
- package/dist/push-notifier-fcm.js +9 -10
- package/dist/push-notifier-fcm.js.map +1 -1
- package/dist/push-notifier.d.ts +16 -35
- package/dist/push-notifier.js +16 -44
- package/dist/push-notifier.js.map +1 -1
- package/dist/relay-addrs.d.ts +45 -0
- package/dist/relay-addrs.js +103 -0
- package/dist/relay-addrs.js.map +1 -0
- package/dist/relay-reservation.d.ts +266 -0
- package/dist/relay-reservation.js +613 -0
- package/dist/relay-reservation.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +263 -79
- package/dist/seed-bootstrap.js +413 -211
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +50 -24
- package/dist/seed-trust-policy.js +28 -19
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +42 -13
- package/dist/strand-addr-protocol.js +58 -52
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.d.ts +109 -0
- package/dist/strand-backfill.js +279 -0
- package/dist/strand-backfill.js.map +1 -0
- package/dist/strand-database.d.ts +5 -5
- package/dist/strand-database.js +4 -4
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +38 -9
- package/dist/strand-formation-manager.js +120 -17
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +104 -5
- package/dist/strand-formation-protocol.js +233 -5
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +40 -2
- package/dist/strand-instance-manager.js +93 -15
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +6 -6
- package/dist/strand-member-key.js +6 -6
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.d.ts +12 -12
- package/dist/strand-member-registry.js +22 -14
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +532 -138
- package/dist/strand-membership-writer.js +800 -179
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +99 -33
- package/dist/strand-solicitation.js +100 -52
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-transport-key.d.ts +40 -0
- package/dist/strand-transport-key.js +56 -0
- package/dist/strand-transport-key.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +9 -7
- package/dist/strand-wake-protocol.js +22 -46
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.d.ts +17 -2
- package/dist/strand-watcher.js +47 -3
- package/dist/strand-watcher.js.map +1 -1
- package/dist/trusted-owner-store-file.d.ts +12 -0
- package/dist/trusted-owner-store-file.js +39 -0
- package/dist/trusted-owner-store-file.js.map +1 -0
- package/dist/trusted-owner-store.d.ts +64 -0
- package/dist/trusted-owner-store.js +126 -0
- package/dist/trusted-owner-store.js.map +1 -0
- package/dist/types.d.ts +325 -50
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/package.json +23 -8
- package/src/bootstrap-peer-store-file.ts +44 -0
- package/src/bootstrap-peer-store.ts +172 -0
- package/src/cadre-node.ts +2129 -295
- package/src/control-authorization.ts +114 -0
- package/src/control-cohort.ts +17 -17
- package/src/control-database.ts +1766 -191
- package/src/control-formation-recorder.ts +174 -21
- package/src/control-schema.ts +786 -198
- package/src/control-stream.ts +81 -4
- package/src/control-write-retry.ts +473 -0
- package/src/delegate-admission.ts +302 -0
- package/src/device-token.ts +19 -14
- package/src/ed25519-key.ts +134 -0
- package/src/file-durable-slot.ts +61 -0
- package/src/formation-approval.ts +604 -0
- package/src/fs-atomic.ts +107 -0
- package/src/identity-key.ts +109 -0
- package/src/index.ts +157 -24
- package/src/key-store-file.ts +10 -81
- package/src/key-store.ts +1 -1
- package/src/membership-connection-gater.ts +204 -0
- package/src/node-local-snapshot.ts +260 -0
- package/src/peer-authorization.ts +219 -13
- package/src/peer-record.ts +31 -13
- package/src/push-node.ts +64 -0
- package/src/push-notifier-apns.ts +16 -12
- package/src/push-notifier-fcm.ts +9 -10
- package/src/push-notifier.ts +16 -57
- package/src/relay-addrs.ts +111 -0
- package/src/relay-reservation.ts +823 -0
- package/src/seed-bootstrap.ts +490 -226
- package/src/seed-trust-policy.ts +65 -32
- package/src/strand-addr-protocol.ts +84 -53
- package/src/strand-backfill.ts +366 -0
- package/src/strand-database.ts +6 -6
- package/src/strand-formation-manager.ts +145 -19
- package/src/strand-formation-protocol.ts +310 -9
- package/src/strand-instance-manager.ts +113 -16
- package/src/strand-member-key.ts +7 -7
- package/src/strand-member-registry.ts +28 -23
- package/src/strand-membership-writer.ts +1430 -640
- package/src/strand-solicitation.ts +155 -90
- package/src/strand-transport-key.ts +58 -0
- package/src/strand-wake-protocol.ts +29 -46
- package/src/strand-watcher.ts +66 -3
- package/src/trusted-owner-store-file.ts +41 -0
- package/src/trusted-owner-store.ts +171 -0
- package/src/types.ts +342 -51
- package/dist/authority-key.d.ts +0 -41
- package/dist/authority-key.js +0 -52
- package/dist/authority-key.js.map +0 -1
- package/src/authority-key.ts +0 -68
package/dist/seed-bootstrap.js
CHANGED
|
@@ -3,10 +3,11 @@ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } fr
|
|
|
3
3
|
import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
|
|
4
4
|
import { multiaddr } from '@multiformats/multiaddr';
|
|
5
5
|
import { peerIdFromString } from '@libp2p/peer-id';
|
|
6
|
-
import { writeFrame, readStreamToEnd } from './control-stream.js';
|
|
6
|
+
import { writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
|
|
7
|
+
import { generateStampId } from './control-database.js';
|
|
7
8
|
import { canonicalJson } from './canonical-json.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
9
|
+
import { deviceTokenAddDigest } from './peer-authorization.js';
|
|
10
|
+
import { anchoredTrustPolicy, } from './seed-trust-policy.js';
|
|
10
11
|
const log = debug('sereus:cadre:seed-bootstrap');
|
|
11
12
|
/** Protocol ID for seed delivery */
|
|
12
13
|
export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
|
|
@@ -16,6 +17,8 @@ const MAX_SEED_SIZE = 1024 * 1024;
|
|
|
16
17
|
const DEFAULT_SEED_READ_TIMEOUT_MS = 10000;
|
|
17
18
|
/** Default cap on concurrent inbound seed streams a single peer can pin open. */
|
|
18
19
|
const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
|
|
20
|
+
/** Default time the sender waits for a seed delivery (dial + ack read) before aborting (ms). */
|
|
21
|
+
const DEFAULT_SEED_DELIVER_TIMEOUT_MS = 10000;
|
|
19
22
|
/**
|
|
20
23
|
* Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
|
|
21
24
|
*
|
|
@@ -46,10 +49,10 @@ export function decodeLengthPrefixedFrame(data, maxLength = MAX_SEED_SIZE) {
|
|
|
46
49
|
*
|
|
47
50
|
* An Ed25519 PeerId is an identity multihash of the public key, so
|
|
48
51
|
* `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
|
|
49
|
-
* base64url form matches the `
|
|
50
|
-
* `
|
|
52
|
+
* base64url form matches the `OwnerKey.Key` representation (and
|
|
53
|
+
* `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
|
|
51
54
|
* id, a missing embedded key, or any parse failure — callers treat null as
|
|
52
|
-
* "not an
|
|
55
|
+
* "not an owner" rather than throwing.
|
|
53
56
|
*/
|
|
54
57
|
export function ed25519PublicKeyB64FromPeerId(peerId) {
|
|
55
58
|
try {
|
|
@@ -75,6 +78,13 @@ export function ed25519PublicKeyB64FromPeerId(peerId) {
|
|
|
75
78
|
export function canonicalSeedPayload(seed) {
|
|
76
79
|
return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
|
|
77
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
|
|
83
|
+
* so the dial counters read zero rather than being absent.
|
|
84
|
+
*/
|
|
85
|
+
function seedRejected(error) {
|
|
86
|
+
return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
87
|
+
}
|
|
78
88
|
/**
|
|
79
89
|
* SeedBootstrapService handles control network seed generation and delivery.
|
|
80
90
|
*
|
|
@@ -90,15 +100,16 @@ export class SeedBootstrapService {
|
|
|
90
100
|
this.activeStreams = 0;
|
|
91
101
|
this.eventCallbacks = {};
|
|
92
102
|
this.config = config;
|
|
93
|
-
this.trustPolicy = config.trustPolicy ??
|
|
103
|
+
this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
|
|
94
104
|
this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
|
|
95
105
|
this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
|
|
106
|
+
this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? DEFAULT_SEED_DELIVER_TIMEOUT_MS;
|
|
96
107
|
// Derive public key from private key if not provided
|
|
97
|
-
if (config.
|
|
98
|
-
this.
|
|
108
|
+
if (config.ownerPrivateKey && !config.ownerPublicKey) {
|
|
109
|
+
this.ownerPublicKey = getPublicKey(config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url');
|
|
99
110
|
}
|
|
100
111
|
else {
|
|
101
|
-
this.
|
|
112
|
+
this.ownerPublicKey = config.ownerPublicKey ?? null;
|
|
102
113
|
}
|
|
103
114
|
log('SeedBootstrapService created for party: %s', config.partyId);
|
|
104
115
|
}
|
|
@@ -110,15 +121,15 @@ export class SeedBootstrapService {
|
|
|
110
121
|
this.eventCallbacks = callbacks;
|
|
111
122
|
}
|
|
112
123
|
/**
|
|
113
|
-
* Whether this service holds an
|
|
114
|
-
*
|
|
124
|
+
* Whether this service holds an owner private key, i.e. can produce the
|
|
125
|
+
* owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
|
|
115
126
|
* and re-authorizations. A seed-listener-only service (`enableSeedListener`,
|
|
116
|
-
* no
|
|
117
|
-
* or re-issue
|
|
118
|
-
* drain to skip
|
|
127
|
+
* no owner key) returns false: it can receive/apply seeds but cannot author
|
|
128
|
+
* or re-issue owner writes. Used by the write-while-alone re-replication
|
|
129
|
+
* drain to skip owner work on a non-owner node.
|
|
119
130
|
*/
|
|
120
131
|
canAuthorize() {
|
|
121
|
-
return !!this.config.
|
|
132
|
+
return !!this.config.ownerPrivateKey;
|
|
122
133
|
}
|
|
123
134
|
/**
|
|
124
135
|
* Initialize the service with libp2p node and control database.
|
|
@@ -143,14 +154,14 @@ export class SeedBootstrapService {
|
|
|
143
154
|
}
|
|
144
155
|
/**
|
|
145
156
|
* Authorize a new peer to join the cadre.
|
|
146
|
-
* Signs
|
|
157
|
+
* Signs a membership voucher with the owner key and inserts into CadrePeer table.
|
|
147
158
|
*
|
|
148
|
-
* The
|
|
159
|
+
* The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
|
|
149
160
|
* caller-supplied key, the binding is enforced by construction — `PublicKey` is
|
|
150
161
|
* DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
|
|
151
162
|
* `PublicKey`, and such a row can never be self-updated (it has no key to
|
|
152
163
|
* verify against), which is correct. The row is inserted with a fresh
|
|
153
|
-
* `UpdatedAt` but no self-signature (`Sig` null) — the
|
|
164
|
+
* `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
|
|
154
165
|
* the peer's self-signature, so the peer must self-publish (see
|
|
155
166
|
* {@link CadreNode.registerSelf}) before the row resolves.
|
|
156
167
|
*/
|
|
@@ -169,15 +180,20 @@ export class SeedBootstrapService {
|
|
|
169
180
|
log('Peer %s authorized successfully', peerId);
|
|
170
181
|
}
|
|
171
182
|
/**
|
|
172
|
-
*
|
|
183
|
+
* Owner-signed INSERT of this node's OWN self-signed address record.
|
|
173
184
|
*
|
|
174
185
|
* Used by {@link CadreNode.registerSelf} when the node is not yet a member and
|
|
175
|
-
* is its own
|
|
186
|
+
* is its own owner (it holds the owner key): the row is owner-signed
|
|
176
187
|
* (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
|
|
177
188
|
* immediately without a follow-up self-update.
|
|
189
|
+
*
|
|
190
|
+
* @returns `true` when this call seated the row, `false` when a concurrent writer
|
|
191
|
+
* (e.g. an {@link authorizePeer} of this node's own id) had already seated it —
|
|
192
|
+
* in which case the row in the database is NOT this record and carries whatever
|
|
193
|
+
* `Sig` that writer had, so the caller must self-update to publish its signature.
|
|
178
194
|
*/
|
|
179
195
|
async insertSelfPeerRecord(record) {
|
|
180
|
-
await this.insertCadrePeerRow({
|
|
196
|
+
return await this.insertCadrePeerRow({
|
|
181
197
|
peerId: record.peerId,
|
|
182
198
|
publicKey: record.publicKey,
|
|
183
199
|
multiaddr: record.addrs.join(','),
|
|
@@ -186,148 +202,249 @@ export class SeedBootstrapService {
|
|
|
186
202
|
});
|
|
187
203
|
}
|
|
188
204
|
/**
|
|
189
|
-
* Shared
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
205
|
+
* Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
|
|
206
|
+
* precondition; the stamp mint, voucher signature, write lock, existence check and
|
|
207
|
+
* membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
|
|
208
|
+
* the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
|
|
209
|
+
* method: the write is what admits the peer's traffic.
|
|
210
|
+
*
|
|
211
|
+
* The DB check precedes the owner-key check here, matching the order this method has
|
|
212
|
+
* always surfaced them (the owner-key error used to come out of the signer, after the
|
|
213
|
+
* DB check).
|
|
214
|
+
*
|
|
215
|
+
* @returns `true` when this call performed the INSERT, `false` when the row was
|
|
216
|
+
* already seated by a concurrent writer. The loser needs to know: an authorize seats
|
|
217
|
+
* a row with a null `Sig`, so a self-publish that lost the race must fall through to
|
|
218
|
+
* a self-update or its record never lands.
|
|
194
219
|
*/
|
|
195
220
|
async insertCadrePeerRow(row) {
|
|
196
|
-
const signature = this.signPeerAuthorization(row.peerId);
|
|
197
221
|
if (!this.controlDatabase) {
|
|
198
222
|
throw new Error('Control database not initialized');
|
|
199
223
|
}
|
|
200
|
-
const
|
|
201
|
-
await
|
|
202
|
-
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig)
|
|
203
|
-
with context AuthorityKey = ?, Signature = ?
|
|
204
|
-
values (?, ?, ?, ?, ?)
|
|
205
|
-
`, [this.authorityPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig]);
|
|
224
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
225
|
+
return await this.controlDatabase.insertCadrePeer(row, ownerKey, message => this.signMessageBytes(message));
|
|
206
226
|
}
|
|
207
227
|
/**
|
|
208
|
-
*
|
|
228
|
+
* Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
|
|
209
229
|
*
|
|
210
230
|
* Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
|
|
211
|
-
* row is
|
|
212
|
-
*
|
|
213
|
-
* own self-`Sig` over the token payload. The
|
|
214
|
-
*
|
|
231
|
+
* row is owner-signed (satisfying `DeviceToken.AuthorizedInsert` via the
|
|
232
|
+
* 'add'-tagged digest, {@link deviceTokenAddDigest}) AND carries the peer's
|
|
233
|
+
* own self-`Sig` over the token payload. The owner signature covers the WHOLE row
|
|
234
|
+
* (every column, ending in a freshly minted single-use `StampId`) — but covering the
|
|
235
|
+
* token contents is not the same as vouching them: the peer's `Sig` (verified at
|
|
215
236
|
* resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
|
|
216
237
|
* resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
|
|
217
|
-
* when the node is its own
|
|
238
|
+
* when the node is its own owner.
|
|
239
|
+
*
|
|
240
|
+
* The stamp is per-INSERT, so a re-register after a clear mints a fresh one and a
|
|
241
|
+
* fresh signature — unaffected by the cleared row's retired stamp
|
|
242
|
+
* (`DeviceToken.NotRevoked`).
|
|
218
243
|
*/
|
|
219
244
|
async insertSelfDeviceToken(record) {
|
|
220
|
-
const
|
|
245
|
+
const stampId = generateStampId(record.peerId);
|
|
246
|
+
const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
|
|
221
247
|
if (!this.controlDatabase) {
|
|
222
248
|
throw new Error('Control database not initialized');
|
|
223
249
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
log('Device token inserted (authority-signed): %s', record.peerId);
|
|
250
|
+
await this.controlDatabase.execWrite(`
|
|
251
|
+
insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
|
|
252
|
+
with context OwnerKey = ?, Signature = ?
|
|
253
|
+
values (?, ?, ?, ?, ?, ?)
|
|
254
|
+
`, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId]);
|
|
255
|
+
log('Device token inserted (owner-signed): %s', record.peerId);
|
|
231
256
|
}
|
|
232
257
|
/**
|
|
233
|
-
*
|
|
234
|
-
* invalidation). The `DeviceToken.
|
|
235
|
-
*
|
|
236
|
-
*
|
|
258
|
+
* Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
|
|
259
|
+
* invalidation). The `DeviceToken.AuthorizedDelete` constraint validates an owner
|
|
260
|
+
* signature over the 'remove'-tagged digest bound to the STORED row's
|
|
261
|
+
* (PeerId, StampId) — deliberately distinct from the insert digest, so a captured
|
|
262
|
+
* insert approval can never be replayed to clear a token. Like {@link removePeer}
|
|
263
|
+
* for `CadrePeer`, clearing a token requires the owner key.
|
|
264
|
+
*
|
|
265
|
+
* The delete and the `Revocation` tombstone retiring the row's `StampId` commit in
|
|
266
|
+
* ONE transaction — `DeviceToken.RevocationRecorded` refuses a bare delete, and
|
|
267
|
+
* without the tombstone the stamp would free up and the never-expiring insert
|
|
268
|
+
* approval (which the cleared device holds a copy of) would re-seat the token. The
|
|
269
|
+
* tombstone carries its OWN owner signature: retiring a stamp permanently forecloses
|
|
270
|
+
* that row, so it is an owner action in its own right.
|
|
271
|
+
*
|
|
272
|
+
* Both digests, the stamp read, and the transaction come from
|
|
273
|
+
* {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
|
|
274
|
+
* `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
|
|
275
|
+
* owner-key precondition; a no-op on an already-absent row is the shared body's
|
|
276
|
+
* behavior, and unlike {@link removePeer} nothing here rides on it.
|
|
237
277
|
*/
|
|
238
278
|
async deleteDeviceToken(peerId) {
|
|
239
|
-
|
|
279
|
+
// Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
|
|
280
|
+
// owner-key error rather than a silent no-op on an absent row.
|
|
281
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
240
282
|
if (!this.controlDatabase) {
|
|
241
283
|
throw new Error('Control database not initialized');
|
|
242
284
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
285
|
+
await this.controlDatabase.deleteDeviceToken(peerId, ownerKey, message => this.signMessageBytes(message));
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Return the configured owner private key, or throw if none is set. The single
|
|
289
|
+
* precondition gate for every owner-signed write. {@link removePeer} /
|
|
290
|
+
* {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
|
|
291
|
+
* they call this up front — otherwise a keyless service would either surface the
|
|
292
|
+
* wrong "Control database not initialized" error or, worse, silently no-op when the
|
|
293
|
+
* target row is absent (the early `stampId === null` return) instead of rejecting.
|
|
294
|
+
*/
|
|
295
|
+
requireOwnerPrivateKey() {
|
|
296
|
+
if (!this.config.ownerPrivateKey) {
|
|
297
|
+
throw new Error('Owner private key required to authorize peers');
|
|
298
|
+
}
|
|
299
|
+
return this.config.ownerPrivateKey;
|
|
250
300
|
}
|
|
251
301
|
/**
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
302
|
+
* The owner PUBLIC key that rides in every owner-signed write's context, non-null.
|
|
303
|
+
*
|
|
304
|
+
* The field is nullable because a read-only service carries no owner key at all, but the
|
|
305
|
+
* constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
|
|
306
|
+
* {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
|
|
307
|
+
* unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
|
|
308
|
+
* their first line and get the owner-key precondition for free.
|
|
256
309
|
*/
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
310
|
+
requireOwnerPublicKey() {
|
|
311
|
+
this.requireOwnerPrivateKey();
|
|
312
|
+
if (!this.ownerPublicKey) {
|
|
313
|
+
throw new Error('Owner public key required to authorize peers');
|
|
260
314
|
}
|
|
261
|
-
return
|
|
315
|
+
return this.ownerPublicKey;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Sign a base64url digest with the owner key (ed25519). The single place the
|
|
319
|
+
* owner private key is applied; callers pass the canonical domain-tagged digest for
|
|
320
|
+
* the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
|
|
321
|
+
* {@link signMessageBytes}. Throws if no owner key is set.
|
|
322
|
+
*/
|
|
323
|
+
signDigest(digestB64url) {
|
|
324
|
+
return sign(digestB64url, this.requireOwnerPrivateKey(), 'ed25519', 'base64url', 'base64url', 'base64url');
|
|
262
325
|
}
|
|
263
326
|
/**
|
|
264
|
-
*
|
|
327
|
+
* Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
|
|
328
|
+
* takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
|
|
329
|
+
* same signed bytes (`sign` decodes its base64url input), so a signature minted here
|
|
330
|
+
* satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
|
|
331
|
+
* `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
|
|
332
|
+
*/
|
|
333
|
+
signMessageBytes(message) {
|
|
334
|
+
return this.signDigest(uint8ArrayToString(message, 'base64url'));
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Remove a peer from the cadre by owner signature.
|
|
338
|
+
*
|
|
339
|
+
* The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
|
|
340
|
+
* signature over the DISTINCT 'remove'-tagged digest
|
|
341
|
+
* `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
|
|
342
|
+
* key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
|
|
343
|
+
* never be replayed to delete.
|
|
344
|
+
*
|
|
345
|
+
* The delete and the `Revocation` tombstone retiring the row's `StampId` commit in ONE
|
|
346
|
+
* transaction — `CadrePeer.RevocationRecorded` refuses a bare delete, and without the
|
|
347
|
+
* tombstone the stamp would free up and the original admission approval (which never
|
|
348
|
+
* expires, and which the removed peer holds a copy of) would re-seat the row.
|
|
349
|
+
*
|
|
350
|
+
* The tombstone is separately owner-signed (satisfying `Revocation.Authorized`):
|
|
351
|
+
* retiring a stamp evicts that peer party-wide and permanently forecloses re-admitting
|
|
352
|
+
* the row, so it is an owner action in its own right, not a side effect the delete's
|
|
353
|
+
* signature covers.
|
|
265
354
|
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
355
|
+
* Both digests, the stamp read, the transaction, and the post-commit membership notify
|
|
356
|
+
* come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
|
|
357
|
+
* `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
|
|
358
|
+
* owner-key precondition and the absent-row gate the notify depends on (below).
|
|
269
359
|
*/
|
|
270
360
|
async removePeer(peerId) {
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
|
|
361
|
+
// Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
|
|
362
|
+
// remove digest, and this precedence (owner key, then control DB) is what the
|
|
363
|
+
// unit contract asserts.
|
|
364
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
274
365
|
if (!this.controlDatabase) {
|
|
275
366
|
throw new Error('Control database not initialized');
|
|
276
367
|
}
|
|
368
|
+
// This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
|
|
369
|
+
// internally: deleteCadrePeer's membership notify fires whenever its body resolves,
|
|
370
|
+
// with no idea whether the body wrote anything, so delegating an absent peer would
|
|
371
|
+
// fire a spurious membership notification.
|
|
372
|
+
// NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
|
|
373
|
+
// between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
|
|
374
|
+
// window only — the common "already absent" case is caught here.
|
|
375
|
+
const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
|
|
376
|
+
if (stampId === null) {
|
|
377
|
+
log('removePeer: no CadrePeer row for %s (already absent)', peerId);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
277
380
|
log('Removing peer: %s', peerId);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
* (`control-write-ensure-replicated`): a membership row that committed
|
|
294
|
-
* local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
|
|
295
|
-
* grows, by re-issuing this monotonic bump. It is an UPDATE (not the original
|
|
296
|
-
* INSERT) because the row already exists locally; a re-INSERT would hit the
|
|
297
|
-
* `PeerId` PK. Only the freshness stamp changes — `PublicKey` / `Multiaddr` /
|
|
298
|
-
* `Sig` are left intact — so it is safe over a row whose peer has not
|
|
299
|
-
* self-published (`Sig` null); the caller must skip a row that already carries a
|
|
300
|
-
* self-`Sig` (that row is the owning peer's to refresh, and bumping `UpdatedAt`
|
|
301
|
-
* without re-signing would invalidate its self-signature).
|
|
381
|
+
await this.controlDatabase.deleteCadrePeer(peerId, ownerKey, message => this.signMessageBytes(message));
|
|
382
|
+
log('Peer %s removed successfully (stamp retired)', peerId);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
|
|
386
|
+
* re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
|
|
387
|
+
* the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
|
|
388
|
+
* a membership row that committed local-only (its block's cluster ≤1 at insert) is
|
|
389
|
+
* pushed to the cohort once it grows.
|
|
390
|
+
*
|
|
391
|
+
* The stamp read, the voucher signature, the write lock and the membership notify are
|
|
392
|
+
* {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
|
|
393
|
+
* `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
|
|
394
|
+
*
|
|
395
|
+
* A no-op (no throw, no notify) when the row does not exist.
|
|
302
396
|
*
|
|
303
397
|
* @param peerId - the membership row to re-touch.
|
|
304
398
|
* @param updatedAt - the strictly-increasing freshness stamp to write.
|
|
305
|
-
* @throws if no
|
|
399
|
+
* @throws if no owner private key is configured (a non-owner cannot
|
|
306
400
|
* re-sign another peer's row) or the control database is not initialized.
|
|
307
401
|
*/
|
|
308
402
|
async reauthorizePeer(peerId, updatedAt) {
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
const
|
|
403
|
+
// Fail fast on a keyless service before any DB read (see removePeer): a non-owner
|
|
404
|
+
// cannot re-sign the voucher, and must not silently no-op on an absent row.
|
|
405
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
312
406
|
if (!this.controlDatabase) {
|
|
313
407
|
throw new Error('Control database not initialized');
|
|
314
408
|
}
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
where PeerId = ?
|
|
321
|
-
`, [this.authorityPublicKey, signature, updatedAt, peerId]);
|
|
409
|
+
const retouched = await this.controlDatabase.reauthorizeCadrePeer(peerId, updatedAt, ownerKey, message => this.signMessageBytes(message));
|
|
410
|
+
if (!retouched) {
|
|
411
|
+
log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
322
414
|
log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
|
|
323
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Owner re-issue of a batch of `Revocation` tombstones: bump each row's
|
|
418
|
+
* `ReissuedAt` so the storage layer re-broadcasts a tombstone that committed
|
|
419
|
+
* while the node was alone. The delete-while-alone counterpart of
|
|
420
|
+
* {@link reauthorizePeer} — a removed row cannot be re-touched (it is gone),
|
|
421
|
+
* but its tombstone can, and every membership read treats a retired stamp as
|
|
422
|
+
* absent.
|
|
423
|
+
*
|
|
424
|
+
* The signatures, the single transaction, and the strictly-monotonic
|
|
425
|
+
* `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
|
|
426
|
+
* stays here is the owner-key precondition.
|
|
427
|
+
*
|
|
428
|
+
* @returns how many tombstones were re-issued (`rows.length` on success).
|
|
429
|
+
* @throws if no owner private key is configured or the control database is not
|
|
430
|
+
* initialized.
|
|
431
|
+
*/
|
|
432
|
+
async reissueRevocations(rows, reissuedAt) {
|
|
433
|
+
// Fail fast on a keyless service before any DB work (see removePeer): a
|
|
434
|
+
// non-owner cannot sign the reissue digests.
|
|
435
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
436
|
+
if (!this.controlDatabase) {
|
|
437
|
+
throw new Error('Control database not initialized');
|
|
438
|
+
}
|
|
439
|
+
return this.controlDatabase.reissueRevocations(rows, reissuedAt, ownerKey, message => this.signMessageBytes(message));
|
|
440
|
+
}
|
|
324
441
|
/**
|
|
325
442
|
* Create a seed from the current control network state.
|
|
326
|
-
* The seed contains peer information and is signed by an
|
|
443
|
+
* The seed contains peer information and is signed by an owner.
|
|
327
444
|
*/
|
|
328
445
|
async createSeed() {
|
|
329
|
-
if (!this.config.
|
|
330
|
-
throw new Error('
|
|
446
|
+
if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
|
|
447
|
+
throw new Error('Owner key required to create seeds');
|
|
331
448
|
}
|
|
332
449
|
if (!this.controlDatabase || !this.libp2pNode) {
|
|
333
450
|
throw new Error('Service not initialized');
|
|
@@ -343,11 +460,11 @@ export class SeedBootstrapService {
|
|
|
343
460
|
// Sign the seed over its canonical byte representation
|
|
344
461
|
const seedJson = canonicalSeedPayload(seedData);
|
|
345
462
|
const seedDigest = digest([seedJson], 'sha256', 'base64url');
|
|
346
|
-
const signature = sign(seedDigest, this.config.
|
|
463
|
+
const signature = sign(seedDigest, this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
|
|
347
464
|
const seed = {
|
|
348
465
|
...seedData,
|
|
349
466
|
signature,
|
|
350
|
-
signerKey: this.
|
|
467
|
+
signerKey: this.ownerPublicKey,
|
|
351
468
|
};
|
|
352
469
|
log('Created seed with %d peers', peers.length);
|
|
353
470
|
return seed;
|
|
@@ -357,10 +474,15 @@ export class SeedBootstrapService {
|
|
|
357
474
|
*
|
|
358
475
|
* Validates the seed signature, then evaluates a trust anchor for the
|
|
359
476
|
* `signerKey` that does NOT come from the seed body: the receiver's
|
|
360
|
-
*
|
|
361
|
-
* TOFU via the configured/overriding
|
|
362
|
-
* self-asserting seed — one that merely lists its
|
|
363
|
-
* peer — no longer passes
|
|
477
|
+
* node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
|
|
478
|
+
* augmented by pinned keys or TOFU via the configured/overriding
|
|
479
|
+
* `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
|
|
480
|
+
* own signer as an owner peer — no longer passes, and neither does one signed
|
|
481
|
+
* by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
|
|
482
|
+
*
|
|
483
|
+
* A key accepted via a pin/TOFU is persisted into the anchor (the policy says
|
|
484
|
+
* so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
|
|
485
|
+
* anchored without re-supplying the invite.
|
|
364
486
|
*
|
|
365
487
|
* @param seed - The seed to apply (already transport-decoded).
|
|
366
488
|
* @param options.trustPolicy - Per-call policy override (e.g. a
|
|
@@ -369,32 +491,36 @@ export class SeedBootstrapService {
|
|
|
369
491
|
*/
|
|
370
492
|
async applySeed(seed, options) {
|
|
371
493
|
if (!this.libp2pNode) {
|
|
372
|
-
return
|
|
494
|
+
return seedRejected('Service not initialized');
|
|
373
495
|
}
|
|
496
|
+
// NOTE: `seed.partyId` is never checked against `config.partyId`. Nothing
|
|
497
|
+
// downstream reads it — trust is keyed on `signerKey` vs the anchor, and the
|
|
498
|
+
// anchor a stray-party seed could write into belongs to THIS party, which
|
|
499
|
+
// only a caller-supplied pin for that signer can reach. If applying a seed
|
|
500
|
+
// ever branches on its partyId (or the anchor becomes multi-party), reject a
|
|
501
|
+
// mismatch here instead.
|
|
374
502
|
log('Applying seed for party: %s', seed.partyId);
|
|
375
503
|
// Validate the seed signature
|
|
376
504
|
if (!this.validateSeedSignature(seed)) {
|
|
377
|
-
return
|
|
378
|
-
}
|
|
379
|
-
// Evaluate the trust anchor for the signer key. The known-
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
505
|
+
return seedRejected('Invalid seed signature');
|
|
506
|
+
}
|
|
507
|
+
// Evaluate the trust anchor for the signer key. The known-owner set comes
|
|
508
|
+
// from the receiver's NODE-LOCAL anchor — never from the seed itself, and
|
|
509
|
+
// never from the replicated OwnerKey table (a stranger can genesis-insert
|
|
510
|
+
// its own key there and let it replicate into every peer's copy). A node
|
|
511
|
+
// whose anchor was never seeded, with no policy override, sees an empty set
|
|
512
|
+
// and rejects.
|
|
513
|
+
const knownOwnerKeys = this.config.trustedOwners?.all() ?? new Set();
|
|
385
514
|
const policy = options?.trustPolicy ?? this.trustPolicy;
|
|
386
515
|
const decision = await policy.evaluate({
|
|
387
516
|
partyId: seed.partyId,
|
|
388
517
|
signerKey: seed.signerKey,
|
|
389
|
-
|
|
518
|
+
knownOwnerKeys,
|
|
390
519
|
});
|
|
391
520
|
if (!decision.trusted) {
|
|
392
|
-
return
|
|
393
|
-
success: false,
|
|
394
|
-
peersAdded: 0,
|
|
395
|
-
error: decision.reason ?? 'Signer key not trusted by trust policy',
|
|
396
|
-
};
|
|
521
|
+
return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
|
|
397
522
|
}
|
|
523
|
+
await this.anchorAcceptedSigner(seed.signerKey, decision);
|
|
398
524
|
let peersAdded = 0;
|
|
399
525
|
// Add peers to the peer store
|
|
400
526
|
for (const peer of seed.peers) {
|
|
@@ -414,22 +540,69 @@ export class SeedBootstrapService {
|
|
|
414
540
|
log('Failed to add peer %s: %o', peer.peerId, error);
|
|
415
541
|
}
|
|
416
542
|
}
|
|
417
|
-
// Dial
|
|
418
|
-
|
|
543
|
+
// Dial owner peers to establish connections. Best-effort and COUNTED: an
|
|
544
|
+
// owner that is momentarily down leaves this node seeded but unconnected,
|
|
545
|
+
// which the caller can only see if the outcome is reported (see
|
|
546
|
+
// `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
|
|
547
|
+
// `CadreNode.dialColdStartBootstrap` retries these same addresses on every
|
|
548
|
+
// control-cohort reconcile pass until the control database has siblings.
|
|
549
|
+
// `createSeed` projects EVERY CadrePeer row, so an owner applying a seed
|
|
550
|
+
// minted after it joined finds ITSELF in the owner list. Dialing self always
|
|
551
|
+
// throws, which would report a healthy owner as "seeded but stranded".
|
|
552
|
+
// Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
|
|
553
|
+
// and an undefined self simply matches nothing.
|
|
554
|
+
const selfPeerId = this.libp2pNode.peerId?.toString();
|
|
555
|
+
let ownerDialsAttempted = 0;
|
|
556
|
+
let ownerDialsFailed = 0;
|
|
557
|
+
for (const peer of seed.peers.filter(p => p.isOwner)) {
|
|
558
|
+
if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
ownerDialsAttempted++;
|
|
419
562
|
try {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
await this.libp2pNode.dial(addr);
|
|
424
|
-
}
|
|
563
|
+
const addr = multiaddr(peer.multiaddrs[0]);
|
|
564
|
+
log('Dialing owner peer: %s', peer.peerId);
|
|
565
|
+
await this.libp2pNode.dial(addr);
|
|
425
566
|
}
|
|
426
567
|
catch (error) {
|
|
568
|
+
ownerDialsFailed++;
|
|
427
569
|
log('Failed to dial peer %s: %o', peer.peerId, error);
|
|
428
570
|
// Continue - not all peers need to be reachable
|
|
429
571
|
}
|
|
430
572
|
}
|
|
431
|
-
log('Applied seed: %d peers added', peersAdded);
|
|
432
|
-
return { success: true, peersAdded };
|
|
573
|
+
log('Applied seed: %d peers added, %d/%d owner dial(s) failed', peersAdded, ownerDialsFailed, ownerDialsAttempted);
|
|
574
|
+
return { success: true, peersAdded, ownerDialsAttempted, ownerDialsFailed };
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
|
|
578
|
+
* later seed from the same owner is anchored without re-supplying the invite
|
|
579
|
+
* or re-prompting. Only the policy decides this happens (`anchorAs` is unset
|
|
580
|
+
* when the key was already anchored, so a plain re-apply writes nothing) and
|
|
581
|
+
* `trust()` is idempotent, keeping the original provenance for a known key.
|
|
582
|
+
*
|
|
583
|
+
* Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
|
|
584
|
+
* in-memory anchor synchronously, so this seed and the rest of the session are
|
|
585
|
+
* unaffected — only durability across a restart is lost, and that is logged.
|
|
586
|
+
*
|
|
587
|
+
* NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
|
|
588
|
+
* unauthorized to authorized, which the write-driven membership-gate refresh
|
|
589
|
+
* (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. Every
|
|
590
|
+
* anchor mutation today rides seed application, and both seed paths refresh the
|
|
591
|
+
* gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`). If some
|
|
592
|
+
* future path anchors an owner OUTSIDE seed application, it owes the same
|
|
593
|
+
* `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
|
|
594
|
+
*/
|
|
595
|
+
async anchorAcceptedSigner(signerKey, decision) {
|
|
596
|
+
if (!decision.anchorAs || !this.config.trustedOwners) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
await this.config.trustedOwners.trust(signerKey, decision.anchorAs);
|
|
601
|
+
log('Anchored seed signer %s as %s', signerKey, decision.anchorAs);
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
log('Failed to persist accepted seed signer into the trusted-owner anchor: %o', error);
|
|
605
|
+
}
|
|
433
606
|
}
|
|
434
607
|
/**
|
|
435
608
|
* Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
|
|
@@ -448,51 +621,64 @@ export class SeedBootstrapService {
|
|
|
448
621
|
}
|
|
449
622
|
/**
|
|
450
623
|
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
624
|
+
*
|
|
625
|
+
* Sender hardening: the whole exchange — dial, write, ack read — is bounded by
|
|
626
|
+
* {@link seedDeliverTimeoutMs}, and the ack is capped at {@link MAX_SEED_SIZE}.
|
|
627
|
+
* The target is a NOT-YET-TRUSTED node the instigator chose to dial during
|
|
628
|
+
* onboarding, so an unbounded read here is strictly more exposed than the
|
|
629
|
+
* membership-gated receiver paths: without the bound a target that accepts the
|
|
630
|
+
* stream and never replies parks this call forever, and one that streams
|
|
631
|
+
* arbitrary bytes as a fake ack exhausts memory.
|
|
451
632
|
*/
|
|
452
633
|
async deliverSeed(targetMultiaddr, seed) {
|
|
453
634
|
if (!this.libp2pNode) {
|
|
454
635
|
throw new Error('Service not initialized');
|
|
455
636
|
}
|
|
637
|
+
// Capture the node so the closure below needs no non-null assertion.
|
|
638
|
+
const node = this.libp2pNode;
|
|
456
639
|
const addr = multiaddr(targetMultiaddr);
|
|
457
640
|
log('Delivering seed to: %s', targetMultiaddr);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
641
|
+
return await withDeadline(this.seedDeliverTimeoutMs, `Seed delivery to ${targetMultiaddr}`, (signal) => this.sendSeed(node, addr, seed, signal));
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Open one stream to the target, send the seed frame, half-close, and read the ack.
|
|
645
|
+
*
|
|
646
|
+
* `signal` is the deadline from {@link deliverSeed}: it goes to `dialProtocol` so
|
|
647
|
+
* a timeout during connect aborts the dial, and into {@link exchangeFrame} so a
|
|
648
|
+
* timeout after the stream is open resets it — releasing the otherwise unbounded
|
|
649
|
+
* ack-read.
|
|
650
|
+
*
|
|
651
|
+
* Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
|
|
652
|
+
* tiny frame over a relay, whereas a seed is up to 1MB and this delivery path
|
|
653
|
+
* does not dial relay addresses today. Changing that is a separate decision.
|
|
654
|
+
*/
|
|
655
|
+
async sendSeed(node, addr, seed, signal) {
|
|
656
|
+
const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
|
|
657
|
+
const message = {
|
|
658
|
+
partyId: seed.partyId,
|
|
659
|
+
peers: seed.peers,
|
|
660
|
+
signature: seed.signature,
|
|
661
|
+
signerKey: seed.signerKey,
|
|
662
|
+
};
|
|
663
|
+
const ack = await exchangeFrame(rawStream, signal, message, (stream) => this.readSeedAck(stream), 'Seed delivery aborted by timeout');
|
|
664
|
+
log('Seed delivery response: accepted=%s', ack.accepted);
|
|
665
|
+
return ack;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Read the ack frame a delivery target writes back, bounded by
|
|
669
|
+
* {@link seedDeliverTimeoutMs} and capped at {@link MAX_SEED_SIZE} — an
|
|
670
|
+
* untrusted target must not be able to stream unlimited bytes as a fake ack.
|
|
671
|
+
* Decoding runs inside {@link exchangeFrame}'s `try`, so a malformed or
|
|
672
|
+
* non-JSON ack resets the stream rather than leaking it.
|
|
673
|
+
*/
|
|
674
|
+
async readSeedAck(stream) {
|
|
675
|
+
const data = await readStreamToEnd(stream, {
|
|
676
|
+
maxBytes: MAX_SEED_SIZE,
|
|
677
|
+
timeoutMs: this.seedDeliverTimeoutMs,
|
|
678
|
+
label: 'Seed ack',
|
|
679
|
+
});
|
|
680
|
+
const body = decodeLengthPrefixedFrame(data, MAX_SEED_SIZE);
|
|
681
|
+
return JSON.parse(new TextDecoder().decode(body));
|
|
496
682
|
}
|
|
497
683
|
/**
|
|
498
684
|
* Get this node's circuit relay address for inclusion in seeds.
|
|
@@ -527,18 +713,26 @@ export class SeedBootstrapService {
|
|
|
527
713
|
/**
|
|
528
714
|
* Query peers from the control database.
|
|
529
715
|
*
|
|
530
|
-
*
|
|
716
|
+
* Owner identity is sourced from the `OwnerKey` table, not from the
|
|
531
717
|
* transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
|
|
532
718
|
* multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
|
|
533
|
-
* peer is an
|
|
534
|
-
* This makes any
|
|
535
|
-
* `
|
|
719
|
+
* peer is an owner iff that derived key is in the `OwnerKey` set.
|
|
720
|
+
* This makes any owner node markable — not just the local one — and ties
|
|
721
|
+
* `isOwner` to the control table rather than to `peerId === self`.
|
|
722
|
+
*
|
|
723
|
+
* NOTE: this is the one owner lookup deliberately left on the REPLICATED
|
|
724
|
+
* table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
|
|
725
|
+
* — the receiver dials owner-flagged peers first — not a trust decision, and
|
|
726
|
+
* the receiver re-derives real trust from its own anchor. So a polluted table
|
|
727
|
+
* costs at most a wasted dial, while anchoring here would silently drop
|
|
728
|
+
* legitimate co-owners this node never pinned. If `isOwner` ever gates
|
|
729
|
+
* anything the receiver TRUSTS, move it to the anchor.
|
|
536
730
|
*/
|
|
537
731
|
async queryPeers() {
|
|
538
732
|
if (!this.controlDatabase) {
|
|
539
733
|
return [];
|
|
540
734
|
}
|
|
541
|
-
const
|
|
735
|
+
const ownerKeys = await this.controlDatabase.getOwnerKeys();
|
|
542
736
|
const db = this.controlDatabase.getDatabase();
|
|
543
737
|
const peers = [];
|
|
544
738
|
// Query CadrePeer table
|
|
@@ -546,15 +740,15 @@ export class SeedBootstrapService {
|
|
|
546
740
|
const peerId = row.PeerId;
|
|
547
741
|
const multiaddr = row.Multiaddr;
|
|
548
742
|
// Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
|
|
549
|
-
// unparsable id yields null and is treated as a non-
|
|
743
|
+
// unparsable id yields null and is treated as a non-owner rather than
|
|
550
744
|
// failing the whole seed creation.
|
|
551
745
|
const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
|
|
552
|
-
const
|
|
746
|
+
const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
|
|
553
747
|
peers.push({
|
|
554
748
|
peerId,
|
|
555
749
|
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
556
|
-
|
|
557
|
-
...(
|
|
750
|
+
isOwner,
|
|
751
|
+
...(isOwner ? { publicKey: pubKeyB64 } : {}),
|
|
558
752
|
});
|
|
559
753
|
}
|
|
560
754
|
return peers;
|
|
@@ -624,7 +818,7 @@ export class SeedBootstrapService {
|
|
|
624
818
|
const result = await this.applySeed(seed);
|
|
625
819
|
// Emit appropriate event based on result
|
|
626
820
|
if (result.success) {
|
|
627
|
-
this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
|
|
821
|
+
this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded, seed);
|
|
628
822
|
}
|
|
629
823
|
else {
|
|
630
824
|
this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
|
|
@@ -714,35 +908,43 @@ export class SeedBootstrapService {
|
|
|
714
908
|
// configured (typically by `@serfab/cadre-host`'s NatService), it takes
|
|
715
909
|
// priority — it may substitute a DDNS hostname and externally-mapped
|
|
716
910
|
// port for the raw LAN multiaddrs libp2p reports.
|
|
717
|
-
let
|
|
911
|
+
let ownerAddrs;
|
|
718
912
|
if (this.config.inviteAddressResolver) {
|
|
719
913
|
try {
|
|
720
|
-
|
|
914
|
+
ownerAddrs = await this.config.inviteAddressResolver();
|
|
721
915
|
}
|
|
722
916
|
catch (err) {
|
|
723
917
|
log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
|
|
724
|
-
|
|
918
|
+
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
725
919
|
}
|
|
726
920
|
}
|
|
727
921
|
else {
|
|
728
|
-
|
|
922
|
+
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
729
923
|
}
|
|
730
|
-
// Carry the cadre's
|
|
731
|
-
//
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
924
|
+
// Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
|
|
925
|
+
// the trusted owner set before applying any seed.
|
|
926
|
+
//
|
|
927
|
+
// Sourced ONLY from this node's own anchor, never from the replicated
|
|
928
|
+
// OwnerKey table: the invitee anchors whatever arrives here
|
|
929
|
+
// (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
|
|
930
|
+
// pollutable table would let a stranger's genesis-inserted key ride an
|
|
931
|
+
// otherwise-legitimate invite straight into the new node's anchor —
|
|
932
|
+
// poisoning the very store this whole trust chain rests on. No anchor wired
|
|
933
|
+
// (a directly-constructed service) means no pins to hand out: an invite
|
|
934
|
+
// without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
|
|
935
|
+
// table-sourced one silently hands it an unanchored key.
|
|
936
|
+
const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
|
|
735
937
|
const now = Date.now();
|
|
736
938
|
const invite = {
|
|
737
939
|
partyId: this.config.partyId,
|
|
738
|
-
|
|
739
|
-
|
|
940
|
+
ownerAddrs,
|
|
941
|
+
ownerKeys: ownerKeys.length ? ownerKeys : undefined,
|
|
740
942
|
token,
|
|
741
943
|
createdAt: now,
|
|
742
944
|
expiresAt: expiresIn ? now + expiresIn : undefined,
|
|
743
945
|
};
|
|
744
946
|
const encodedInvite = this.encodeInvite(invite);
|
|
745
|
-
log('Invite created with %d
|
|
947
|
+
log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
|
|
746
948
|
return { invite, encodedInvite };
|
|
747
949
|
}
|
|
748
950
|
/**
|
|
@@ -819,11 +1021,11 @@ export class SeedBootstrapService {
|
|
|
819
1021
|
return JSON.parse(json);
|
|
820
1022
|
}
|
|
821
1023
|
/**
|
|
822
|
-
* Dial an
|
|
823
|
-
* Use this on a phone after receiving an invite to connect to the
|
|
1024
|
+
* Dial an owner from an invite.
|
|
1025
|
+
* Use this on a phone after receiving an invite to connect to the owner.
|
|
824
1026
|
*
|
|
825
1027
|
* @param invite - The invite received out-of-band
|
|
826
|
-
* @returns Connection to the
|
|
1028
|
+
* @returns Connection to the owner
|
|
827
1029
|
*/
|
|
828
1030
|
async dialInvite(invite) {
|
|
829
1031
|
if (!this.libp2pNode) {
|
|
@@ -833,14 +1035,14 @@ export class SeedBootstrapService {
|
|
|
833
1035
|
if (invite.expiresAt && Date.now() > invite.expiresAt) {
|
|
834
1036
|
throw new Error('Invite has expired');
|
|
835
1037
|
}
|
|
836
|
-
log('Dialing invite
|
|
837
|
-
// Try each
|
|
1038
|
+
log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
|
|
1039
|
+
// Try each owner address until one succeeds
|
|
838
1040
|
let lastError = null;
|
|
839
|
-
for (const addrStr of invite.
|
|
1041
|
+
for (const addrStr of invite.ownerAddrs) {
|
|
840
1042
|
try {
|
|
841
1043
|
const addr = multiaddr(addrStr);
|
|
842
1044
|
await this.libp2pNode.dial(addr);
|
|
843
|
-
log('Connected to
|
|
1045
|
+
log('Connected to owner at: %s', addrStr);
|
|
844
1046
|
return;
|
|
845
1047
|
}
|
|
846
1048
|
catch (error) {
|
|
@@ -848,7 +1050,7 @@ export class SeedBootstrapService {
|
|
|
848
1050
|
log('Failed to dial %s: %o', addrStr, error);
|
|
849
1051
|
}
|
|
850
1052
|
}
|
|
851
|
-
throw lastError ?? new Error('No
|
|
1053
|
+
throw lastError ?? new Error('No owner addresses available');
|
|
852
1054
|
}
|
|
853
1055
|
}
|
|
854
1056
|
//# sourceMappingURL=seed-bootstrap.js.map
|