@serfab/cadre-core 0.10.0 → 0.12.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 (145) hide show
  1. package/README.md +255 -255
  2. package/dist/announce-addrs.d.ts +49 -0
  3. package/dist/announce-addrs.js +78 -0
  4. package/dist/announce-addrs.js.map +1 -0
  5. package/dist/cadre-node.d.ts +484 -58
  6. package/dist/cadre-node.js +953 -182
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +26 -0
  9. package/dist/control-cohort.js +26 -0
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +222 -125
  12. package/dist/control-database.js +356 -266
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +8 -7
  15. package/dist/control-formation-recorder.js +8 -7
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-read-retry.d.ts +103 -0
  18. package/dist/control-read-retry.js +145 -0
  19. package/dist/control-read-retry.js.map +1 -0
  20. package/dist/control-retry.d.ts +101 -0
  21. package/dist/control-retry.js +108 -0
  22. package/dist/control-retry.js.map +1 -0
  23. package/dist/control-schema.d.ts +1 -1
  24. package/dist/control-schema.js +805 -774
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +65 -31
  27. package/dist/control-write-retry.js +33 -103
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/delegate-admission.d.ts +17 -15
  30. package/dist/delegate-admission.js +17 -15
  31. package/dist/delegate-admission.js.map +1 -1
  32. package/dist/index.d.ts +7 -5
  33. package/dist/index.js +18 -9
  34. package/dist/index.js.map +1 -1
  35. package/dist/membership-connection-gater.d.ts +211 -25
  36. package/dist/membership-connection-gater.js +309 -29
  37. package/dist/membership-connection-gater.js.map +1 -1
  38. package/dist/peer-addr-book.d.ts +92 -0
  39. package/dist/peer-addr-book.js +220 -0
  40. package/dist/peer-addr-book.js.map +1 -0
  41. package/dist/peer-record.d.ts +26 -0
  42. package/dist/peer-record.js +32 -0
  43. package/dist/peer-record.js.map +1 -1
  44. package/dist/relay-addrs.d.ts +76 -26
  45. package/dist/relay-addrs.js +115 -27
  46. package/dist/relay-addrs.js.map +1 -1
  47. package/dist/relay-reservation.d.ts +20 -15
  48. package/dist/relay-reservation.js +18 -14
  49. package/dist/relay-reservation.js.map +1 -1
  50. package/dist/seed-bootstrap.d.ts +7 -0
  51. package/dist/seed-bootstrap.js +15 -13
  52. package/dist/seed-bootstrap.js.map +1 -1
  53. package/dist/strand-addr-protocol.js +16 -0
  54. package/dist/strand-addr-protocol.js.map +1 -1
  55. package/dist/strand-backfill.js +13 -4
  56. package/dist/strand-backfill.js.map +1 -1
  57. package/dist/strand-cohort.d.ts +4 -37
  58. package/dist/strand-cohort.js +4 -15
  59. package/dist/strand-cohort.js.map +1 -1
  60. package/dist/strand-database.d.ts +11 -16
  61. package/dist/strand-database.js +19 -7
  62. package/dist/strand-database.js.map +1 -1
  63. package/dist/strand-formation-manager.d.ts +18 -17
  64. package/dist/strand-formation-manager.js +25 -27
  65. package/dist/strand-formation-manager.js.map +1 -1
  66. package/dist/strand-formation-protocol.d.ts +7 -7
  67. package/dist/strand-formation-protocol.js +8 -8
  68. package/dist/strand-formation-protocol.js.map +1 -1
  69. package/dist/strand-instance-manager.d.ts +53 -21
  70. package/dist/strand-instance-manager.js +128 -37
  71. package/dist/strand-instance-manager.js.map +1 -1
  72. package/dist/strand-membership-writer.d.ts +113 -19
  73. package/dist/strand-membership-writer.js +219 -51
  74. package/dist/strand-membership-writer.js.map +1 -1
  75. package/dist/strand-solicitation.d.ts +3 -3
  76. package/dist/strand-solicitation.js +4 -5
  77. package/dist/strand-solicitation.js.map +1 -1
  78. package/dist/strand-wake-protocol.d.ts +47 -2
  79. package/dist/strand-wake-protocol.js +76 -6
  80. package/dist/strand-wake-protocol.js.map +1 -1
  81. package/dist/types.d.ts +107 -54
  82. package/dist/types.js.map +1 -1
  83. package/package.json +8 -7
  84. package/src/announce-addrs.ts +93 -0
  85. package/src/arachnode-stub.ts +159 -159
  86. package/src/bootstrap-peer-store-file.ts +44 -44
  87. package/src/bootstrap-peer-store.ts +172 -172
  88. package/src/cadre-node.ts +1026 -194
  89. package/src/canonical-json.ts +30 -30
  90. package/src/control-authorization.ts +114 -114
  91. package/src/control-cohort.ts +130 -103
  92. package/src/control-database.ts +376 -298
  93. package/src/control-formation-recorder.ts +305 -304
  94. package/src/control-read-retry.ts +167 -0
  95. package/src/control-retry.ts +190 -0
  96. package/src/control-schema.ts +817 -786
  97. package/src/control-stream.ts +205 -205
  98. package/src/control-write-retry.ts +383 -473
  99. package/src/delegate-admission.ts +304 -302
  100. package/src/device-token.ts +94 -94
  101. package/src/diagnostics/connection-path.ts +277 -277
  102. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  103. package/src/ed25519-key.ts +134 -134
  104. package/src/enrollment.ts +199 -199
  105. package/src/file-durable-slot.ts +61 -61
  106. package/src/formation-approval.ts +604 -604
  107. package/src/fs-atomic.ts +107 -107
  108. package/src/hibernation-manager.ts +437 -437
  109. package/src/identity-key.ts +109 -109
  110. package/src/index.ts +435 -406
  111. package/src/key-store-file.ts +132 -132
  112. package/src/key-store.ts +99 -99
  113. package/src/membership-connection-gater.ts +368 -39
  114. package/src/node-local-snapshot.ts +260 -260
  115. package/src/peer-addr-book.ts +250 -0
  116. package/src/peer-authorization.ts +259 -259
  117. package/src/peer-record.ts +197 -164
  118. package/src/push-credentials.ts +84 -84
  119. package/src/push-node.ts +64 -64
  120. package/src/push-notifier-apns.ts +247 -247
  121. package/src/push-notifier-fcm.ts +220 -220
  122. package/src/push-notifier-shared.ts +28 -28
  123. package/src/push-notifier.ts +52 -52
  124. package/src/relay-addrs.ts +134 -27
  125. package/src/relay-reservation.ts +22 -17
  126. package/src/seed-bootstrap.ts +15 -15
  127. package/src/seed-trust-policy.ts +140 -140
  128. package/src/strand-addr-protocol.ts +458 -441
  129. package/src/strand-backfill.ts +28 -5
  130. package/src/strand-cohort.ts +38 -77
  131. package/src/strand-database.ts +202 -204
  132. package/src/strand-formation-manager.ts +26 -28
  133. package/src/strand-formation-protocol.ts +692 -692
  134. package/src/strand-instance-manager.ts +636 -553
  135. package/src/strand-member-key.ts +39 -39
  136. package/src/strand-member-registry.ts +182 -182
  137. package/src/strand-membership-writer.ts +1627 -1430
  138. package/src/strand-solicitation.ts +471 -472
  139. package/src/strand-transport-key.ts +58 -58
  140. package/src/strand-wake-payload.ts +32 -32
  141. package/src/strand-wake-protocol.ts +400 -314
  142. package/src/strand-watcher.ts +314 -314
  143. package/src/trusted-owner-store-file.ts +41 -41
  144. package/src/trusted-owner-store.ts +171 -171
  145. package/src/types.ts +1354 -1303
