@serfab/cadre-core 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/cadre-invite-protocol.d.ts +348 -0
  2. package/dist/cadre-invite-protocol.js +605 -0
  3. package/dist/cadre-invite-protocol.js.map +1 -0
  4. package/dist/cadre-node.d.ts +523 -180
  5. package/dist/cadre-node.js +921 -293
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/claim-proof.d.ts +45 -0
  8. package/dist/claim-proof.js +132 -0
  9. package/dist/claim-proof.js.map +1 -0
  10. package/dist/control-authorization.d.ts +80 -19
  11. package/dist/control-authorization.js +55 -13
  12. package/dist/control-authorization.js.map +1 -1
  13. package/dist/control-cohort.js +1 -1
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +514 -97
  16. package/dist/control-database.js +1033 -195
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-protocol-guard.d.ts +142 -0
  19. package/dist/control-protocol-guard.js +293 -0
  20. package/dist/control-protocol-guard.js.map +1 -0
  21. package/dist/control-schema.d.ts +1 -1
  22. package/dist/control-schema.js +801 -1067
  23. package/dist/control-schema.js.map +1 -1
  24. package/dist/control-stream.d.ts +4 -0
  25. package/dist/control-stream.js +22 -7
  26. package/dist/control-stream.js.map +1 -1
  27. package/dist/delegate-admission.d.ts +1 -1
  28. package/dist/delegate-admission.js +1 -1
  29. package/dist/device-token.d.ts +11 -9
  30. package/dist/device-token.js +14 -12
  31. package/dist/device-token.js.map +1 -1
  32. package/dist/ed25519-key.d.ts +15 -0
  33. package/dist/ed25519-key.js +28 -1
  34. package/dist/ed25519-key.js.map +1 -1
  35. package/dist/index.d.ts +14 -9
  36. package/dist/index.js +27 -9
  37. package/dist/index.js.map +1 -1
  38. package/dist/link-budget.d.ts +16 -29
  39. package/dist/link-budget.js +16 -31
  40. package/dist/link-budget.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +190 -151
  42. package/dist/membership-connection-gater.js +235 -173
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-claim-payload.d.ts +19 -0
  45. package/dist/node-claim-payload.js +120 -0
  46. package/dist/node-claim-payload.js.map +1 -0
  47. package/dist/owner-anchor-sync.d.ts +22 -0
  48. package/dist/owner-anchor-sync.js +143 -0
  49. package/dist/owner-anchor-sync.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +113 -18
  51. package/dist/peer-authorization.js +225 -29
  52. package/dist/peer-authorization.js.map +1 -1
  53. package/dist/peer-dial.d.ts +19 -0
  54. package/dist/peer-dial.js +22 -1
  55. package/dist/peer-dial.js.map +1 -1
  56. package/dist/peer-join-backfill.d.ts +26 -2
  57. package/dist/peer-join-backfill.js +54 -12
  58. package/dist/peer-join-backfill.js.map +1 -1
  59. package/dist/peer-record.d.ts +13 -9
  60. package/dist/peer-record.js +16 -12
  61. package/dist/peer-record.js.map +1 -1
  62. package/dist/pending-join-runner.d.ts +66 -58
  63. package/dist/pending-join-runner.js +136 -145
  64. package/dist/pending-join-runner.js.map +1 -1
  65. package/dist/push-fanout.d.ts +8 -2
  66. package/dist/push-fanout.js +8 -2
  67. package/dist/push-fanout.js.map +1 -1
  68. package/dist/seed-bootstrap.d.ts +147 -122
  69. package/dist/seed-bootstrap.js +284 -269
  70. package/dist/seed-bootstrap.js.map +1 -1
  71. package/dist/seed-trust-policy.d.ts +98 -37
  72. package/dist/seed-trust-policy.js +188 -2
  73. package/dist/seed-trust-policy.js.map +1 -1
  74. package/dist/strand-addr-protocol.d.ts +24 -26
  75. package/dist/strand-addr-protocol.js +19 -26
  76. package/dist/strand-addr-protocol.js.map +1 -1
  77. package/dist/strand-formation-protocol.js +1 -1
  78. package/dist/strand-formation-protocol.js.map +1 -1
  79. package/dist/strand-revocation-enforcer.d.ts +11 -11
  80. package/dist/strand-revocation-enforcer.js +11 -11
  81. package/dist/strand-wake-protocol.d.ts +13 -16
  82. package/dist/strand-wake-protocol.js +13 -20
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/trusted-owner-store.d.ts +35 -5
  85. package/dist/trusted-owner-store.js +50 -8
  86. package/dist/trusted-owner-store.js.map +1 -1
  87. package/dist/types.d.ts +302 -87
  88. package/dist/types.js.map +1 -1
  89. package/package.json +8 -7
  90. package/src/cadre-invite-protocol.ts +864 -0
  91. package/src/cadre-node.ts +9381 -8686
  92. package/src/claim-proof.ts +147 -0
  93. package/src/control-authorization.ts +203 -117
  94. package/src/control-cohort.ts +1 -1
  95. package/src/control-database.ts +1216 -218
  96. package/src/control-protocol-guard.ts +348 -0
  97. package/src/control-schema.ts +812 -1078
  98. package/src/control-stream.ts +26 -7
  99. package/src/delegate-admission.ts +1 -1
  100. package/src/device-token.ts +14 -10
  101. package/src/ed25519-key.ts +28 -1
  102. package/src/index.ts +84 -10
  103. package/src/link-budget.ts +16 -32
  104. package/src/membership-connection-gater.ts +637 -570
  105. package/src/node-claim-payload.ts +125 -0
  106. package/src/owner-anchor-sync.ts +171 -0
  107. package/src/peer-authorization.ts +258 -29
  108. package/src/peer-dial.ts +22 -1
  109. package/src/peer-join-backfill.ts +56 -11
  110. package/src/peer-record.ts +16 -11
  111. package/src/pending-join-runner.ts +729 -735
  112. package/src/push-fanout.ts +8 -2
  113. package/src/seed-bootstrap.ts +344 -321
  114. package/src/seed-trust-policy.ts +353 -140
  115. package/src/strand-addr-protocol.ts +26 -35
  116. package/src/strand-formation-protocol.ts +1 -1
  117. package/src/strand-revocation-enforcer.ts +11 -11
  118. package/src/strand-wake-protocol.ts +13 -23
  119. package/src/trusted-owner-store.ts +242 -171
  120. package/src/types.ts +2471 -2242
