@serfab/cadre-core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +4 -2
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +918 -53
  9. package/dist/cadre-node.js +1843 -190
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-database.d.ts +990 -50
  15. package/dist/control-database.js +1459 -108
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -10
  18. package/dist/control-formation-recorder.js +129 -14
  19. package/dist/control-formation-recorder.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +774 -210
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +38 -4
  24. package/dist/control-stream.js +70 -4
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +151 -0
  27. package/dist/control-write-retry.js +411 -0
  28. package/dist/control-write-retry.js.map +1 -0
  29. package/dist/delegate-admission.d.ts +134 -0
  30. package/dist/delegate-admission.js +244 -0
  31. package/dist/delegate-admission.js.map +1 -0
  32. package/dist/device-token.d.ts +11 -11
  33. package/dist/device-token.js +14 -13
  34. package/dist/device-token.js.map +1 -1
  35. package/dist/ed25519-key.d.ts +26 -0
  36. package/dist/ed25519-key.js +56 -1
  37. package/dist/ed25519-key.js.map +1 -1
  38. package/dist/file-durable-slot.d.ts +20 -0
  39. package/dist/file-durable-slot.js +53 -0
  40. package/dist/file-durable-slot.js.map +1 -0
  41. package/dist/formation-approval.d.ts +157 -0
  42. package/dist/formation-approval.js +375 -0
  43. package/dist/formation-approval.js.map +1 -0
  44. package/dist/fs-atomic.d.ts +38 -0
  45. package/dist/fs-atomic.js +97 -0
  46. package/dist/fs-atomic.js.map +1 -0
  47. package/dist/identity-key.d.ts +49 -0
  48. package/dist/identity-key.js +81 -0
  49. package/dist/identity-key.js.map +1 -0
  50. package/dist/index.d.ts +16 -6
  51. package/dist/index.js +39 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/key-store-file.d.ts +2 -24
  54. package/dist/key-store-file.js +10 -77
  55. package/dist/key-store-file.js.map +1 -1
  56. package/dist/membership-connection-gater.d.ts +146 -0
  57. package/dist/membership-connection-gater.js +180 -0
  58. package/dist/membership-connection-gater.js.map +1 -0
  59. package/dist/node-local-snapshot.d.ts +109 -0
  60. package/dist/node-local-snapshot.js +182 -0
  61. package/dist/node-local-snapshot.js.map +1 -0
  62. package/dist/peer-authorization.d.ts +135 -18
  63. package/dist/peer-authorization.js +152 -21
  64. package/dist/peer-authorization.js.map +1 -1
  65. package/dist/peer-record.d.ts +21 -8
  66. package/dist/peer-record.js +26 -10
  67. package/dist/peer-record.js.map +1 -1
  68. package/dist/relay-addrs.d.ts +45 -0
  69. package/dist/relay-addrs.js +103 -0
  70. package/dist/relay-addrs.js.map +1 -0
  71. package/dist/relay-reservation.d.ts +266 -0
  72. package/dist/relay-reservation.js +613 -0
  73. package/dist/relay-reservation.js.map +1 -0
  74. package/dist/seed-bootstrap.d.ts +221 -56
  75. package/dist/seed-bootstrap.js +328 -188
  76. package/dist/seed-bootstrap.js.map +1 -1
  77. package/dist/seed-trust-policy.d.ts +47 -21
  78. package/dist/seed-trust-policy.js +24 -15
  79. package/dist/seed-trust-policy.js.map +1 -1
  80. package/dist/strand-addr-protocol.d.ts +42 -13
  81. package/dist/strand-addr-protocol.js +58 -52
  82. package/dist/strand-addr-protocol.js.map +1 -1
  83. package/dist/strand-backfill.d.ts +109 -0
  84. package/dist/strand-backfill.js +279 -0
  85. package/dist/strand-backfill.js.map +1 -0
  86. package/dist/strand-formation-manager.d.ts +38 -9
  87. package/dist/strand-formation-manager.js +120 -17
  88. package/dist/strand-formation-manager.js.map +1 -1
  89. package/dist/strand-formation-protocol.d.ts +103 -4
  90. package/dist/strand-formation-protocol.js +232 -4
  91. package/dist/strand-formation-protocol.js.map +1 -1
  92. package/dist/strand-instance-manager.d.ts +39 -1
  93. package/dist/strand-instance-manager.js +92 -14
  94. package/dist/strand-instance-manager.js.map +1 -1
  95. package/dist/strand-member-registry.d.ts +5 -5
  96. package/dist/strand-member-registry.js +18 -10
  97. package/dist/strand-member-registry.js.map +1 -1
  98. package/dist/strand-membership-writer.d.ts +482 -88
  99. package/dist/strand-membership-writer.js +757 -136
  100. package/dist/strand-membership-writer.js.map +1 -1
  101. package/dist/strand-solicitation.d.ts +99 -33
  102. package/dist/strand-solicitation.js +100 -52
  103. package/dist/strand-solicitation.js.map +1 -1
  104. package/dist/strand-transport-key.d.ts +40 -0
  105. package/dist/strand-transport-key.js +56 -0
  106. package/dist/strand-transport-key.js.map +1 -0
  107. package/dist/strand-wake-protocol.d.ts +9 -7
  108. package/dist/strand-wake-protocol.js +22 -46
  109. package/dist/strand-wake-protocol.js.map +1 -1
  110. package/dist/strand-watcher.d.ts +17 -2
  111. package/dist/strand-watcher.js +47 -3
  112. package/dist/strand-watcher.js.map +1 -1
  113. package/dist/trusted-owner-store-file.d.ts +12 -0
  114. package/dist/trusted-owner-store-file.js +39 -0
  115. package/dist/trusted-owner-store-file.js.map +1 -0
  116. package/dist/trusted-owner-store.d.ts +64 -0
  117. package/dist/trusted-owner-store.js +126 -0
  118. package/dist/trusted-owner-store.js.map +1 -0
  119. package/dist/types.d.ts +275 -20
  120. package/dist/types.js +13 -0
  121. package/dist/types.js.map +1 -1
  122. package/package.json +19 -8
  123. package/src/bootstrap-peer-store-file.ts +44 -0
  124. package/src/bootstrap-peer-store.ts +172 -0
  125. package/src/cadre-node.ts +1992 -202
  126. package/src/control-authorization.ts +114 -0
  127. package/src/control-database.ts +1704 -146
  128. package/src/control-formation-recorder.ts +172 -19
  129. package/src/control-schema.ts +786 -222
  130. package/src/control-stream.ts +81 -4
  131. package/src/control-write-retry.ts +473 -0
  132. package/src/delegate-admission.ts +302 -0
  133. package/src/device-token.ts +18 -13
  134. package/src/ed25519-key.ts +134 -69
  135. package/src/file-durable-slot.ts +61 -0
  136. package/src/formation-approval.ts +604 -0
  137. package/src/fs-atomic.ts +107 -0
  138. package/src/identity-key.ts +109 -0
  139. package/src/index.ts +136 -5
  140. package/src/key-store-file.ts +10 -81
  141. package/src/membership-connection-gater.ts +204 -0
  142. package/src/node-local-snapshot.ts +260 -0
  143. package/src/peer-authorization.ts +199 -21
  144. package/src/peer-record.ts +28 -10
  145. package/src/relay-addrs.ts +111 -0
  146. package/src/relay-reservation.ts +823 -0
  147. package/src/seed-bootstrap.ts +397 -197
  148. package/src/seed-trust-policy.ts +58 -25
  149. package/src/strand-addr-protocol.ts +84 -53
  150. package/src/strand-backfill.ts +366 -0
  151. package/src/strand-formation-manager.ts +145 -19
  152. package/src/strand-formation-protocol.ts +309 -8
  153. package/src/strand-instance-manager.ts +111 -14
  154. package/src/strand-member-registry.ts +18 -13
  155. package/src/strand-membership-writer.ts +1430 -640
  156. package/src/strand-solicitation.ts +155 -90
  157. package/src/strand-transport-key.ts +58 -0
  158. package/src/strand-wake-protocol.ts +29 -46
  159. package/src/strand-watcher.ts +66 -3
  160. package/src/trusted-owner-store-file.ts +41 -0
  161. package/src/trusted-owner-store.ts +171 -0
  162. package/src/types.ts +292 -21
