@serfab/cadre-core 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/cadre-invite-protocol.d.ts +348 -0
  2. package/dist/cadre-invite-protocol.js +605 -0
  3. package/dist/cadre-invite-protocol.js.map +1 -0
  4. package/dist/cadre-node.d.ts +523 -180
  5. package/dist/cadre-node.js +921 -293
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/claim-proof.d.ts +45 -0
  8. package/dist/claim-proof.js +132 -0
  9. package/dist/claim-proof.js.map +1 -0
  10. package/dist/control-authorization.d.ts +80 -19
  11. package/dist/control-authorization.js +55 -13
  12. package/dist/control-authorization.js.map +1 -1
  13. package/dist/control-cohort.js +1 -1
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +514 -97
  16. package/dist/control-database.js +1033 -195
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-protocol-guard.d.ts +142 -0
  19. package/dist/control-protocol-guard.js +293 -0
  20. package/dist/control-protocol-guard.js.map +1 -0
  21. package/dist/control-schema.d.ts +1 -1
  22. package/dist/control-schema.js +801 -1067
  23. package/dist/control-schema.js.map +1 -1
  24. package/dist/control-stream.d.ts +4 -0
  25. package/dist/control-stream.js +22 -7
  26. package/dist/control-stream.js.map +1 -1
  27. package/dist/delegate-admission.d.ts +1 -1
  28. package/dist/delegate-admission.js +1 -1
  29. package/dist/device-token.d.ts +11 -9
  30. package/dist/device-token.js +14 -12
  31. package/dist/device-token.js.map +1 -1
  32. package/dist/ed25519-key.d.ts +15 -0
  33. package/dist/ed25519-key.js +28 -1
  34. package/dist/ed25519-key.js.map +1 -1
  35. package/dist/index.d.ts +14 -9
  36. package/dist/index.js +27 -9
  37. package/dist/index.js.map +1 -1
  38. package/dist/link-budget.d.ts +16 -29
  39. package/dist/link-budget.js +16 -31
  40. package/dist/link-budget.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +190 -151
  42. package/dist/membership-connection-gater.js +235 -173
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-claim-payload.d.ts +19 -0
  45. package/dist/node-claim-payload.js +120 -0
  46. package/dist/node-claim-payload.js.map +1 -0
  47. package/dist/owner-anchor-sync.d.ts +22 -0
  48. package/dist/owner-anchor-sync.js +143 -0
  49. package/dist/owner-anchor-sync.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +113 -18
  51. package/dist/peer-authorization.js +225 -29
  52. package/dist/peer-authorization.js.map +1 -1
  53. package/dist/peer-dial.d.ts +19 -0
  54. package/dist/peer-dial.js +22 -1
  55. package/dist/peer-dial.js.map +1 -1
  56. package/dist/peer-join-backfill.d.ts +26 -2
  57. package/dist/peer-join-backfill.js +54 -12
  58. package/dist/peer-join-backfill.js.map +1 -1
  59. package/dist/peer-record.d.ts +13 -9
  60. package/dist/peer-record.js +16 -12
  61. package/dist/peer-record.js.map +1 -1
  62. package/dist/pending-join-runner.d.ts +66 -58
  63. package/dist/pending-join-runner.js +136 -145
  64. package/dist/pending-join-runner.js.map +1 -1
  65. package/dist/push-fanout.d.ts +8 -2
  66. package/dist/push-fanout.js +8 -2
  67. package/dist/push-fanout.js.map +1 -1
  68. package/dist/seed-bootstrap.d.ts +147 -122
  69. package/dist/seed-bootstrap.js +284 -269
  70. package/dist/seed-bootstrap.js.map +1 -1
  71. package/dist/seed-trust-policy.d.ts +98 -37
  72. package/dist/seed-trust-policy.js +188 -2
  73. package/dist/seed-trust-policy.js.map +1 -1
  74. package/dist/strand-addr-protocol.d.ts +24 -26
  75. package/dist/strand-addr-protocol.js +19 -26
  76. package/dist/strand-addr-protocol.js.map +1 -1
  77. package/dist/strand-formation-protocol.js +1 -1
  78. package/dist/strand-formation-protocol.js.map +1 -1
  79. package/dist/strand-revocation-enforcer.d.ts +11 -11
  80. package/dist/strand-revocation-enforcer.js +11 -11
  81. package/dist/strand-wake-protocol.d.ts +13 -16
  82. package/dist/strand-wake-protocol.js +13 -20
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/trusted-owner-store.d.ts +35 -5
  85. package/dist/trusted-owner-store.js +50 -8
  86. package/dist/trusted-owner-store.js.map +1 -1
  87. package/dist/types.d.ts +302 -87
  88. package/dist/types.js.map +1 -1
  89. package/package.json +8 -7
  90. package/src/cadre-invite-protocol.ts +864 -0
  91. package/src/cadre-node.ts +9381 -8686
  92. package/src/claim-proof.ts +147 -0
  93. package/src/control-authorization.ts +203 -117
  94. package/src/control-cohort.ts +1 -1
  95. package/src/control-database.ts +1216 -218
  96. package/src/control-protocol-guard.ts +348 -0
  97. package/src/control-schema.ts +812 -1078
  98. package/src/control-stream.ts +26 -7
  99. package/src/delegate-admission.ts +1 -1
  100. package/src/device-token.ts +14 -10
  101. package/src/ed25519-key.ts +28 -1
  102. package/src/index.ts +84 -10
  103. package/src/link-budget.ts +16 -32
  104. package/src/membership-connection-gater.ts +637 -570
  105. package/src/node-claim-payload.ts +125 -0
  106. package/src/owner-anchor-sync.ts +171 -0
  107. package/src/peer-authorization.ts +258 -29
  108. package/src/peer-dial.ts +22 -1
  109. package/src/peer-join-backfill.ts +56 -11
  110. package/src/peer-record.ts +16 -11
  111. package/src/pending-join-runner.ts +729 -735
  112. package/src/push-fanout.ts +8 -2
  113. package/src/seed-bootstrap.ts +344 -321
  114. package/src/seed-trust-policy.ts +353 -140
  115. package/src/strand-addr-protocol.ts +26 -35
  116. package/src/strand-formation-protocol.ts +1 -1
  117. package/src/strand-revocation-enforcer.ts +11 -11
  118. package/src/strand-wake-protocol.ts +13 -23
  119. package/src/trusted-owner-store.ts +242 -171
  120. package/src/types.ts +2471 -2242
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Claim proof: how the first seed delivered to a brand-new node proves that its sender
3
+ * holds the node's one-time claim secret.
4
+ *
5
+ * A node that belongs to nobody yet (a cadre-host child before its owner scans the QR
6
+ * code) has an empty trusted-owner anchor, so every seed trust policy refuses its first
7
+ * seed. The claim secret is the out-of-band channel that breaks the deadlock: the host
8
+ * mints 32 random bytes, shows them beside the node's addresses, and the owner's phone
9
+ * proves possession by sending, next to the seed, an HMAC keyed by those bytes.
10
+ *
11
+ * The proof is `base64url(HMAC-SHA256(secret, utf8(canonicalJson(payload))))` where
12
+ * `payload` is `{ purpose: 'sereus-node-claim', v: 1, nodePeerId, signerKey, seedDigest }`:
13
+ *
14
+ * - `nodePeerId` binds the proof to one node, so a captured proof cannot claim another
15
+ * node minted with the same secret;
16
+ * - `signerKey` binds it to one owner, so it cannot anchor a different key;
17
+ * - `seedDigest` (the same digest the seed signature covers, `seedDigest()` in
18
+ * `seed-bootstrap.ts`) binds it to one seed, so it cannot be reattached to another.
19
+ *
20
+ * A passive observer of one delivery therefore learns a proof that is useless anywhere
21
+ * else, and useless on this node once it is claimed. A keyed MAC is the standard way to
22
+ * prove possession of a secret without revealing it; the crypto plugin's framed
23
+ * `digest([...])` helper is not specified as one, so the secret is never fed to it.
24
+ *
25
+ * Verified by `claimSecretTrustPolicy` (`seed-trust-policy.ts`). Imports only
26
+ * `@noble/hashes`, `uint8arrays` and `canonical-json.ts`: this module loads on React
27
+ * Native and in the browser as well as Node.
28
+ */
29
+ import debug from 'debug';
30
+ import { hmac } from '@noble/hashes/hmac.js';
31
+ import { sha256 } from '@noble/hashes/sha2.js';
32
+ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
33
+ import { canonicalJson } from './canonical-json.js';
34
+ import type { SeedRefusalCode } from './types.js';
35
+
36
+ const log = debug('sereus:cadre:claim-proof');
37
+
38
+ /** Length of a claim secret: 256 bits, so guessing one is infeasible. */
39
+ export const CLAIM_SECRET_BYTES = 32;
40
+
41
+ /**
42
+ * Thrown by `CadreNode.claimNode` when the node answered the claim seed with a refusal.
43
+ * Distinct from `PeerUnreachableError` (no connection to the node formed) and from a
44
+ * failure after the node was reached (thrown by `deliverSeed` as is): here the node was
45
+ * reached and said no. `code` is the node's machine-readable cause
46
+ * when its policy supplied one (`already-claimed`, `claim-proof-invalid`, ...), so a
47
+ * phone can tell "someone else owns it" from "wrong secret" without parsing `reason`.
48
+ */
49
+ export class ClaimRefusedError extends Error {
50
+ constructor(
51
+ readonly nodePeerId: string,
52
+ readonly reason: string,
53
+ readonly code?: SeedRefusalCode,
54
+ ) {
55
+ super(`Node ${nodePeerId} refused the claim${code ? ` (${code})` : ''}: ${reason}`);
56
+ this.name = 'ClaimRefusedError';
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Decode a claim secret from the base64url text the host shows and the phone scans.
62
+ *
63
+ * Refuses anything that is not base64url of exactly {@link CLAIM_SECRET_BYTES} bytes,
64
+ * naming the problem. The rejected value is never echoed: unlike a public key, a
65
+ * mistyped secret may still be a secret.
66
+ *
67
+ * @returns The 32 secret bytes.
68
+ */
69
+ export function parseClaimSecret(text: string): Uint8Array {
70
+ const trimmed = text.trim();
71
+ if (trimmed.length === 0) {
72
+ throw new Error('A claim secret is required (received an empty or whitespace-only value)');
73
+ }
74
+
75
+ let decoded: Uint8Array;
76
+ try {
77
+ decoded = uint8ArrayFromString(trimmed, 'base64url');
78
+ } catch (error) {
79
+ throw new Error('A claim secret must be base64url text (the value could not be decoded as base64url)', { cause: error });
80
+ }
81
+
82
+ if (decoded.length !== CLAIM_SECRET_BYTES) {
83
+ throw new Error(`A claim secret must decode to exactly ${CLAIM_SECRET_BYTES} bytes (the value decoded to ${decoded.length})`);
84
+ }
85
+ return decoded;
86
+ }
87
+
88
+ /** The HMAC tag bytes over the canonical claim payload (see the module doc). */
89
+ function claimProofBytes(secret: Uint8Array, nodePeerId: string, signerKey: string, seedDigest: string): Uint8Array {
90
+ const payload = canonicalJson({ purpose: 'sereus-node-claim', v: 1, nodePeerId, signerKey, seedDigest });
91
+ return hmac(sha256, secret, new TextEncoder().encode(payload));
92
+ }
93
+
94
+ /**
95
+ * Compute the claim proof the owner side sends beside its seed.
96
+ *
97
+ * @param secret - The parsed 32-byte claim secret ({@link parseClaimSecret}).
98
+ * @param nodePeerId - The peer id of the node being claimed.
99
+ * @param signerKey - The owner key that signed the seed (base64url).
100
+ * @param seedDigest - The seed's signature digest (`seedDigest()` in `seed-bootstrap.ts`).
101
+ * @returns The proof, base64url.
102
+ */
103
+ export function claimProof(secret: Uint8Array, nodePeerId: string, signerKey: string, seedDigest: string): string {
104
+ return uint8ArrayToString(claimProofBytes(secret, nodePeerId, signerKey, seedDigest), 'base64url');
105
+ }
106
+
107
+ /**
108
+ * Check a presented proof against the receiver's own view of the three bound fields.
109
+ *
110
+ * Recomputes the tag and compares in constant time. A proof that does not decode as
111
+ * base64url is simply invalid. `nodePeerId` must be the RECEIVER's own peer id, never
112
+ * one the sender supplied; that is what makes a captured proof useless elsewhere.
113
+ */
114
+ export function verifyClaimProof(
115
+ secret: Uint8Array,
116
+ nodePeerId: string,
117
+ signerKey: string,
118
+ seedDigest: string,
119
+ proof: string,
120
+ ): boolean {
121
+ let presented: Uint8Array;
122
+ try {
123
+ presented = uint8ArrayFromString(proof, 'base64url');
124
+ } catch (error) {
125
+ log('claim proof is not base64url: %o', error);
126
+ return false;
127
+ }
128
+ return constantTimeEqual(presented, claimProofBytes(secret, nodePeerId, signerKey, seedDigest));
129
+ }
130
+
131
+ /**
132
+ * Byte equality whose running time does not depend on WHERE the arrays differ, so a
133
+ * remote cannot learn a valid proof prefix from response timing. The length check
134
+ * short-circuits, which leaks nothing: a valid proof's length is public. A plain loop
135
+ * rather than `node:crypto`'s `timingSafeEqual` because this module must load on React
136
+ * Native and in the browser.
137
+ */
138
+ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
139
+ if (a.length !== b.length) {
140
+ return false;
141
+ }
142
+ let diff = 0;
143
+ for (let i = 0; i < a.length; i++) {
144
+ diff |= a[i]! ^ b[i]!;
145
+ }
146
+ return diff === 0;
147
+ }
@@ -1,117 +1,203 @@
1
- /**
2
- * The single definition of the field vector every signed control-plane message
3
- * covers. Both producers of signed bytes — `control-database.ts`'s
4
- * `buildAuthorizationMessage` (raw digest bytes) and `peer-authorization.ts`'s
5
- * digest helpers (base64url digest strings) — build their vector here, so the
6
- * byte layout cannot drift between them or away from the SQL constraints in
7
- * `schemas/control.qsql`.
8
- *
9
- * Every vector leads with two fixed literals:
10
- *
11
- * digest(<domain>, <action>, <row field 1>, ..., <row field n>)
12
- *
13
- * so a signature verifies ONLY against the one rule it was minted for. Without
14
- * the tags, several rules built byte-identical tuples (e.g. `ValidationKey`
15
- * insert and `OwnerKey` insert both signed `digest(Key, StampId)`), so an
16
- * approval for a narrow grant doubled as an approval for full ownership.
17
- *
18
- * This module deliberately has no Quereus / Optimystic / libp2p imports so the
19
- * lightweight verifiers (`peer-authorization.ts`, consumed by the offline
20
- * `cadre enroll register` check) can use it without pulling in the runtime.
21
- */
22
-
23
- /**
24
- * Names of the CadreControl tables, in schema order. The single list: the
25
- * {@link ControlTable} union is derived from it and `ControlDatabase.countRows`
26
- * guards its dynamic `from` clause against it (keeping the table name off the
27
- * SQL-injection surface), so a new table cannot be added to one and missed in
28
- * the other.
29
- *
30
- * `Revocation` is here for the same two reasons as every other entry: it
31
- * derives a `'CadreControl.Revocation'` domain tag — its `Authorized` CHECK
32
- * verifies an owner signature over the `'remove'`-tagged digest that
33
- * `peer-authorization.ts`'s `revocationDigest` mints, and its `AuthorizedReissue`
34
- * CHECK the `'reissue'`-tagged digest `ControlDatabase.reissueRevocations`
35
- * signs — and `countRows` counts it.
36
- */
37
- export const CONTROL_TABLES = [
38
- 'OwnerKey',
39
- 'ValidationKey',
40
- 'Strand',
41
- 'StrandPartyKey',
42
- 'JoinedStrand',
43
- 'PendingJoin',
44
- 'CadrePeer',
45
- 'DeviceToken',
46
- 'FormationInvite',
47
- 'FormationUsage',
48
- 'Revocation',
49
- ] as const;
50
-
51
- /**
52
- * Name of a CadreControl table. Doubles as the table half of a
53
- * {@link ControlDomain} tag.
54
- */
55
- export type ControlTable = typeof CONTROL_TABLES[number];
56
-
57
- /**
58
- * The control tables whose rows carry a single-use `StampId` retired into
59
- * `CadreControl.Revocation` on delete — i.e. the ones the schema's
60
- * `NotRevoked` / `RevocationRecorded` CHECK pair guards, and the only values
61
- * `Revocation.RowIsGone` accepts in `TableName`. `Extract` from
62
- * {@link ControlTable} rather than a fresh literal list, so a renamed table is a
63
- * compile error here instead of a silently dead branch.
64
- *
65
- * Lives in this import-free module rather than beside its main consumer
66
- * (`control-database.ts`) so the lightweight signers — `peer-authorization.ts`'s
67
- * `revocationDigest` — can type against it without pulling in the runtime.
68
- */
69
- export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'JoinedStrand' | 'PendingJoin' | 'DeviceToken'>;
70
-
71
- /**
72
- * What a signature authorizes, scoped to one table rule — or, for
73
- * `'Cadre.Enrollment'`, the offline peer vouch that `cadre enroll register`
74
- * verifies (no table checks it, so it needs its own domain to stay disjoint
75
- * from every table rule).
76
- */
77
- export type ControlDomain = `CadreControl.${ControlTable}` | 'Cadre.Enrollment';
78
-
79
- /**
80
- * The action half of the tag:
81
- * - `'add'` / `'remove'` — insert / delete of the named row.
82
- * - `'vouch'` — an owner (or validation key) vouches the row's semantics
83
- * without adding or removing it: the `CadrePeer` membership vouch (shared,
84
- * deliberately, by its insert and its owner-update branch), the
85
- * `FormationUsage` disclosure validation, and the offline enrollment vouch.
86
- * `DeviceToken` has no `'vouch'` digest — its owner re-touch branch was
87
- * removed (it rewrote the row outside the monotonicity guard), so an owner
88
- * correcting a token deletes and re-inserts it.
89
- * - `'publish'` — a peer self-signs its OWN record (the `CadrePeer` /
90
- * `DeviceToken` self-update branches), with its own key rather than an
91
- * owner key.
92
- * - `'consent'` — a peer self-signs its OWN `FormationUsage` redemption (the
93
- * joiner proving it agreed to join), with its own key. Distinct from the
94
- * approver's `'vouch'` over the same table so the two stored signatures are
95
- * never interchangeable.
96
- * - `'reissue'` — an owner re-writes an existing `Revocation` tombstone,
97
- * bumping its `ReissuedAt` counter so a tombstone committed while the node
98
- * was alone can be re-broadcast. Distinct from `'remove'` so a tombstone
99
- * append approval can never be replayed as a re-issue and vice versa.
100
- */
101
- export type ControlAction = 'add' | 'remove' | 'vouch' | 'publish' | 'consent' | 'reissue';
102
-
103
- /**
104
- * The full ordered field vector a control-plane signature covers. Digest this
105
- * with the crypto plugin's injective multi-field encoding (every field TEXT);
106
- * the SQL mirror passes the same literals as leading `digest(...)` arguments:
107
- *
108
- * TS: digest(controlAuthorizationFields('CadreControl.X', 'add', [a, b]), 'sha256', ...)
109
- * SQL: digest('CadreControl.X', 'add', new.A, new.B)
110
- */
111
- export function controlAuthorizationFields(
112
- domain: ControlDomain,
113
- action: ControlAction,
114
- rowFields: string[],
115
- ): string[] {
116
- return [domain, action, ...rowFields];
117
- }
1
+ /**
2
+ * The single definition of the field vector every signed control-plane message
3
+ * covers. Both producers of signed bytes — `control-database.ts`'s
4
+ * `buildAuthorizationMessage` (raw digest bytes) and `peer-authorization.ts`'s
5
+ * digest helpers (base64url digest strings) — build their vector here, so the
6
+ * byte layout cannot drift between them or away from the SQL constraints in
7
+ * `schemas/control.qsql`.
8
+ *
9
+ * Every vector leads with two fixed literals and the party id:
10
+ *
11
+ * digest(<domain>, <action>, <party id>, <row field 1>, ..., <row field n>)
12
+ *
13
+ * so a signature verifies ONLY against the one rule, in the one party, it was
14
+ * minted for. Without the tags, several rules built byte-identical tuples (e.g.
15
+ * `ValidationKey` insert and `OwnerKey` insert both signed `digest(Key, StampId)`),
16
+ * so an approval for a narrow grant doubled as an approval for full ownership.
17
+ * Without the party, two parties sharing an owner key (one node key owning two
18
+ * cadres, a hardware owner key) would accept each other's approvals. The SQL side
19
+ * reads the party from the node-local `party_id()` function `ControlDatabase`
20
+ * registers (never from a context value or a replicated table), and every
21
+ * TypeScript signer and verifier passes the party it is configured for.
22
+ *
23
+ * Two digests carry NO party ({@link UnboundControlDomain}): the `FormationUsage`
24
+ * `'consent'` and `'vouch'` digests, whose signers do not know the party (see the
25
+ * schema's `FormationUsage.Authorized` comment), and the offline `'Cadre.Enrollment'`
26
+ * vouch, which no table checks.
27
+ *
28
+ * This module deliberately has no Quereus / Optimystic / libp2p imports so the
29
+ * lightweight verifiers (`peer-authorization.ts`, consumed by the offline
30
+ * `cadre enroll register` check) can use it without pulling in the runtime.
31
+ */
32
+
33
+ /**
34
+ * Names of the CadreControl tables, in schema order. The single list: the
35
+ * {@link ControlTable} union is derived from it and `ControlDatabase.countRows`
36
+ * guards its dynamic `from` clause against it (keeping the table name off the
37
+ * SQL-injection surface), so a new table cannot be added to one and missed in
38
+ * the other.
39
+ *
40
+ * `Revocation` is here for the same two reasons as every other entry: it
41
+ * derives a `'CadreControl.Revocation'` domain tag — its `Authorized` CHECK
42
+ * verifies an owner signature over the `'remove'`-tagged digest that
43
+ * `peer-authorization.ts`'s `revocationDigest` mints, and its `AuthorizedReissue`
44
+ * CHECK the `'reissue'`-tagged digest `ControlDatabase.reissueRevocations`
45
+ * signs — and `countRows` counts it.
46
+ */
47
+ export const CONTROL_TABLES = [
48
+ 'OwnerKey',
49
+ 'ValidationKey',
50
+ 'Strand',
51
+ 'StrandPartyKey',
52
+ 'JoinedStrand',
53
+ 'JoinRequest',
54
+ 'JoinSuccess',
55
+ 'JoinFailure',
56
+ 'CadrePeer',
57
+ 'DeviceToken',
58
+ 'FormationInvite',
59
+ 'FormationUsage',
60
+ 'CadreInvite',
61
+ 'CadreInviteUsage',
62
+ 'Revocation',
63
+ ] as const;
64
+
65
+ /**
66
+ * Name of a CadreControl table. Doubles as the table half of a
67
+ * {@link ControlDomain} tag.
68
+ */
69
+ export type ControlTable = typeof CONTROL_TABLES[number];
70
+
71
+ /**
72
+ * The control tables whose rows carry a single-use `StampId` retired into
73
+ * `CadreControl.Revocation` on delete — i.e. the ones the schema's
74
+ * `NotRevoked` / `RevocationRecorded` CHECK pair guards, and the only values
75
+ * `Revocation.RowIsGone` accepts in `TableName`. `Extract` from
76
+ * {@link ControlTable} rather than a fresh literal list, so a renamed table is a
77
+ * compile error here instead of a silently dead branch.
78
+ *
79
+ * `CadreInvite` is the one member whose rows are never deleted: its tombstone is a
80
+ * withdrawal over a row that stays (`NotRevoked` refuses re-seating the withdrawn
81
+ * row; there is no `RevocationRecorded`, since nothing is ever removed), so it is
82
+ * revocable without being removable — `control-database.ts`'s `RemovableTable`
83
+ * excludes it.
84
+ *
85
+ * Lives in this import-free module rather than beside its main consumer
86
+ * (`control-database.ts`) so the lightweight signers — `peer-authorization.ts`'s
87
+ * `revocationDigest` — can type against it without pulling in the runtime.
88
+ */
89
+ export type RevocableTable = Extract<ControlTable, 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'JoinedStrand' | 'JoinRequest' | 'DeviceToken' | 'CadreInvite'>;
90
+
91
+ /**
92
+ * What a signature authorizes, scoped to one table rule — or, for
93
+ * `'Cadre.Enrollment'`, the offline peer vouch that `cadre enroll register`
94
+ * verifies (no table checks it, so it needs its own domain to stay disjoint
95
+ * from every table rule).
96
+ */
97
+ export type ControlDomain = `CadreControl.${ControlTable}` | 'Cadre.Enrollment';
98
+
99
+ /**
100
+ * The domains whose digests bind no party id — the only two
101
+ * {@link unboundAuthorizationFields} accepts. `FormationUsage`: neither signer knows
102
+ * the party, and the invitation token scopes the digest instead (the reason is at the
103
+ * schema's `FormationUsage.Authorized` comment). `Cadre.Enrollment`: no table checks it.
104
+ */
105
+ export type UnboundControlDomain = 'CadreControl.FormationUsage' | 'Cadre.Enrollment';
106
+
107
+ /** Every domain whose digests bind the party id — the only ones {@link controlAuthorizationFields} accepts. */
108
+ export type BoundControlDomain = Exclude<ControlDomain, UnboundControlDomain>;
109
+
110
+ /**
111
+ * The action half of the tag:
112
+ * - `'add'` / `'remove'` — insert / delete of the named row.
113
+ * - `'vouch'` — an owner (or validation key) vouches the row's semantics
114
+ * without adding or removing it: the `CadrePeer` membership vouch (shared,
115
+ * deliberately, by its insert and its owner-update branch), the
116
+ * `FormationUsage` disclosure validation, and the offline enrollment vouch.
117
+ * `DeviceToken` has no `'vouch'` digest — its owner re-touch branch was
118
+ * removed (it rewrote the row outside the monotonicity guard), so an owner
119
+ * correcting a token deletes and re-inserts it.
120
+ * - `'publish'` — a peer self-signs its OWN record (the `CadrePeer` /
121
+ * `DeviceToken` self-update branches), with its own key rather than an
122
+ * owner key.
123
+ * - `'consent'` — a peer self-signs its OWN `FormationUsage` or
124
+ * `CadreInviteUsage` redemption (the joiner proving it agreed to join), with
125
+ * its own key. Distinct from the approver's `'vouch'` over the same table so
126
+ * the two stored signatures are never interchangeable.
127
+ * - `'redeem'` — the holder of a `CadreInvite`'s private key proves possession
128
+ * on a `CadreInviteUsage` row. Distinct from the device's `'consent'` over the
129
+ * same fields: the two signatures are by different keys and neither may stand
130
+ * in for the other.
131
+ * - `'reissue'` — an owner re-writes an existing `Revocation` tombstone,
132
+ * bumping its `ReissuedAt` counter so a tombstone committed while the node
133
+ * was alone can be re-broadcast. Distinct from `'remove'` so a tombstone
134
+ * append approval can never be replayed as a re-issue and vice versa.
135
+ */
136
+ export type ControlAction = 'add' | 'remove' | 'vouch' | 'publish' | 'consent' | 'redeem' | 'reissue';
137
+
138
+ /**
139
+ * The `CadreInvite` columns its `'add'` signature binds, in the schema's order and
140
+ * with the row's stored forms: `expiresAt` is the engine-canonical `datetime` string
141
+ * (`canonicalDatetime`), never epoch ms, because the SQL side digests
142
+ * `cast(new.ExpiresAt as text)` after coercion. Structurally satisfied by a
143
+ * `CadreInviteRow` (`types.ts`), which adds the issuer pair the signature does not cover.
144
+ */
145
+ export interface CadreInviteSignedFields {
146
+ key: string;
147
+ peerId: string | null;
148
+ grantsOwner: boolean;
149
+ expiresAt: string | null;
150
+ totalUses: number | null;
151
+ stampId: string;
152
+ }
153
+
154
+ /**
155
+ * The row-field half of a `CadreInvite` `'add'` digest, shared by the signer
156
+ * (`control-database.ts`'s `cadreInviteAddMessage`, raw bytes) and the verifier
157
+ * (`peer-authorization.ts`'s `cadreInviteAddDigest`, base64url) so the two cannot
158
+ * drift. SQL mirror, in `CadreInvite.AuthorizedInsert`:
159
+ * `new.Key, coalesce(new.PeerId, ''), cast(new.GrantsOwner as text),
160
+ * coalesce(cast(new.ExpiresAt as text), ''), coalesce(cast(new.TotalUses as text), ''), new.StampId`.
161
+ */
162
+ export function cadreInviteRowFields(row: CadreInviteSignedFields): string[] {
163
+ return [
164
+ row.key,
165
+ row.peerId ?? '',
166
+ row.grantsOwner ? '1' : '0',
167
+ row.expiresAt ?? '',
168
+ row.totalUses === null ? '' : String(row.totalUses),
169
+ row.stampId,
170
+ ];
171
+ }
172
+
173
+ /**
174
+ * The full ordered field vector a party-bound control-plane signature covers. Digest
175
+ * this with the crypto plugin's injective multi-field encoding (every field TEXT);
176
+ * the SQL mirror passes the same literals as leading `digest(...)` arguments and
177
+ * reads the party from its node-local `party_id()`:
178
+ *
179
+ * TS: digest(controlAuthorizationFields('CadreControl.X', 'add', partyId, [a, b]), 'sha256', ...)
180
+ * SQL: digest('CadreControl.X', 'add', party_id(), new.A, new.B)
181
+ */
182
+ export function controlAuthorizationFields(
183
+ domain: BoundControlDomain,
184
+ action: ControlAction,
185
+ partyId: string,
186
+ rowFields: string[],
187
+ ): string[] {
188
+ return [domain, action, partyId, ...rowFields];
189
+ }
190
+
191
+ /**
192
+ * The field vector of the two party-free digests ({@link UnboundControlDomain}):
193
+ *
194
+ * TS: digest(unboundAuthorizationFields('CadreControl.FormationUsage', 'consent', [a, b]), 'sha256', ...)
195
+ * SQL: digest('CadreControl.FormationUsage', 'consent', new.A, new.B)
196
+ */
197
+ export function unboundAuthorizationFields(
198
+ domain: UnboundControlDomain,
199
+ action: ControlAction,
200
+ rowFields: string[],
201
+ ): string[] {
202
+ return [domain, action, ...rowFields];
203
+ }
@@ -21,7 +21,7 @@
21
21
  * party it bounds out-degree to `backbone + targetDegree`.
22
22
  */
23
23
 
24
- import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
24
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
25
25
  import type { CohortPeerRow } from './strand-cohort.js';
26
26
 
27
27
  /**