@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.
Files changed (93) hide show
  1. package/README.md +2 -2
  2. package/dist/cadre-node.d.ts +101 -61
  3. package/dist/cadre-node.js +165 -124
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-cohort.d.ts +15 -15
  6. package/dist/control-cohort.js +14 -14
  7. package/dist/control-cohort.js.map +1 -1
  8. package/dist/control-database.d.ts +51 -34
  9. package/dist/control-database.js +72 -56
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-formation-recorder.d.ts +2 -2
  12. package/dist/control-formation-recorder.js +2 -2
  13. package/dist/control-schema.d.ts +1 -1
  14. package/dist/control-schema.js +57 -33
  15. package/dist/control-schema.js.map +1 -1
  16. package/dist/device-token.d.ts +1 -1
  17. package/dist/device-token.js +1 -1
  18. package/dist/{authority-key.d.ts → ed25519-key.d.ts} +11 -10
  19. package/dist/{authority-key.js → ed25519-key.js} +9 -10
  20. package/dist/ed25519-key.js.map +1 -0
  21. package/dist/index.d.ts +3 -3
  22. package/dist/index.js +8 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/key-store.d.ts +1 -1
  25. package/dist/key-store.js +1 -1
  26. package/dist/peer-authorization.d.ts +28 -6
  27. package/dist/peer-authorization.js +33 -7
  28. package/dist/peer-authorization.js.map +1 -1
  29. package/dist/peer-record.d.ts +3 -3
  30. package/dist/peer-record.js +3 -3
  31. package/dist/push-node.d.ts +38 -0
  32. package/dist/push-node.js +48 -0
  33. package/dist/push-node.js.map +1 -0
  34. package/dist/push-notifier-apns.d.ts +10 -2
  35. package/dist/push-notifier-apns.js +14 -10
  36. package/dist/push-notifier-apns.js.map +1 -1
  37. package/dist/push-notifier-fcm.d.ts +7 -1
  38. package/dist/push-notifier-fcm.js +9 -10
  39. package/dist/push-notifier-fcm.js.map +1 -1
  40. package/dist/push-notifier.d.ts +16 -35
  41. package/dist/push-notifier.js +16 -44
  42. package/dist/push-notifier.js.map +1 -1
  43. package/dist/seed-bootstrap.d.ts +70 -51
  44. package/dist/seed-bootstrap.js +168 -106
  45. package/dist/seed-bootstrap.js.map +1 -1
  46. package/dist/seed-trust-policy.d.ts +11 -11
  47. package/dist/seed-trust-policy.js +11 -11
  48. package/dist/seed-trust-policy.js.map +1 -1
  49. package/dist/strand-database.d.ts +5 -5
  50. package/dist/strand-database.js +4 -4
  51. package/dist/strand-database.js.map +1 -1
  52. package/dist/strand-formation-protocol.d.ts +1 -1
  53. package/dist/strand-formation-protocol.js +1 -1
  54. package/dist/strand-instance-manager.d.ts +1 -1
  55. package/dist/strand-instance-manager.js +1 -1
  56. package/dist/strand-instance-manager.js.map +1 -1
  57. package/dist/strand-member-key.d.ts +6 -6
  58. package/dist/strand-member-key.js +6 -6
  59. package/dist/strand-member-key.js.map +1 -1
  60. package/dist/strand-member-registry.d.ts +7 -7
  61. package/dist/strand-member-registry.js +5 -5
  62. package/dist/strand-member-registry.js.map +1 -1
  63. package/dist/strand-membership-writer.d.ts +84 -84
  64. package/dist/strand-membership-writer.js +90 -90
  65. package/dist/strand-membership-writer.js.map +1 -1
  66. package/dist/types.d.ts +52 -32
  67. package/dist/types.js.map +1 -1
  68. package/package.json +7 -3
  69. package/src/cadre-node.ts +170 -126
  70. package/src/control-cohort.ts +17 -17
  71. package/src/control-database.ts +76 -59
  72. package/src/control-formation-recorder.ts +2 -2
  73. package/src/control-schema.ts +57 -33
  74. package/src/device-token.ts +1 -1
  75. package/src/{authority-key.ts → ed25519-key.ts} +12 -11
  76. package/src/index.ts +21 -19
  77. package/src/key-store.ts +1 -1
  78. package/src/peer-authorization.ts +35 -7
  79. package/src/peer-record.ts +3 -3
  80. package/src/push-node.ts +64 -0
  81. package/src/push-notifier-apns.ts +16 -12
  82. package/src/push-notifier-fcm.ts +9 -10
  83. package/src/push-notifier.ts +16 -57
  84. package/src/seed-bootstrap.ts +179 -115
  85. package/src/seed-trust-policy.ts +18 -18
  86. package/src/strand-database.ts +6 -6
  87. package/src/strand-formation-protocol.ts +1 -1
  88. package/src/strand-instance-manager.ts +2 -2
  89. package/src/strand-member-key.ts +7 -7
  90. package/src/strand-member-registry.ts +10 -10
  91. package/src/strand-membership-writer.ts +127 -127
  92. package/src/types.ts +52 -32
  93. package/dist/authority-key.js.map +0 -1