@@ -1,48 +1,150 @@
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
5
 
4
6
  const log = debug('sereus:cadre:peer-authorization');
5
7
 
6
8
  /**
7
- * Canonical digest an owner signs to authorize a peer.
9
+ * base64url SHA-256 digest over the shared domain-tagged field vector (see
10
+ * control-authorization.ts). The base64url twin of
11
+ * control-database.ts:buildAuthorizationMessage (which returns the same digest as raw
12
+ * bytes): sign either encoding with input encoding to match and the signed bytes agree.
13
+ */
14
+ function taggedDigest(domain: ControlDomain, action: ControlAction, rowFields: string[]): string {
15
+ return digest(controlAuthorizationFields(domain, action, rowFields), 'sha256', 'base64url') as string;
16
+ }
17
+
18
+ /**
19
+ * Canonical digest an owner signs to vouch a peer's ENROLLMENT — the offline
20
+ * credential `cadre enroll register` verifies. No table checks this digest, so it
21
+ * carries its own `'Cadre.Enrollment'` domain tag to stay disjoint from every
22
+ * CadreControl table rule (pre-tag it collided with the `DeviceToken` owner digests,
23
+ * so an enrollment vouch doubled as a push-token delete approval).
8
24
  *
9
- * This is the exact byte construction {@link SeedBootstrapService.authorizePeer}
10
- * signs over when it inserts an authorized `CadrePeer` row:
11
- * `digest([peerId], 'sha256', 'base64url')`. Factored into one place so the
12
- * producer (owner signing) and the verifier (the offline `cadre enroll
13
- * register` check) can never drift apart — change the digest here and both move
14
- * together. The SQL mirror is the bare single-field `digest(coalesce(new.PeerId, old.PeerId))`.
25
+ * Factored into one place so the producer (owner signing) and the verifier (the
26
+ * offline `cadre enroll register` check) can never drift apart — change the digest
27
+ * here and both move together.
15
28
  */
