@serfab/cadre-core 1.15.0 → 1.17.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 (199) hide show
  1. package/README.md +1 -1
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +55 -138
  6. package/dist/cadre-invite-protocol.js +94 -449
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +530 -247
  12. package/dist/cadre-node.js +1046 -525
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +22 -23
  18. package/dist/control-cohort.js +24 -24
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +416 -136
  21. package/dist/control-database.js +757 -378
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-protocol-guard.d.ts +5 -4
  24. package/dist/control-protocol-guard.js +6 -3
  25. package/dist/control-protocol-guard.js.map +1 -1
  26. package/dist/control-schema-version.d.ts +80 -0
  27. package/dist/control-schema-version.js +203 -0
  28. package/dist/control-schema-version.js.map +1 -0
  29. package/dist/control-schema.d.ts +45 -5
  30. package/dist/control-schema.js +254 -70
  31. package/dist/control-schema.js.map +1 -1
  32. package/dist/control-write-retry.d.ts +5 -5
  33. package/dist/control-write-retry.js +10 -8
  34. package/dist/control-write-retry.js.map +1 -1
  35. package/dist/delegate-admission.d.ts +13 -0
  36. package/dist/delegate-admission.js +27 -0
  37. package/dist/delegate-admission.js.map +1 -1
  38. package/dist/ed25519-key.d.ts +10 -7
  39. package/dist/ed25519-key.js +23 -20
  40. package/dist/ed25519-key.js.map +1 -1
  41. package/dist/edge-node/config.d.ts +122 -0
  42. package/dist/edge-node/config.js +199 -0
  43. package/dist/edge-node/config.js.map +1 -0
  44. package/dist/edge-node/dissolve.d.ts +95 -0
  45. package/dist/edge-node/dissolve.js +184 -0
  46. package/dist/edge-node/dissolve.js.map +1 -0
  47. package/dist/edge-node/edge-join.d.ts +44 -0
  48. package/dist/edge-node/edge-join.js +227 -0
  49. package/dist/edge-node/edge-join.js.map +1 -0
  50. package/dist/edge-node/edge-node.d.ts +295 -0
  51. package/dist/edge-node/edge-node.js +704 -0
  52. package/dist/edge-node/edge-node.js.map +1 -0
  53. package/dist/edge-node/index.d.ts +18 -0
  54. package/dist/edge-node/index.js +18 -0
  55. package/dist/edge-node/index.js.map +1 -0
  56. package/dist/edge-node/join-credential.d.ts +11 -0
  57. package/dist/edge-node/join-credential.js +11 -0
  58. package/dist/edge-node/join-credential.js.map +1 -0
  59. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  60. package/dist/edge-node/lazy-raw-storage.js +134 -0
  61. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  62. package/dist/edge-node/node-local.d.ts +60 -0
  63. package/dist/edge-node/node-local.js +58 -0
  64. package/dist/edge-node/node-local.js.map +1 -0
  65. package/dist/edge-node/options.d.ts +83 -0
  66. package/dist/edge-node/options.js +110 -0
  67. package/dist/edge-node/options.js.map +1 -0
  68. package/dist/edge-node/owner-grant.d.ts +41 -0
  69. package/dist/edge-node/owner-grant.js +38 -0
  70. package/dist/edge-node/owner-grant.js.map +1 -0
  71. package/dist/edge-node/platform.d.ts +140 -0
  72. package/dist/edge-node/platform.js +12 -0
  73. package/dist/edge-node/platform.js.map +1 -0
  74. package/dist/edge-node/strand-move.d.ts +115 -0
  75. package/dist/edge-node/strand-move.js +223 -0
  76. package/dist/edge-node/strand-move.js.map +1 -0
  77. package/dist/edge-node/strands.d.ts +28 -0
  78. package/dist/edge-node/strands.js +60 -0
  79. package/dist/edge-node/strands.js.map +1 -0
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +26 -7
  82. package/dist/index.js.map +1 -1
  83. package/dist/joined-strand-store.d.ts +3 -3
  84. package/dist/joined-strand-store.js +3 -3
  85. package/dist/joined-strand-store.js.map +1 -1
  86. package/dist/key-store.d.ts +9 -4
  87. package/dist/key-store.js +9 -4
  88. package/dist/key-store.js.map +1 -1
  89. package/dist/machine-removal.d.ts +19 -39
  90. package/dist/machine-removal.js +24 -98
  91. package/dist/machine-removal.js.map +1 -1
  92. package/dist/member-redemption.d.ts +250 -0
  93. package/dist/member-redemption.js +485 -0
  94. package/dist/member-redemption.js.map +1 -0
  95. package/dist/owner-anchor-sync.d.ts +4 -4
  96. package/dist/owner-anchor-sync.js +16 -15
  97. package/dist/owner-anchor-sync.js.map +1 -1
  98. package/dist/owner-grant-protocol.d.ts +247 -0
  99. package/dist/owner-grant-protocol.js +330 -0
  100. package/dist/owner-grant-protocol.js.map +1 -0
  101. package/dist/owner-key-export.d.ts +70 -0
  102. package/dist/owner-key-export.js +279 -0
  103. package/dist/owner-key-export.js.map +1 -0
  104. package/dist/owner-key-flows.d.ts +55 -0
  105. package/dist/owner-key-flows.js +60 -0
  106. package/dist/owner-key-flows.js.map +1 -0
  107. package/dist/owner-key-store.d.ts +31 -0
  108. package/dist/owner-key-store.js +81 -0
  109. package/dist/owner-key-store.js.map +1 -0
  110. package/dist/owner-keys.d.ts +78 -0
  111. package/dist/owner-keys.js +133 -0
  112. package/dist/owner-keys.js.map +1 -0
  113. package/dist/owner-signer.d.ts +54 -0
  114. package/dist/owner-signer.js +51 -0
  115. package/dist/owner-signer.js.map +1 -0
  116. package/dist/peer-authorization.d.ts +83 -18
  117. package/dist/peer-authorization.js +123 -53
  118. package/dist/peer-authorization.js.map +1 -1
  119. package/dist/provider/rent-node.js +1 -1
  120. package/dist/provider/rent-node.js.map +1 -1
  121. package/dist/seed-bootstrap.d.ts +131 -109
  122. package/dist/seed-bootstrap.js +260 -248
  123. package/dist/seed-bootstrap.js.map +1 -1
  124. package/dist/seed-trust-policy.js +4 -4
  125. package/dist/strand-addr-protocol.d.ts +4 -1
  126. package/dist/strand-addr-protocol.js +8 -2
  127. package/dist/strand-addr-protocol.js.map +1 -1
  128. package/dist/strand-cohort.d.ts +3 -0
  129. package/dist/strand-cohort.js.map +1 -1
  130. package/dist/strand-database.d.ts +1 -0
  131. package/dist/strand-database.js +3 -0
  132. package/dist/strand-database.js.map +1 -1
  133. package/dist/strand-first-sync-gate.d.ts +4 -3
  134. package/dist/strand-first-sync-gate.js +4 -3
  135. package/dist/strand-first-sync-gate.js.map +1 -1
  136. package/dist/strand-instance-manager.d.ts +1 -1
  137. package/dist/strand-membership-writer.d.ts +13 -2
  138. package/dist/strand-membership-writer.js +52 -16
  139. package/dist/strand-membership-writer.js.map +1 -1
  140. package/dist/strand-move.d.ts +6 -5
  141. package/dist/strand-move.js +31 -30
  142. package/dist/strand-move.js.map +1 -1
  143. package/dist/strand-peer-redial.js +2 -3
  144. package/dist/strand-peer-redial.js.map +1 -1
  145. package/dist/types.d.ts +286 -103
  146. package/dist/types.js.map +1 -1
  147. package/package.json +16 -7
  148. package/src/bootstrap-peer-store-file.ts +44 -44
  149. package/src/bootstrap-peer-store.ts +214 -214
  150. package/src/cadre-invite-protocol.ts +131 -531
  151. package/src/cadre-join-request.ts +188 -167
  152. package/src/cadre-node.ts +1166 -541
  153. package/src/control-authorization.ts +47 -14
  154. package/src/control-cohort.ts +127 -128
  155. package/src/control-database.ts +940 -456
  156. package/src/control-protocol-guard.ts +7 -4
  157. package/src/control-schema-version.ts +251 -0
  158. package/src/control-schema.ts +265 -70
  159. package/src/control-write-retry.ts +569 -567
  160. package/src/delegate-admission.ts +37 -0
  161. package/src/ed25519-key.ts +174 -171
  162. package/src/edge-node/config.ts +277 -0
  163. package/src/edge-node/dissolve.ts +251 -0
  164. package/src/edge-node/edge-join.ts +319 -0
  165. package/src/edge-node/edge-node.ts +1045 -0
  166. package/src/edge-node/index.ts +59 -0
  167. package/src/edge-node/join-credential.ts +16 -0
  168. package/src/edge-node/lazy-raw-storage.ts +178 -0
  169. package/src/edge-node/node-local.ts +84 -0
  170. package/src/edge-node/options.ts +179 -0
  171. package/src/edge-node/owner-grant.ts +63 -0
  172. package/src/edge-node/platform.ts +151 -0
  173. package/src/edge-node/strand-move.ts +270 -0
  174. package/src/edge-node/strands.ts +72 -0
  175. package/src/index.ts +102 -5
  176. package/src/joined-strand-store.ts +4 -4
  177. package/src/key-store.ts +10 -4
  178. package/src/machine-removal.ts +26 -111
  179. package/src/member-redemption.ts +642 -0
  180. package/src/owner-anchor-sync.ts +19 -18
  181. package/src/owner-grant-protocol.ts +532 -0
  182. package/src/owner-key-export.ts +348 -0
  183. package/src/owner-key-flows.ts +109 -0
  184. package/src/owner-key-store.ts +85 -0
  185. package/src/owner-keys.ts +178 -0
  186. package/src/owner-signer.ts +94 -0
  187. package/src/peer-authorization.ts +558 -488
  188. package/src/provider/rent-node.ts +1 -1
  189. package/src/seed-bootstrap.ts +301 -312
  190. package/src/seed-trust-policy.ts +4 -4
  191. package/src/strand-addr-protocol.ts +547 -538
  192. package/src/strand-cohort.ts +4 -0
  193. package/src/strand-database.ts +3 -0
  194. package/src/strand-first-sync-gate.ts +4 -3
  195. package/src/strand-instance-manager.ts +1 -1
  196. package/src/strand-membership-writer.ts +61 -16
  197. package/src/strand-move.ts +45 -36
  198. package/src/strand-peer-redial.ts +2 -3
  199. package/src/types.ts +304 -103
