@serfab/cadre-core 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +6 -4
  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 +1006 -101
  9. package/dist/cadre-node.js +1977 -283
  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-cohort.d.ts +15 -15
  15. package/dist/control-cohort.js +14 -14
  16. package/dist/control-cohort.js.map +1 -1
  17. package/dist/control-database.d.ts +1033 -76
  18. package/dist/control-database.js +1520 -153
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +91 -12
  21. package/dist/control-formation-recorder.js +131 -16
  22. package/dist/control-formation-recorder.js.map +1 -1
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +774 -186
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-stream.d.ts +38 -4
  27. package/dist/control-stream.js +70 -4
  28. package/dist/control-stream.js.map +1 -1
  29. package/dist/control-write-retry.d.ts +151 -0
  30. package/dist/control-write-retry.js +411 -0
  31. package/dist/control-write-retry.js.map +1 -0
  32. package/dist/delegate-admission.d.ts +134 -0
  33. package/dist/delegate-admission.js +244 -0
  34. package/dist/delegate-admission.js.map +1 -0
  35. package/dist/device-token.d.ts +12 -12
  36. package/dist/device-token.js +15 -14
  37. package/dist/device-token.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +68 -0
  39. package/dist/ed25519-key.js +106 -0
  40. package/dist/ed25519-key.js.map +1 -0
  41. package/dist/file-durable-slot.d.ts +20 -0
  42. package/dist/file-durable-slot.js +53 -0
  43. package/dist/file-durable-slot.js.map +1 -0
  44. package/dist/formation-approval.d.ts +157 -0
  45. package/dist/formation-approval.js +375 -0
  46. package/dist/formation-approval.js.map +1 -0
  47. package/dist/fs-atomic.d.ts +38 -0
  48. package/dist/fs-atomic.js +97 -0
  49. package/dist/fs-atomic.js.map +1 -0
  50. package/dist/identity-key.d.ts +49 -0
  51. package/dist/identity-key.js +81 -0
  52. package/dist/identity-key.js.map +1 -0
  53. package/dist/index.d.ts +16 -6
  54. package/dist/index.js +44 -10
  55. package/dist/index.js.map +1 -1
  56. package/dist/key-store-file.d.ts +2 -24
  57. package/dist/key-store-file.js +10 -77
  58. package/dist/key-store-file.js.map +1 -1
  59. package/dist/key-store.d.ts +1 -1
  60. package/dist/key-store.js +1 -1
  61. package/dist/membership-connection-gater.d.ts +146 -0
  62. package/dist/membership-connection-gater.js +180 -0
  63. package/dist/membership-connection-gater.js.map +1 -0
  64. package/dist/node-local-snapshot.d.ts +109 -0
  65. package/dist/node-local-snapshot.js +182 -0
  66. package/dist/node-local-snapshot.js.map +1 -0
  67. package/dist/peer-authorization.d.ts +150 -11
  68. package/dist/peer-authorization.js +170 -13
  69. package/dist/peer-authorization.js.map +1 -1
  70. package/dist/peer-record.d.ts +24 -11
  71. package/dist/peer-record.js +29 -13
  72. package/dist/peer-record.js.map +1 -1
  73. package/dist/push-node.d.ts +38 -0
  74. package/dist/push-node.js +48 -0
  75. package/dist/push-node.js.map +1 -0
  76. package/dist/push-notifier-apns.d.ts +10 -2
  77. package/dist/push-notifier-apns.js +14 -10
  78. package/dist/push-notifier-apns.js.map +1 -1
  79. package/dist/push-notifier-fcm.d.ts +7 -1
  80. package/dist/push-notifier-fcm.js +9 -10
  81. package/dist/push-notifier-fcm.js.map +1 -1
  82. package/dist/push-notifier.d.ts +16 -35
  83. package/dist/push-notifier.js +16 -44
  84. package/dist/push-notifier.js.map +1 -1
  85. package/dist/relay-addrs.d.ts +45 -0
  86. package/dist/relay-addrs.js +103 -0
  87. package/dist/relay-addrs.js.map +1 -0
  88. package/dist/relay-reservation.d.ts +266 -0
  89. package/dist/relay-reservation.js +613 -0
  90. package/dist/relay-reservation.js.map +1 -0
  91. package/dist/seed-bootstrap.d.ts +263 -79
  92. package/dist/seed-bootstrap.js +413 -211
  93. package/dist/seed-bootstrap.js.map +1 -1
  94. package/dist/seed-trust-policy.d.ts +50 -24
  95. package/dist/seed-trust-policy.js +28 -19
  96. package/dist/seed-trust-policy.js.map +1 -1
  97. package/dist/strand-addr-protocol.d.ts +42 -13
  98. package/dist/strand-addr-protocol.js +58 -52
  99. package/dist/strand-addr-protocol.js.map +1 -1
  100. package/dist/strand-backfill.d.ts +109 -0
  101. package/dist/strand-backfill.js +279 -0
  102. package/dist/strand-backfill.js.map +1 -0
  103. package/dist/strand-database.d.ts +5 -5
  104. package/dist/strand-database.js +4 -4
  105. package/dist/strand-database.js.map +1 -1
  106. package/dist/strand-formation-manager.d.ts +38 -9
  107. package/dist/strand-formation-manager.js +120 -17
  108. package/dist/strand-formation-manager.js.map +1 -1
  109. package/dist/strand-formation-protocol.d.ts +104 -5
  110. package/dist/strand-formation-protocol.js +233 -5
  111. package/dist/strand-formation-protocol.js.map +1 -1
  112. package/dist/strand-instance-manager.d.ts +40 -2
  113. package/dist/strand-instance-manager.js +93 -15
  114. package/dist/strand-instance-manager.js.map +1 -1
  115. package/dist/strand-member-key.d.ts +6 -6
  116. package/dist/strand-member-key.js +6 -6
  117. package/dist/strand-member-key.js.map +1 -1
  118. package/dist/strand-member-registry.d.ts +12 -12
  119. package/dist/strand-member-registry.js +22 -14
  120. package/dist/strand-member-registry.js.map +1 -1
  121. package/dist/strand-membership-writer.d.ts +532 -138
  122. package/dist/strand-membership-writer.js +800 -179
  123. package/dist/strand-membership-writer.js.map +1 -1
  124. package/dist/strand-solicitation.d.ts +99 -33
  125. package/dist/strand-solicitation.js +100 -52
  126. package/dist/strand-solicitation.js.map +1 -1
  127. package/dist/strand-transport-key.d.ts +40 -0
  128. package/dist/strand-transport-key.js +56 -0
  129. package/dist/strand-transport-key.js.map +1 -0
  130. package/dist/strand-wake-protocol.d.ts +9 -7
  131. package/dist/strand-wake-protocol.js +22 -46
  132. package/dist/strand-wake-protocol.js.map +1 -1
  133. package/dist/strand-watcher.d.ts +17 -2
  134. package/dist/strand-watcher.js +47 -3
  135. package/dist/strand-watcher.js.map +1 -1
  136. package/dist/trusted-owner-store-file.d.ts +12 -0
  137. package/dist/trusted-owner-store-file.js +39 -0
  138. package/dist/trusted-owner-store-file.js.map +1 -0
  139. package/dist/trusted-owner-store.d.ts +64 -0
  140. package/dist/trusted-owner-store.js +126 -0
  141. package/dist/trusted-owner-store.js.map +1 -0
  142. package/dist/types.d.ts +325 -50
  143. package/dist/types.js +13 -0
  144. package/dist/types.js.map +1 -1
  145. package/package.json +23 -8
  146. package/src/bootstrap-peer-store-file.ts +44 -0
  147. package/src/bootstrap-peer-store.ts +172 -0
  148. package/src/cadre-node.ts +2129 -295
  149. package/src/control-authorization.ts +114 -0
  150. package/src/control-cohort.ts +17 -17
  151. package/src/control-database.ts +1766 -191
  152. package/src/control-formation-recorder.ts +174 -21
  153. package/src/control-schema.ts +786 -198
  154. package/src/control-stream.ts +81 -4
  155. package/src/control-write-retry.ts +473 -0
  156. package/src/delegate-admission.ts +302 -0
  157. package/src/device-token.ts +19 -14
  158. package/src/ed25519-key.ts +134 -0
  159. package/src/file-durable-slot.ts +61 -0
  160. package/src/formation-approval.ts +604 -0
  161. package/src/fs-atomic.ts +107 -0
  162. package/src/identity-key.ts +109 -0
  163. package/src/index.ts +157 -24
  164. package/src/key-store-file.ts +10 -81
  165. package/src/key-store.ts +1 -1
  166. package/src/membership-connection-gater.ts +204 -0
  167. package/src/node-local-snapshot.ts +260 -0
  168. package/src/peer-authorization.ts +219 -13
  169. package/src/peer-record.ts +31 -13
  170. package/src/push-node.ts +64 -0
  171. package/src/push-notifier-apns.ts +16 -12
  172. package/src/push-notifier-fcm.ts +9 -10
  173. package/src/push-notifier.ts +16 -57
  174. package/src/relay-addrs.ts +111 -0
  175. package/src/relay-reservation.ts +823 -0
  176. package/src/seed-bootstrap.ts +490 -226
  177. package/src/seed-trust-policy.ts +65 -32
  178. package/src/strand-addr-protocol.ts +84 -53
  179. package/src/strand-backfill.ts +366 -0
  180. package/src/strand-database.ts +6 -6
  181. package/src/strand-formation-manager.ts +145 -19
  182. package/src/strand-formation-protocol.ts +310 -9
  183. package/src/strand-instance-manager.ts +113 -16
  184. package/src/strand-member-key.ts +7 -7
  185. package/src/strand-member-registry.ts +28 -23
  186. package/src/strand-membership-writer.ts +1430 -640
  187. package/src/strand-solicitation.ts +155 -90
  188. package/src/strand-transport-key.ts +58 -0
  189. package/src/strand-wake-protocol.ts +29 -46
  190. package/src/strand-watcher.ts +66 -3
  191. package/src/trusted-owner-store-file.ts +41 -0
  192. package/src/trusted-owner-store.ts +171 -0
  193. package/src/types.ts +342 -51
  194. package/dist/authority-key.d.ts +0 -41
  195. package/dist/authority-key.js +0 -52
  196. package/dist/authority-key.js.map +0 -1
  197. package/src/authority-key.ts +0 -68