16
29
  export function peerAuthorizationDigest(peerId: string): string {
17
- return digest([peerId], 'sha256', 'base64url') as string;
30
+ return taggedDigest('Cadre.Enrollment', 'vouch', [peerId]);
31
+ }
32
+
33
+ /**
34
+ * The `CadreControl.DeviceToken` columns {@link deviceTokenAddDigest} binds, in the
35
+ * schema's order. A whole-row struct rather than six positional strings, so a caller
36
+ * cannot silently transpose `platform` and `token` (both opaque strings) and mint a
37
+ * signature over a row it did not mean to approve.
38
+ *
39
+ * Structurally satisfied by a `DeviceTokenRecord` spread with the row's `stampId`;
40
+ * `types.ts:DeviceTokenRecord` deliberately does NOT carry the stamp — it is the
41
+ * SELF-signed record shape, and the peer's own `Sig` does not cover `StampId`.
42
+ * `updatedAt` / `sig` are nullable because the columns are: both sign as `''` when
43
+ * absent, mirroring the schema's `coalesce(...)`.
44
+ */
45
+ export interface DeviceTokenAuthorizedRow {
46
+ peerId: string;
47
+ platform: string;
48
+ token: string;
49
+ updatedAt: number | null;
50
+ sig: string | null;
51
+ stampId: string;
52
+ }
53
+
54
+ /**
55
+ * Canonical digest an owner signs to authorize a `DeviceToken` INSERT — the WHOLE row,
56
+ * ending in its single-use `StampId` nonce. SQL mirror:
57
+ * `digest('CadreControl.DeviceToken', 'add', new.PeerId, new.Platform, new.Token,
58
+ * coalesce(cast(new.UpdatedAt as text), ''), coalesce(new.Sig, ''), new.StampId)` in
59
+ * `DeviceToken.AuthorizedInsert`.
60
+ *
61
+ * Binding every column means a captured approval can only ever reproduce the exact row
62
+ * it approved — never one carrying attacker-chosen `Platform`/`Token`/`UpdatedAt` — and
63
+ * binding the stamp makes it single-use: while the row lives the `unique` column blocks
64
+ * a replay, and after a clear the stamp is retired permanently into
65
+ * `CadreControl.Revocation` (`DeviceToken.NotRevoked`). This matters more here than for
66
+ * `CadrePeer`: a resurrected push token has NO freshness ceiling to retire it
67
+ * (`CadreNode.resolveDeviceToken` defaults `maxAgeMs` to infinity by design), so stamp
68
+ * retirement is the only thing that sticks.
69
+ *
70
+ * Distinct from {@link deviceTokenRemoveDigest} so a captured insert approval can never
71
+ * be replayed to delete the token, and vice versa.
72
+ */
73
+ export function deviceTokenAddDigest(row: DeviceTokenAuthorizedRow): string {
74
+ return taggedDigest('CadreControl.DeviceToken', 'add', [
75
+ row.peerId,
76
+ row.platform,
77
+ row.token,
78
+ row.updatedAt === null ? '' : String(row.updatedAt),
79
+ row.sig ?? '',
80
+ row.stampId,
81
+ ]);
82
+ }
83
+
84
+ /**
85
+ * Canonical digest an owner signs to authorize a `DeviceToken` DELETE, bound to the
86
+ * STORED row's (PeerId, StampId). SQL mirror:
87
+ * `digest('CadreControl.DeviceToken', 'remove', old.PeerId, old.StampId)` in
88
+ * `DeviceToken.AuthorizedDelete`.
89
+ *
90
+ * A narrower vector than {@link deviceTokenAddDigest} on purpose (the same split
91
+ * `CadrePeer` makes): the clear approval names only which row is being retired, so it
92
+ * cannot be re-cut into an insert approval, and it is dead the moment the stamp it
93
+ * names is tombstoned.
94
+ */
95
+ export function deviceTokenRemoveDigest(peerId: string, stampId: string): string {
96
+ return taggedDigest('CadreControl.DeviceToken', 'remove', [peerId, stampId]);
18
97
  }