@@ -1,18 +1,27 @@
1
1
  import debug from 'debug';
2
2
  import { digest, verify } from '@optimystic/quereus-plugin-crypto';
3
- import { controlAuthorizationFields } from './control-authorization.js';
4
- import type { ControlAction, ControlDomain, RevocableTable } from './control-authorization.js';
3
+ import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields } from './control-authorization.js';
4
+ import type { BoundControlDomain, CadreInviteSignedFields, ControlAction, RevocableTable, UnboundControlDomain } from './control-authorization.js';
5
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
6
+ import type { CadreInviteRow, CadreInviteUsageRow, CadrePeerVoucherFields, OwnerKeyRow, RevocationRow } from './types.js';
5
7
 
6
8
  const log = debug('sereus:cadre:peer-authorization');
7
9
 
8
10
  /**
9
- * base64url SHA-256 digest over the shared domain-tagged field vector (see
11
+ * base64url SHA-256 digest over the shared domain-tagged, party-bound field vector (see
10
12
  * control-authorization.ts). The base64url twin of
11
13
  * control-database.ts:buildAuthorizationMessage (which returns the same digest as raw
12
14
  * bytes): sign either encoding with input encoding to match and the signed bytes agree.
15
+ * `partyId` is the party the verifier is configured for: a stored proof minted for
16
+ * another party fails here exactly as it fails the schema's `party_id()`-bound check.
13
17
  */