@@ -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 `AuthorityKey.Key` representation (and
77
- * `authorityKeyFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
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 authority" rather than throwing.
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
- /** Authority private key for signing seeds and peer authorizations (base64url) */
115
- authorityPrivateKey?: string;
116
- /** Authority public key (base64url) - derived from private key if not provided */
117
- authorityPublicKey?: string;
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 authority
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 `AuthorityKey` table. An enrollment
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 authorityPublicKey: string | null;
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.authorityPrivateKey && !config.authorityPublicKey) {
190
- this.authorityPublicKey = getPublicKey(
191
- config.authorityPrivateKey,
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.authorityPublicKey = config.authorityPublicKey ?? null;
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 authority private key, i.e. can produce the
213
- * authority signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
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 authority key) returns false: it can receive/apply seeds but cannot author
216
- * or re-issue authority writes. Used by the write-while-alone re-replication
217
- * drain to skip authority work on a non-authority node.
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.authorityPrivateKey;
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 authority key and inserts into CadrePeer table.
254
+ * Signs the peer ID with the owner key and inserts into CadrePeer table.
254
255
  *
255
- * The authority vouches the `PublicKey <-> PeerId` binding: rather than trust a
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 authority cannot produce
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
- * Authority-signed INSERT of this node's OWN self-signed address record.
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 authority (it holds the authority key): the row is authority-signed
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 authority-signed `CadrePeer` INSERT. Signs `digest(peerId)` with the
299
- * authority key (satisfying `AuthorizedInsert`) and writes the full record
300
- * row. The authority signature does NOT cover the address columns — those are
301
- * vouched only as far as the authority asserts them, and a peer's own `Sig`
302
- * (when present) is what makes the row resolvable.
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 AuthorityKey = ?, Signature = ?
319
- values (?, ?, ?, ?, ?)
320
- `, [this.authorityPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig]);
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
- * Authority-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
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 authority-signed (satisfying `DeviceToken.AuthorizedInsert`, which vouches
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 authority signature covers only 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 authority.
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 AuthorityKey = ?, Signature = ?
359
+ with context OwnerKey = ?, Signature = ?
344
360
  values (?, ?, ?, ?, ?)
345
- `, [this.authorityPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig]);
346
- log('Device token inserted (authority-signed): %s', record.peerId);
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
- * Authority-signed DELETE of a peer's `DeviceToken` row (logout / token
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 authority signature over `digest(old.PeerId)`, so — like
353
- * {@link removePeer} for `CadrePeer` — clearing a token requires the authority key.
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 AuthorityKey = ?, Signature = ?
379
+ with context OwnerKey = ?, Signature = ?
364
380
  where PeerId = ?
365
- `, [this.authorityPublicKey, signature, peerId]);
366
- log('Device token removed (authority-signed): %s', peerId);
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 authority key for a `CadrePeer` / `DeviceToken`
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 authority key is set.
389
+ * verifier checks the exact same construction. Throws if no owner key is set.
374
390
  */
375
391
  private signPeerAuthorization(peerId: string): string {
376
- if (!this.config.authorityPrivateKey) {
377
- throw new Error('Authority private key required to authorize peers');
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
- peerAuthorizationDigest(peerId),
381
- this.config.authorityPrivateKey,
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 authority signature.
428
+ * Remove a peer from the cadre by owner signature.
391
429
  *
392
- * The constraint over CadrePeer's `check on insert, delete` validates a
393
- * signature over `digest(old.PeerId)` by an authority key. We use the same
394
- * digest pattern as authorizePeer.
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
- // Same canonical digest as the authorizing INSERT (see peerAuthorizationDigest);
398
- // also validates the authority key is present before touching the database.
399
- const signature = this.signPeerAuthorization(peerId);
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 AuthorityKey = ?, Signature = ?
458
+ with context OwnerKey = ?, Signature = ?
410
459
  where PeerId = ?
411
- `, [this.authorityPublicKey, signature, peerId]);
460
+ `, [this.ownerPublicKey, signature, peerId]);
412
461
 
413
462
  log('Peer %s removed successfully', peerId);
414
463
  }