19
98
 
20
99
  /**
21
100
  * Canonical digest an owner signs to VOUCH a `CadrePeer` membership row (insert
22
- * and the owner re-touch update). Binds the peer id to the row's single-use
23
- * `StampId` nonce, so a captured signed insert cannot be replayed (the `StampId` is
24
- * unique) and — because {@link cadrePeerRemoveDigest} scopes a DIFFERENT payload — the
25
- * stored voucher (`VouchSig`) cannot be replayed to authorize a delete.
101
+ * and the owner re-touch update — same semantics, deliberately the same digest).
102
+ * Binds the peer id to the row's single-use `StampId` nonce, so a captured signed
103
+ * insert cannot be replayed — while the row lives the `unique` column blocks it, and
104
+ * after a removal the stamp is retired permanently into `CadreControl.Revocation`
105
+ * (`CadrePeer.NotRevoked`) — and, because {@link cadrePeerRemoveDigest} scopes a
106
+ * DIFFERENT payload, the stored voucher (`VouchSig`) cannot be replayed to authorize
107
+ * a delete. The domain tag keeps the stored, replicated `VouchSig` useless against
108
+ * every OTHER table's rules.
26
109
  *
27
- * The SQL mirror is `digest(new.PeerId, new.StampId)` (two TEXT fields), matching the
28
- * canonical multi-field framing in control-database.ts:buildAuthorizationMessage.
110
+ * SQL mirror: `digest('CadreControl.CadrePeer', 'vouch', new.PeerId, new.StampId)`.
29
111
  */
30
112
  export function cadrePeerVoucherDigest(peerId: string, stampId: string): string {
31
- return digest([peerId, stampId], 'sha256', 'base64url') as string;
113
+ return taggedDigest('CadreControl.CadrePeer', 'vouch', [peerId, stampId]);
32
114
  }
33
115
 
