@serfab/cadre-core 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cadre-node.d.ts +101 -61
- package/dist/cadre-node.js +165 -124
- package/dist/cadre-node.js.map +1 -1
- 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 +51 -34
- package/dist/control-database.js +72 -56
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +2 -2
- package/dist/control-formation-recorder.js +2 -2
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +57 -33
- package/dist/control-schema.js.map +1 -1
- package/dist/device-token.d.ts +1 -1
- package/dist/device-token.js +1 -1
- package/dist/{authority-key.d.ts → ed25519-key.d.ts} +11 -10
- package/dist/{authority-key.js → ed25519-key.js} +9 -10
- package/dist/ed25519-key.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/key-store.d.ts +1 -1
- package/dist/key-store.js +1 -1
- package/dist/peer-authorization.d.ts +28 -6
- package/dist/peer-authorization.js +33 -7
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-record.d.ts +3 -3
- package/dist/peer-record.js +3 -3
- 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/seed-bootstrap.d.ts +70 -51
- package/dist/seed-bootstrap.js +168 -106
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +11 -11
- package/dist/seed-trust-policy.js +11 -11
- package/dist/seed-trust-policy.js.map +1 -1
- 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-protocol.d.ts +1 -1
- package/dist/strand-formation-protocol.js +1 -1
- package/dist/strand-instance-manager.d.ts +1 -1
- package/dist/strand-instance-manager.js +1 -1
- 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 +7 -7
- package/dist/strand-member-registry.js +5 -5
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +84 -84
- package/dist/strand-membership-writer.js +90 -90
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/types.d.ts +52 -32
- package/dist/types.js.map +1 -1
- package/package.json +7 -3
- package/src/cadre-node.ts +170 -126
- package/src/control-cohort.ts +17 -17
- package/src/control-database.ts +76 -59
- package/src/control-formation-recorder.ts +2 -2
- package/src/control-schema.ts +57 -33
- package/src/device-token.ts +1 -1
- package/src/{authority-key.ts → ed25519-key.ts} +12 -11
- package/src/index.ts +21 -19
- package/src/key-store.ts +1 -1
- package/src/peer-authorization.ts +35 -7
- package/src/peer-record.ts +3 -3
- 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/seed-bootstrap.ts +179 -115
- package/src/seed-trust-policy.ts +18 -18
- package/src/strand-database.ts +6 -6
- package/src/strand-formation-protocol.ts +1 -1
- package/src/strand-instance-manager.ts +2 -2
- package/src/strand-member-key.ts +7 -7
- package/src/strand-member-registry.ts +10 -10
- package/src/strand-membership-writer.ts +127 -127
- package/src/types.ts +52 -32
- package/dist/authority-key.js.map +0 -1
package/src/seed-bootstrap.ts
CHANGED
|
@@ -21,8 +21,9 @@ import type {
|
|
|
21
21
|
DeviceTokenRecord
|
|
22
22
|
} from './types.js';
|
|
23
23
|
import type { ControlDatabase } from './control-database.js';
|
|
24
|
+
import { generateStampId } from './control-database.js';
|
|
24
25
|
import { canonicalJson } from './canonical-json.js';
|
|
25
|
-
import { peerAuthorizationDigest } from './peer-authorization.js';
|
|
26
|
+
import { peerAuthorizationDigest, cadrePeerVoucherDigest, cadrePeerRemoveDigest } from './peer-authorization.js';
|
|
26
27
|
import {
|
|
27
28
|
type SeedTrustPolicy,
|
|
28
29
|
dbAnchoredTrustPolicy,
|
|
@@ -73,10 +74,10 @@ export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED
|
|
|
73
74
|
*
|
|
74
75
|
* An Ed25519 PeerId is an identity multihash of the public key, so
|
|
75
76
|
* `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
|
|
76
|
-
* base64url form matches the `
|
|
77
|
-
* `
|
|
77
|
+
* base64url form matches the `OwnerKey.Key` representation (and
|
|
78
|
+
* `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
|
|
78
79
|
* id, a missing embedded key, or any parse failure — callers treat null as
|
|
79
|
-
* "not an
|
|
80
|
+
* "not an owner" rather than throwing.
|
|
80
81
|
*/
|
|
81
82
|
export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
|
|
82
83
|
try {
|
|
@@ -111,10 +112,10 @@ export function canonicalSeedPayload(
|
|
|
111
112
|
export interface SeedBootstrapConfig {
|
|
112
113
|
/** Party ID for this cadre */
|
|
113
114
|
partyId: string;
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
|
|
115
|
+
/** Owner private key for signing seeds and peer authorizations (base64url) */
|
|
116
|
+
ownerPrivateKey?: string;
|
|
117
|
+
/** Owner public key (base64url) - derived from private key if not provided */
|
|
118
|
+
ownerPublicKey?: string;
|
|
118
119
|
/**
|
|
119
120
|
* Optional async resolver returning the multiaddrs to embed in invites.
|
|
120
121
|
* When unset, `libp2pNode.getMultiaddrs()` is used. Hosts behind NAT supply
|
|
@@ -124,9 +125,9 @@ export interface SeedBootstrapConfig {
|
|
|
124
125
|
inviteAddressResolver?: () => Promise<string[]>;
|
|
125
126
|
/**
|
|
126
127
|
* Trust anchor for incoming seeds. Decides whether a signature-verified
|
|
127
|
-
* `signerKey` should be trusted, against the receiver's known
|
|
128
|
+
* `signerKey` should be trusted, against the receiver's known owner
|
|
128
129
|
* keys (NOT the seed body). Defaults to `dbAnchoredTrustPolicy()`, which
|
|
129
|
-
* rejects any signer not already in the `
|
|
130
|
+
* rejects any signer not already in the `OwnerKey` table. An enrollment
|
|
130
131
|
* caller can pass a per-seed override to `applySeed` instead.
|
|
131
132
|
*
|
|
132
133
|
* A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
|
|
@@ -171,7 +172,7 @@ export class SeedBootstrapService {
|
|
|
171
172
|
private readonly config: SeedBootstrapConfig;
|
|
172
173
|
private libp2pNode: Libp2p | null = null;
|
|
173
174
|
private controlDatabase: ControlDatabase | null = null;
|
|
174
|
-
private readonly
|
|
175
|
+
private readonly ownerPublicKey: string | null;
|
|
175
176
|
private readonly trustPolicy: SeedTrustPolicy;
|
|
176
177
|
private readonly seedReadTimeoutMs: number;
|
|
177
178
|
private readonly maxConcurrentSeeds: number;
|
|
@@ -186,15 +187,15 @@ export class SeedBootstrapService {
|
|
|
186
187
|
this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
|
|
187
188
|
|
|
188
189
|
// Derive public key from private key if not provided
|
|
189
|
-
if (config.
|
|
190
|
-
this.
|
|
191
|
-
config.
|
|
190
|
+
if (config.ownerPrivateKey && !config.ownerPublicKey) {
|
|
191
|
+
this.ownerPublicKey = getPublicKey(
|
|
192
|
+
config.ownerPrivateKey,
|
|
192
193
|
'ed25519',
|
|
193
194
|
'base64url',
|
|
194
195
|
'base64url'
|
|
195
196
|
) as string;
|
|
196
197
|
} else {
|
|
197
|
-
this.
|
|
198
|
+
this.ownerPublicKey = config.ownerPublicKey ?? null;
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
log('SeedBootstrapService created for party: %s', config.partyId);
|
|
@@ -209,15 +210,15 @@ export class SeedBootstrapService {
|
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
/**
|
|
212
|
-
* Whether this service holds an
|
|
213
|
-
*
|
|
213
|
+
* Whether this service holds an owner private key, i.e. can produce the
|
|
214
|
+
* owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
|
|
214
215
|
* and re-authorizations. A seed-listener-only service (`enableSeedListener`,
|
|
215
|
-
* no
|
|
216
|
-
* or re-issue
|
|
217
|
-
* drain to skip
|
|
216
|
+
* no owner key) returns false: it can receive/apply seeds but cannot author
|
|
217
|
+
* or re-issue owner writes. Used by the write-while-alone re-replication
|
|
218
|
+
* drain to skip owner work on a non-owner node.
|
|
218
219
|
*/
|
|
219
220
|
canAuthorize(): boolean {
|
|
220
|
-
return !!this.config.
|
|
221
|
+
return !!this.config.ownerPrivateKey;
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
/**
|
|
@@ -250,14 +251,14 @@ export class SeedBootstrapService {
|
|
|
250
251
|
|
|
251
252
|
/**
|
|
252
253
|
* Authorize a new peer to join the cadre.
|
|
253
|
-
* Signs the peer ID with the
|
|
254
|
+
* Signs the peer ID with the owner key and inserts into CadrePeer table.
|
|
254
255
|
*
|
|
255
|
-
* The
|
|
256
|
+
* The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
|
|
256
257
|
* caller-supplied key, the binding is enforced by construction — `PublicKey` is
|
|
257
258
|
* DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
|
|
258
259
|
* `PublicKey`, and such a row can never be self-updated (it has no key to
|
|
259
260
|
* verify against), which is correct. The row is inserted with a fresh
|
|
260
|
-
* `UpdatedAt` but no self-signature (`Sig` null) — the
|
|
261
|
+
* `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
|
|
261
262
|
* the peer's self-signature, so the peer must self-publish (see
|
|
262
263
|
* {@link CadreNode.registerSelf}) before the row resolves.
|
|
263
264
|
*/
|
|
@@ -277,10 +278,10 @@ export class SeedBootstrapService {
|
|
|
277
278
|
}
|
|
278
279
|
|
|
279
280
|
/**
|
|
280
|
-
*
|
|
281
|
+
* Owner-signed INSERT of this node's OWN self-signed address record.
|
|
281
282
|
*
|
|
282
283
|
* Used by {@link CadreNode.registerSelf} when the node is not yet a member and
|
|
283
|
-
* is its own
|
|
284
|
+
* is its own owner (it holds the owner key): the row is owner-signed
|
|
284
285
|
* (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
|
|
285
286
|
* immediately without a follow-up self-update.
|
|
286
287
|
*/
|
|
@@ -295,11 +296,13 @@ export class SeedBootstrapService {
|
|
|
295
296
|
}
|
|
296
297
|
|
|
297
298
|
/**
|
|
298
|
-
* Shared
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
299
|
+
* Shared owner-signed `CadrePeer` INSERT. Mints a fresh single-use `StampId`
|
|
300
|
+
* and signs the voucher digest `digest(peerId, stampId)` ({@link cadrePeerVoucherDigest})
|
|
301
|
+
* with the owner key (satisfying `AuthorizedInsert`), then writes the full record
|
|
302
|
+
* row with the vouching (owner, signature) persisted into VouchOwner/VouchSig.
|
|
303
|
+
* The owner signature does NOT cover the address columns — those are vouched only
|
|
304
|
+
* as far as the owner asserts them, and a peer's own `Sig` (when present) is what
|
|
305
|
+
* makes the row resolvable.
|
|
303
306
|
*/
|
|
304
307
|
private async insertCadrePeerRow(row: {
|
|
305
308
|
peerId: string;
|
|
@@ -308,29 +311,42 @@ export class SeedBootstrapService {
|
|
|
308
311
|
updatedAt: number;
|
|
309
312
|
sig: string | null;
|
|
310
313
|
}): Promise<void> {
|
|
311
|
-
const signature = this.signPeerAuthorization(row.peerId);
|
|
312
314
|
if (!this.controlDatabase) {
|
|
313
315
|
throw new Error('Control database not initialized');
|
|
314
316
|
}
|
|
317
|
+
// Fresh single-use nonce; the voucher signs digest(peerId, stampId) so a captured
|
|
318
|
+
// insert can't be replayed (StampId is unique) and can't be repurposed as a delete
|
|
319
|
+
// (which signs a distinct 'remove'-scoped digest).
|
|
320
|
+
// NOTE: StampId uniqueness only blocks insert-replay while the row (or its StampId)
|
|
321
|
+
// is still present — a delete frees the StampId, so a captured authorized insert could
|
|
322
|
+
// be replayed to re-add a peer the owner had removed (a revocation bypass). Fully
|
|
323
|
+
// subsumed by the connection gater (ticket 6): with no outsider write there is no
|
|
324
|
+
// replay at all. If that gate ever proves insufficient, bind inserts to a persistent
|
|
325
|
+
// per-peer sequence / tombstone instead of a row-scoped nonce.
|
|
326
|
+
const stampId = generateStampId(row.peerId);
|
|
327
|
+
const signature = this.signDigest(cadrePeerVoucherDigest(row.peerId, stampId));
|
|
315
328
|
const db = this.controlDatabase.getDatabase();
|
|
329
|
+
// Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig)
|
|
330
|
+
// — identical to the context pair, which the AuthorizedInsert constraint binds — so a
|
|
331
|
+
// reader can later re-check the voucher against its node-local trusted-owner anchor.
|
|
316
332
|
await db.exec(`
|
|
317
|
-
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig)
|
|
318
|
-
with context
|
|
319
|
-
values (?, ?, ?, ?, ?)
|
|
320
|
-
`, [this.
|
|
333
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
|
|
334
|
+
with context OwnerKey = ?, Signature = ?
|
|
335
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
336
|
+
`, [this.ownerPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, this.ownerPublicKey, signature]);
|
|
321
337
|
}
|
|
322
338
|
|
|
323
339
|
/**
|
|
324
|
-
*
|
|
340
|
+
* Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
|
|
325
341
|
*
|
|
326
342
|
* Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
|
|
327
|
-
* row is
|
|
343
|
+
* row is owner-signed (satisfying `DeviceToken.AuthorizedInsert`, which vouches
|
|
328
344
|
* membership exactly as `CadrePeer.AuthorizedInsert` does) AND carries the peer's
|
|
329
|
-
* own self-`Sig` over the token payload. The
|
|
345
|
+
* own self-`Sig` over the token payload. The owner signature covers only the
|
|
330
346
|
* PeerId — it does NOT vouch the token contents; the peer's `Sig` (verified at
|
|
331
347
|
* resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
|
|
332
348
|
* resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
|
|
333
|
-
* when the node is its own
|
|
349
|
+
* when the node is its own owner.
|
|
334
350
|
*/
|
|
335
351
|
async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
|
|
336
352
|
const signature = this.signPeerAuthorization(record.peerId);
|
|
@@ -340,17 +356,17 @@ export class SeedBootstrapService {
|
|
|
340
356
|
const db = this.controlDatabase.getDatabase();
|
|
341
357
|
await db.exec(`
|
|
342
358
|
insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig)
|
|
343
|
-
with context
|
|
359
|
+
with context OwnerKey = ?, Signature = ?
|
|
344
360
|
values (?, ?, ?, ?, ?)
|
|
345
|
-
`, [this.
|
|
346
|
-
log('Device token inserted (
|
|
361
|
+
`, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig]);
|
|
362
|
+
log('Device token inserted (owner-signed): %s', record.peerId);
|
|
347
363
|
}
|
|
348
364
|
|
|
349
365
|
/**
|
|
350
|
-
*
|
|
366
|
+
* Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
|
|
351
367
|
* invalidation). The `DeviceToken.AuthorizedInsert` constraint gates both insert
|
|
352
|
-
* AND delete on an
|
|
353
|
-
* {@link removePeer} for `CadrePeer` — clearing a token requires the
|
|
368
|
+
* AND delete on an owner signature over `digest(old.PeerId)`, so — like
|
|
369
|
+
* {@link removePeer} for `CadrePeer` — clearing a token requires the owner key.
|
|
354
370
|
*/
|
|
355
371
|
async deleteDeviceToken(peerId: string): Promise<void> {
|
|
356
372
|
const signature = this.signPeerAuthorization(peerId);
|
|
@@ -360,25 +376,47 @@ export class SeedBootstrapService {
|
|
|
360
376
|
const db = this.controlDatabase.getDatabase();
|
|
361
377
|
await db.exec(`
|
|
362
378
|
delete from CadreControl.DeviceToken
|
|
363
|
-
with context
|
|
379
|
+
with context OwnerKey = ?, Signature = ?
|
|
364
380
|
where PeerId = ?
|
|
365
|
-
`, [this.
|
|
366
|
-
log('Device token removed (
|
|
381
|
+
`, [this.ownerPublicKey, signature, peerId]);
|
|
382
|
+
log('Device token removed (owner-signed): %s', peerId);
|
|
367
383
|
}
|
|
368
384
|
|
|
369
385
|
/**
|
|
370
|
-
* Sign a peer ID with the
|
|
386
|
+
* Sign a peer ID with the owner key for a `CadrePeer` / `DeviceToken`
|
|
371
387
|
* insert-or-delete. The signed bytes come from the shared
|
|
372
388
|
* {@link peerAuthorizationDigest} helper so the offline `cadre enroll register`
|
|
373
|
-
* verifier checks the exact same construction. Throws if no
|
|
389
|
+
* verifier checks the exact same construction. Throws if no owner key is set.
|
|
374
390
|
*/
|
|
375
391
|
private signPeerAuthorization(peerId: string): string {
|
|
376
|
-
|
|
377
|
-
|
|
392
|
+
return this.signDigest(peerAuthorizationDigest(peerId));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Return the configured owner private key, or throw if none is set. The single
|
|
397
|
+
* precondition gate for every owner-signed write. {@link removePeer} /
|
|
398
|
+
* {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
|
|
399
|
+
* they call this up front — otherwise a keyless service would either surface the
|
|
400
|
+
* wrong "Control database not initialized" error or, worse, silently no-op when the
|
|
401
|
+
* target row is absent (the early `stampId === null` return) instead of rejecting.
|
|
402
|
+
*/
|
|
403
|
+
private requireOwnerPrivateKey(): string {
|
|
404
|
+
if (!this.config.ownerPrivateKey) {
|
|
405
|
+
throw new Error('Owner private key required to authorize peers');
|
|
378
406
|
}
|
|
407
|
+
return this.config.ownerPrivateKey;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Sign a base64url digest with the owner key (ed25519). The single place the
|
|
412
|
+
* owner private key is applied; callers pass the canonical digest for the specific
|
|
413
|
+
* action ({@link cadrePeerVoucherDigest} / {@link cadrePeerRemoveDigest} /
|
|
414
|
+
* {@link peerAuthorizationDigest}). Throws if no owner key is set.
|
|
415
|
+
*/
|
|
416
|
+
private signDigest(digestB64url: string): string {
|
|
379
417
|
return sign(
|
|
380
|
-
|
|
381
|
-
this.
|
|
418
|
+
digestB64url,
|
|
419
|
+
this.requireOwnerPrivateKey(),
|
|
382
420
|
'ed25519',
|
|
383
421
|
'base64url',
|
|
384
422
|
'base64url',
|
|
@@ -387,35 +425,46 @@ export class SeedBootstrapService {
|
|
|
387
425
|
}
|
|
388
426
|
|
|
389
427
|
/**
|
|
390
|
-
* Remove a peer from the cadre by
|
|
428
|
+
* Remove a peer from the cadre by owner signature.
|
|
391
429
|
*
|
|
392
|
-
* The
|
|
393
|
-
* signature over `digest(old.PeerId
|
|
394
|
-
*
|
|
430
|
+
* The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
|
|
431
|
+
* signature over the DISTINCT 'remove'-scoped digest `digest(old.PeerId, old.StampId,
|
|
432
|
+
* 'remove')` ({@link cadrePeerRemoveDigest}) by an owner key — deliberately NOT the
|
|
433
|
+
* insert voucher digest, so the row's stored `VouchSig` can never be replayed to delete.
|
|
395
434
|
*/
|
|
396
435
|
async removePeer(peerId: string): Promise<void> {
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
436
|
+
// Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
|
|
437
|
+
// remove digest, and this precedence (owner key, then control DB) is what the
|
|
438
|
+
// unit contract asserts.
|
|
439
|
+
this.requireOwnerPrivateKey();
|
|
400
440
|
if (!this.controlDatabase) {
|
|
401
441
|
throw new Error('Control database not initialized');
|
|
402
442
|
}
|
|
443
|
+
// Delete is authorized by a signature over a DISTINCT 'remove'-scoped digest bound to
|
|
444
|
+
// the row's CURRENT StampId — so the row's stored voucher (a signature over the
|
|
445
|
+
// voucher digest) can never be replayed to authorize a delete.
|
|
446
|
+
const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
|
|
447
|
+
if (stampId === null) {
|
|
448
|
+
log('removePeer: no CadrePeer row for %s (already absent)', peerId);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const signature = this.signDigest(cadrePeerRemoveDigest(peerId, stampId));
|
|
403
452
|
|
|
404
453
|
log('Removing peer: %s', peerId);
|
|
405
454
|
|
|
406
455
|
const db = this.controlDatabase.getDatabase();
|
|
407
456
|
await db.exec(`
|
|
408
457
|
delete from CadreControl.CadrePeer
|
|
409
|
-
with context
|
|
458
|
+
with context OwnerKey = ?, Signature = ?
|
|
410
459
|
where PeerId = ?
|
|
411
|
-
`, [this.
|
|
460
|
+
`, [this.ownerPublicKey, signature, peerId]);
|
|
412
461
|
|
|
413
462
|
log('Peer %s removed successfully', peerId);
|
|
414
463
|
}
|
|
415
464
|
|
|
416
465
|
/**
|
|
417
|
-
*
|
|
418
|
-
* `UpdatedAt` under the
|
|
466
|
+
* Owner "re-touch" of an existing `CadrePeer` membership row: bump
|
|
467
|
+
* `UpdatedAt` under the owner branch of `CadrePeer.AuthorizedUpdate` (a
|
|
419
468
|
* signature over `digest(peerId)` — the same construction {@link authorizePeer}
|
|
420
469
|
* uses) so the row is re-emitted as a fresh, broadcasting transaction.
|
|
421
470
|
*
|
|
@@ -432,33 +481,48 @@ export class SeedBootstrapService {
|
|
|
432
481
|
*
|
|
433
482
|
* @param peerId - the membership row to re-touch.
|
|
434
483
|
* @param updatedAt - the strictly-increasing freshness stamp to write.
|
|
435
|
-
* @throws if no
|
|
484
|
+
* @throws if no owner private key is configured (a non-owner cannot
|
|
436
485
|
* re-sign another peer's row) or the control database is not initialized.
|
|
437
486
|
*/
|
|
438
487
|
async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
488
|
+
// Fail fast on a keyless service before any DB read (see removePeer): a non-owner
|
|
489
|
+
// cannot re-sign the voucher, and must not silently no-op on an absent row.
|
|
490
|
+
this.requireOwnerPrivateKey();
|
|
442
491
|
if (!this.controlDatabase) {
|
|
443
492
|
throw new Error('Control database not initialized');
|
|
444
493
|
}
|
|
494
|
+
// The owner branch of AuthorizedUpdate verifies a voucher over digest(PeerId,
|
|
495
|
+
// StampId) and re-binds VouchOwner/VouchSig. Sign over the row's CURRENT StampId
|
|
496
|
+
// (unchanged by this re-touch) and re-set the voucher columns so the branch passes.
|
|
497
|
+
// NOTE: this rebinds VouchOwner to THIS node's owner key. Benign today — the
|
|
498
|
+
// write-while-alone drain only re-touches rows this node itself authored. If a future
|
|
499
|
+
// path lets one owner re-touch a row a DIFFERENT owner vouched, the voucher
|
|
500
|
+
// silently flips to this owner; that only matters once ticket-4's predicate checks
|
|
501
|
+
// VouchOwner against a node-local anchor, and only if the two owners differ in
|
|
502
|
+
// anchor membership.
|
|
503
|
+
const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
|
|
504
|
+
if (stampId === null) {
|
|
505
|
+
log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const signature = this.signDigest(cadrePeerVoucherDigest(peerId, stampId));
|
|
445
509
|
const db = this.controlDatabase.getDatabase();
|
|
446
510
|
await db.exec(`
|
|
447
511
|
update CadreControl.CadrePeer
|
|
448
|
-
with context
|
|
449
|
-
set UpdatedAt = ?
|
|
512
|
+
with context OwnerKey = ?, Signature = ?
|
|
513
|
+
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
|
|
450
514
|
where PeerId = ?
|
|
451
|
-
`, [this.
|
|
515
|
+
`, [this.ownerPublicKey, signature, updatedAt, this.ownerPublicKey, signature, peerId]);
|
|
452
516
|
log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
|
|
453
517
|
}
|
|
454
518
|
|
|
455
519
|
/**
|
|
456
520
|
* Create a seed from the current control network state.
|
|
457
|
-
* The seed contains peer information and is signed by an
|
|
521
|
+
* The seed contains peer information and is signed by an owner.
|
|
458
522
|
*/
|
|
459
523
|
async createSeed(): Promise<ControlNetworkSeed> {
|
|
460
|
-
if (!this.config.
|
|
461
|
-
throw new Error('
|
|
524
|
+
if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
|
|
525
|
+
throw new Error('Owner key required to create seeds');
|
|
462
526
|
}
|
|
463
527
|
|
|
464
528
|
if (!this.controlDatabase || !this.libp2pNode) {
|
|
@@ -481,7 +545,7 @@ export class SeedBootstrapService {
|
|
|
481
545
|
const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
|
|
482
546
|
const signature = sign(
|
|
483
547
|
seedDigest,
|
|
484
|
-
this.config.
|
|
548
|
+
this.config.ownerPrivateKey,
|
|
485
549
|
'ed25519',
|
|
486
550
|
'base64url',
|
|
487
551
|
'base64url',
|
|
@@ -491,7 +555,7 @@ export class SeedBootstrapService {
|
|
|
491
555
|
const seed: ControlNetworkSeed = {
|
|
492
556
|
...seedData,
|
|
493
557
|
signature,
|
|
494
|
-
signerKey: this.
|
|
558
|
+
signerKey: this.ownerPublicKey,
|
|
495
559
|
};
|
|
496
560
|
|
|
497
561
|
log('Created seed with %d peers', peers.length);
|
|
@@ -503,9 +567,9 @@ export class SeedBootstrapService {
|
|
|
503
567
|
*
|
|
504
568
|
* Validates the seed signature, then evaluates a trust anchor for the
|
|
505
569
|
* `signerKey` that does NOT come from the seed body: the receiver's
|
|
506
|
-
* `
|
|
570
|
+
* `OwnerKey` table (DB-anchored), optionally augmented by pinned keys or
|
|
507
571
|
* TOFU via the configured/overriding `SeedTrustPolicy`. A forged
|
|
508
|
-
* self-asserting seed — one that merely lists its own signer as an
|
|
572
|
+
* self-asserting seed — one that merely lists its own signer as an owner
|
|
509
573
|
* peer — no longer passes.
|
|
510
574
|
*
|
|
511
575
|
* @param seed - The seed to apply (already transport-decoded).
|
|
@@ -528,17 +592,17 @@ export class SeedBootstrapService {
|
|
|
528
592
|
return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
|
|
529
593
|
}
|
|
530
594
|
|
|
531
|
-
// Evaluate the trust anchor for the signer key. The known-
|
|
595
|
+
// Evaluate the trust anchor for the signer key. The known-owner set is
|
|
532
596
|
// sourced from the receiver's control DB, never from the seed itself; a
|
|
533
597
|
// cold-start node with no DB and no override therefore sees an empty set.
|
|
534
|
-
const
|
|
535
|
-
? await this.controlDatabase.
|
|
598
|
+
const knownOwnerKeys = this.controlDatabase
|
|
599
|
+
? await this.controlDatabase.getOwnerKeys()
|
|
536
600
|
: new Set<string>();
|
|
537
601
|
const policy = options?.trustPolicy ?? this.trustPolicy;
|
|
538
602
|
const decision = await policy.evaluate({
|
|
539
603
|
partyId: seed.partyId,
|
|
540
604
|
signerKey: seed.signerKey,
|
|
541
|
-
|
|
605
|
+
knownOwnerKeys,
|
|
542
606
|
});
|
|
543
607
|
if (!decision.trusted) {
|
|
544
608
|
return {
|
|
@@ -570,13 +634,13 @@ export class SeedBootstrapService {
|
|
|
570
634
|
}
|
|
571
635
|
}
|
|
572
636
|
|
|
573
|
-
// Dial
|
|
574
|
-
for (const peer of seed.peers.filter(p => p.
|
|
637
|
+
// Dial owner peers to establish connections
|
|
638
|
+
for (const peer of seed.peers.filter(p => p.isOwner)) {
|
|
575
639
|
try {
|
|
576
640
|
if (peer.multiaddrs.length > 0) {
|
|
577
641
|
const addr = multiaddr(peer.multiaddrs[0]);
|
|
578
642
|
|
|
579
|
-
log('Dialing
|
|
643
|
+
log('Dialing owner peer: %s', peer.peerId);
|
|
580
644
|
await this.libp2pNode.dial(addr);
|
|
581
645
|
}
|
|
582
646
|
} catch (error) {
|
|
@@ -711,19 +775,19 @@ export class SeedBootstrapService {
|
|
|
711
775
|
/**
|
|
712
776
|
* Query peers from the control database.
|
|
713
777
|
*
|
|
714
|
-
*
|
|
778
|
+
* Owner identity is sourced from the `OwnerKey` table, not from the
|
|
715
779
|
* transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
|
|
716
780
|
* multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
|
|
717
|
-
* peer is an
|
|
718
|
-
* This makes any
|
|
719
|
-
* `
|
|
781
|
+
* peer is an owner iff that derived key is in the `OwnerKey` set.
|
|
782
|
+
* This makes any owner node markable — not just the local one — and ties
|
|
783
|
+
* `isOwner` to the control table rather than to `peerId === self`.
|
|
720
784
|
*/
|
|
721
785
|
private async queryPeers(): Promise<SeedPeer[]> {
|
|
722
786
|
if (!this.controlDatabase) {
|
|
723
787
|
return [];
|
|
724
788
|
}
|
|
725
789
|
|
|
726
|
-
const
|
|
790
|
+
const ownerKeys = await this.controlDatabase.getOwnerKeys();
|
|
727
791
|
const db = this.controlDatabase.getDatabase();
|
|
728
792
|
const peers: SeedPeer[] = [];
|
|
729
793
|
|
|
@@ -733,16 +797,16 @@ export class SeedBootstrapService {
|
|
|
733
797
|
const multiaddr = row.Multiaddr as string | null;
|
|
734
798
|
|
|
735
799
|
// Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
|
|
736
|
-
// unparsable id yields null and is treated as a non-
|
|
800
|
+
// unparsable id yields null and is treated as a non-owner rather than
|
|
737
801
|
// failing the whole seed creation.
|
|
738
802
|
const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
|
|
739
|
-
const
|
|
803
|
+
const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
|
|
740
804
|
|
|
741
805
|
peers.push({
|
|
742
806
|
peerId,
|
|
743
807
|
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
744
|
-
|
|
745
|
-
...(
|
|
808
|
+
isOwner,
|
|
809
|
+
...(isOwner ? { publicKey: pubKeyB64 } : {}),
|
|
746
810
|
});
|
|
747
811
|
}
|
|
748
812
|
|
|
@@ -921,29 +985,29 @@ export class SeedBootstrapService {
|
|
|
921
985
|
// configured (typically by `@serfab/cadre-host`'s NatService), it takes
|
|
922
986
|
// priority — it may substitute a DDNS hostname and externally-mapped
|
|
923
987
|
// port for the raw LAN multiaddrs libp2p reports.
|
|
924
|
-
let
|
|
988
|
+
let ownerAddrs: string[];
|
|
925
989
|
if (this.config.inviteAddressResolver) {
|
|
926
990
|
try {
|
|
927
|
-
|
|
991
|
+
ownerAddrs = await this.config.inviteAddressResolver();
|
|
928
992
|
} catch (err) {
|
|
929
993
|
log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
|
|
930
|
-
|
|
994
|
+
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
931
995
|
}
|
|
932
996
|
} else {
|
|
933
|
-
|
|
997
|
+
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
934
998
|
}
|
|
935
999
|
|
|
936
|
-
// Carry the cadre's
|
|
937
|
-
// pin the trusted
|
|
938
|
-
const
|
|
939
|
-
? Array.from(await this.controlDatabase.
|
|
1000
|
+
// Carry the cadre's owner keys out-of-band so a cold-start invitee can
|
|
1001
|
+
// pin the trusted owner set before applying any seed.
|
|
1002
|
+
const ownerKeys = this.controlDatabase
|
|
1003
|
+
? Array.from(await this.controlDatabase.getOwnerKeys())
|
|
940
1004
|
: [];
|
|
941
1005
|
|
|
942
1006
|
const now = Date.now();
|
|
943
1007
|
const invite: CadreInvite = {
|
|
944
1008
|
partyId: this.config.partyId,
|
|
945
|
-
|
|
946
|
-
|
|
1009
|
+
ownerAddrs,
|
|
1010
|
+
ownerKeys: ownerKeys.length ? ownerKeys : undefined,
|
|
947
1011
|
token,
|
|
948
1012
|
createdAt: now,
|
|
949
1013
|
expiresAt: expiresIn ? now + expiresIn : undefined,
|
|
@@ -951,7 +1015,7 @@ export class SeedBootstrapService {
|
|
|
951
1015
|
|
|
952
1016
|
const encodedInvite = this.encodeInvite(invite);
|
|
953
1017
|
|
|
954
|
-
log('Invite created with %d
|
|
1018
|
+
log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
|
|
955
1019
|
|
|
956
1020
|
return { invite, encodedInvite };
|
|
957
1021
|
}
|
|
@@ -1045,11 +1109,11 @@ export class SeedBootstrapService {
|
|
|
1045
1109
|
}
|
|
1046
1110
|
|
|
1047
1111
|
/**
|
|
1048
|
-
* Dial an
|
|
1049
|
-
* Use this on a phone after receiving an invite to connect to the
|
|
1112
|
+
* Dial an owner from an invite.
|
|
1113
|
+
* Use this on a phone after receiving an invite to connect to the owner.
|
|
1050
1114
|
*
|
|
1051
1115
|
* @param invite - The invite received out-of-band
|
|
1052
|
-
* @returns Connection to the
|
|
1116
|
+
* @returns Connection to the owner
|
|
1053
1117
|
*/
|
|
1054
1118
|
async dialInvite(invite: CadreInvite): Promise<void> {
|
|
1055
1119
|
if (!this.libp2pNode) {
|
|
@@ -1061,15 +1125,15 @@ export class SeedBootstrapService {
|
|
|
1061
1125
|
throw new Error('Invite has expired');
|
|
1062
1126
|
}
|
|
1063
1127
|
|
|
1064
|
-
log('Dialing invite
|
|
1128
|
+
log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
|
|
1065
1129
|
|
|
1066
|
-
// Try each
|
|
1130
|
+
// Try each owner address until one succeeds
|
|
1067
1131
|
let lastError: Error | null = null;
|
|
1068
|
-
for (const addrStr of invite.
|
|
1132
|
+
for (const addrStr of invite.ownerAddrs) {
|
|
1069
1133
|
try {
|
|
1070
1134
|
const addr = multiaddr(addrStr);
|
|
1071
1135
|
await this.libp2pNode.dial(addr);
|
|
1072
|
-
log('Connected to
|
|
1136
|
+
log('Connected to owner at: %s', addrStr);
|
|
1073
1137
|
return;
|
|
1074
1138
|
} catch (error) {
|
|
1075
1139
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -1077,7 +1141,7 @@ export class SeedBootstrapService {
|
|
|
1077
1141
|
}
|
|
1078
1142
|
}
|
|
1079
1143
|
|
|
1080
|
-
throw lastError ?? new Error('No
|
|
1144
|
+
throw lastError ?? new Error('No owner addresses available');
|
|
1081
1145
|
}
|
|
1082
1146
|
}
|
|
1083
1147
|
|