@@ -1,30 +1,160 @@
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 authority 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 (authority 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]);
97
+ }
98
+
99
+ /**
100
+ * Canonical digest an owner signs to VOUCH a `CadrePeer` membership row (insert
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.
109
+ *
110
+ * SQL mirror: `digest('CadreControl.CadrePeer', 'vouch', new.PeerId, new.StampId)`.
111
+ */
112
+ export function cadrePeerVoucherDigest(peerId: string, stampId: string): string {
113
+ return taggedDigest('CadreControl.CadrePeer', 'vouch', [peerId, stampId]);
114
+ }
115
+
116
+ /**
117
+ * Canonical digest an owner signs to REMOVE a `CadrePeer` row. Deliberately a
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
120
+ * satisfy this delete check. The signature is supplied in write context and never
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).
124
+ *
125
+ * SQL mirror: `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)`.
126
+ */
127
+ export function cadrePeerRemoveDigest(peerId: string, stampId: string): 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]);
18
148
  }
19
149
 
20
150
  /**
21
- * Verify that `signature` is a valid authority ed25519 signature over `peerId`'s
22
- * authorization digest, using `authorityPublicKey` (base64url).
151
+ * Verify that `signature` is a valid owner ed25519 signature over `peerId`'s
152
+ * authorization digest, using `ownerPublicKey` (base64url).
23
153
  *
24
154
  * This is the mirror of the signing done in
25
155
  * {@link SeedBootstrapService.authorizePeer}: it checks the signature against
26
156
  * {@link peerAuthorizationDigest}. A `true` result means the holder of the
27
- * authority private key vouched for this peer ID — it does NOT mean the peer is
157
+ * owner private key vouched for this peer ID — it does NOT mean the peer is
28
158
  * registered anywhere.
29
159
  *
30
160
  * Returns a boolean and never throws: malformed base64url, a bad/garbage key, or
@@ -33,14 +163,14 @@ export function peerAuthorizationDigest(peerId: string): string {
33
163
  */