34
116
  /**
35
117
  * Canonical digest an owner signs to REMOVE a `CadrePeer` row. Deliberately a
36
- * distinct payload from {@link cadrePeerVoucherDigest} (adds the `'remove'` action
37
- * tag) so the row's stored voucher — a signature over the voucher digest — can never
118
+ * distinct payload from {@link cadrePeerVoucherDigest} (the `'remove'` action tag)
119
+ * so the row's stored voucher — a signature over the voucher digest — can never
38
120
  * satisfy this delete check. The signature is supplied in write context and never
39
- * stored, so no reader can replay it; binding the live row's `StampId` also invalidates
40
- * any captured remove after a delete+reinsert (the nonce rotates).
121
+ * stored, so no reader can replay it; a captured remove is also dead after the
122
+ * delete lands, because a re-added row carries a FRESH `StampId` (the removed row's
123
+ * stamp is retired into `CadreControl.Revocation` and never reused).
41
124
  *
42
- * SQL mirror: `digest(old.PeerId, old.StampId, 'remove')`.
125
+ * SQL mirror: `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)`.
43
126
  */
44
127
  export function cadrePeerRemoveDigest(peerId: string, stampId: string): string {
45
- return digest([peerId, stampId, 'remove'], 'sha256', 'base64url') as string;
128
+ return taggedDigest('CadreControl.CadrePeer', 'remove', [peerId, stampId]);
129
+ }
130
+
131
+ /**
132
+ * Canonical digest an owner signs to APPEND a `CadreControl.Revocation` tombstone —
133
+ * the row retiring `stampId` for the named guarded table, and recording `rowKey`
134
+ * (the removed row's primary key: OwnerKey.Key / ValidationKey.Key /
135
+ * CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id) as which row was retired.
136
+ * SQL mirror:
137
+ * `digest('CadreControl.Revocation', 'remove', new.TableName, new.RowKey, new.StampId)`
138
+ * in `Revocation.Authorized`.
139
+ *
140
+ * Its own `'CadreControl.Revocation'` domain tag makes it disjoint from the
141
+ * `'CadreControl.CadrePeer'` (or `OwnerKey` / `ValidationKey` / `Strand`)
142
+ * `'remove'` digest the same owner signs in the SAME transaction for the delete
143
+ * this tombstone accompanies — a removal signature is not a retirement
144
+ * signature and cannot be replayed as one.
145
+ */
146
+ export function revocationDigest(tableName: RevocableTable, rowKey: string, stampId: string): string {
147
+ return taggedDigest('CadreControl.Revocation', 'remove', [tableName, rowKey, stampId]);
46
148
  }
47
149
 