415
464
 
416
465
  /**
417
- * Authority "re-touch" of an existing `CadrePeer` membership row: bump
418
- * `UpdatedAt` under the authority branch of `CadrePeer.AuthorizedUpdate` (a
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 authority private key is configured (a non-authority cannot
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
- // Reuses the exact digest authorizePeer signs (peerAuthorizationDigest), which
440
- // the authority branch of AuthorizedUpdate verifies; throws if no authority key.
441
- const signature = this.signPeerAuthorization(peerId);
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 AuthorityKey = ?, Signature = ?
449
- set UpdatedAt = ?
512
+ with context OwnerKey = ?, Signature = ?
513
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
450
514
  where PeerId = ?
451
- `, [this.authorityPublicKey, signature, updatedAt, peerId]);
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 authority.
521
+ * The seed contains peer information and is signed by an owner.
458
522
  */
459
523
  async createSeed(): Promise<ControlNetworkSeed> {
460
- if (!this.config.authorityPrivateKey || !this.authorityPublicKey) {
461
- throw new Error('Authority key required to create seeds');
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.authorityPrivateKey,
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.authorityPublicKey,
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
- * `AuthorityKey` table (DB-anchored), optionally augmented by pinned keys or
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 authority
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-authority set is
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 knownAuthorityKeys = this.controlDatabase
535
- ? await this.controlDatabase.getAuthorityKeys()
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
- knownAuthorityKeys,
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 authority peers to establish connections
574
- for (const peer of seed.peers.filter(p => p.isAuthority)) {
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 authority peer: %s', peer.peerId);
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
- * Authority identity is sourced from the `AuthorityKey` table, not from the
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 authority iff that derived key is in the `AuthorityKey` set.
718
- * This makes any authority node markable — not just the local one — and ties
719
- * `isAuthority` to the control table rather than to `peerId === self`.
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 authorityKeys = await this.controlDatabase.getAuthorityKeys();
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-authority rather than
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 isAuthority = pubKeyB64 !== null && authorityKeys.has(pubKeyB64);
803
+ const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
740
804
 
741
805
  peers.push({
742
806
  peerId,
743
807
  multiaddrs: multiaddr ? multiaddr.split(',') : [],
744
- isAuthority,
745
- ...(isAuthority ? { publicKey: pubKeyB64 } : {}),
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 authorityAddrs: string[];
988
+ let ownerAddrs: string[];
925
989
  if (this.config.inviteAddressResolver) {
926
990
  try {
927
- authorityAddrs = await this.config.inviteAddressResolver();
991
+ ownerAddrs = await this.config.inviteAddressResolver();
928
992
  } catch (err) {
929
993
  log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
930
- authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
994
+ ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
931
995
  }
932
996
  } else {
933
- authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
997
+ ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
934
998
  }
935
999
 
936
- // Carry the cadre's authority keys out-of-band so a cold-start invitee can
937
- // pin the trusted authority set before applying any seed.
938
- const authorityKeys = this.controlDatabase
939
- ? Array.from(await this.controlDatabase.getAuthorityKeys())
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
- authorityAddrs,
946
- authorityKeys: authorityKeys.length ? authorityKeys : undefined,
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 authority addresses, %d authority keys', authorityAddrs.length, authorityKeys.length);
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 authority from an invite.
1049
- * Use this on a phone after receiving an invite to connect to the authority.
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 authority
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 authority with %d addresses', invite.authorityAddrs.length);
1128
+ log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
1065
1129
 
1066
- // Try each authority address until one succeeds
1130
+ // Try each owner address until one succeeds
1067
1131
  let lastError: Error | null = null;
1068
- for (const addrStr of invite.authorityAddrs) {
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 authority at: %s', addrStr);
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 authority addresses available');
1144
+ throw lastError ?? new Error('No owner addresses available');
1081
1145
  }
1082
1146
  }
1083
1147