14
- function taggedDigest(domain: ControlDomain, action: ControlAction, rowFields: string[]): string {
15
- return digest(controlAuthorizationFields(domain, action, rowFields), 'sha256', 'base64url') as string;
18
+ function taggedDigest(domain: BoundControlDomain, action: ControlAction, partyId: string, rowFields: string[]): string {
19
+ return digest(controlAuthorizationFields(domain, action, partyId, rowFields), 'sha256', 'base64url') as string;
20
+ }
21
+
22
+ /** {@link taggedDigest} for the two party-free digests (`UnboundControlDomain`). */
23
+ function unboundTaggedDigest(domain: UnboundControlDomain, action: ControlAction, rowFields: string[]): string {
24
+ return digest(unboundAuthorizationFields(domain, action, rowFields), 'sha256', 'base64url') as string;
16
25
  }
17
26
 
18
27
  /**
@@ -27,7 +36,7 @@ function taggedDigest(domain: ControlDomain, action: ControlAction, rowFields: s
27
36
  * here and both move together.
28
37
  */
29
38
  export function peerAuthorizationDigest(peerId: string): string {
30
- return taggedDigest('Cadre.Enrollment', 'vouch', [peerId]);
39
+ return unboundTaggedDigest('Cadre.Enrollment', 'vouch', [peerId]);
31
40
  }
32
41
 
33
42
  /**
@@ -54,7 +63,7 @@ export interface DeviceTokenAuthorizedRow {
54
63
  /**
55
64
  * Canonical digest an owner signs to authorize a `DeviceToken` INSERT — the WHOLE row,
56
65
  * ending in its single-use `StampId` nonce. SQL mirror:
57
- * `digest('CadreControl.DeviceToken', 'add', new.PeerId, new.Platform, new.Token,
66
+ * `digest('CadreControl.DeviceToken', 'add', party_id(), new.PeerId, new.Platform, new.Token,
58
67
  * coalesce(cast(new.UpdatedAt as text), ''), coalesce(new.Sig, ''), new.StampId)` in
59
68
  * `DeviceToken.AuthorizedInsert`.
60
69
  *
@@ -70,8 +79,8 @@ export interface DeviceTokenAuthorizedRow {
70
79
  * Distinct from {@link deviceTokenRemoveDigest} so a captured insert approval can never
71
80
  * be replayed to delete the token, and vice versa.
72
81
  */
73
- export function deviceTokenAddDigest(row: DeviceTokenAuthorizedRow): string {
74
- return taggedDigest('CadreControl.DeviceToken', 'add', [
82
+ export function deviceTokenAddDigest(partyId: string, row: DeviceTokenAuthorizedRow): string {
83
+ return taggedDigest('CadreControl.DeviceToken', 'add', partyId, [
75
84
  row.peerId,
76
85
  row.platform,
77
86
  row.token,
@@ -84,7 +93,7 @@ export function deviceTokenAddDigest(row: DeviceTokenAuthorizedRow): string {
84
93
  /**
85
94
  * Canonical digest an owner signs to authorize a `DeviceToken` DELETE, bound to the
86
95
  * STORED row's (PeerId, StampId). SQL mirror:
87
- * `digest('CadreControl.DeviceToken', 'remove', old.PeerId, old.StampId)` in
96
+ * `digest('CadreControl.DeviceToken', 'remove', party_id(), old.PeerId, old.StampId)` in
88
97
  * `DeviceToken.AuthorizedDelete`.
89
98
  *
90
99
  * A narrower vector than {@link deviceTokenAddDigest} on purpose (the same split
@@ -92,8 +101,8 @@ export function deviceTokenAddDigest(row: DeviceTokenAuthorizedRow): string {
92
101
  * cannot be re-cut into an insert approval, and it is dead the moment the stamp it
93
102
  * names is tombstoned.
94
103
  */
95
- export function deviceTokenRemoveDigest(peerId: string, stampId: string): string {
96
- return taggedDigest('CadreControl.DeviceToken', 'remove', [peerId, stampId]);
104
+ export function deviceTokenRemoveDigest(partyId: string, peerId: string, stampId: string): string {
105
+ return taggedDigest('CadreControl.DeviceToken', 'remove', partyId, [peerId, stampId]);
97
106
  }
98
107
 
99
108
  /**
@@ -107,10 +116,10 @@ export function deviceTokenRemoveDigest(peerId: string, stampId: string): string
107
116
  * a delete. The domain tag keeps the stored, replicated `VouchSig` useless against
108
117
  * every OTHER table's rules.
109
118
  *
110
- * SQL mirror: `digest('CadreControl.CadrePeer', 'vouch', new.PeerId, new.StampId)`.
119
+ * SQL mirror: `digest('CadreControl.CadrePeer', 'vouch', party_id(), new.PeerId, new.StampId)`.
111
120
  */
112
- export function cadrePeerVoucherDigest(peerId: string, stampId: string): string {
113
- return taggedDigest('CadreControl.CadrePeer', 'vouch', [peerId, stampId]);
121
+ export function cadrePeerVoucherDigest(partyId: string, peerId: string, stampId: string): string {
122
+ return taggedDigest('CadreControl.CadrePeer', 'vouch', partyId, [peerId, stampId]);
114
123
  }
115
124
 
116
125
  /**
@@ -122,10 +131,10 @@ export function cadrePeerVoucherDigest(peerId: string, stampId: string): string
122
131
  * delete lands, because a re-added row carries a FRESH `StampId` (the removed row's
123
132
  * stamp is retired into `CadreControl.Revocation` and never reused).
124
133
  *
125
- * SQL mirror: `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)`.
134
+ * SQL mirror: `digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId)`.
126
135
  */
127
- export function cadrePeerRemoveDigest(peerId: string, stampId: string): string {
128
- return taggedDigest('CadreControl.CadrePeer', 'remove', [peerId, stampId]);
136
+ export function cadrePeerRemoveDigest(partyId: string, peerId: string, stampId: string): string {
137
+ return taggedDigest('CadreControl.CadrePeer', 'remove', partyId, [peerId, stampId]);
129
138
  }
130
139
 
131
140
  /**
@@ -134,7 +143,7 @@ export function cadrePeerRemoveDigest(peerId: string, stampId: string): string {
134
143
  * (the removed row's primary key: OwnerKey.Key / ValidationKey.Key /
135
144
  * CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id) as which row was retired.
136
145
  * SQL mirror:
137
- * `digest('CadreControl.Revocation', 'remove', new.TableName, new.RowKey, new.StampId)`
146
+ * `digest('CadreControl.Revocation', 'remove', party_id(), new.TableName, new.RowKey, new.StampId)`
138
147
  * in `Revocation.Authorized`.
139
148
  *
140
149
  * Its own `'CadreControl.Revocation'` domain tag makes it disjoint from the
@@ -143,8 +152,48 @@ export function cadrePeerRemoveDigest(peerId: string, stampId: string): string {
143
152
  * this tombstone accompanies — a removal signature is not a retirement
144
153
  * signature and cannot be replayed as one.
145
154
  */
146
- export function revocationDigest(tableName: RevocableTable, rowKey: string, stampId: string): string {
147
- return taggedDigest('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId]);
155
+ export function revocationDigest(partyId: string, tableName: RevocableTable, rowKey: string, stampId: string): string {
156
+ return taggedDigest('CadreControl.Revocation', 'remove', partyId, [tableName, rowKey, stampId]);
157
+ }
158
+
159
+ /**
160
+ * Does a `Revocation` row's stored signer pair (`SignerKey` / `SignerSig`) verify over the
161
+ * tombstone's own triple, for `partyId`? The read-side mirror of `Revocation.Authorized`,
162
+ * for a node that received the row by replication and must decide whether to honour it.
163
+ * Whether `row.signerKey` is an owner this node trusts is the caller's question, as for
164
+ * {@link verifyCadrePeerVoucher}. Never throws: a malformed row verifies as `false`.
165
+ */
166
+ export function verifyRevocationSigner(partyId: string, row: RevocationRow): boolean {
167
+ try {
168
+ return verifyB64(revocationDigest(partyId, row.tableName, row.rowKey, row.stampId), row.signerSig, row.signerKey);
169
+ } catch (error) {
170
+ log('verifyRevocationSigner failed: %o', error);
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Canonical digest an owner signs to seat a further `OwnerKey` row — the signed-add branch
177
+ * of `OwnerKey.Authorized`, which stores the pair as `VouchOwner` / `VouchSig`. SQL mirror:
178
+ * `digest('CadreControl.OwnerKey', 'add', party_id(), new.Key, new.StampId)`. The removal
179
+ * digest needs no builder here: it is the generic guarded `'remove'` digest over (Key, StampId).
180
+ */
181
+ export function ownerKeyAddDigest(partyId: string, key: string, stampId: string): string {
182
+ return taggedDigest('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
183
+ }
184
+
185
+ /**
186
+ * Does an owner-signed `OwnerKey` row's stored voucher verify — `vouchSig` by `vouchOwner`
187
+ * over {@link ownerKeyAddDigest} for (`partyId`, `key`, `stampId`)? Whether `vouchOwner` is
188
+ * anchored is the caller's question, as for {@link verifyCadrePeerVoucher}. Never throws.
189
+ */
190
+ export function verifyOwnerKeyVoucher(partyId: string, key: string, stampId: string, vouchOwner: string, vouchSig: string): boolean {
191
+ try {
192
+ return verifyB64(ownerKeyAddDigest(partyId, key, stampId), vouchSig, vouchOwner);
193
+ } catch (error) {
194
+ log('verifyOwnerKeyVoucher failed: %o', error);
195
+ return false;
196
+ }
148
197
  }
149
198
 
150
199
  /**
@@ -184,12 +233,13 @@ export function verifyPeerAuthorization(
184
233
 
185
234
  /**
186
235
  * Verify that `signature` is a valid owner ed25519 signature over the
187
- * `CadrePeer` voucher digest for (`peerId`, `stampId`) — the read-side mirror
188
- * of the voucher {@link ControlDatabase.insertCadrePeer} signs and persists into
236
+ * `CadrePeer` voucher digest for (`partyId`, `peerId`, `stampId`) — the read-side
237
+ * mirror of the voucher {@link ControlDatabase.insertCadrePeer} signs and persists into
189
238
  * `VouchOwner`/`VouchSig` (see {@link cadrePeerVoucherDigest}).
190
239
  *
191
240
  * A `true` result means the holder of `ownerPublicKey` vouched THIS membership
192
- * row (the peer id bound to the row's single-use `StampId` nonce). It says
241
+ * row (the peer id bound to the row's single-use `StampId` nonce) in THIS party: a
242
+ * voucher copied from another party's table that shares the owner key fails. It says
193
243
  * nothing about whether that owner key is itself trustworthy — the caller must
194
244
  * separately check the key against the node-local trusted-owner anchor
195
245
  * (`TrustedOwnerStore`), never the replicated `OwnerKey` table.
@@ -199,6 +249,7 @@ export function verifyPeerAuthorization(
199
249
  * resolves to `false`, logged at debug.
200
250
  */
201
251
  export function verifyCadrePeerVoucher(
252
+ partyId: string,
202
253
  peerId: string,
203
254
  stampId: string,
204
255
  ownerPublicKey: string,
@@ -206,7 +257,7 @@ export function verifyCadrePeerVoucher(
206
257
  ): boolean {
207
258
  try {
208
259
  return verify(
209
- cadrePeerVoucherDigest(peerId, stampId),
260
+ cadrePeerVoucherDigest(partyId, peerId, stampId),
210
261
  signature,
211
262
  ownerPublicKey,
212
263
  'ed25519',
@@ -224,14 +275,14 @@ export function verifyCadrePeerVoucher(
224
275
  * Canonical digest a JOINING peer signs to consent to ONE `FormationUsage`
225
276
  * redemption — the read-side mirror of `formationConsentMessage` in
226
277
  * control-database.ts, base64url-encoded instead of raw bytes (see
227
- * {@link taggedDigest}). That doc comment carries the field-vector rationale
228
- * (notably why `strandId` is not signed); the two vectors must not drift, which
229
- * peer-authorization.spec.ts pins by signing one form and verifying the other.
278
+ * {@link unboundTaggedDigest}). That doc comment carries the field-vector rationale
279
+ * (notably why neither `strandId` nor the party id is signed); the two vectors must not
280
+ * drift, which peer-authorization.spec.ts pins by signing one form and verifying the other.
230
281
  */
231
282
  export function formationConsentDigest(
232
283
  token: string, usageStampId: string, peerKey: string, disclosure: string
233
284
  ): string {
234
- return taggedDigest('CadreControl.FormationUsage', 'consent', [token, usageStampId, peerKey, disclosure]);
285
+ return unboundTaggedDigest('CadreControl.FormationUsage', 'consent', [token, usageStampId, peerKey, disclosure]);
235
286
  }
236
287
 
237
288
  /**
@@ -257,3 +308,181 @@ export function verifyFormationConsent(row: {
257
308
  return false;
258
309
  }
259
310
  }
311
+
312
+ /**
313
+ * Canonical digest an owner signs to seat a `CadreInvite` row — the read-side mirror
314
+ * of `cadreInviteAddMessage` in control-database.ts, base64url instead of raw bytes
315
+ * (see {@link taggedDigest}). The whole row is bound, nullable columns as `''` and
316
+ * `ExpiresAt` in its stored canonical form, through the one field builder both sides
317
+ * share (`cadreInviteRowFields`). SQL mirror: `CadreInvite.AuthorizedInsert`.
318
+ */
319
+ export function cadreInviteAddDigest(partyId: string, row: CadreInviteSignedFields): string {
320
+ return taggedDigest('CadreControl.CadreInvite', 'add', partyId, cadreInviteRowFields(row));
321
+ }
322
+
323
+ /**
324
+ * Canonical digest the HOLDER of a `CadreInvite`'s private key signs to redeem it once:
325
+ * proof of possession, verified against the invitation key itself
326
+ * (`CadreInviteUsage.InvitePossessed`). Binds the redemption's single-use nonce and the
327
+ * device being admitted, so one redemption cannot be re-presented for another device
328
+ * or another use. Mirror of `cadreInviteRedeemMessage` in control-database.ts.
329
+ */
330
+ export function cadreInviteRedeemDigest(partyId: string, inviteKey: string, usageStampId: string, peerKey: string): string {
331
+ return taggedDigest('CadreControl.CadreInviteUsage', 'redeem', partyId, [inviteKey, usageStampId, peerKey]);
332
+ }
333
+
334
+ /**
335
+ * Canonical digest the DEVICE signs to consent to being admitted by one redemption
336
+ * (`CadreInviteUsage.PeerConsented`), over the same fields as
337
+ * {@link cadreInviteRedeemDigest} under a distinct action tag, so the holder's and
338
+ * the device's signatures are never interchangeable. Mirror of
339
+ * `cadreInviteConsentMessage` in control-database.ts.
340
+ */
341
+ export function cadreInviteConsentDigest(partyId: string, inviteKey: string, usageStampId: string, peerKey: string): string {
342
+ return taggedDigest('CadreControl.CadreInviteUsage', 'consent', partyId, [inviteKey, usageStampId, peerKey]);
343
+ }
344
+
345
+ /** ed25519 verify over a base64url digest, with the siblings' never-throws contract left to the caller. */
346
+ function verifyB64(digestB64: string, signature: string, publicKey: string): boolean {
347
+ return verify(digestB64, signature, publicKey, 'ed25519', 'base64url', 'base64url', 'base64url');
348
+ }
349
+
350
+ /**
351
+ * Does a `CadreInvite` row carry a valid `'add'` signature by its own `issuerKey`, for
352
+ * `partyId`? The redeeming device runs this over the row a member replies with, after
353
+ * checking the issuer against its pinned owner keys and the reply's party against the
354
+ * invitation's, and a member over a bundle's copy it does not hold yet
355
+ * (`ControlDatabase.isCadreInviteLive`); whether that issuer IS an owner is the caller's question.
356
+ * Never throws: a malformed row verifies as `false`.
357
+ */
358
+ export function verifyCadreInviteRow(partyId: string, invite: CadreInviteRow): boolean {
359
+ try {
360
+ return verifyB64(cadreInviteAddDigest(partyId, invite), invite.issuerSig, invite.issuerKey);
361
+ } catch (error) {
362
+ log('verifyCadreInviteRow failed: %o', error);
363
+ return false;
364
+ }
365
+ }
366
+
367
+ /**
368
+ * The two signatures a redemption carries, checked before any database work: the holder's
369
+ * `'redeem'` signature with the invitation key, and the device's `'consent'` signature with
370
+ * its own key, both over (`inviteKey`, `usageStampId`, `peerKey`). The pre-check twin of
371
+ * `CadreInviteUsage.InvitePossessed` / `PeerConsented`, as `verifyFormationConsent` is for
372
+ * formation: the schema stays the authority, this turns a bad signature into a clean
373
+ * refusal instead of a constraint failure at commit. Never throws.
374
+ */
375
+ export function verifyCadreInviteRedemption(
376
+ partyId: string,
377
+ fields: { inviteKey: string; usageStampId: string; peerKey: string },
378
+ inviteSig: string,
379
+ peerSig: string,
380
+ ): boolean {
381
+ try {
382
+ return verifyB64(cadreInviteRedeemDigest(partyId, fields.inviteKey, fields.usageStampId, fields.peerKey), inviteSig, fields.inviteKey)
383
+ && verifyB64(cadreInviteConsentDigest(partyId, fields.inviteKey, fields.usageStampId, fields.peerKey), peerSig, fields.peerKey);
384
+ } catch (error) {
385
+ log('verifyCadreInviteRedemption failed: %o', error);
386
+ return false;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * The links of an invitation admission that do not depend on WHICH table's row was
392
+ * admitted — shared by {@link verifyInvitationAdmission} (`CadrePeer`) and
393
+ * {@link verifyInvitationOwnerAdmission} (`OwnerKey`), which differ only in the row
394
+ * columns the usage must name and, for an owner row, in `grantsOwner`. The chain is
395
+ *
396
+ * row --vouchUsage--> usage --inviteKey--> invitation --issuerKey--> anchored owner
397
+ *
398
+ * and every link here is verified: the issuer is anchored (`isAnchored`) and is the row's
399
+ * `vouchOwner`; the usage names this invitation and is the row's `vouchUsage`; a targeted
400
+ * invitation names the redeeming device; the stored `peerKey` really is the key behind the
401
+ * usage's `peerId` (the schema cannot unwrap a multihash, so a writer could assert any
402
+ * pair); the invitation's stored `'add'` signature verifies over the row rebuilt from its
403
+ * columns; the holder's `'redeem'` signature verifies with the invitation key and the
404
+ * device's `'consent'` signature with its own key. All three signatures are judged for
405
+ * `partyId`. Throws on malformed input; the two callers turn that into `false`.
406
+ */
407
+ function verifyAdmissionChain(
408
+ partyId: string,
409
+ vouchOwner: string,
410
+ vouchUsage: string,
411
+ usage: CadreInviteUsageRow,
412
+ invite: CadreInviteRow,
413
+ isAnchored: (ownerKey: string) => boolean,
414
+ ): boolean {
415
+ return isAnchored(vouchOwner)
416
+ && invite.issuerKey === vouchOwner
417
+ && usage.usageStampId === vouchUsage
418
+ && usage.inviteKey === invite.key
419
+ && (invite.peerId === null || invite.peerId === usage.peerId)
420
+ && ed25519PublicKeyB64FromPeerId(usage.peerId) === usage.peerKey
421
+ && verifyB64(cadreInviteAddDigest(partyId, invite), invite.issuerSig, invite.issuerKey)
422
+ && verifyB64(cadreInviteRedeemDigest(partyId, invite.key, usage.usageStampId, usage.peerKey), usage.inviteSig, invite.key)
423
+ && verifyB64(cadreInviteConsentDigest(partyId, invite.key, usage.usageStampId, usage.peerKey), usage.peerSig, usage.peerKey);
424
+ }
425
+
426
+ /**
427
+ * Is an invitation-admitted `CadrePeer` row (`vouchSig` null, `vouchUsage` set) a
428
+ * member this node should trust? The read-side mirror of the consent branch of
429
+ * `CadrePeer.AuthorizedInsert`, re-checked against THIS node's anchor because the
430
+ * replicated `OwnerKey` table can be polluted. The usage must name this peer id and this
431
+ * exact row incarnation (`peerStampId`); every other link is {@link verifyAdmissionChain}.
432
+ *
433
+ * Expiry, use count and withdrawal are deliberately NOT re-checked: they were conditions
434
+ * at redemption (`CadreInviteUsage.Authorized`), and membership persists until an owner
435
+ * removes the row — exactly as an owner-vouched row's membership does. Keeping a device
436
+ * out is removing its row AND withdrawing the invitation.
437
+ *
438
+ * Returns a boolean and never throws (the siblings' contract): malformed input or any
439
+ * crypto failure resolves to `false`, logged at debug.
440
+ */
441
+ export function verifyInvitationAdmission(
442
+ partyId: string,
443
+ row: CadrePeerVoucherFields,
444
+ usage: CadreInviteUsageRow,
445
+ invite: CadreInviteRow,
446
+ isAnchored: (ownerKey: string) => boolean,
447
+ ): boolean {
448
+ try {
449
+ return row.stampId !== null
450
+ && row.vouchOwner !== null
451
+ && row.vouchUsage !== null
452
+ && usage.peerStampId === row.stampId
453
+ && usage.peerId === row.peerId
454
+ && verifyAdmissionChain(partyId, row.vouchOwner, row.vouchUsage, usage, invite, isAnchored);
455
+ } catch (error) {
456
+ log('verifyInvitationAdmission failed: %o', error);
457
+ return false;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Is an invitation-admitted `OwnerKey` row (`vouchSig` null, `vouchUsage` set) an owner
463
+ * this node should derive into its anchor? The read-side mirror of the consent branch of
464
+ * `OwnerKey.Authorized` (`owner-anchor-sync.ts` → `deriveOwnerAnchor`): the invitation
465
+ * must grant ownership, and the usage must name this key (`peerKey`) and this exact row
466
+ * incarnation (`ownerStampId`); every other link is {@link verifyAdmissionChain}. Same
467
+ * never-throws contract and the same deliberate omissions as
468
+ * {@link verifyInvitationAdmission}.
469
+ */
470
+ export function verifyInvitationOwnerAdmission(
471
+ partyId: string,
472
+ row: OwnerKeyRow,
473
+ usage: CadreInviteUsageRow,
474
+ invite: CadreInviteRow,
475
+ isAnchored: (ownerKey: string) => boolean,
476
+ ): boolean {
477
+ try {
478
+ return row.vouchOwner !== null
479
+ && row.vouchUsage !== null
480
+ && invite.grantsOwner
481
+ && usage.peerKey === row.key
482
+ && usage.ownerStampId === row.stampId
483
+ && verifyAdmissionChain(partyId, row.vouchOwner, row.vouchUsage, usage, invite, isAnchored);
484
+ } catch (error) {
485
+ log('verifyInvitationOwnerAdmission failed: %o', error);
486
+ return false;
487
+ }
488
+ }
package/src/peer-dial.ts CHANGED
@@ -144,6 +144,22 @@ export class SelfRelayOnlyError extends Error {
144
144
  }
145
145
  }
146
146
 
147
+ /**
148
+ * No connection to the peer formed, so nothing was sent to it. `cause` is what the dial threw:
149
+ * {@link dialPeerAddrs}'s error naming every address and why it failed, or a
150
+ * {@link SelfRelayOnlyError}.
151
+ *
152
+ * Kept apart from failures after a connection formed so a caller can tell "this device could not
153
+ * reach the peer" from "the peer was reached and then something else failed" without reading
154
+ * libp2p's error text. `SeedBootstrapService.deliverSeed` throws it, and so `CadreNode.claimNode`.
155
+ */
156
+ export class PeerUnreachableError extends Error {
157
+ constructor(readonly peerId: string, cause: unknown) {
158
+ super(`Could not reach ${peerId}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
159
+ this.name = 'PeerUnreachableError';
160
+ }
161
+ }
162
+
147
163
  /** One candidate's outcome, kept so the thrown error can name every attempt. */
148
164
  interface AddrAttemptFailure {
149
165
  addr: Multiaddr;
@@ -310,6 +326,11 @@ function allAttemptsFailed(label: string, failures: AddrAttemptFailure[]): Error
310
326
  return new Error(`${label} failed for all ${failures.length} candidate addresses: ${detail}`, { cause: failures[0].error });
311
327
  }
312
328
 
313
- function nextMacrotask(): Promise<void> {
329
+ /**
330
+ * Yield one macrotask: libp2p's dial queue drops an aborted job only on the next turn, so a
331
+ * dialer that moves on to another address after an abort waits this long first. Shared with
332
+ * the cadre invitation dialer (`cadre-invite-protocol.ts`).
333
+ */
334
+ export function nextMacrotask(): Promise<void> {
314
335
  return new Promise((resolve) => setTimeout(resolve, 0));
315
336
  }
@@ -315,10 +315,16 @@ export class PeerJoinBackfill {
315
315
  private readonly createPushClient: (peerId: PeerId) => PeerJoinBackfillPushClient;
316
316
  private started = false;
317
317
  private stopped = false;
318
- /** Peers fully caught up this runtime (never retried until the runtime is rebuilt). */
318
+ /**
319
+ * Peers fully caught up this runtime (never rescheduled until the runtime is rebuilt; only
320
+ * {@link forceCatchUpPeer} pushes to one again).
321
+ */
319
322
  private readonly done = new Set<string>();
320
- /** Peers with a catch-up currently running (suppresses concurrent duplicates). */
321
- private readonly inFlight = new Set<string>();
323
+ /**
324
+ * Peers with a catch-up currently running, each with that run (suppresses concurrent
325
+ * duplicates; {@link forceCatchUpPeer} waits on the run).
326
+ */
327
+ private readonly inFlight = new Map<string, Promise<PeerJoinBackfillResult>>();
322
328
  /**
323
329
  * Peers whose (re-)schedule arrived while their OWN run was in flight, replayed once
324
330
  * that run finishes. Load-bearing for the gated path: the gate's authorization check is
@@ -464,7 +470,53 @@ export class PeerJoinBackfill {
464
470
  if (this.stopped || this.done.has(key) || this.inFlight.has(key)) {
465
471
  return emptyResult();
466
472
  }
467
- this.inFlight.add(key);
473
+ return await this.trackRun(peerId);
474
+ }
475
+
476
+ /**
477
+ * Run one peer's catch-up now and wait for it, whatever this instance remembers about the
478
+ * peer: wait out a run already in flight for it, then push the whole store again even when
479
+ * the peer is memoized as caught up. Never rejects; an all-zero result only when stopped.
480
+ *
481
+ * For a caller that must know the peer holds this store before it proceeds, where
482
+ * {@link catchUpPeer}'s empty answer would hide a run that was denied (a `peer:identify`
483
+ * run can pass the gate a moment before the caller authorized the peer) or a memo older
484
+ * than the commits since.
485
+ */
486
+ async forceCatchUpPeer(peerId: PeerId): Promise<PeerJoinBackfillResult> {
487
+ const key = peerId.toString();
488
+ for (let pending = this.inFlight.get(key); pending; pending = this.inFlight.get(key)) {
489
+ await pending;
490
+ }
491
+ if (this.stopped) {
492
+ return emptyResult();
493
+ }
494
+ return await this.trackRun(peerId);
495
+ }
496
+
497
+ /**
498
+ * Register one run in {@link inFlight} before its first await, so no second run for the
499
+ * peer can start beside it, and replay a schedule that arrived while it ran.
500
+ */
501
+ private async trackRun(peerId: PeerId): Promise<PeerJoinBackfillResult> {
502
+ const key = peerId.toString();
503
+ const run = this.settleRun(peerId);
504
+ this.inFlight.set(key, run);
505
+ try {
506
+ return await run;
507
+ } finally {
508
+ this.inFlight.delete(key);
509
+ // Replay a schedule that arrived mid-run. `schedulePeer` re-checks `done`, so a run
510
+ // that finished clean re-arms nothing; a denied or failed one gets its retry.
511
+ if (this.rearmAfterFlight.delete(key)) {
512
+ this.schedulePeer(peerId);
513
+ }
514
+ }
515
+ }
516
+
517
+ /** One run and what follows from its outcome: the memo, or a backoff re-arm. Never rejects. */
518
+ private async settleRun(peerId: PeerId): Promise<PeerJoinBackfillResult> {
519
+ const key = peerId.toString();
468
520
  try {
469
521
  const { result, clean, pushFailed } = await this.runCatchUp(peerId);
470
522
  // NOTE: a run that hit `maxBlocks` is still "clean" and still memoizes the peer, so
@@ -496,13 +548,6 @@ export class PeerJoinBackfill {
496
548
  // metadata reads too — a backfill fault must never surface through a libp2p event.
497
549
  log('[%s] catch-up peer=%s failed: %o', this.deps.label, key, error);
498
550
  return emptyResult();
499
- } finally {
500
- this.inFlight.delete(key);
501
- // Replay a schedule that arrived mid-run. `schedulePeer` re-checks `done`, so a run
502
- // that finished clean re-arms nothing; a denied or failed one gets its retry.
503
- if (this.rearmAfterFlight.delete(key)) {
504
- this.schedulePeer(peerId);
505
- }
506
551
  }
507
552
  }
508
553
 
@@ -8,15 +8,17 @@
8
8
  * multi-field encoding, so no field split is ambiguous. It is intentionally NOT
9
9
  * JSON-canonicalized (deterministic across node/browser/RN, no key ordering)
10
10
  * and — crucially — is reconstructable inside the `CadrePeer.AuthorizedUpdate`
11
- * SQL constraint from the row's own columns:
11
+ * SQL constraint from the row's own columns and the node-local `party_id()`:
12
12
  *
13
- * digest('CadreControl.CadrePeer', 'publish',
13
+ * digest('CadreControl.CadrePeer', 'publish', party_id(),
14
14
  * new.PeerId, new.Multiaddr, cast(new.UpdatedAt as text))
15
15
  *
16
16
  * Keep {@link peerRecordSignedPayload} and that constraint byte-for-byte in
17
17
  * sync. The `'publish'` action tag marks this as a peer's SELF-signed record
18
18
  * (signed with the peer's own key, not an owner key) and keeps it disjoint from
19
- * every owner-signed digest.
19
+ * every owner-signed digest; the party id keeps a record published into one
20
+ * party's table from verifying in another's. A node signs and verifies with its
21
+ * own party: these records come only from its own party's control database.
20
22
  *
21
23
  * `publicKey` is deliberately excluded from the payload — it is the key the
22
24
  * signature is verified *with* (so a signature already commits to exactly one
@@ -51,12 +53,13 @@ const SIGNALING_PREFIX = '/p2p-circuit';
51
53
  * base64url output of a single `digest(...)`, which round-trips cleanly (unlike
52
54
  * concatenating several base64url digests).
53
55
  *
56
+ * @param partyId - the party whose `CadrePeer` table the record is published into
54
57
  * @param peerId - base58btc libp2p peer id (the row key)
55
58
  * @param multiaddr - the comma-joined `Multiaddr` string EXACTLY as stored
56
59
  * @param updatedAt - epoch-ms freshness stamp
57
60
  */
58
- export function peerRecordSignedPayload(peerId: string, multiaddr: string, updatedAt: number): string {
59
- const fields = controlAuthorizationFields('CadreControl.CadrePeer', 'publish', [peerId, multiaddr, String(updatedAt)]);
61
+ export function peerRecordSignedPayload(partyId: string, peerId: string, multiaddr: string, updatedAt: number): string {
62
+ const fields = controlAuthorizationFields('CadreControl.CadrePeer', 'publish', partyId, [peerId, multiaddr, String(updatedAt)]);
60
63
  return digest(fields, 'sha256', 'base64url') as string;
61
64
  }
62
65
 
@@ -65,14 +68,16 @@ export function peerRecordSignedPayload(peerId: string, multiaddr: string, updat
65
68
  * Returns a fully-populated {@link PeerAddressRecord} (the `addrs` order is
66
69
  * preserved and is the order signed over).
67
70
  *
71
+ * @param partyId - the party whose `CadrePeer` table the record is published into
68
72
  * @param fields - the record fields to sign (signaling addr first by convention)
69
73
  * @param privateKeyB64 - base64url ed25519 seed (see `ed25519KeyPairFromLibp2p`)
70
74
  */
71
75
  export function signPeerRecord(
76
+ partyId: string,
72
77
  fields: { peerId: string; publicKey: string; addrs: string[]; updatedAt: number },
73
78
  privateKeyB64: string
74
79
  ): PeerAddressRecord {
75
- const payloadDigest = peerRecordSignedPayload(fields.peerId, fields.addrs.join(','), fields.updatedAt);
80
+ const payloadDigest = peerRecordSignedPayload(partyId, fields.peerId, fields.addrs.join(','), fields.updatedAt);
76
81
  const sig = sign(
77
82
  payloadDigest,
78
83
  privateKeyB64,
@@ -85,15 +90,15 @@ export function signPeerRecord(
85
90
  }
86
91
 
87
92
  /**
88
- * Verify a record's self-signature against its own `publicKey`. Reconstructs the
89
- * signed bytes from the record exactly as {@link signPeerRecord} produced them.
90
- * Returns false on a missing key/sig or any verification failure.
93
+ * Verify a record's self-signature against its own `publicKey`, for `partyId`.
94
+ * Reconstructs the signed bytes from the record exactly as {@link signPeerRecord}
95
+ * produced them. Returns false on a missing key/sig or any verification failure.
91
96
  */
92
- export function verifyPeerRecordSignature(record: PeerAddressRecord): boolean {
97
+ export function verifyPeerRecordSignature(partyId: string, record: PeerAddressRecord): boolean {
93
98
  if (!record.publicKey || !record.sig) {
94
99
  return false;
95
100
  }
96
- const payloadDigest = peerRecordSignedPayload(record.peerId, record.addrs.join(','), record.updatedAt);
101
+ const payloadDigest = peerRecordSignedPayload(partyId, record.peerId, record.addrs.join(','), record.updatedAt);
97
102
  return verify(
98
103
  payloadDigest,
99
104
  record.sig,