@@ -1,259 +1,259 @@
1
- import debug from 'debug';
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';
5
-
6
- const log = debug('sereus:cadre:peer-authorization');
7
-
8
- /**
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).
24
- *
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.
28
- */
29
- export function peerAuthorizationDigest(peerId: string): 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]);
148
- }
149
-
150
- /**
151
- * Verify that `signature` is a valid owner ed25519 signature over `peerId`'s
152
- * authorization digest, using `ownerPublicKey` (base64url).
153
- *
154
- * This is the mirror of the signing done in
155
- * {@link SeedBootstrapService.authorizePeer}: it checks the signature against
156
- * {@link peerAuthorizationDigest}. A `true` result means the holder of the
157
- * owner private key vouched for this peer ID — it does NOT mean the peer is
158
- * registered anywhere.
159
- *
160
- * Returns a boolean and never throws: malformed base64url, a bad/garbage key, or
161
- * any crypto failure resolves to `false` (callers want a verdict, not an
162
- * exception). The catch is logged at debug.
163
- */
164
- export function verifyPeerAuthorization(
165
- peerId: string,
166
- ownerPublicKey: string,
167
- signature: string
168
- ): boolean {
169
- try {
170
- return verify(
171
- peerAuthorizationDigest(peerId),
172
- signature,
173
- ownerPublicKey,
174
- 'ed25519',
175
- 'base64url',
176
- 'base64url',
177
- 'base64url'
178
- );
179
- } catch (error) {
180
- log('verifyPeerAuthorization failed: %o', error);
181
- return false;
182
- }
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
- }
1
+ import debug from 'debug';
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';
5
+
6
+ const log = debug('sereus:cadre:peer-authorization');
7
+
8
+ /**
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).
24
+ *
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.
28
+ */
29
+ export function peerAuthorizationDigest(peerId: string): 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]);
148
+ }
149
+
150
+ /**
151
+ * Verify that `signature` is a valid owner ed25519 signature over `peerId`'s
152
+ * authorization digest, using `ownerPublicKey` (base64url).
153
+ *
154
+ * This is the mirror of the signing done in
155
+ * {@link SeedBootstrapService.authorizePeer}: it checks the signature against
156
+ * {@link peerAuthorizationDigest}. A `true` result means the holder of the
157
+ * owner private key vouched for this peer ID — it does NOT mean the peer is
158
+ * registered anywhere.
159
+ *
160
+ * Returns a boolean and never throws: malformed base64url, a bad/garbage key, or
161
+ * any crypto failure resolves to `false` (callers want a verdict, not an
162
+ * exception). The catch is logged at debug.
163
+ */
164
+ export function verifyPeerAuthorization(
165
+ peerId: string,
166
+ ownerPublicKey: string,
167
+ signature: string
168
+ ): boolean {
169
+ try {
170
+ return verify(
171
+ peerAuthorizationDigest(peerId),
172
+ signature,
173
+ ownerPublicKey,
174
+ 'ed25519',
175
+ 'base64url',
176
+ 'base64url',
177
+ 'base64url'
178
+ );
179
+ } catch (error) {
180
+ log('verifyPeerAuthorization failed: %o', error);
181
+ return false;
182
+ }
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
+ }