48
150
  /**
@@ -79,3 +181,79 @@ export function verifyPeerAuthorization(
79
181
  return false;
80
182
  }
81
183
  }
184
+
185
+ /**
186
+ * 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
189
+ * `VouchOwner`/`VouchSig` (see {@link cadrePeerVoucherDigest}).
190
+ *
191
+ * 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
193
+ * nothing about whether that owner key is itself trustworthy — the caller must
194
+ * separately check the key against the node-local trusted-owner anchor
195
+ * (`TrustedOwnerStore`), never the replicated `OwnerKey` table.
196
+ *
197
+ * Returns a boolean and never throws (same contract as
198
+ * {@link verifyPeerAuthorization}): malformed input or any crypto failure
199
+ * resolves to `false`, logged at debug.
200
+ */
201
+ export function verifyCadrePeerVoucher(
202
+ peerId: string,
203
+ stampId: string,
204
+ ownerPublicKey: string,
205
+ signature: string
206
+ ): boolean {
207
+ try {
208
+ return verify(
209
+ cadrePeerVoucherDigest(peerId, stampId),
210
+ signature,
211
+ ownerPublicKey,
212
+ 'ed25519',
213
+ 'base64url',
214
+ 'base64url',
215
+ 'base64url'
216
+ );
217
+ } catch (error) {
218
+ log('verifyCadrePeerVoucher failed: %o', error);
219
+ return false;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Canonical digest a JOINING peer signs to consent to ONE `FormationUsage`
225
+ * redemption — the read-side mirror of `formationConsentMessage` in
226
+ * 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.
230
+ */
231
+ export function formationConsentDigest(
232
+ token: string, usageStampId: string, peerKey: string, disclosure: string
233
+ ): string {
234
+ return taggedDigest('CadreControl.FormationUsage', 'consent', [token, usageStampId, peerKey, disclosure]);
235
+ }
236
+
237
+ /**
238
+ * Verify that `row.peerSig` is a valid ed25519 signature over the joining peer's
239
+ * consent digest (see {@link formationConsentDigest}) — the row's OWN `peerKey`.
240
+ *
241
+ * Unlike {@link verifyCadrePeerVoucher} there is no separate enrolled/owner row to
242
+ * look up: the identity IS the key carried on the row, so a forged consent would
243
+ * need that joiner's own private key. Returns a boolean and never throws (same
244
+ * contract as the siblings above): malformed input or any crypto failure resolves
245
+ * to `false`, logged at debug.
246
+ */
247
+ export function verifyFormationConsent(row: {
248
+ token: string; usageStampId: string; peerKey: string; disclosure: string; peerSig: string;
249
+ }): boolean {
250
+ try {
251
+ return verify(
252
+ formationConsentDigest(row.token, row.usageStampId, row.peerKey, row.disclosure),
253
+ row.peerSig, row.peerKey, 'ed25519', 'base64url', 'base64url', 'base64url'
254
+ );
255
+ } catch (error) {
256
+ log('verifyFormationConsent failed: %o', error);
257
+ return false;
258
+ }
259
+ }
@@ -3,24 +3,29 @@
3
3
  * signs when publishing its own `CadrePeer` row and re-verifies when resolving
4
4
  * another peer's row.
5
5
  *
6
- * The signed payload is a single SHA-256 digest of a delimited string of the
7
- * authenticated fields. It is intentionally NOT JSON-canonicalized: a delimited
8
- * digest is trivially deterministic across node/browser/RN (plain string concat
9
- * + sha256, no key ordering) and — crucially — is reconstructable inside the
10
- * `CadrePeer.AuthorizedUpdate` SQL constraint from the row's own columns:
6
+ * The signed payload is a single SHA-256 digest over the shared domain-tagged
7
+ * field vector (see control-authorization.ts) — the crypto plugin's injective
8
+ * multi-field encoding, so no field split is ambiguous. It is intentionally NOT
9
+ * JSON-canonicalized (deterministic across node/browser/RN, no key ordering)
10
+ * and — crucially — is reconstructable inside the `CadrePeer.AuthorizedUpdate`
11
+ * SQL constraint from the row's own columns:
11
12
  *
12
- * digest(new.PeerId || '|' || new.Multiaddr || '|' || cast(new.UpdatedAt as text))
13
+ * digest('CadreControl.CadrePeer', 'publish',
14
+ * new.PeerId, new.Multiaddr, cast(new.UpdatedAt as text))
13
15
  *
14
16
  * Keep {@link peerRecordSignedPayload} and that constraint byte-for-byte in
15
- * sync. The `'|'` delimiter is safe: a base58btc PeerId, a multiaddr (which uses
16
- * `/`), and a base-10 integer never contain it.
17
+ * sync. The `'publish'` action tag marks this as a peer's SELF-signed record
18
+ * (signed with the peer's own key, not an owner key) and keeps it disjoint from
19
+ * every owner-signed digest.
17
20
  *
18
21
  * `publicKey` is deliberately excluded from the payload — it is the key the
19
22
  * signature is verified *with* (so a signature already commits to exactly one
20
23
  * key), and its binding to `peerId` is checked separately at resolve time.
21
24
  */
22
25
 
26
+ import type { Multiaddr } from '@multiformats/multiaddr';
23
27
  import { digest, sign, verify } from '@optimystic/quereus-plugin-crypto';
28
+ import { controlAuthorizationFields } from './control-authorization.js';
24
29
  import type { PeerAddressRecord, PeerResolveTrustPolicy } from './types.js';
25
30
 
26
31
  /**
@@ -51,8 +56,8 @@ const SIGNALING_PREFIX = '/p2p-circuit';
51
56
  * @param updatedAt - epoch-ms freshness stamp
52
57
  */
53
58
  export function peerRecordSignedPayload(peerId: string, multiaddr: string, updatedAt: number): string {
54
- const joined = `${peerId}|${multiaddr}|${updatedAt}`;
55
- return digest([joined], 'sha256', 'base64url') as string;
59
+ const fields = controlAuthorizationFields('CadreControl.CadrePeer', 'publish', [peerId, multiaddr, String(updatedAt)]);
60
+ return digest(fields, 'sha256', 'base64url') as string;
56
61
  }
57
62
 
58
63
  /**
@@ -117,6 +122,19 @@ export function isSignalingAddr(addr: string): boolean {
117
122
  return addr.includes(SIGNALING_PREFIX);
118
123
  }
119
124
 
125
+ /**
126
+ * The peer id a multiaddr terminates in, or `null` when it names none.
127
+ *
128
+ * The LAST `/p2p/` component is the dial target: a circuit address
129
+ * (`…/p2p/<relay>/p2p-circuit/p2p/<target>`) names the relay first and the peer
130
+ * being reached last. Replaces the deprecated `Multiaddr.getPeerId()`, which
131
+ * cannot express that distinction.
132
+ */
133
+ export function trailingPeerId(addr: Multiaddr): string | null {
134
+ const p2p = addr.getComponents().filter((c) => c.name === 'p2p');
135
+ return p2p.length > 0 ? (p2p[p2p.length - 1].value ?? null) : null;
136
+ }
137
+
120
138
  /**
121
139
  * Return `addrs` with signaling (`/p2p-circuit`) addrs first, otherwise stable.
122
140
  * Used to present the WebRTC dial input ahead of direct addrs. Does NOT mutate
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Translate `network.relayAddrs` — the operator-facing "use these relay servers"
3
+ * setting — into the mechanism this repo already has for reserving a circuit-relay
4
+ * slot: a `…/p2p/<relayPeerId>/p2p-circuit` entry in the node's listen addrs.
5
+ *
6
+ * `@libp2p/circuit-relay-v2`'s transport is always present on the Node path
7
+ * (`../optimystic/packages/db-p2p/src/libp2p-node.ts` pushes `circuitRelayTransport()`
8
+ * unconditionally), and listening on a relay's circuit addr is what makes libp2p dial
9
+ * that relay and hold a reservation. Everything downstream of the listen list —
10
+ * the reservation itself, `CadreNode.circuitRelayTargets()` picking delegate-announce
11
+ * targets, strand nodes inheriting the listen addrs, the `/p2p-circuit`-first address
12
+ * publishing in `registerSelf` — then works unchanged.
13
+ *
14
+ * So `relayAddrs` is a second, friendlier door onto an existing mechanism:
15
+ *
16
+ * effective listen addrs = listenAddrs ++ relayAddrs.map(a => a + '/p2p-circuit')
17
+ *
18
+ * NOTE: this is the FAIL-FAST route to a reservation — libp2p's transport manager
19
+ * throws when a configured circuit listener cannot listen, so naming a relay that
20
+ * is down means the node does not start. The fail-soft route is the bare
21
+ * `/p2p-circuit` search listener driven by `relay-reservation.ts`. The two are
22
+ * ALTERNATIVES, not layers: configuring `relayAddrs` on a node that already
23
+ * reserves via the search listener adds the fatal listener back and the node
24
+ * stops booting when its relay is down.
25
+ */
26
+
27
+ import { multiaddr, type Component } from '@multiformats/multiaddr';
28
+ import { circuitRelayTargetOrThrow } from './delegate-admission.js';
29
+ import type { NetworkConfig } from './types.js';
30
+
31
+ /**
32
+ * The direct listener a node keeps when it configures `relayAddrs` but no
33
+ * `listenAddrs`. Without this, naming a relay would silently REPLACE the node's
34
+ * direct TCP listener with a circuit-only one.
35
+ *
36
+ * NOTE: mirrors `createLibp2pNode`'s own default listen addr in
37
+ * `../optimystic/packages/db-p2p/src/libp2p-node.ts` (`/ip4/0.0.0.0/tcp/<port>`,
38
+ * and `CadreNode` passes `port: 0`) — that file is the source of truth. If it
39
+ * ever changes its default, this constant has to follow.
40
+ */
41
+ const DEFAULT_DIRECT_LISTEN_ADDR = '/ip4/0.0.0.0/tcp/0';
42
+
43
+ /**
44
+ * The circuit-listen multiaddr for each configured relay: `<relayAddr>/p2p-circuit`.
45
+ * An entry that already carries `/p2p-circuit` is passed through unchanged.
46
+ * Throws on an entry that is unparsable or names no relay peerId — this is
47
+ * operator configuration, so a typo must fail loudly at start rather than
48
+ * silently costing the node its reachability.
49
+ *
50
+ * Deliberately unlike `extractCircuitRelayTargets` (`delegate-admission.ts`), which
51
+ * SKIPS a bad entry: that one reads addresses discovered at runtime from peers, where
52
+ * one bad entry must not be fatal.
53
+ */
54
+ export function relayCircuitAddrs(relayAddrs: readonly string[]): string[] {
55
+ return dedupe(relayAddrs.map(circuitListenAddr));
56
+ }
57
+
58
+ /**
59
+ * The listen multiaddrs a node actually binds: configured `listenAddrs` plus the
60
+ * circuit-listen addr of every configured relay, deduplicated, order-stable
61
+ * (configured entries first). Returns `undefined` when neither field is set, so
62
+ * callers keep omitting `listenAddrs` and inherit db-p2p's default.
63
+ */
64
+ export function resolveListenAddrs(network: NetworkConfig | undefined): string[] | undefined {
65
+ const circuits = relayCircuitAddrs(network?.relayAddrs ?? []);
66
+ const listenAddrs = network?.listenAddrs;
67
+ if (!listenAddrs && circuits.length === 0) {
68
+ return undefined;
69
+ }
70
+ // An explicitly empty `listenAddrs` (the React Native "cannot listen" case) stays
71
+ // empty — but a relay named alongside it still gets its circuit listener, since
72
+ // acquiring one is the whole point of configuring a relay.
73
+ return dedupe([...(listenAddrs ?? [DEFAULT_DIRECT_LISTEN_ADDR]), ...circuits]);
74
+ }
75
+
76
+ /**
77
+ * One entry of {@link relayCircuitAddrs}: the addr the node listens on for this relay.
78
+ *
79
+ * NOTE: an entry carrying components AFTER `/p2p-circuit` — a full relayed DIAL addr,
80
+ * `…/p2p/<relay>/p2p-circuit/p2p/<someone else>` — is passed through as written and
81
+ * would be listened on verbatim. It validates (the relay it names is real), so only an
82
+ * operator who pastes a peer's relayed address into `relayAddrs` hits it; if that turns
83
+ * out to be a common paste, truncate at the `/p2p-circuit` component here.
84
+ */
85
+ function circuitListenAddr(relayAddr: string): string {
86
+ const alreadyCircuit = parseComponents(relayAddr).some((c) => c.name === 'p2p-circuit');
87
+ const listenAddr = alreadyCircuit ? relayAddr : `${relayAddr}/p2p-circuit`;
88
+ try {
89
+ circuitRelayTargetOrThrow(listenAddr); // validates the relay peerId
90
+ } catch (err) {
91
+ throw new Error(
92
+ `network.relayAddrs entry names no relay peerId (expected <dial addr>/p2p/<relayPeerId>): ${relayAddr}`,
93
+ { cause: err }
94
+ );
95
+ }
96
+ return listenAddr;
97
+ }
98
+
99
+ /** Multiaddr components of `relayAddr`, with the config field named on a parse failure. */
100
+ function parseComponents(relayAddr: string): Component[] {
101
+ try {
102
+ return multiaddr(relayAddr).getComponents();
103
+ } catch (err) {
104
+ throw new Error(`network.relayAddrs entry is not a valid multiaddr: ${relayAddr}`, { cause: err });
105
+ }
106
+ }
107
+
108
+ /** Exact-string dedupe, first occurrence wins, so the list is stable across restarts. */
109
+ function dedupe(addrs: readonly string[]): string[] {
110
+ return [...new Set(addrs)];
111
+ }