34
164
  export function verifyPeerAuthorization(
35
165
  peerId: string,
36
- authorityPublicKey: string,
166
+ ownerPublicKey: string,
37
167
  signature: string
38
168
  ): boolean {
39
169
  try {
40
170
  return verify(
41
171
  peerAuthorizationDigest(peerId),
42
172
  signature,
43
- authorityPublicKey,
173
+ ownerPublicKey,
44
174
  'ed25519',
45
175
  'base64url',
46
176
  'base64url',
@@ -51,3 +181,79 @@ export function verifyPeerAuthorization(
51
181
  return false;
52
182
  }
53
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
  /**
@@ -61,7 +66,7 @@ export function peerRecordSignedPayload(peerId: string, multiaddr: string, updat
61
66
  * preserved and is the order signed over).
62
67
  *
63
68
  * @param fields - the record fields to sign (signaling addr first by convention)
64
- * @param privateKeyB64 - base64url ed25519 seed (see `authorityKeyFromLibp2p`)
69
+ * @param privateKeyB64 - base64url ed25519 seed (see `ed25519KeyPairFromLibp2p`)
65
70
  */
66
71
  export function signPeerRecord(
67
72
  fields: { peerId: string; publicKey: string; addrs: string[]; updatedAt: number },
@@ -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
@@ -132,8 +150,8 @@ export function orderSignalingFirst(addrs: string[]): string[] {
132
150
  /**
133
151
  * Default resolve trust gate: trust any peer that has a `CadrePeer` row.
134
152
  *
135
- * A row only exists because an authority signed its `AuthorizedInsert`, so row
136
- * presence already means "authority-vouched member". This is the seam where a
153
+ * A row only exists because an owner signed its `AuthorizedInsert`, so row
154
+ * presence already means "owner-vouched member". This is the seam where a
137
155
  * stricter policy (trust circle, pinned keys) plugs in — see
138
156
  * {@link PeerResolveTrustPolicy}.
139
157
  */
@@ -0,0 +1,64 @@
1
+ /**
2
+ * push-node.ts — Node-only subpath entry for platform push **construction**.
3
+ *
4
+ * Import from the dedicated subpath, never the package root:
5
+ *
6
+ * ```ts
7
+ * import { createPushNotifier } from '@serfab/cadre-core/push-node';
8
+ * ```
9
+ *
10
+ * The FCM/APNs implementations reach for `node:crypto` / `node:http2`, so they
11
+ * are quarantined here — unreachable from the cross-platform `./index.js` graph
12
+ * (same isolation pattern as `./key-store-file`). A React Native / browser entry
13
+ * never resolves this path, so the Node builtins never reach a bundler that
14
+ * cannot satisfy them. The core references only the {@link PushNotifier}
15
+ * *interface* (`push-notifier.ts`) and a Node host injects the instance built
16
+ * here into `CadreNodeConfig.push.notifier`.
17
+ */
18
+
19
+ import type { PushCredentials, PushPlatform } from './types.js';
20
+ import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
21
+ import { createFcmPushNotifier, type FcmPushDeps } from './push-notifier-fcm.js';
22
+ import { createApnsPushNotifier, type ApnsPushDeps } from './push-notifier-apns.js';
23
+
24
+ /**
25
+ * Per-platform transport/clock/log seams, injected by tests. Defaults are the
26
+ * real implementations (global `fetch` for FCM, a `node:http2` session for APNs).
27
+ */
28
+ export interface PushNotifierDeps {
29
+ fcm?: FcmPushDeps;
30
+ apns?: ApnsPushDeps;
31
+ }
32
+
33
+ /**
34
+ * Build a {@link PushNotifier} router over the configured credentials. Only the
35
+ * platforms whose credentials are present get a backing implementation; a `send`
36
+ * for an unconfigured platform returns a best-effort `no <platform> credentials`
37
+ * failure rather than throwing.
38
+ */
39
+ export function createPushNotifier(creds: PushCredentials, deps: PushNotifierDeps = {}): PushNotifier {
40
+ const fcm = creds.fcm ? createFcmPushNotifier(creds.fcm, deps.fcm) : undefined;
41
+ const apns = creds.apns ? createApnsPushNotifier(creds.apns, deps.apns) : undefined;
42
+
43
+ async function send(msg: PushMessage): Promise<PushSendResult> {
44
+ if (msg.platform === 'fcm') return fcm ? fcm.send(msg) : noCreds('fcm');
45
+ if (msg.platform === 'apns') return apns ? apns.send(msg) : noCreds('apns');
46
+ return { ok: false, unregistered: false, error: `unknown platform ${String(msg.platform)}` };
47
+ }
48
+
49
+ async function close(): Promise<void> {
50
+ await Promise.all([fcm?.close(), apns?.close()]);
51
+ }
52
+
53
+ return { send, close };
54
+ }
55
+
56
+ /** Best-effort no-op result for a platform with no configured credentials. */
57
+ function noCreds(platform: PushPlatform): PushSendResult {
58
+ return { ok: false, unregistered: false, error: `no ${platform} credentials` };
59
+ }
60
+
61
+ // Per-platform builders + their injected-dep types, for a host that wants a
62
+ // single-platform notifier or to inject a fake transport directly.
63
+ export { createFcmPushNotifier, createApnsPushNotifier };
64
+ export type { FcmPushDeps, ApnsPushDeps };
@@ -1,8 +1,16 @@
1
1
  /**
2
2
  * push-notifier-apns.ts — APNs HTTP/2 strand-wake delivery.
3
3
  *
4
- * Server-only: signs a provider JWT (ES256, JOSE raw r‖s via `node:crypto`) and
5
- * sends a background data push over an HTTP/2 session to Apple's gateway.
4
+ * Node-only: signs a provider JWT (ES256, JOSE raw r‖s via `node:crypto`) and
5
+ * sends a background data push over an HTTP/2 session to Apple's gateway. APNs
6
+ * mandates HTTP/2, so `node:http2` is unavoidable here — one reason the push
7
+ * path is injected rather than rebuilt on WebCrypto/`fetch`.
8
+ *
9
+ * Reachable only through the `@serfab/cadre-core/push-node` subpath — never from
10
+ * the cross-platform `./index.js` graph — so the `node:crypto` / `node:http2`
11
+ * imports below are safe: no RN/browser bundler ever resolves this module. (Plain
12
+ * named imports; the old namespace-import-to-only-warn dance is obsolete now that
13
+ * the module is unreachable from a browser build by construction.)
6
14
  *
7
15
  * The HTTP/2 call is behind an injected `Http2Requester` seam (bundled with a
8
16
  * `close` into an {@link ApnsTransport}) so unit tests assert the request shape
@@ -19,12 +27,8 @@
19
27
  * token prefix.
20
28
  */
21
29
 
22
- // Namespace import (not `import { sign }`): a *named* import of the
23
- // browser-externalized `node:crypto` hard-fails the web build, whereas a
24
- // namespace import only warns — same reasoning as the `node:http2` import below
25
- // and the matching note in push-notifier-fcm.ts. Server-only; never run in browser.
26
- import * as nodeCrypto from 'node:crypto';
27
- import * as http2 from 'node:http2';
30
+ import { sign } from 'node:crypto';
31
+ import { connect, type ClientHttp2Session } from 'node:http2';
28
32
  import debug from 'debug';
29
33
  import type { ApnsCredentials } from './types.js';
30
34
  import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
@@ -171,7 +175,7 @@ export function createApnsPushNotifier(creds: ApnsCredentials, deps: ApnsPushDep
171
175
  const header = { alg: 'ES256', kid: creds.keyId };
172
176
  const claims = { iss: creds.teamId, iat };
173
177
  const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
174
- const sig = nodeCrypto.sign('SHA256', Buffer.from(signingInput), {
178
+ const sig = sign('SHA256', Buffer.from(signingInput), {
175
179
  key: creds.privateKey,
176
180
  dsaEncoding: 'ieee-p1363',
177
181
  }).toString('base64url');
@@ -195,11 +199,11 @@ export function createApnsPushNotifier(creds: ApnsCredentials, deps: ApnsPushDep
195
199
  * network) — tests inject a fake transport; the real path is validated out-of-band.
196
200
  */
197
201
  function createHttp2Transport(host: string): ApnsTransport {
198
- let session: http2.ClientHttp2Session | null = null;
202
+ let session: ClientHttp2Session | null = null;
199
203
 
200
- function ensureSession(): http2.ClientHttp2Session {
204
+ function ensureSession(): ClientHttp2Session {
201
205
  if (session && !session.closed && !session.destroyed) return session;
202
- const s = http2.connect(host);
206
+ const s = connect(host);
203
207
  // Drop a dead session so ensureSession recreates it on the next request.
204
208
  s.on('error', () => { if (session === s) session = null; });
205
209
  s.on('goaway', () => { s.destroy(); });
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * push-notifier-fcm.ts — FCM HTTP v1 strand-wake delivery.
3
3
  *
4
- * Server-only: mints a Google OAuth2 access token (RS256 JWT bearer grant signed
4
+ * Node-only: mints a Google OAuth2 access token (RS256 JWT bearer grant signed
5
5
  * with `node:crypto`) and POSTs a data message to the FCM v1 endpoint. The legacy
6
6
  * server-key `fcm.googleapis.com/fcm/send` API is deprecated, so we use HTTP v1.
7
7
  *
8
+ * Reachable only through the `@serfab/cadre-core/push-node` subpath — never from
9
+ * the cross-platform `./index.js` graph — so the `node:crypto` import below is
10
+ * safe: no RN/browser bundler ever resolves this module. (Plain named import;
11
+ * the old namespace-import-to-only-warn dance is obsolete now that the module is
12
+ * unreachable from a browser build by construction.)
13
+ *
8
14
  * The network call is behind an injected `fetch`-like seam so unit tests assert
9
15
  * the request shape and map every documented response code with no real network
10
16
  * and no credentials. Failures are returned as {@link PushSendResult} values.
@@ -14,14 +20,7 @@
14
20
  * code and a redacted token prefix.
15
21
  */
16
22
 
17
- // Namespace import (not `import { sign }`): this module is server-only and is
18
- // reached only via the guarded dynamic import in CadreNode, but bundlers still
19
- // analyse that lazy chunk. A *named* import of `node:crypto` hard-fails the
20
- // browser build ("sign is not exported by __vite-browser-external"), whereas a
21
- // namespace import of the deliberately-unaliased external only warns (matching
22
- // the `node:http2` namespace import in push-notifier-apns.ts). The browser
23
- // never executes this code (config.push is node-only), so the warning is benign.
24
- import * as nodeCrypto from 'node:crypto';
23
+ import { sign } from 'node:crypto';
25
24
  import debug from 'debug';
26
25
  import type { FcmCredentials } from './types.js';
27
26
  import type { PushMessage, PushNotifier, PushSendResult } from './push-notifier.js';
@@ -166,7 +165,7 @@ export function createFcmPushNotifier(creds: FcmCredentials, deps: FcmPushDeps =
166
165
  exp: iat + 3600,
167
166
  };
168
167
  const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
169
- const sig = nodeCrypto.sign('RSA-SHA256', Buffer.from(signingInput), creds.privateKey).toString('base64url');
168
+ const sig = sign('RSA-SHA256', Buffer.from(signingInput), creds.privateKey).toString('base64url');
170
169
  return `${signingInput}.${sig}`;
171
170
  }
172
171
 
@@ -1,30 +1,26 @@
1
1
  /**
2
- * push-notifier.ts — the platform-push **delivery** seam for strand-wake.
2
+ * push-notifier.ts — the platform-push **delivery** contract for strand-wake.
3
3
  *
4
- * Given a resolved mobile peer token, send one `strand-wake` data message over
5
- * the right platform channel (FCM for Android, APNs for iOS). This module owns no
6
- * policy — *who* to wake and *when* is the fan-out (`push-fanout.ts`). It is the
7
- * reusable, credential-injected, transport-injected unit the fan-out constructs
8
- * inside a participating `CadreNode` when push credentials are configured.
4
+ * This module is the cross-platform-safe *interface* half: the message shape
5
+ * ({@link PushMessage}), the outcome value ({@link PushSendResult}), and the
6
+ * {@link PushNotifier} port a `CadreNode` sends over. It carries ZERO runtime
7
+ * dependencies and imports no implementation — so the RN/browser entry graph can
8
+ * reference the type without ever resolving `node:crypto` / `node:http2`.
9
9
  *
10
- * `createPushNotifier` returns a router that dispatches by `msg.platform` to an
11
- * `FcmPushNotifier` and/or `ApnsPushNotifier`, constructing only the
12
- * implementations whose credentials are present. Failures are values, never
13
- * thrown — mirroring `ServiceWakeResult` / `resolveDeviceToken`'s no-throw
14
- * conventions so a best-effort fan-out can branch on the outcome.
10
+ * The concrete FCM/APNs implementations (`push-notifier-fcm.ts` /
11
+ * `push-notifier-apns.ts`) and the `createPushNotifier` router that builds them
12
+ * live behind the Node-only subpath `@serfab/cadre-core/push-node`
13
+ * (`push-node.ts`). A Node host constructs a notifier from that subpath and
14
+ * injects the instance into `CadreNodeConfig.push.notifier`; the cross-platform
15
+ * core never constructs one, so the Node-only builtins stay out of its graph.
15
16
  *
16
- * Server-only by construction: the FCM/APNs implementations reach for
17
- * `node:crypto` / `node:http2`. They are never pulled into the RN/browser bundle
18
- * because the cross-platform `index.ts` re-exports only the *types* here (a
19
- * type-only re-export is erased at emit), and the only runtime constructor of a
20
- * notifier is the in-package fan-out that runs inside a Node `CadreNode`. A
21
- * cross-platform consumer imports the payload contract + types, never this value.
17
+ * Keep this file zero-import (beyond erased type-only imports): the whole point
18
+ * of the seam is that referencing the interface can never drag an implementation
19
+ * module into a bundler's graph.
22
20
  */
23
21
 
24
- import type { PushPlatform, PushCredentials } from './types.js';
22
+ import type { PushPlatform } from './types.js';
25
23
  import type { StrandWakePayload } from './strand-wake-payload.js';
26
- import { createFcmPushNotifier, type FcmPushDeps } from './push-notifier-fcm.js';
27
- import { createApnsPushNotifier, type ApnsPushDeps } from './push-notifier-apns.js';
28
24
 
29
25
  /** One strand-wake data message addressed to one device. */
30
26
  export interface PushMessage {
@@ -54,40 +50,3 @@ export interface PushNotifier {
54
50
  /** Release transport resources (e.g. the APNs HTTP/2 session). */
55
51
  close(): Promise<void>;
56
52
  }
57
-
58
- /**
59
- * Per-platform transport/clock/log seams, injected by tests. Defaults are the
60
- * real implementations (global `fetch` for FCM, a `node:http2` session for APNs).
61
- */
62
- export interface PushNotifierDeps {
63
- fcm?: FcmPushDeps;
64
- apns?: ApnsPushDeps;
65
- }
66
-
67
- /**
68
- * Build a {@link PushNotifier} router over the configured credentials. Only the
69
- * platforms whose credentials are present get a backing implementation; a `send`
70
- * for an unconfigured platform returns a best-effort `no <platform> credentials`
71
- * failure rather than throwing.
72
- */
73
- export function createPushNotifier(creds: PushCredentials, deps: PushNotifierDeps = {}): PushNotifier {
74
- const fcm = creds.fcm ? createFcmPushNotifier(creds.fcm, deps.fcm) : undefined;
75
- const apns = creds.apns ? createApnsPushNotifier(creds.apns, deps.apns) : undefined;
76
-
77
- async function send(msg: PushMessage): Promise<PushSendResult> {
78
- if (msg.platform === 'fcm') return fcm ? fcm.send(msg) : noCreds('fcm');
79
- if (msg.platform === 'apns') return apns ? apns.send(msg) : noCreds('apns');
80
- return { ok: false, unregistered: false, error: `unknown platform ${String(msg.platform)}` };
81
- }
82
-
83
- async function close(): Promise<void> {
84
- await Promise.all([fcm?.close(), apns?.close()]);
85
- }
86
-
87
- return { send, close };
88
- }
89
-
90
- /** Best-effort no-op result for a platform with no configured credentials. */
91
- function noCreds(platform: PushPlatform): PushSendResult {
92
- return { ok: false, unregistered: false, error: `no ${platform} credentials` };
93
- }