@@ -1,488 +1,558 @@
1
- import debug from 'debug';
2
- import { digest, verify } from '@optimystic/quereus-plugin-crypto';
3
- import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields } from './control-authorization.js';
4
- import type { BoundControlDomain, CadreInviteSignedFields, ControlAction, RevocableTable, UnboundControlDomain } from './control-authorization.js';
5
- import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
6
- import type { CadreInviteRow, CadreInviteUsageRow, CadrePeerVoucherFields, OwnerKeyRow, RevocationRow } from './types.js';
7
-
8
- const log = debug('sereus:cadre:peer-authorization');
9
-
10
- /**
11
- * base64url SHA-256 digest over the shared domain-tagged, party-bound field vector (see
12
- * control-authorization.ts). The base64url twin of
13
- * control-database.ts:buildAuthorizationMessage (which returns the same digest as raw
14
- * bytes): sign either encoding with input encoding to match and the signed bytes agree.
15
- * `partyId` is the party the verifier is configured for: a stored proof minted for
16
- * another party fails here exactly as it fails the schema's `party_id()`-bound check.
17
- */
18
- function taggedDigest(domain: BoundControlDomain, action: ControlAction, partyId: string, rowFields: string[]): string {
19
- return digest(controlAuthorizationFields(domain, action, partyId, rowFields), 'sha256', 'base64url') as string;
20
- }
21
-
22
- /** {@link taggedDigest} for the two party-free digests (`UnboundControlDomain`). */
23
- function unboundTaggedDigest(domain: UnboundControlDomain, action: ControlAction, rowFields: string[]): string {
24
- return digest(unboundAuthorizationFields(domain, action, rowFields), 'sha256', 'base64url') as string;
25
- }
26
-
27
- /**
28
- * Canonical digest an owner signs to vouch a peer's ENROLLMENT — the offline
29
- * credential `cadre enroll register` verifies. No table checks this digest, so it
30
- * carries its own `'Cadre.Enrollment'` domain tag to stay disjoint from every
31
- * CadreControl table rule (pre-tag it collided with the `DeviceToken` owner digests,
32
- * so an enrollment vouch doubled as a push-token delete approval).
33
- *
34
- * Factored into one place so the producer (owner signing) and the verifier (the
35
- * offline `cadre enroll register` check) can never drift apart — change the digest
36
- * here and both move together.
37
- */
38
- export function peerAuthorizationDigest(peerId: string): string {
39
- return unboundTaggedDigest('Cadre.Enrollment', 'vouch', [peerId]);
40
- }
41
-
42
- /**
43
- * The `CadreControl.DeviceToken` columns {@link deviceTokenAddDigest} binds, in the
44
- * schema's order. A whole-row struct rather than six positional strings, so a caller
45
- * cannot silently transpose `platform` and `token` (both opaque strings) and mint a
46
- * signature over a row it did not mean to approve.
47
- *
48
- * Structurally satisfied by a `DeviceTokenRecord` spread with the row's `stampId`;
49
- * `types.ts:DeviceTokenRecord` deliberately does NOT carry the stamp — it is the
50
- * SELF-signed record shape, and the peer's own `Sig` does not cover `StampId`.
51
- * `updatedAt` / `sig` are nullable because the columns are: both sign as `''` when
52
- * absent, mirroring the schema's `coalesce(...)`.
53
- */
54
- export interface DeviceTokenAuthorizedRow {
55
- peerId: string;
56
- platform: string;
57
- token: string;
58
- updatedAt: number | null;
59
- sig: string | null;
60
- stampId: string;
61
- }
62
-
63
- /**
64
- * Canonical digest an owner signs to authorize a `DeviceToken` INSERT — the WHOLE row,
65
- * ending in its single-use `StampId` nonce. SQL mirror:
66
- * `digest('CadreControl.DeviceToken', 'add', party_id(), new.PeerId, new.Platform, new.Token,
67
- * coalesce(cast(new.UpdatedAt as text), ''), coalesce(new.Sig, ''), new.StampId)` in
68
- * `DeviceToken.AuthorizedInsert`.
69
- *
70
- * Binding every column means a captured approval can only ever reproduce the exact row
71
- * it approved — never one carrying attacker-chosen `Platform`/`Token`/`UpdatedAt` — and
72
- * binding the stamp makes it single-use: while the row lives the `unique` column blocks
73
- * a replay, and after a clear the stamp is retired permanently into
74
- * `CadreControl.Revocation` (`DeviceToken.NotRevoked`). This matters more here than for
75
- * `CadrePeer`: a resurrected push token has NO freshness ceiling to retire it
76
- * (`CadreNode.resolveDeviceToken` defaults `maxAgeMs` to infinity by design), so stamp
77
- * retirement is the only thing that sticks.
78
- *
79
- * Distinct from {@link deviceTokenRemoveDigest} so a captured insert approval can never
80
- * be replayed to delete the token, and vice versa.
81
- */
82
- export function deviceTokenAddDigest(partyId: string, row: DeviceTokenAuthorizedRow): string {
83
- return taggedDigest('CadreControl.DeviceToken', 'add', partyId, [
84
- row.peerId,
85
- row.platform,
86
- row.token,
87
- row.updatedAt === null ? '' : String(row.updatedAt),
88
- row.sig ?? '',
89
- row.stampId,
90
- ]);
91
- }
92
-
93
- /**
94
- * Canonical digest an owner signs to authorize a `DeviceToken` DELETE, bound to the
95
- * STORED row's (PeerId, StampId). SQL mirror:
96
- * `digest('CadreControl.DeviceToken', 'remove', party_id(), old.PeerId, old.StampId)` in
97
- * `DeviceToken.AuthorizedDelete`.
98
- *
99
- * A narrower vector than {@link deviceTokenAddDigest} on purpose (the same split
100
- * `CadrePeer` makes): the clear approval names only which row is being retired, so it
101
- * cannot be re-cut into an insert approval, and it is dead the moment the stamp it
102
- * names is tombstoned.
103
- */
104
- export function deviceTokenRemoveDigest(partyId: string, peerId: string, stampId: string): string {
105
- return taggedDigest('CadreControl.DeviceToken', 'remove', partyId, [peerId, stampId]);
106
- }
107
-
108
- /**
109
- * Canonical digest an owner signs to VOUCH a `CadrePeer` membership row (insert
110
- * and the owner re-touch update — same semantics, deliberately the same digest).
111
- * Binds the peer id to the row's single-use `StampId` nonce, so a captured signed
112
- * insert cannot be replayed — while the row lives the `unique` column blocks it, and
113
- * after a removal the stamp is retired permanently into `CadreControl.Revocation`
114
- * (`CadrePeer.NotRevoked`) — and, because {@link cadrePeerRemoveDigest} scopes a
115
- * DIFFERENT payload, the stored voucher (`VouchSig`) cannot be replayed to authorize
116
- * a delete. The domain tag keeps the stored, replicated `VouchSig` useless against
117
- * every OTHER table's rules.
118
- *
119
- * SQL mirror: `digest('CadreControl.CadrePeer', 'vouch', party_id(), new.PeerId, new.StampId)`.
120
- */
121
- export function cadrePeerVoucherDigest(partyId: string, peerId: string, stampId: string): string {
122
- return taggedDigest('CadreControl.CadrePeer', 'vouch', partyId, [peerId, stampId]);
123
- }
124
-
125
- /**
126
- * Canonical digest an owner signs to REMOVE a `CadrePeer` row. Deliberately a
127
- * distinct payload from {@link cadrePeerVoucherDigest} (the `'remove'` action tag)
128
- * so the row's stored voucher — a signature over the voucher digest — can never
129
- * satisfy this delete check. The signature is supplied in write context and never
130
- * stored, so no reader can replay it; a captured remove is also dead after the
131
- * delete lands, because a re-added row carries a FRESH `StampId` (the removed row's
132
- * stamp is retired into `CadreControl.Revocation` and never reused).
133
- *
134
- * SQL mirror: `digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId)`.
135
- */
136
- export function cadrePeerRemoveDigest(partyId: string, peerId: string, stampId: string): string {
137
- return taggedDigest('CadreControl.CadrePeer', 'remove', partyId, [peerId, stampId]);
138
- }
139
-
140
- /**
141
- * Canonical digest an owner signs to APPEND a `CadreControl.Revocation` tombstone —
142
- * the row retiring `stampId` for the named guarded table, and recording `rowKey`
143
- * (the removed row's primary key: OwnerKey.Key / ValidationKey.Key /
144
- * CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id) as which row was retired.
145
- * SQL mirror:
146
- * `digest('CadreControl.Revocation', 'remove', party_id(), new.TableName, new.RowKey, new.StampId)`
147
- * in `Revocation.Authorized`.
148
- *
149
- * Its own `'CadreControl.Revocation'` domain tag makes it disjoint from the
150
- * `'CadreControl.CadrePeer'` (or `OwnerKey` / `ValidationKey` / `Strand`)
151
- * `'remove'` digest the same owner signs in the SAME transaction for the delete
152
- * this tombstone accompanies — a removal signature is not a retirement
153
- * signature and cannot be replayed as one.
154
- */
155
- export function revocationDigest(partyId: string, tableName: RevocableTable, rowKey: string, stampId: string): string {
156
- return taggedDigest('CadreControl.Revocation', 'remove', partyId, [tableName, rowKey, stampId]);
157
- }
158
-
159
- /**
160
- * Does a `Revocation` row's stored signer pair (`SignerKey` / `SignerSig`) verify over the
161
- * tombstone's own triple, for `partyId`? The read-side mirror of `Revocation.Authorized`,
162
- * for a node that received the row by replication and must decide whether to honour it.
163
- * Whether `row.signerKey` is an owner this node trusts is the caller's question, as for
164
- * {@link verifyCadrePeerVoucher}. Never throws: a malformed row verifies as `false`.
165
- */
166
- export function verifyRevocationSigner(partyId: string, row: RevocationRow): boolean {
167
- try {
168
- return verifyB64(revocationDigest(partyId, row.tableName, row.rowKey, row.stampId), row.signerSig, row.signerKey);
169
- } catch (error) {
170
- log('verifyRevocationSigner failed: %o', error);
171
- return false;
172
- }
173
- }
174
-
175
- /**
176
- * Canonical digest an owner signs to seat a further `OwnerKey` row — the signed-add branch
177
- * of `OwnerKey.Authorized`, which stores the pair as `VouchOwner` / `VouchSig`. SQL mirror:
178
- * `digest('CadreControl.OwnerKey', 'add', party_id(), new.Key, new.StampId)`. The removal
179
- * digest needs no builder here: it is the generic guarded `'remove'` digest over (Key, StampId).
180
- */
181
- export function ownerKeyAddDigest(partyId: string, key: string, stampId: string): string {
182
- return taggedDigest('CadreControl.OwnerKey', 'add', partyId, [key, stampId]);
183
- }
184
-
185
- /**
186
- * Does an owner-signed `OwnerKey` row's stored voucher verify — `vouchSig` by `vouchOwner`
187
- * over {@link ownerKeyAddDigest} for (`partyId`, `key`, `stampId`)? Whether `vouchOwner` is
188
- * anchored is the caller's question, as for {@link verifyCadrePeerVoucher}. Never throws.
189
- */
190
- export function verifyOwnerKeyVoucher(partyId: string, key: string, stampId: string, vouchOwner: string, vouchSig: string): boolean {
191
- try {
192
- return verifyB64(ownerKeyAddDigest(partyId, key, stampId), vouchSig, vouchOwner);
193
- } catch (error) {
194
- log('verifyOwnerKeyVoucher failed: %o', error);
195
- return false;
196
- }
197
- }
198
-
199
- /**
200
- * Verify that `signature` is a valid owner ed25519 signature over `peerId`'s
201
- * authorization digest, using `ownerPublicKey` (base64url).
202
- *
203
- * This is the mirror of the signing done in
204
- * {@link SeedBootstrapService.authorizePeer}: it checks the signature against
205
- * {@link peerAuthorizationDigest}. A `true` result means the holder of the
206
- * owner private key vouched for this peer ID — it does NOT mean the peer is
207
- * registered anywhere.
208
- *
209
- * Returns a boolean and never throws: malformed base64url, a bad/garbage key, or
210
- * any crypto failure resolves to `false` (callers want a verdict, not an
211
- * exception). The catch is logged at debug.
212
- */
213
- export function verifyPeerAuthorization(
214
- peerId: string,
215
- ownerPublicKey: string,
216
- signature: string
217
- ): boolean {
218
- try {
219
- return verify(
220
- peerAuthorizationDigest(peerId),
221
- signature,
222
- ownerPublicKey,
223
- 'ed25519',
224
- 'base64url',
225
- 'base64url',
226
- 'base64url'
227
- );
228
- } catch (error) {
229
- log('verifyPeerAuthorization failed: %o', error);
230
- return false;
231
- }
232
- }
233
-
234
- /**
235
- * Verify that `signature` is a valid owner ed25519 signature over the
236
- * `CadrePeer` voucher digest for (`partyId`, `peerId`, `stampId`) — the read-side
237
- * mirror of the voucher {@link ControlDatabase.insertCadrePeer} signs and persists into
238
- * `VouchOwner`/`VouchSig` (see {@link cadrePeerVoucherDigest}).
239
- *
240
- * A `true` result means the holder of `ownerPublicKey` vouched THIS membership
241
- * row (the peer id bound to the row's single-use `StampId` nonce) in THIS party: a
242
- * voucher copied from another party's table that shares the owner key fails. It says
243
- * nothing about whether that owner key is itself trustworthy — the caller must
244
- * separately check the key against the node-local trusted-owner anchor
245
- * (`TrustedOwnerStore`), never the replicated `OwnerKey` table.
246
- *
247
- * Returns a boolean and never throws (same contract as
248
- * {@link verifyPeerAuthorization}): malformed input or any crypto failure
249
- * resolves to `false`, logged at debug.
250
- */
251
- export function verifyCadrePeerVoucher(
252
- partyId: string,
253
- peerId: string,
254
- stampId: string,
255
- ownerPublicKey: string,
256
- signature: string
257
- ): boolean {
258
- try {
259
- return verify(
260
- cadrePeerVoucherDigest(partyId, peerId, stampId),
261
- signature,
262
- ownerPublicKey,
263
- 'ed25519',
264
- 'base64url',
265
- 'base64url',
266
- 'base64url'
267
- );
268
- } catch (error) {
269
- log('verifyCadrePeerVoucher failed: %o', error);
270
- return false;
271
- }
272
- }
273
-
274
- /**
275
- * Canonical digest a JOINING peer signs to consent to ONE `FormationUsage`
276
- * redemption — the read-side mirror of `formationConsentMessage` in
277
- * control-database.ts, base64url-encoded instead of raw bytes (see
278
- * {@link unboundTaggedDigest}). That doc comment carries the field-vector rationale
279
- * (notably why neither `strandId` nor the party id is signed); the two vectors must not
280
- * drift, which peer-authorization.spec.ts pins by signing one form and verifying the other.
281
- */
282
- export function formationConsentDigest(
283
- token: string, usageStampId: string, peerKey: string, disclosure: string
284
- ): string {
285
- return unboundTaggedDigest('CadreControl.FormationUsage', 'consent', [token, usageStampId, peerKey, disclosure]);
286
- }
287
-
288
- /**
289
- * Verify that `row.peerSig` is a valid ed25519 signature over the joining peer's
290
- * consent digest (see {@link formationConsentDigest}) — the row's OWN `peerKey`.
291
- *
292
- * Unlike {@link verifyCadrePeerVoucher} there is no separate enrolled/owner row to
293
- * look up: the identity IS the key carried on the row, so a forged consent would
294
- * need that joiner's own private key. Returns a boolean and never throws (same
295
- * contract as the siblings above): malformed input or any crypto failure resolves
296
- * to `false`, logged at debug.
297
- */
298
- export function verifyFormationConsent(row: {
299
- token: string; usageStampId: string; peerKey: string; disclosure: string; peerSig: string;
300
- }): boolean {
301
- try {
302
- return verify(
303
- formationConsentDigest(row.token, row.usageStampId, row.peerKey, row.disclosure),
304
- row.peerSig, row.peerKey, 'ed25519', 'base64url', 'base64url', 'base64url'
305
- );
306
- } catch (error) {
307
- log('verifyFormationConsent failed: %o', error);
308
- return false;
309
- }
310
- }
311
-
312
- /**
313
- * Canonical digest an owner signs to seat a `CadreInvite` row — the read-side mirror
314
- * of `cadreInviteAddMessage` in control-database.ts, base64url instead of raw bytes
315
- * (see {@link taggedDigest}). The whole row is bound, nullable columns as `''` and
316
- * `ExpiresAt` in its stored canonical form, through the one field builder both sides
317
- * share (`cadreInviteRowFields`). SQL mirror: `CadreInvite.AuthorizedInsert`.
318
- */
319
- export function cadreInviteAddDigest(partyId: string, row: CadreInviteSignedFields): string {
320
- return taggedDigest('CadreControl.CadreInvite', 'add', partyId, cadreInviteRowFields(row));
321
- }
322
-
323
- /**
324
- * Canonical digest the HOLDER of a `CadreInvite`'s private key signs to redeem it once:
325
- * proof of possession, verified against the invitation key itself
326
- * (`CadreInviteUsage.InvitePossessed`). Binds the redemption's single-use nonce and the
327
- * device being admitted, so one redemption cannot be re-presented for another device
328
- * or another use. Mirror of `cadreInviteRedeemMessage` in control-database.ts.
329
- */
330
- export function cadreInviteRedeemDigest(partyId: string, inviteKey: string, usageStampId: string, peerKey: string): string {
331
- return taggedDigest('CadreControl.CadreInviteUsage', 'redeem', partyId, [inviteKey, usageStampId, peerKey]);
332
- }
333
-
334
- /**
335
- * Canonical digest the DEVICE signs to consent to being admitted by one redemption
336
- * (`CadreInviteUsage.PeerConsented`), over the same fields as
337
- * {@link cadreInviteRedeemDigest} under a distinct action tag, so the holder's and
338
- * the device's signatures are never interchangeable. Mirror of
339
- * `cadreInviteConsentMessage` in control-database.ts.
340
- */
341
- export function cadreInviteConsentDigest(partyId: string, inviteKey: string, usageStampId: string, peerKey: string): string {
342
- return taggedDigest('CadreControl.CadreInviteUsage', 'consent', partyId, [inviteKey, usageStampId, peerKey]);
343
- }
344
-
345
- /** ed25519 verify over a base64url digest, with the siblings' never-throws contract left to the caller. */
346
- function verifyB64(digestB64: string, signature: string, publicKey: string): boolean {
347
- return verify(digestB64, signature, publicKey, 'ed25519', 'base64url', 'base64url', 'base64url');
348
- }
349
-
350
- /**
351
- * Does a `CadreInvite` row carry a valid `'add'` signature by its own `issuerKey`, for
352
- * `partyId`? The redeeming device runs this over the row a member replies with, after
353
- * checking the issuer against its pinned owner keys and the reply's party against the
354
- * invitation's, and a member over a bundle's copy it does not hold yet
355
- * (`ControlDatabase.isCadreInviteLive`); whether that issuer IS an owner is the caller's question.
356
- * Never throws: a malformed row verifies as `false`.
357
- */
358
- export function verifyCadreInviteRow(partyId: string, invite: CadreInviteRow): boolean {
359
- try {
360
- return verifyB64(cadreInviteAddDigest(partyId, invite), invite.issuerSig, invite.issuerKey);
361
- } catch (error) {
362
- log('verifyCadreInviteRow failed: %o', error);
363
- return false;
364
- }
365
- }
366
-
367
- /**
368
- * The two signatures a redemption carries, checked before any database work: the holder's
369
- * `'redeem'` signature with the invitation key, and the device's `'consent'` signature with
370
- * its own key, both over (`inviteKey`, `usageStampId`, `peerKey`). The pre-check twin of
371
- * `CadreInviteUsage.InvitePossessed` / `PeerConsented`, as `verifyFormationConsent` is for
372
- * formation: the schema stays the authority, this turns a bad signature into a clean
373
- * refusal instead of a constraint failure at commit. Never throws.
374
- */
375
- export function verifyCadreInviteRedemption(
376
- partyId: string,
377
- fields: { inviteKey: string; usageStampId: string; peerKey: string },
378
- inviteSig: string,
379
- peerSig: string,
380
- ): boolean {
381
- try {
382
- return verifyB64(cadreInviteRedeemDigest(partyId, fields.inviteKey, fields.usageStampId, fields.peerKey), inviteSig, fields.inviteKey)
383
- && verifyB64(cadreInviteConsentDigest(partyId, fields.inviteKey, fields.usageStampId, fields.peerKey), peerSig, fields.peerKey);
384
- } catch (error) {
385
- log('verifyCadreInviteRedemption failed: %o', error);
386
- return false;
387
- }
388
- }
389
-
390
- /**
391
- * The links of an invitation admission that do not depend on WHICH table's row was
392
- * admitted — shared by {@link verifyInvitationAdmission} (`CadrePeer`) and
393
- * {@link verifyInvitationOwnerAdmission} (`OwnerKey`), which differ only in the row
394
- * columns the usage must name and, for an owner row, in `grantsOwner`. The chain is
395
- *
396
- * row --vouchUsage--> usage --inviteKey--> invitation --issuerKey--> anchored owner
397
- *
398
- * and every link here is verified: the issuer is anchored (`isAnchored`) and is the row's
399
- * `vouchOwner`; the usage names this invitation and is the row's `vouchUsage`; a targeted
400
- * invitation names the redeeming device; the stored `peerKey` really is the key behind the
401
- * usage's `peerId` (the schema cannot unwrap a multihash, so a writer could assert any
402
- * pair); the invitation's stored `'add'` signature verifies over the row rebuilt from its
403
- * columns; the holder's `'redeem'` signature verifies with the invitation key and the
404
- * device's `'consent'` signature with its own key. All three signatures are judged for
405
- * `partyId`. Throws on malformed input; the two callers turn that into `false`.
406
- */
407
- function verifyAdmissionChain(
408
- partyId: string,
409
- vouchOwner: string,
410
- vouchUsage: string,
411
- usage: CadreInviteUsageRow,
412
- invite: CadreInviteRow,
413
- isAnchored: (ownerKey: string) => boolean,
414
- ): boolean {
415
- return isAnchored(vouchOwner)
416
- && invite.issuerKey === vouchOwner
417
- && usage.usageStampId === vouchUsage
418
- && usage.inviteKey === invite.key
419
- && (invite.peerId === null || invite.peerId === usage.peerId)
420
- && ed25519PublicKeyB64FromPeerId(usage.peerId) === usage.peerKey
421
- && verifyB64(cadreInviteAddDigest(partyId, invite), invite.issuerSig, invite.issuerKey)
422
- && verifyB64(cadreInviteRedeemDigest(partyId, invite.key, usage.usageStampId, usage.peerKey), usage.inviteSig, invite.key)
423
- && verifyB64(cadreInviteConsentDigest(partyId, invite.key, usage.usageStampId, usage.peerKey), usage.peerSig, usage.peerKey);
424
- }
425
-
426
- /**
427
- * Is an invitation-admitted `CadrePeer` row (`vouchSig` null, `vouchUsage` set) a
428
- * member this node should trust? The read-side mirror of the consent branch of
429
- * `CadrePeer.AuthorizedInsert`, re-checked against THIS node's anchor because the
430
- * replicated `OwnerKey` table can be polluted. The usage must name this peer id and this
431
- * exact row incarnation (`peerStampId`); every other link is {@link verifyAdmissionChain}.
432
- *
433
- * Expiry, use count and withdrawal are deliberately NOT re-checked: they were conditions
434
- * at redemption (`CadreInviteUsage.Authorized`), and membership persists until an owner
435
- * removes the row — exactly as an owner-vouched row's membership does. Keeping a device
436
- * out is removing its row AND withdrawing the invitation.
437
- *
438
- * Returns a boolean and never throws (the siblings' contract): malformed input or any
439
- * crypto failure resolves to `false`, logged at debug.
440
- */
441
- export function verifyInvitationAdmission(
442
- partyId: string,
443
- row: CadrePeerVoucherFields,
444
- usage: CadreInviteUsageRow,
445
- invite: CadreInviteRow,
446
- isAnchored: (ownerKey: string) => boolean,
447
- ): boolean {
448
- try {
449
- return row.stampId !== null
450
- && row.vouchOwner !== null
451
- && row.vouchUsage !== null
452
- && usage.peerStampId === row.stampId
453
- && usage.peerId === row.peerId
454
- && verifyAdmissionChain(partyId, row.vouchOwner, row.vouchUsage, usage, invite, isAnchored);
455
- } catch (error) {
456
- log('verifyInvitationAdmission failed: %o', error);
457
- return false;
458
- }
459
- }
460
-
461
- /**
462
- * Is an invitation-admitted `OwnerKey` row (`vouchSig` null, `vouchUsage` set) an owner
463
- * this node should derive into its anchor? The read-side mirror of the consent branch of
464
- * `OwnerKey.Authorized` (`owner-anchor-sync.ts` → `deriveOwnerAnchor`): the invitation
465
- * must grant ownership, and the usage must name this key (`peerKey`) and this exact row
466
- * incarnation (`ownerStampId`); every other link is {@link verifyAdmissionChain}. Same
467
- * never-throws contract and the same deliberate omissions as
468
- * {@link verifyInvitationAdmission}.
469
- */
470
- export function verifyInvitationOwnerAdmission(
471
- partyId: string,
472
- row: OwnerKeyRow,
473
- usage: CadreInviteUsageRow,
474
- invite: CadreInviteRow,
475
- isAnchored: (ownerKey: string) => boolean,
476
- ): boolean {
477
- try {
478
- return row.vouchOwner !== null
479
- && row.vouchUsage !== null
480
- && invite.grantsOwner
481
- && usage.peerKey === row.key
482
- && usage.ownerStampId === row.stampId
483
- && verifyAdmissionChain(partyId, row.vouchOwner, row.vouchUsage, usage, invite, isAnchored);
484
- } catch (error) {
485
- log('verifyInvitationOwnerAdmission failed: %o', error);
486
- return false;
487
- }
488
- }
1
+ import debug from 'debug';
2
+ import { digest, verify } from '@optimystic/quereus-plugin-crypto';
3
+ import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields } from './control-authorization.js';
4
+ import type { BoundControlDomain, CadreInviteSignedFields, ControlAction, OwnerGrantSignedFields, RevocableTable, UnboundControlDomain } from './control-authorization.js';
5
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
6
+ import type { CadreInviteRow, CadreInviteUsageRow, CadrePeerVoucherFields, OwnerGrantRow, OwnerGrantUsageRow, OwnerKeyRow, RevocationRow } from './types.js';
7
+
8
+ const log = debug('sereus:cadre:peer-authorization');
9
+
10
+ /**
11
+ * base64url SHA-256 digest over the shared domain-tagged, party-bound field vector (see
12
+ * control-authorization.ts). The base64url twin of
13
+ * control-database.ts:buildAuthorizationMessage (which returns the same digest as raw
14
+ * bytes): sign either encoding with input encoding to match and the signed bytes agree.
15
+ * `partyId` is the party the verifier is configured for: a stored proof minted for
16
+ * another party fails here exactly as it fails the schema's `party_id()`-bound check.
17
+ */
18
+ function taggedDigest(domain: BoundControlDomain, action: ControlAction, partyId: string, rowFields: string[]): string {
19
+ return digest(controlAuthorizationFields(domain, action, partyId, rowFields), 'sha256', 'base64url') as string;
20
+ }
21
+
22
+ /** {@link taggedDigest} for the two party-free digests (`UnboundControlDomain`). */
23
+ function unboundTaggedDigest(domain: UnboundControlDomain, action: ControlAction, rowFields: string[]): string {
24
+ return digest(unboundAuthorizationFields(domain, action, rowFields), 'sha256', 'base64url') as string;
25
+ }
26
+
27
+ /**
28
+ * Canonical digest an owner signs to vouch a peer's ENROLLMENT — the offline
29
+ * credential `cadre enroll register` verifies. No table checks this digest, so it
30
+ * carries its own `'Cadre.Enrollment'` domain tag to stay disjoint from every
31
+ * CadreControl table rule (pre-tag it collided with the `DeviceToken` owner digests,
32
+ * so an enrollment vouch doubled as a push-token delete approval).
33
+ *
34
+ * Factored into one place so the producer (owner signing) and the verifier (the
35
+ * offline `cadre enroll register` check) can never drift apart — change the digest
36
+ * here and both move together.
37
+ */
38
+ export function peerAuthorizationDigest(peerId: string): string {
39
+ return unboundTaggedDigest('Cadre.Enrollment', 'vouch', [peerId]);
40
+ }
41
+
42
+ /**
43
+ * The `CadreControl.DeviceToken` columns {@link deviceTokenAddDigest} binds, in the
44
+ * schema's order. A whole-row struct rather than six positional strings, so a caller
45
+ * cannot silently transpose `platform` and `token` (both opaque strings) and mint a
46
+ * signature over a row it did not mean to approve.
47
+ *
48
+ * Structurally satisfied by a `DeviceTokenRecord` spread with the row's `stampId`;
49
+ * `types.ts:DeviceTokenRecord` deliberately does NOT carry the stamp — it is the
50
+ * SELF-signed record shape, and the peer's own `Sig` does not cover `StampId`.
51
+ * `updatedAt` / `sig` are nullable because the columns are: both sign as `''` when
52
+ * absent, mirroring the schema's `coalesce(...)`.
53
+ */
54
+ export interface DeviceTokenAuthorizedRow {
55
+ peerId: string;
56
+ platform: string;
57
+ token: string;
58
+ updatedAt: number | null;
59
+ sig: string | null;
60
+ stampId: string;
61
+ }
62
+
63
+ /**
64
+ * Canonical digest an owner signs to authorize a `DeviceToken` INSERT — the WHOLE row,
65
+ * ending in its single-use `StampId` nonce. SQL mirror:
66
+ * `digest('CadreControl.DeviceToken', 'add', party_id(), new.PeerId, new.Platform, new.Token,
67
+ * coalesce(cast(new.UpdatedAt as text), ''), coalesce(new.Sig, ''), new.StampId)` in
68
+ * `DeviceToken.AuthorizedInsert`.
69
+ *
70
+ * Binding every column means a captured approval can only ever reproduce the exact row
71
+ * it approved — never one carrying attacker-chosen `Platform`/`Token`/`UpdatedAt` — and
72
+ * binding the stamp makes it single-use: while the row lives the `unique` column blocks
73
+ * a replay, and after a clear the stamp is retired permanently into
74
+ * `CadreControl.Revocation` (`DeviceToken.NotRevoked`). This matters more here than for
75
+ * `CadrePeer`: a resurrected push token has NO freshness ceiling to retire it
76
+ * (`CadreNode.resolveDeviceToken` defaults `maxAgeMs` to infinity by design), so stamp
77
+ * retirement is the only thing that sticks.
78
+ *
79
+ * Distinct from {@link deviceTokenRemoveDigest} so a captured insert approval can never
80
+ * be replayed to delete the token, and vice versa.
81
+ */
82
+ export function deviceTokenAddDigest(partyId: string, row: DeviceTokenAuthorizedRow): string {
83
+ return taggedDigest('CadreControl.DeviceToken', 'add', partyId, [
84
+ row.peerId,
85
+ row.platform,
86
+ row.token,
87
+ row.updatedAt === null ? '' : String(row.updatedAt),
88
+ row.sig ?? '',
89
+ row.stampId,
90
+ ]);
91
+ }
92
+
93
+ /**
94
+ * Canonical digest an owner signs to authorize a `DeviceToken` DELETE, bound to the
95
+ * STORED row's (PeerId, StampId). SQL mirror:
96
+ * `digest('CadreControl.DeviceToken', 'remove', party_id(), old.PeerId, old.StampId)` in
97
+ * `DeviceToken.AuthorizedDelete`.
98
+ *
99
+ * A narrower vector than {@link deviceTokenAddDigest} on purpose (the same split
100
+ * `CadrePeer` makes): the clear approval names only which row is being retired, so it
101
+ * cannot be re-cut into an insert approval, and it is dead the moment the stamp it
102
+ * names is tombstoned.
103
+ */
104
+ export function deviceTokenRemoveDigest(partyId: string, peerId: string, stampId: string): string {
105
+ return taggedDigest('CadreControl.DeviceToken', 'remove', partyId, [peerId, stampId]);
106
+ }
107
+
108
+ /**
109
+ * Canonical digest an owner signs to VOUCH a `CadrePeer` membership row (insert
110
+ * and the owner re-touch update — same semantics, deliberately the same digest).
111
+ * Binds the peer id to the row's single-use `StampId` nonce, so a captured signed
112
+ * insert cannot be replayed — while the row lives the `unique` column blocks it, and
113
+ * after a removal the stamp is retired permanently into `CadreControl.Revocation`
114
+ * (`CadrePeer.NotRevoked`) — and, because {@link cadrePeerRemoveDigest} scopes a
115
+ * DIFFERENT payload, the stored voucher (`VouchSig`) cannot be replayed to authorize
116
+ * a delete. The domain tag keeps the stored, replicated `VouchSig` useless against
117
+ * every OTHER table's rules.
118
+ *
119
+ * SQL mirror: `digest('CadreControl.CadrePeer', 'vouch', party_id(), new.PeerId, new.StampId)`.
120
+ */
121
+ export function cadrePeerVoucherDigest(partyId: string, peerId: string, stampId: string): string {
122
+ return taggedDigest('CadreControl.CadrePeer', 'vouch', partyId, [peerId, stampId]);
123
+ }
124
+
125
+ /**
126
+ * Canonical digest an owner signs to REMOVE a `CadrePeer` row. Deliberately a
127
+ * distinct payload from {@link cadrePeerVoucherDigest} (the `'remove'` action tag)
128
+ * so the row's stored voucher — a signature over the voucher digest — can never
129
+ * satisfy this delete check. The signature is supplied in write context and never
130
+ * stored, so no reader can replay it; a captured remove is also dead after the
131
+ * delete lands, because a re-added row carries a FRESH `StampId` (the removed row's
132
+ * stamp is retired into `CadreControl.Revocation` and never reused).
133
+ *
134
+ * SQL mirror: `digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId)`.
135
+ */
136
+ export function cadrePeerRemoveDigest(partyId: string, peerId: string, stampId: string): string {
137
+ return taggedDigest('CadreControl.CadrePeer', 'remove', partyId, [peerId, stampId]);
138
+ }
139
+
140
+ /**
141
+ * Canonical digest an owner signs to APPEND a `CadreControl.Revocation` tombstone —
142
+ * the row retiring `stampId` for the named guarded table, and recording `rowKey`
143
+ * (the removed row's primary key: OwnerKey.Key / ValidationKey.Key /
144
+ * CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id) as which row was retired.
145
+ * SQL mirror:
146
+ * `digest('CadreControl.Revocation', 'remove', party_id(), new.TableName, new.RowKey, new.StampId)`
147
+ * in `Revocation.Authorized`.
148
+ *
149
+ * Its own `'CadreControl.Revocation'` domain tag makes it disjoint from the
150
+ * `'CadreControl.CadrePeer'` (or `OwnerKey` / `ValidationKey` / `Strand`)
151
+ * `'remove'` digest the same owner signs in the SAME transaction for the delete
152
+ * this tombstone accompanies — a removal signature is not a retirement
153
+ * signature and cannot be replayed as one.
154
+ */
155
+ export function revocationDigest(partyId: string, tableName: RevocableTable, rowKey: string, stampId: string): string {
156
+ return taggedDigest('CadreControl.Revocation', 'remove', partyId, [tableName, rowKey, stampId]);
157
+ }
158
+
159
+ /**
160
+ * Does a `Revocation` row's stored signer pair (`SignerKey` / `SignerSig`) verify over the
161
+ * tombstone's own triple, for `partyId`? The read-side mirror of `Revocation.Authorized`,
162
+ * for a node that received the row by replication and must decide whether to honour it.
163
+ * Whether `row.signerKey` is an owner this node trusts is the caller's question, as for
164
+ * {@link verifyCadrePeerVoucher}. Never throws: a malformed row verifies as `false`.
165
+ */
166
+ export function verifyRevocationSigner(partyId: string, row: RevocationRow): boolean {
167
+ try {
168
+ return verifyB64(revocationDigest(partyId, row.tableName, row.rowKey, row.stampId), row.signerSig, row.signerKey);
169
+ } catch (error) {
170
+ log('verifyRevocationSigner failed: %o', error);
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Canonical digest an owner signs to seat a further `OwnerKey` row — the signed-add branch
177
+ * of `OwnerKey.Authorized`, which stores the pair as `VouchOwner` / `VouchSig`. SQL mirror:
178
+ * `digest('CadreControl.OwnerKey', 'add', party_id(), new.Key, coalesce(new.Label, ''), new.StampId)`.
179
+ * The removal digest needs no builder here: it is the generic guarded `'remove'` digest over
180
+ * (Key, StampId).
181
+ */
182
+ export function ownerKeyAddDigest(partyId: string, key: string, label: string | null, stampId: string): string {
183
+ return taggedDigest('CadreControl.OwnerKey', 'add', partyId, [key, label ?? '', stampId]);
184
+ }
185
+
186
+ /**
187
+ * Does an owner-signed `OwnerKey` row's stored voucher verify — `vouchSig` by `vouchOwner`
188
+ * over {@link ownerKeyAddDigest} for (`partyId`, `key`, `label`, `stampId`)? Whether
189
+ * `vouchOwner` is anchored is the caller's question, as for {@link verifyCadrePeerVoucher}.
190
+ * Never throws.
191
+ */
192
+ export function verifyOwnerKeyVoucher(partyId: string, key: string, label: string | null, stampId: string, vouchOwner: string, vouchSig: string): boolean {
193
+ try {
194
+ return verifyB64(ownerKeyAddDigest(partyId, key, label, stampId), vouchSig, vouchOwner);
195
+ } catch (error) {
196
+ log('verifyOwnerKeyVoucher failed: %o', error);
197
+ return false;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Verify that `signature` is a valid owner ed25519 signature over `peerId`'s
203
+ * authorization digest, using `ownerPublicKey` (base64url).
204
+ *
205
+ * This is the mirror of the signing done in
206
+ * {@link SeedBootstrapService.authorizePeer}: it checks the signature against
207
+ * {@link peerAuthorizationDigest}. A `true` result means the holder of the
208
+ * owner private key vouched for this peer ID — it does NOT mean the peer is
209
+ * registered anywhere.
210
+ *
211
+ * Returns a boolean and never throws: malformed base64url, a bad/garbage key, or
212
+ * any crypto failure resolves to `false` (callers want a verdict, not an
213
+ * exception). The catch is logged at debug.
214
+ */
215
+ export function verifyPeerAuthorization(
216
+ peerId: string,
217
+ ownerPublicKey: string,
218
+ signature: string
219
+ ): boolean {
220
+ try {
221
+ return verify(
222
+ peerAuthorizationDigest(peerId),
223
+ signature,
224
+ ownerPublicKey,
225
+ 'ed25519',
226
+ 'base64url',
227
+ 'base64url',
228
+ 'base64url'
229
+ );
230
+ } catch (error) {
231
+ log('verifyPeerAuthorization failed: %o', error);
232
+ return false;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Verify that `signature` is a valid owner ed25519 signature over the
238
+ * `CadrePeer` voucher digest for (`partyId`, `peerId`, `stampId`) — the read-side
239
+ * mirror of the voucher {@link ControlDatabase.insertCadrePeer} signs and persists into
240
+ * `VouchOwner`/`VouchSig` (see {@link cadrePeerVoucherDigest}).
241
+ *
242
+ * A `true` result means the holder of `ownerPublicKey` vouched THIS membership
243
+ * row (the peer id bound to the row's single-use `StampId` nonce) in THIS party: a
244
+ * voucher copied from another party's table that shares the owner key fails. It says
245
+ * nothing about whether that owner key is itself trustworthy — the caller must
246
+ * separately check the key against the node-local trusted-owner anchor
247
+ * (`TrustedOwnerStore`), never the replicated `OwnerKey` table.
248
+ *
249
+ * Returns a boolean and never throws (same contract as
250
+ * {@link verifyPeerAuthorization}): malformed input or any crypto failure
251
+ * resolves to `false`, logged at debug.
252
+ */
253
+ export function verifyCadrePeerVoucher(
254
+ partyId: string,
255
+ peerId: string,
256
+ stampId: string,
257
+ ownerPublicKey: string,
258
+ signature: string
259
+ ): boolean {
260
+ try {
261
+ return verify(
262
+ cadrePeerVoucherDigest(partyId, peerId, stampId),
263
+ signature,
264
+ ownerPublicKey,
265
+ 'ed25519',
266
+ 'base64url',
267
+ 'base64url',
268
+ 'base64url'
269
+ );
270
+ } catch (error) {
271
+ log('verifyCadrePeerVoucher failed: %o', error);
272
+ return false;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * Canonical digest a JOINING peer signs to consent to ONE `FormationUsage`
278
+ * redemption — the read-side mirror of `formationConsentMessage` in
279
+ * control-database.ts, base64url-encoded instead of raw bytes (see
280
+ * {@link unboundTaggedDigest}). That doc comment carries the field-vector rationale
281
+ * (notably why neither `strandId` nor the party id is signed); the two vectors must not
282
+ * drift, which peer-authorization.spec.ts pins by signing one form and verifying the other.
283
+ */
284
+ export function formationConsentDigest(
285
+ token: string, usageStampId: string, peerKey: string, disclosure: string
286
+ ): string {
287
+ return unboundTaggedDigest('CadreControl.FormationUsage', 'consent', [token, usageStampId, peerKey, disclosure]);
288
+ }
289
+
290
+ /**
291
+ * Verify that `row.peerSig` is a valid ed25519 signature over the joining peer's
292
+ * consent digest (see {@link formationConsentDigest}) — the row's OWN `peerKey`.
293
+ *
294
+ * Unlike {@link verifyCadrePeerVoucher} there is no separate enrolled/owner row to
295
+ * look up: the identity IS the key carried on the row, so a forged consent would
296
+ * need that joiner's own private key. Returns a boolean and never throws (same
297
+ * contract as the siblings above): malformed input or any crypto failure resolves
298
+ * to `false`, logged at debug.
299
+ */
300
+ export function verifyFormationConsent(row: {
301
+ token: string; usageStampId: string; peerKey: string; disclosure: string; peerSig: string;
302
+ }): boolean {
303
+ try {
304
+ return verify(
305
+ formationConsentDigest(row.token, row.usageStampId, row.peerKey, row.disclosure),
306
+ row.peerSig, row.peerKey, 'ed25519', 'base64url', 'base64url', 'base64url'
307
+ );
308
+ } catch (error) {
309
+ log('verifyFormationConsent failed: %o', error);
310
+ return false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Canonical digest an owner signs to seat a `CadreInvite` row — the read-side mirror
316
+ * of `cadreInviteAddMessage` in control-database.ts, base64url instead of raw bytes
317
+ * (see {@link taggedDigest}). The whole row is bound, nullable columns as `''` and
318
+ * `ExpiresAt` in its stored canonical form, through the one field builder both sides
319
+ * share (`cadreInviteRowFields`). SQL mirror: `CadreInvite.AuthorizedInsert`.
320
+ */
321
+ export function cadreInviteAddDigest(partyId: string, row: CadreInviteSignedFields): string {
322
+ return taggedDigest('CadreControl.CadreInvite', 'add', partyId, cadreInviteRowFields(row));
323
+ }
324
+
325
+ /**
326
+ * Canonical digest the HOLDER of a `CadreInvite`'s private key signs to redeem it once:
327
+ * proof of possession, verified against the invitation key itself
328
+ * (`CadreInviteUsage.InvitePossessed`). Binds the redemption's single-use nonce and the
329
+ * device being admitted, so one redemption cannot be re-presented for another device
330
+ * or another use. Mirror of `cadreInviteRedeemMessage` in control-database.ts.
331
+ */
332
+ export function cadreInviteRedeemDigest(partyId: string, inviteKey: string, usageStampId: string, peerKey: string): string {
333
+ return taggedDigest('CadreControl.CadreInviteUsage', 'redeem', partyId, [inviteKey, usageStampId, peerKey]);
334
+ }
335
+
336
+ /**
337
+ * Canonical digest the DEVICE signs to consent to being admitted by one redemption
338
+ * (`CadreInviteUsage.PeerConsented`), over the same fields as
339
+ * {@link cadreInviteRedeemDigest} under a distinct action tag, so the holder's and
340
+ * the device's signatures are never interchangeable. Mirror of
341
+ * `cadreInviteConsentMessage` in control-database.ts.
342
+ */
343
+ export function cadreInviteConsentDigest(partyId: string, inviteKey: string, usageStampId: string, peerKey: string): string {
344
+ return taggedDigest('CadreControl.CadreInviteUsage', 'consent', partyId, [inviteKey, usageStampId, peerKey]);
345
+ }
346
+
347
+ /** ed25519 verify over a base64url digest, with the siblings' never-throws contract left to the caller. */
348
+ function verifyB64(digestB64: string, signature: string, publicKey: string): boolean {
349
+ return verify(digestB64, signature, publicKey, 'ed25519', 'base64url', 'base64url', 'base64url');
350
+ }
351
+
352
+ /**
353
+ * Does a `CadreInvite` row carry a valid `'add'` signature by its own `issuerKey`, for
354
+ * `partyId`? The redeeming device runs this over the row a member replies with, after
355
+ * checking the issuer against its pinned owner keys and the reply's party against the
356
+ * invitation's, and a member over a bundle's copy it does not hold yet
357
+ * (`ControlDatabase.isCadreInviteLive`); whether that issuer IS an owner is the caller's question.
358
+ * Never throws: a malformed row verifies as `false`.
359
+ */
360
+ export function verifyCadreInviteRow(partyId: string, invite: CadreInviteRow): boolean {
361
+ try {
362
+ return verifyB64(cadreInviteAddDigest(partyId, invite), invite.issuerSig, invite.issuerKey);
363
+ } catch (error) {
364
+ log('verifyCadreInviteRow failed: %o', error);
365
+ return false;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * The two signatures a redemption carries, checked before any database work: the holder's
371
+ * `'redeem'` signature with the invitation key, and the device's `'consent'` signature with
372
+ * its own key, both over (`inviteKey`, `usageStampId`, `peerKey`). The pre-check twin of
373
+ * `CadreInviteUsage.InvitePossessed` / `PeerConsented`, as `verifyFormationConsent` is for
374
+ * formation: the schema stays the authority, this turns a bad signature into a clean
375
+ * refusal instead of a constraint failure at commit. Never throws.
376
+ */
377
+ export function verifyCadreInviteRedemption(
378
+ partyId: string,
379
+ fields: { inviteKey: string; usageStampId: string; peerKey: string },
380
+ inviteSig: string,
381
+ peerSig: string,
382
+ ): boolean {
383
+ try {
384
+ return verifyB64(cadreInviteRedeemDigest(partyId, fields.inviteKey, fields.usageStampId, fields.peerKey), inviteSig, fields.inviteKey)
385
+ && verifyB64(cadreInviteConsentDigest(partyId, fields.inviteKey, fields.usageStampId, fields.peerKey), peerSig, fields.peerKey);
386
+ } catch (error) {
387
+ log('verifyCadreInviteRedemption failed: %o', error);
388
+ return false;
389
+ }
390
+ }
391
+
392
+ /**
393
+ * Is an invitation-admitted `CadrePeer` row (`vouchSig` null, `vouchUsage` set) a
394
+ * member this node should trust? The read-side mirror of the consent branch of
395
+ * `CadrePeer.AuthorizedInsert`, re-checked against THIS node's anchor because the
396
+ * replicated `OwnerKey` table can be polluted. The chain is
397
+ *
398
+ * row --vouchUsage--> usage --inviteKey--> invitation --issuerKey--> anchored owner
399
+ *
400
+ * and every link is verified: the issuer is anchored (`isAnchored`) and is the row's
401
+ * `vouchOwner`; the usage is the row's `vouchUsage`, names this peer id and this exact row
402
+ * incarnation (`peerStampId`), and names this invitation; a targeted invitation names the
403
+ * redeeming device; the stored `peerKey` really is the key behind the usage's `peerId` (the
404
+ * schema cannot unwrap a multihash, so a writer could assert any pair); the invitation's
405
+ * stored `'add'` signature verifies over the row rebuilt from its columns; the holder's
406
+ * `'redeem'` signature verifies with the invitation key and the device's `'consent'`
407
+ * signature with its own key. All three signatures are judged for `partyId`. A machine
408
+ * invitation admits a machine and nothing else, so no `OwnerKey` row is ever verified
409
+ * through this chain; a grant-seated owner row is {@link verifyOwnerGrantAdmission}'s.
410
+ *
411
+ * Expiry, use count and withdrawal are deliberately NOT re-checked: they were conditions
412
+ * at redemption (`CadreInviteUsage.Authorized`), and membership persists until an owner
413
+ * removes the row — exactly as an owner-vouched row's membership does. Keeping a device
414
+ * out is removing its row AND withdrawing the invitation.
415
+ *
416
+ * Returns a boolean and never throws (the siblings' contract): malformed input or any
417
+ * crypto failure resolves to `false`, logged at debug.
418
+ */
419
+ export function verifyInvitationAdmission(
420
+ partyId: string,
421
+ row: CadrePeerVoucherFields,
422
+ usage: CadreInviteUsageRow,
423
+ invite: CadreInviteRow,
424
+ isAnchored: (ownerKey: string) => boolean,
425
+ ): boolean {
426
+ try {
427
+ return row.stampId !== null
428
+ && row.vouchOwner !== null
429
+ && row.vouchUsage !== null
430
+ && isAnchored(row.vouchOwner)
431
+ && invite.issuerKey === row.vouchOwner
432
+ && usage.usageStampId === row.vouchUsage
433
+ && usage.peerStampId === row.stampId
434
+ && usage.peerId === row.peerId
435
+ && usage.inviteKey === invite.key
436
+ && (invite.peerId === null || invite.peerId === usage.peerId)
437
+ && ed25519PublicKeyB64FromPeerId(usage.peerId) === usage.peerKey
438
+ && verifyB64(cadreInviteAddDigest(partyId, invite), invite.issuerSig, invite.issuerKey)
439
+ && verifyB64(cadreInviteRedeemDigest(partyId, invite.key, usage.usageStampId, usage.peerKey), usage.inviteSig, invite.key)
440
+ && verifyB64(cadreInviteConsentDigest(partyId, invite.key, usage.usageStampId, usage.peerKey), usage.peerSig, usage.peerKey);
441
+ } catch (error) {
442
+ log('verifyInvitationAdmission failed: %o', error);
443
+ return false;
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Canonical digest an owner signs to seat an `OwnerGrant` row — the read-side mirror of
449
+ * `ownerGrantAddMessage` in control-database.ts, base64url instead of raw bytes. The whole
450
+ * row is bound, the label included, through the one field builder both sides share
451
+ * (`ownerGrantRowFields`). SQL mirror: `OwnerGrant.AuthorizedInsert`.
452
+ */
453
+ export function ownerGrantAddDigest(partyId: string, row: OwnerGrantSignedFields): string {
454
+ return taggedDigest('CadreControl.OwnerGrant', 'add', partyId, ownerGrantRowFields(row));
455
+ }
456
+
457
+ /**
458
+ * Canonical digest the HOLDER of an `OwnerGrant`'s private key signs to redeem it once:
459
+ * proof of possession, verified against the grant key itself
460
+ * (`OwnerGrantUsage.GrantPossessed`). Binds the redemption's nonce and the key being seated,
461
+ * so one redemption cannot be re-presented for another key or another use. Mirror of
462
+ * `ownerGrantRedeemMessage` in control-database.ts.
463
+ */
464
+ export function ownerGrantRedeemDigest(partyId: string, grantKey: string, usageStampId: string, seatedKey: string): string {
465
+ return taggedDigest('CadreControl.OwnerGrantUsage', 'redeem', partyId, [grantKey, usageStampId, seatedKey]);
466
+ }
467
+
468
+ /**
469
+ * Canonical digest the key being SEATED signs to prove it is held by the redeemer
470
+ * (`OwnerGrantUsage.OwnerConsented`), over the same fields as {@link ownerGrantRedeemDigest}
471
+ * under a distinct action tag, so the holder's and the key's signatures are never
472
+ * interchangeable. Mirror of `ownerGrantConsentMessage` in control-database.ts.
473
+ */
474
+ export function ownerGrantConsentDigest(partyId: string, grantKey: string, usageStampId: string, seatedKey: string): string {
475
+ return taggedDigest('CadreControl.OwnerGrantUsage', 'consent', partyId, [grantKey, usageStampId, seatedKey]);
476
+ }
477
+
478
+ /**
479
+ * Does an `OwnerGrant` row carry a valid `'add'` signature by its own `issuerKey`, for
480
+ * `partyId`? The grant twin of {@link verifyCadreInviteRow}, with the same contract: whether
481
+ * that issuer IS an owner is the caller's question, and a malformed row verifies as `false`.
482
+ */
483
+ export function verifyOwnerGrantRow(partyId: string, grant: OwnerGrantRow): boolean {
484
+ try {
485
+ return verifyB64(ownerGrantAddDigest(partyId, grant), grant.issuerSig, grant.issuerKey);
486
+ } catch (error) {
487
+ log('verifyOwnerGrantRow failed: %o', error);
488
+ return false;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * The two signatures an owner-grant redemption carries, checked before any database work:
494
+ * the holder's `'redeem'` signature with the grant key and the seated key's `'consent'`
495
+ * signature with itself, both over (`grantKey`, `usageStampId`, `seatedKey`). The grant twin
496
+ * of {@link verifyCadreInviteRedemption}, with the same contract: the schema
497
+ * (`OwnerGrantUsage.GrantPossessed` / `OwnerConsented`) stays the authority, this turns a bad
498
+ * signature into a clean refusal instead of a constraint failure at commit. Never throws.
499
+ */
500
+ export function verifyOwnerGrantRedemption(
501
+ partyId: string,
502
+ fields: { grantKey: string; usageStampId: string; seatedKey: string },
503
+ grantSig: string,
504
+ ownerSig: string,
505
+ ): boolean {
506
+ try {
507
+ return verifyB64(ownerGrantRedeemDigest(partyId, fields.grantKey, fields.usageStampId, fields.seatedKey), grantSig, fields.grantKey)
508
+ && verifyB64(ownerGrantConsentDigest(partyId, fields.grantKey, fields.usageStampId, fields.seatedKey), ownerSig, fields.seatedKey);
509
+ } catch (error) {
510
+ log('verifyOwnerGrantRedemption failed: %o', error);
511
+ return false;
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Is a grant-seated `OwnerKey` row (`vouchSig` null, `vouchUsage` naming an `OwnerGrantUsage`
517
+ * row) an owner this node should derive into its anchor? The read-side mirror of the grant
518
+ * branch of `OwnerKey.Authorized` (`owner-anchor-sync.ts` → `deriveOwnerAnchor`). The chain is
519
+ *
520
+ * row --vouchUsage--> usage --grantKey--> grant --issuerKey--> anchored owner
521
+ *
522
+ * and every link is verified: the issuer is anchored and is the row's `vouchOwner`; the usage
523
+ * is the row's `vouchUsage` and names this key and this exact row incarnation
524
+ * (`seatedStampId`); the usage names this grant; the row carries the grant's label; the
525
+ * grant's stored `'add'` signature verifies over the row rebuilt from its columns; the
526
+ * holder's `'redeem'` signature verifies with the grant key and the seated key's `'consent'`
527
+ * signature with itself. All three signatures are judged for `partyId`.
528
+ *
529
+ * Expiry, use count and withdrawal are deliberately NOT re-checked, as
530
+ * {@link verifyInvitationAdmission} omits them: they were conditions at redemption
531
+ * (`OwnerGrantUsage.Authorized`), and ownership persists until an owner removes the row.
532
+ * Same never-throws contract as the siblings.
533
+ */
534
+ export function verifyOwnerGrantAdmission(
535
+ partyId: string,
536
+ row: OwnerKeyRow,
537
+ usage: OwnerGrantUsageRow,
538
+ grant: OwnerGrantRow,
539
+ isAnchored: (ownerKey: string) => boolean,
540
+ ): boolean {
541
+ try {
542
+ return row.vouchOwner !== null
543
+ && row.vouchUsage !== null
544
+ && isAnchored(row.vouchOwner)
545
+ && grant.issuerKey === row.vouchOwner
546
+ && usage.usageStampId === row.vouchUsage
547
+ && usage.seatedKey === row.key
548
+ && usage.seatedStampId === row.stampId
549
+ && usage.grantKey === grant.key
550
+ && (grant.label ?? '') === (row.label ?? '')
551
+ && verifyB64(ownerGrantAddDigest(partyId, grant), grant.issuerSig, grant.issuerKey)
552
+ && verifyB64(ownerGrantRedeemDigest(partyId, grant.key, usage.usageStampId, usage.seatedKey), usage.grantSig, grant.key)
553
+ && verifyB64(ownerGrantConsentDigest(partyId, grant.key, usage.usageStampId, usage.seatedKey), usage.ownerSig, usage.seatedKey);
554
+ } catch (error) {
555
+ log('verifyOwnerGrantAdmission failed: %o', error);
556
+ return false;
557
+ }
558
+ }