@serfab/cadre-core 1.13.0 → 1.15.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 (230) hide show
  1. package/README.md +293 -274
  2. package/dist/cadre-invite-protocol.d.ts +383 -0
  3. package/dist/cadre-invite-protocol.js +774 -0
  4. package/dist/cadre-invite-protocol.js.map +1 -0
  5. package/dist/cadre-join-request.d.ts +42 -0
  6. package/dist/cadre-join-request.js +144 -0
  7. package/dist/cadre-join-request.js.map +1 -0
  8. package/dist/cadre-node.d.ts +849 -210
  9. package/dist/cadre-node.js +1574 -374
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/claim-proof.d.ts +45 -0
  12. package/dist/claim-proof.js +132 -0
  13. package/dist/claim-proof.js.map +1 -0
  14. package/dist/control-authorization.d.ts +80 -19
  15. package/dist/control-authorization.js +55 -13
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.js +1 -1
  18. package/dist/control-cohort.js.map +1 -1
  19. package/dist/control-database.d.ts +605 -108
  20. package/dist/control-database.js +1299 -280
  21. package/dist/control-database.js.map +1 -1
  22. package/dist/control-protocol-guard.d.ts +142 -0
  23. package/dist/control-protocol-guard.js +293 -0
  24. package/dist/control-protocol-guard.js.map +1 -0
  25. package/dist/control-schema.d.ts +1 -1
  26. package/dist/control-schema.js +817 -1067
  27. package/dist/control-schema.js.map +1 -1
  28. package/dist/control-stream.d.ts +4 -0
  29. package/dist/control-stream.js +22 -7
  30. package/dist/control-stream.js.map +1 -1
  31. package/dist/delegate-admission.d.ts +1 -1
  32. package/dist/delegate-admission.js +1 -1
  33. package/dist/device-token.d.ts +11 -9
  34. package/dist/device-token.js +14 -12
  35. package/dist/device-token.js.map +1 -1
  36. package/dist/ed25519-key.d.ts +22 -9
  37. package/dist/ed25519-key.js +47 -10
  38. package/dist/ed25519-key.js.map +1 -1
  39. package/dist/fetch-impl.d.ts +12 -0
  40. package/dist/fetch-impl.js +23 -0
  41. package/dist/fetch-impl.js.map +1 -0
  42. package/dist/file-durable-slot.js +2 -1
  43. package/dist/file-durable-slot.js.map +1 -1
  44. package/dist/file-safe-name.d.ts +25 -0
  45. package/dist/file-safe-name.js +29 -0
  46. package/dist/file-safe-name.js.map +1 -0
  47. package/dist/formation-approval.js +5 -11
  48. package/dist/formation-approval.js.map +1 -1
  49. package/dist/fs-atomic.d.ts +0 -19
  50. package/dist/fs-atomic.js +3 -24
  51. package/dist/fs-atomic.js.map +1 -1
  52. package/dist/index.d.ts +22 -10
  53. package/dist/index.js +54 -11
  54. package/dist/index.js.map +1 -1
  55. package/dist/invitation-bootstrap.d.ts +50 -12
  56. package/dist/invitation-bootstrap.js +76 -13
  57. package/dist/invitation-bootstrap.js.map +1 -1
  58. package/dist/joined-strand-store.d.ts +7 -0
  59. package/dist/joined-strand-store.js +9 -0
  60. package/dist/joined-strand-store.js.map +1 -1
  61. package/dist/key-store-file.js +2 -1
  62. package/dist/key-store-file.js.map +1 -1
  63. package/dist/link-budget.d.ts +25 -33
  64. package/dist/link-budget.js +25 -35
  65. package/dist/link-budget.js.map +1 -1
  66. package/dist/machine-removal.d.ts +78 -0
  67. package/dist/machine-removal.js +166 -0
  68. package/dist/machine-removal.js.map +1 -0
  69. package/dist/membership-connection-gater.d.ts +191 -151
  70. package/dist/membership-connection-gater.js +236 -173
  71. package/dist/membership-connection-gater.js.map +1 -1
  72. package/dist/node-claim-payload.d.ts +74 -0
  73. package/dist/node-claim-payload.js +224 -0
  74. package/dist/node-claim-payload.js.map +1 -0
  75. package/dist/node-default-transports.d.ts +27 -0
  76. package/dist/node-default-transports.js +35 -0
  77. package/dist/node-default-transports.js.map +1 -0
  78. package/dist/node-transports.d.ts +30 -0
  79. package/dist/node-transports.js +40 -0
  80. package/dist/node-transports.js.map +1 -0
  81. package/dist/owner-anchor-sync.d.ts +22 -0
  82. package/dist/owner-anchor-sync.js +143 -0
  83. package/dist/owner-anchor-sync.js.map +1 -0
  84. package/dist/peer-authorization.d.ts +113 -18
  85. package/dist/peer-authorization.js +225 -29
  86. package/dist/peer-authorization.js.map +1 -1
  87. package/dist/peer-dial.d.ts +143 -27
  88. package/dist/peer-dial.js +276 -31
  89. package/dist/peer-dial.js.map +1 -1
  90. package/dist/peer-join-backfill.d.ts +26 -2
  91. package/dist/peer-join-backfill.js +60 -12
  92. package/dist/peer-join-backfill.js.map +1 -1
  93. package/dist/peer-record.d.ts +17 -52
  94. package/dist/peer-record.js +20 -58
  95. package/dist/peer-record.js.map +1 -1
  96. package/dist/pending-join-runner.d.ts +66 -58
  97. package/dist/pending-join-runner.js +136 -145
  98. package/dist/pending-join-runner.js.map +1 -1
  99. package/dist/primary-lan-address.d.ts +8 -0
  100. package/dist/primary-lan-address.js +46 -0
  101. package/dist/primary-lan-address.js.map +1 -0
  102. package/dist/provider/client.d.ts +131 -0
  103. package/dist/provider/client.js +226 -0
  104. package/dist/provider/client.js.map +1 -0
  105. package/dist/provider/descriptor.d.ts +41 -0
  106. package/dist/provider/descriptor.js +103 -0
  107. package/dist/provider/descriptor.js.map +1 -0
  108. package/dist/provider/index.d.ts +9 -0
  109. package/dist/provider/index.js +10 -0
  110. package/dist/provider/index.js.map +1 -0
  111. package/dist/provider/oauth.d.ts +125 -0
  112. package/dist/provider/oauth.js +199 -0
  113. package/dist/provider/oauth.js.map +1 -0
  114. package/dist/provider/rent-node.d.ts +101 -0
  115. package/dist/provider/rent-node.js +220 -0
  116. package/dist/provider/rent-node.js.map +1 -0
  117. package/dist/push-fanout.d.ts +8 -2
  118. package/dist/push-fanout.js +8 -2
  119. package/dist/push-fanout.js.map +1 -1
  120. package/dist/relay-addrs.d.ts +9 -4
  121. package/dist/relay-addrs.js +9 -4
  122. package/dist/relay-addrs.js.map +1 -1
  123. package/dist/relay-reservation.d.ts +51 -16
  124. package/dist/relay-reservation.js +107 -24
  125. package/dist/relay-reservation.js.map +1 -1
  126. package/dist/seed-bootstrap.d.ts +165 -123
  127. package/dist/seed-bootstrap.js +313 -275
  128. package/dist/seed-bootstrap.js.map +1 -1
  129. package/dist/seed-trust-policy.d.ts +98 -37
  130. package/dist/seed-trust-policy.js +188 -2
  131. package/dist/seed-trust-policy.js.map +1 -1
  132. package/dist/strand-addr-protocol.d.ts +24 -26
  133. package/dist/strand-addr-protocol.js +19 -26
  134. package/dist/strand-addr-protocol.js.map +1 -1
  135. package/dist/strand-commit-durability.d.ts +54 -0
  136. package/dist/strand-commit-durability.js +74 -0
  137. package/dist/strand-commit-durability.js.map +1 -0
  138. package/dist/strand-database.d.ts +23 -8
  139. package/dist/strand-database.js +41 -13
  140. package/dist/strand-database.js.map +1 -1
  141. package/dist/strand-first-sync-gate.d.ts +7 -1
  142. package/dist/strand-first-sync-gate.js +7 -1
  143. package/dist/strand-first-sync-gate.js.map +1 -1
  144. package/dist/strand-formation-protocol.js +5 -2
  145. package/dist/strand-formation-protocol.js.map +1 -1
  146. package/dist/strand-fret-addrs.d.ts +19 -1
  147. package/dist/strand-fret-addrs.js +27 -9
  148. package/dist/strand-fret-addrs.js.map +1 -1
  149. package/dist/strand-instance-manager.d.ts +81 -19
  150. package/dist/strand-instance-manager.js +216 -105
  151. package/dist/strand-instance-manager.js.map +1 -1
  152. package/dist/strand-move.d.ts +124 -0
  153. package/dist/strand-move.js +438 -0
  154. package/dist/strand-move.js.map +1 -0
  155. package/dist/strand-network-config.js +7 -6
  156. package/dist/strand-network-config.js.map +1 -1
  157. package/dist/strand-peer-redial.d.ts +113 -0
  158. package/dist/strand-peer-redial.js +236 -0
  159. package/dist/strand-peer-redial.js.map +1 -0
  160. package/dist/strand-revocation-enforcer.d.ts +11 -11
  161. package/dist/strand-revocation-enforcer.js +11 -11
  162. package/dist/strand-wake-protocol.d.ts +13 -16
  163. package/dist/strand-wake-protocol.js +23 -27
  164. package/dist/strand-wake-protocol.js.map +1 -1
  165. package/dist/trusted-owner-store.d.ts +35 -5
  166. package/dist/trusted-owner-store.js +50 -8
  167. package/dist/trusted-owner-store.js.map +1 -1
  168. package/dist/types.d.ts +512 -89
  169. package/dist/types.js +10 -0
  170. package/dist/types.js.map +1 -1
  171. package/package.json +19 -10
  172. package/src/cadre-invite-protocol.ts +1052 -0
  173. package/src/cadre-join-request.ts +167 -0
  174. package/src/cadre-node.ts +1703 -386
  175. package/src/claim-proof.ts +147 -0
  176. package/src/control-authorization.ts +203 -117
  177. package/src/control-cohort.ts +1 -1
  178. package/src/control-database.ts +4663 -3453
  179. package/src/control-protocol-guard.ts +348 -0
  180. package/src/control-schema.ts +828 -1078
  181. package/src/control-stream.ts +26 -7
  182. package/src/delegate-admission.ts +1 -1
  183. package/src/device-token.ts +14 -10
  184. package/src/ed25519-key.ts +47 -10
  185. package/src/fetch-impl.ts +23 -0
  186. package/src/file-durable-slot.ts +64 -63
  187. package/src/file-safe-name.ts +32 -0
  188. package/src/formation-approval.ts +604 -610
  189. package/src/fs-atomic.ts +92 -117
  190. package/src/index.ts +789 -648
  191. package/src/invitation-bootstrap.ts +91 -13
  192. package/src/joined-strand-store.ts +508 -498
  193. package/src/key-store-file.ts +142 -141
  194. package/src/link-budget.ts +25 -36
  195. package/src/machine-removal.ts +216 -0
  196. package/src/membership-connection-gater.ts +259 -191
  197. package/src/node-claim-payload.ts +259 -0
  198. package/src/node-default-transports.ts +38 -0
  199. package/src/node-transports.ts +49 -0
  200. package/src/owner-anchor-sync.ts +171 -0
  201. package/src/peer-authorization.ts +258 -29
  202. package/src/peer-dial.ts +604 -315
  203. package/src/peer-join-backfill.ts +62 -11
  204. package/src/peer-record.ts +159 -197
  205. package/src/pending-join-runner.ts +729 -735
  206. package/src/primary-lan-address.ts +47 -0
  207. package/src/provider/client.ts +326 -0
  208. package/src/provider/descriptor.ts +129 -0
  209. package/src/provider/index.ts +59 -0
  210. package/src/provider/oauth.ts +285 -0
  211. package/src/provider/rent-node.ts +275 -0
  212. package/src/push-fanout.ts +8 -2
  213. package/src/relay-addrs.ts +9 -4
  214. package/src/relay-reservation.ts +1211 -1121
  215. package/src/seed-bootstrap.ts +1502 -1448
  216. package/src/seed-trust-policy.ts +353 -140
  217. package/src/strand-addr-protocol.ts +26 -35
  218. package/src/strand-commit-durability.ts +116 -0
  219. package/src/strand-database.ts +46 -14
  220. package/src/strand-first-sync-gate.ts +7 -1
  221. package/src/strand-formation-protocol.ts +1039 -1036
  222. package/src/strand-fret-addrs.ts +179 -145
  223. package/src/strand-instance-manager.ts +2026 -1883
  224. package/src/strand-move.ts +585 -0
  225. package/src/strand-network-config.ts +7 -6
  226. package/src/strand-peer-redial.ts +307 -0
  227. package/src/strand-revocation-enforcer.ts +11 -11
  228. package/src/strand-wake-protocol.ts +23 -30
  229. package/src/trusted-owner-store.ts +242 -171
  230. package/src/types.ts +2687 -2242
@@ -1,140 +1,353 @@
1
- /**
2
- * Trust-anchor policy for control-network seeds.
3
- *
4
- * A seed carries an ed25519 signature over its body and the `signerKey` that
5
- * produced it. Verifying that signature only proves the seed is internally
6
- * consistent — it does NOT prove the signer is an owner, because both the
7
- * signer key and the seed's own `isOwner` peer flags are attacker-supplied.
8
- * The trust decision must therefore rest on an anchor that does not come from
9
- * the seed body:
10
- *
11
- * 1. Anchored — keys in the receiver's NODE-LOCAL `TrustedOwnerStore`: the
12
- * non-replicated, per-party record of owner keys established out of band
13
- * (founding the party, the `CadreInvite` that enrolled this node, an
14
- * operator pin). Deliberately NOT the replicated `CadreControl.OwnerKey`
15
- * table — any connecting node can genesis-insert its own key there and let
16
- * it replicate, so that table can be made to say "yes" by a stranger.
17
- * 2. Pinned out-of-band — keys handed to the node for this seed only (carried
18
- * by a `CadreInvite.ownerKeys`, or pinned by an operator) without having
19
- * been anchored yet.
20
- * 3. TOFU (opt-in) — an explicit confirmation callback invoked on first sight
21
- * of an unknown signer key. Interactive hosts only; off by default.
22
- *
23
- * Secure default (`anchoredTrustPolicy`): a node with an empty anchor and no
24
- * pinned keys rejects the seed — a seed can no longer vouch for its own signer,
25
- * and neither can a polluted replicated table.
26
- *
27
- * A key accepted via anchor 2 or 3 is reported back through
28
- * {@link SeedTrustDecision.anchorAs} so `applySeed` can persist it into the
29
- * node-local anchor, and later seeds from the same owner are accepted without
30
- * re-supplying the invite/confirmation.
31
- */
32
- import type { TrustSource } from './trusted-owner-store.js';
33
-
34
- export interface SeedTrustContext {
35
- /** Party the seed claims to belong to. */
36
- partyId: string;
37
- /** ed25519 base64url signer key — already signature-verified by `applySeed`. */
38
- signerKey: string;
39
- /**
40
- * The receiver's anchored owner keys, sourced from its node-local
41
- * `TrustedOwnerStore` — NOT from the seed, and NOT from the replicated
42
- * `OwnerKey` table. Empty for a node whose anchor was never seeded.
43
- */
44
- knownOwnerKeys: ReadonlySet<string>;
45
- }
46
-
47
- export interface SeedTrustDecision {
48
- /** Whether the signer key is trusted. */
49
- trusted: boolean;
50
- /** Human-readable reason, surfaced as the seed-apply error when not trusted. */
51
- reason?: string;
52
- /**
53
- * Set when the key was trusted via an anchor OUTSIDE the node-local store (a
54
- * pinned key, a TOFU confirmation): the provenance to record it under, so
55
- * `applySeed` persists it and the next seed from that owner is anchored
56
- * without re-supplying the pin. Omitted when the key was already anchored
57
- * (nothing to persist) or not trusted at all.
58
- */
59
- anchorAs?: Exclude<TrustSource, 'genesis'>;
60
- }
61
-
62
- /**
63
- * Decides whether a signature-verified seed's signer key should be trusted.
64
- * May be synchronous (anchored/pinned) or asynchronous (TOFU confirmation).
65
- */
66
- export interface SeedTrustPolicy {
67
- evaluate(ctx: SeedTrustContext): Promise<SeedTrustDecision> | SeedTrustDecision;
68
- }
69
-
70
- /**
71
- * Default policy: trust only keys already in the receiver's node-local
72
- * trusted-owner anchor. A node whose anchor was never seeded (no genesis, no
73
- * invite pin, no operator pin) rejects every seed.
74
- */
75
- export function anchoredTrustPolicy(): SeedTrustPolicy {
76
- return {
77
- evaluate({ signerKey, knownOwnerKeys }) {
78
- if (knownOwnerKeys.has(signerKey)) {
79
- return { trusted: true };
80
- }
81
- return {
82
- trusted: false,
83
- reason: 'Signer key is not an anchored owner (anchored trust policy)',
84
- };
85
- },
86
- };
87
- }
88
-
89
- /**
90
- * Cold-start policy: trust anchored keys plus a set pinned out-of-band
91
- * (typically `CadreInvite.ownerKeys` or operator config). Lets an unenrolled
92
- * invitee accept its first seed without the seed vouching for itself.
93
- *
94
- * @param anchorAs - provenance under which a pin-only acceptance is persisted
95
- * into the node-local anchor ('invite' by default — the invite-redemption
96
- * case; pass 'operator' for an operator-supplied pin).
97
- */
98
- export function pinnedKeyTrustPolicy(
99
- pinned: Iterable<string>,
100
- anchorAs: Exclude<TrustSource, 'genesis'> = 'invite'
101
- ): SeedTrustPolicy {
102
- const pinnedSet = new Set(pinned);
103
- return {
104
- evaluate({ signerKey, knownOwnerKeys }) {
105
- if (knownOwnerKeys.has(signerKey)) {
106
- return { trusted: true };
107
- }
108
- if (pinnedSet.has(signerKey)) {
109
- return { trusted: true, anchorAs };
110
- }
111
- return {
112
- trusted: false,
113
- reason: 'Signer key is neither an anchored nor a pinned owner (pinned-key trust policy)',
114
- };
115
- },
116
- };
117
- }
118
-
119
- /**
120
- * Opt-in interactive policy: trust keys already in the node-local anchor, and
121
- * on an unknown key invoke `confirm` (e.g. a trust-circle UI prompt). The key is
122
- * trusted iff `confirm` resolves true, and a confirmed key is persisted into the
123
- * anchor as an 'operator' pin (a human at the console is the same provenance as
124
- * an explicit operator pin) so the prompt is not repeated. Not enabled by default.
125
- */
126
- export function tofuTrustPolicy(
127
- confirm: (ctx: SeedTrustContext) => Promise<boolean>
128
- ): SeedTrustPolicy {
129
- return {
130
- async evaluate(ctx) {
131
- if (ctx.knownOwnerKeys.has(ctx.signerKey)) {
132
- return { trusted: true };
133
- }
134
- const accepted = await confirm(ctx);
135
- return accepted
136
- ? { trusted: true, anchorAs: 'operator' }
137
- : { trusted: false, reason: 'TOFU confirmation declined for unknown signer key' };
138
- },
139
- };
140
- }
1
+ /**
2
+ * Trust-anchor policy for control-network seeds.
3
+ *
4
+ * A seed carries an ed25519 signature over its body and the `signerKey` that
5
+ * produced it. Verifying that signature only proves the seed is internally
6
+ * consistent — it does NOT prove the signer is an owner, because both the
7
+ * signer key and the seed's own `isOwner` peer flags are attacker-supplied.
8
+ * The trust decision must therefore rest on an anchor that does not come from
9
+ * the seed body:
10
+ *
11
+ * 1. Anchored — keys in the receiver's NODE-LOCAL `TrustedOwnerStore`: the
12
+ * non-replicated, per-party record of owner keys established out of band
13
+ * (founding the party, the cadre invitation that enrolled this node, an
14
+ * operator pin, a claim). Deliberately NOT the replicated `CadreControl.OwnerKey`
15
+ * table — any connecting node can genesis-insert its own key there and let
16
+ * it replicate, so that table can be made to say "yes" by a stranger.
17
+ * 2. Pinned out-of-band — keys handed to the node for this seed only (carried
18
+ * by a cadre invitation's `ownerKeys`, or pinned by an operator) without having
19
+ * been anchored yet.
20
+ * 3. Claim secret — a brand-new node that belongs to nobody accepts its first seed
21
+ * from whoever proves they hold its one-time claim secret (`claim-proof.ts`), and
22
+ * anchors that signer itself under source `claim`.
23
+ * 4. TOFU (opt-in) — an explicit confirmation callback invoked on first sight
24
+ * of an unknown signer key. Interactive hosts only; off by default.
25
+ *
26
+ * Secure default (`anchoredTrustPolicy`): a node with an empty anchor and no
27
+ * pinned keys rejects the seed — a seed can no longer vouch for its own signer,
28
+ * and neither can a polluted replicated table.
29
+ *
30
+ * A key accepted via anchor 2 or 4 is reported back through
31
+ * {@link SeedTrustDecision.anchorAs} so `applySeed` can persist it into the
32
+ * node-local anchor, and later seeds from the same owner are accepted without
33
+ * re-supplying the invite/confirmation. Anchor 3 persists the key itself before it
34
+ * answers, because for a claim the persist must succeed for the acceptance to mean
35
+ * anything (see {@link claimSecretTrustPolicy}).
36
+ */
37
+ import debug from 'debug';
38
+ import type { NodeClaimRecord, SeedRefusalCode } from './types.js';
39
+ import type { TrustSource, TrustedOwnerStore } from './trusted-owner-store.js';
40
+ import { verifyClaimProof } from './claim-proof.js';
41
+
42
+ const log = debug('sereus:cadre:seed-trust-policy');
43
+
44
+ export interface SeedTrustContext {
45
+ /** Party the seed claims to belong to. */
46
+ partyId: string;
47
+ /** ed25519 base64url signer key — already signature-verified by `applySeed`. */
48
+ signerKey: string;
49
+ /**
50
+ * The receiver's anchored owner keys, sourced from its node-local
51
+ * `TrustedOwnerStore` — NOT from the seed, and NOT from the replicated
52
+ * `OwnerKey` table. Empty for a node whose anchor was never seeded.
53
+ */
54
+ knownOwnerKeys: ReadonlySet<string>;
55
+ /**
56
+ * The receiver's OWN libp2p peer id. A claim proof is bound to the node it was
57
+ * minted for, and this is the receiver's side of that binding; it must never be
58
+ * taken from the message.
59
+ */
60
+ localPeerId: string;
61
+ /**
62
+ * The seed's signature digest (`seedDigest` in `seed-bootstrap.ts`), computed once
63
+ * per seed and shared with the signature check. The third field a claim proof binds.
64
+ */
65
+ seedDigest: string;
66
+ /** The claim proof the message carried beside the seed, if any (`SeedMessage.claimProof`). */
67
+ claimProof?: string;
68
+ /** The delivering peer, when the seed arrived over the wire; absent for a local `applySeed`. */
69
+ remotePeerId?: string;
70
+ }
71
+
72
+ export interface SeedTrustDecision {
73
+ /** Whether the signer key is trusted. */
74
+ trusted: boolean;
75
+ /** Human-readable reason, surfaced as the seed-apply error when not trusted. */
76
+ reason?: string;
77
+ /** Machine-readable refusal cause, for callers that must branch on it (the claiming phone). */
78
+ code?: SeedRefusalCode;
79
+ /**
80
+ * Set when the key was trusted via an anchor OUTSIDE the node-local store (a
81
+ * pinned key, a TOFU confirmation): the provenance to record it under, so
82
+ * `applySeed` persists it and the next seed from that owner is anchored
83
+ * without re-supplying the pin. Omitted when the key was already anchored
84
+ * (nothing to persist) or not trusted at all.
85
+ *
86
+ * `claim` is excluded on purpose: `applySeed` logs and continues when this persist
87
+ * fails, which is right for a pin that is re-supplied at the next start and wrong for
88
+ * a claim, so the claim policy anchors its signer itself and never asks for it here.
89
+ */
90
+ anchorAs?: Exclude<TrustSource, 'genesis' | 'claim'>;
91
+ }
92
+
93
+ /**
94
+ * Decides whether a signature-verified seed's signer key should be trusted.
95
+ * May be synchronous (anchored/pinned) or asynchronous (TOFU confirmation, claim).
96
+ */
97
+ export interface SeedTrustPolicy {
98
+ evaluate(ctx: SeedTrustContext): Promise<SeedTrustDecision> | SeedTrustDecision;
99
+ }
100
+
101
+ /**
102
+ * Default policy: trust only keys already in the receiver's node-local
103
+ * trusted-owner anchor. A node whose anchor was never seeded (no genesis, no
104
+ * invite pin, no operator pin) rejects every seed.
105
+ */
106
+ export function anchoredTrustPolicy(): SeedTrustPolicy {
107
+ return {
108
+ evaluate({ signerKey, knownOwnerKeys }) {
109
+ if (knownOwnerKeys.has(signerKey)) {
110
+ return { trusted: true };
111
+ }
112
+ return {
113
+ trusted: false,
114
+ reason: 'Signer key is not an anchored owner (anchored trust policy)',
115
+ };
116
+ },
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Cold-start policy: trust anchored keys plus a set pinned out-of-band
122
+ * (typically a cadre invitation's `ownerKeys` or operator config). Lets an unenrolled
123
+ * invitee accept its first seed without the seed vouching for itself.
124
+ *
125
+ * @param anchorAs - provenance under which a pin-only acceptance is persisted
126
+ * into the node-local anchor ('invite' by default — the invite-redemption
127
+ * case; pass 'operator' for an operator-supplied pin).
128
+ */
129
+ export function pinnedKeyTrustPolicy(
130
+ pinned: Iterable<string>,
131
+ anchorAs: Exclude<TrustSource, 'genesis' | 'claim'> = 'invite'
132
+ ): SeedTrustPolicy {
133
+ const pinnedSet = new Set(pinned);
134
+ return {
135
+ evaluate({ signerKey, knownOwnerKeys }) {
136
+ if (knownOwnerKeys.has(signerKey)) {
137
+ return { trusted: true };
138
+ }
139
+ if (pinnedSet.has(signerKey)) {
140
+ return { trusted: true, anchorAs };
141
+ }
142
+ return {
143
+ trusted: false,
144
+ reason: 'Signer key is neither an anchored nor a pinned owner (pinned-key trust policy)',
145
+ };
146
+ },
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Opt-in interactive policy: trust keys already in the node-local anchor, and
152
+ * on an unknown key invoke `confirm` (e.g. a host UI prompt). The key is
153
+ * trusted iff `confirm` resolves true, and a confirmed key is persisted into the
154
+ * anchor as an 'operator' pin (a human at the console is the same provenance as
155
+ * an explicit operator pin) so the prompt is not repeated. Not enabled by default.
156
+ */
157
+ export function tofuTrustPolicy(
158
+ confirm: (ctx: SeedTrustContext) => Promise<boolean>
159
+ ): SeedTrustPolicy {
160
+ return {
161
+ async evaluate(ctx) {
162
+ if (ctx.knownOwnerKeys.has(ctx.signerKey)) {
163
+ return { trusted: true };
164
+ }
165
+ const accepted = await confirm(ctx);
166
+ return accepted
167
+ ? { trusted: true, anchorAs: 'operator' }
168
+ : { trusted: false, reason: 'TOFU confirmation declined for unknown signer key' };
169
+ },
170
+ };
171
+ }
172
+
173
+ /** Failed proofs tolerated inside one window before further proofs are refused unverified. */
174
+ const DEFAULT_CLAIM_FAILURE_LIMIT = 10;
175
+ /** The sliding window the failure limit counts over (ms). */
176
+ const DEFAULT_CLAIM_FAILURE_WINDOW_MS = 60_000;
177
+
178
+ export interface ClaimSecretTrustPolicyOptions {
179
+ /** The node's one-time claim secret, already parsed (`parseClaimSecret`): 32 bytes. */
180
+ secret: Uint8Array;
181
+ /**
182
+ * The node's anchor. A key under source `claim` here is the durable "this node is
183
+ * claimed" marker; the policy writes it and awaits its durability before accepting.
184
+ * Must be the same store the `SeedBootstrapService` snapshots into
185
+ * `SeedTrustContext.knownOwnerKeys`: steps 1 and 2 read that snapshot, and a claim
186
+ * written to a different store would never be seen there.
187
+ */
188
+ trustedOwners: TrustedOwnerStore;
189
+ /**
190
+ * Record the claim — the seed's party and the claimant's key — BEFORE the claimant is
191
+ * anchored, and await its durability (`CadreNodeConfig.claim.record`). The party is the
192
+ * one fact the anchor cannot hold: an unclaimed node's own party is a placeholder, and
193
+ * the claim seed is the first thing to name the real one. A rejection refuses the claim
194
+ * `claim-not-persisted` with nothing anchored. Optional: a policy with nothing to record
195
+ * only anchors.
196
+ */
197
+ recordClaim?: (claim: NodeClaimRecord) => Promise<void>;
198
+ /** Called once, after the claim is durable, with the party and owner key that claimed the node. */
199
+ onClaimed?: (claim: NodeClaimRecord) => void;
200
+ /** Failed proofs tolerated per window; default {@link DEFAULT_CLAIM_FAILURE_LIMIT}. */
201
+ failureLimit?: number;
202
+ /** The window those failures are counted over (ms); default {@link DEFAULT_CLAIM_FAILURE_WINDOW_MS}. */
203
+ failureWindowMs?: number;
204
+ /** Clock, injectable for tests; defaults to `Date.now`. */
205
+ now?: () => number;
206
+ }
207
+
208
+ /**
209
+ * Cold-start policy for a node that belongs to nobody yet: accept the first seed whose
210
+ * sender proves it holds this node's one-time claim secret, anchor that signer under
211
+ * source `claim`, and from then on behave as the anchored policy.
212
+ *
213
+ * Decision order, for a signer key `K` with proof `P`:
214
+ *
215
+ * 1. `K` is anchored → trusted. The node was claimed by this owner earlier, possibly in
216
+ * an earlier process. This is also why a claimed node restarted with the secret still
217
+ * in its config ignores the secret.
218
+ * 2. The anchor holds some other key, or the in-process latch names another key →
219
+ * refused, `already-claimed`.
220
+ * 3. The latch names `K` → trusted. The same owner re-sent while its first delivery's
221
+ * persist is still in flight (a dropped response); idempotent success.
222
+ * 4. No `P` → refused, `claim-proof-invalid`.
223
+ * 5. Too many failed proofs recently → refused, `claim-rate-limited`, without verifying.
224
+ * 6. `P` does not verify → one failure counted, refused, `claim-proof-invalid`.
225
+ * 7. Latch `K`, then `recordClaim({ partyId, ownerKey: K })` and await it. Rejected →
226
+ * clear the latch, refuse `claim-not-persisted` with nothing anchored.
227
+ * 8. `trustedOwners.trust(K, 'claim')` and await it. Durable → trusted, with no
228
+ * `anchorAs` (the policy anchored the key itself). Persist rejected → remove `K`
229
+ * from the anchor, clear the latch, refuse `claim-not-persisted`; the claimant may
230
+ * retry. The record stands: an embedder that starts from its record comes up
231
+ * claimed by `K`, and `K`'s retry is accepted at step 1.
232
+ *
233
+ * The anchor write is the commit point. `SeedBootstrapService.anchorAcceptedSigner`
234
+ * deliberately logs and continues when a persist fails, which is right for a pin that
235
+ * is re-supplied at the next start but wrong here: a lost claim anchor would leave the
236
+ * node unclaimed again after a restart with nobody told. Keeping the latch, the anchor
237
+ * write and the rollback in one object is what lets the refusal be definite.
238
+ *
239
+ * The record goes first because it names the party, which the anchor does not. A crash
240
+ * between the two leaves a record and an empty anchor, which the embedder's next start
241
+ * repairs (above); the other order would leave an anchored node that serves its
242
+ * placeholder party forever with no record of the real one.
243
+ *
244
+ * The failure limit is node-wide, not per peer: libp2p peer ids are free to mint, so a
245
+ * per-peer limit is bypassed by reconnecting. With a 256-bit secret guessing is
246
+ * infeasible either way; the limit bounds CPU and log noise, not security.
247
+ */
248
+ export function claimSecretTrustPolicy(options: ClaimSecretTrustPolicyOptions): SeedTrustPolicy {
249
+ const { secret, trustedOwners, recordClaim, onClaimed } = options;
250
+ const failureLimit = options.failureLimit ?? DEFAULT_CLAIM_FAILURE_LIMIT;
251
+ const failureWindowMs = options.failureWindowMs ?? DEFAULT_CLAIM_FAILURE_WINDOW_MS;
252
+ const now = options.now ?? (() => Date.now());
253
+
254
+ /**
255
+ * The signer whose claim this process has accepted or is persisting. Set
256
+ * synchronously in `evaluate`, before its first `await`, so a second claimant whose
257
+ * evaluation interleaves with the first's persist sees it and is refused.
258
+ */
259
+ let latchedSigner: string | undefined;
260
+ /** Wall-clock times of recent failed proofs, oldest first. */
261
+ const failureTimes: number[] = [];
262
+
263
+ function refuse(code: SeedRefusalCode, reason: string): SeedTrustDecision {
264
+ return { trusted: false, code, reason };
265
+ }
266
+
267
+ /** Drop failures older than the window, then say whether the limit is reached. */
268
+ function rateLimited(at: number): boolean {
269
+ while (failureTimes.length > 0 && failureTimes[0]! <= at - failureWindowMs) {
270
+ failureTimes.shift();
271
+ }
272
+ return failureTimes.length >= failureLimit;
273
+ }
274
+
275
+ /** Steps 7 and 8: record the claim, then anchor the claimant; either failure refuses. */
276
+ async function anchorClaim(claim: NodeClaimRecord): Promise<SeedTrustDecision> {
277
+ try {
278
+ await recordClaim?.(claim);
279
+ } catch (error) {
280
+ log('claim record failed; the node stays unclaimed: %o', error);
281
+ latchedSigner = undefined;
282
+ return refuse('claim-not-persisted', 'This node could not durably record the claim; it remains unclaimed, retry');
283
+ }
284
+ try {
285
+ await trustedOwners.trust(claim.ownerKey, 'claim');
286
+ } catch (error) {
287
+ log('claim anchor persist failed; rolling the claim back: %o', error);
288
+ await rollbackClaim(claim.ownerKey);
289
+ return refuse('claim-not-persisted', 'This node could not durably record the claim; it remains unclaimed, retry');
290
+ }
291
+ log('node claimed by signer %s into party %s', claim.ownerKey, claim.partyId);
292
+ notifyClaimed(claim);
293
+ return { trusted: true };
294
+ }
295
+
296
+ /**
297
+ * Undo an in-memory anchor whose persist failed, so the node is unclaimed again.
298
+ * Best effort: the remove is a second write to the same slot and may fail the same
299
+ * way, and the latch clears regardless so the claimant (or another) can retry. A
300
+ * claimant refused `already-claimed` while this was in flight was refused wrongly and
301
+ * simply retries; acceptable.
302
+ */
303
+ async function rollbackClaim(signerKey: string): Promise<void> {
304
+ try {
305
+ await trustedOwners.remove(signerKey);
306
+ } catch (error) {
307
+ log('failed to remove the un-persisted claim anchor for %s: %o', signerKey, error);
308
+ }
309
+ latchedSigner = undefined;
310
+ }
311
+
312
+ /** The claim is durable whatever the callback does; a throwing embedder must not undo it. */
313
+ function notifyClaimed(claim: NodeClaimRecord): void {
314
+ try {
315
+ onClaimed?.(claim);
316
+ } catch (error) {
317
+ log('onClaimed callback threw; the claim stands: %o', error);
318
+ }
319
+ }
320
+
321
+ return {
322
+ evaluate(ctx) {
323
+ if (ctx.knownOwnerKeys.has(ctx.signerKey)) {
324
+ return { trusted: true };
325
+ }
326
+ if (ctx.knownOwnerKeys.size > 0 || (latchedSigner !== undefined && latchedSigner !== ctx.signerKey)) {
327
+ return refuse('already-claimed', 'This node has already been claimed by another owner');
328
+ }
329
+ if (latchedSigner === ctx.signerKey) {
330
+ return { trusted: true };
331
+ }
332
+ if (ctx.claimProof === undefined) {
333
+ return refuse('claim-proof-invalid', 'This node is unclaimed and accepts only a seed carrying its claim proof');
334
+ }
335
+ const at = now();
336
+ if (rateLimited(at)) {
337
+ return refuse('claim-rate-limited', 'Too many failed claim proofs recently; retry later');
338
+ }
339
+ if (!verifyClaimProof(secret, ctx.localPeerId, ctx.signerKey, ctx.seedDigest, ctx.claimProof)) {
340
+ failureTimes.push(at);
341
+ log('claim proof from %s did not verify (%d recent failure(s))', ctx.remotePeerId ?? 'local', failureTimes.length);
342
+ return refuse('claim-proof-invalid', 'The claim proof does not verify for this node, signer and seed');
343
+ }
344
+ // Everything above ran synchronously: this is the first point the policy yields,
345
+ // and the latch is set before it. Two claimants racing with valid proofs therefore
346
+ // cannot both reach the record or the anchor write; the second sees the latch at
347
+ // step 2. The party is the seed's: signed by the claimant, and the only statement of
348
+ // which cadre this node now belongs to.
349
+ latchedSigner = ctx.signerKey;
350
+ return anchorClaim({ partyId: ctx.partyId, ownerKey: ctx.signerKey });
351
+ },
352
+ };
353
+ }
@@ -18,14 +18,14 @@
18
18
  * one stream, so each side reads to EOF (under a read timeout) and decodes one
19
19
  * frame via the shared {@link decodeLengthPrefixedFrame} guard.
20
20
  *
21
- * **Authorization (v1):** like wake, the receiver defers entirely to the injected
22
- * `isMember` predicate and requires no further signature; a peer it rejects gets
23
- * a `refused` reply with no addresses. `CadreNode` injects its AUTHORIZED-membership predicate
24
- * (`isAuthorizedMember`: voucher on the requester's `CadrePeer` row verified
25
- * against the node-local trusted-owner anchor), so an outsider that published its
26
- * own rows into the replicated control DB cannot harvest live strand addresses.
27
- * Cross-party strand bootstrap is a different mechanism (strand formation /
28
- * `MemberPeer`) and is out of scope here.
21
+ * **Authorization (v1):** like wake, the request carries no signature. Membership
22
+ * is checked before this handler runs, by the control node's protocol guard
23
+ * (`control-protocol-guard.ts`, the `members` class: `CadreNode.isAuthorizedMember`,
24
+ * a voucher on the requester's `CadrePeer` row verified against the node-local
25
+ * trusted-owner anchor). A peer it refuses gets its stream reset, never a reply, so
26
+ * an outsider that published its own rows into the replicated control DB can neither
27
+ * harvest live strand addresses nor announce a delegate. Cross-party strand bootstrap
28
+ * is a different mechanism (strand formation / `MemberPeer`) and is out of scope here.
29
29
  */
30
30
 
31
31
  import debug from 'debug';
@@ -69,7 +69,7 @@ async function readFrame<T>(stream: ControlStream, timeoutMs: number): Promise<T
69
69
  return JSON.parse(new TextDecoder().decode(body)) as T;
70
70
  }
71
71
 
72
- /** An address-free reply, for every path but a member's lookup: cap, unreadable request, lookup failure, non-member. */
72
+ /** An address-free reply, for every path but a lookup: over the cap, an unreadable request, a lookup that threw. */
73
73
  function addrlessResponse(status: StrandAddrStatus, strandId: string): StrandAddrResponse {
74
74
  return { status, strandId, multiaddrs: [] };
75
75
  }
@@ -79,8 +79,6 @@ function addrlessResponse(status: StrandAddrStatus, strandId: string): StrandAdd
79
79
  * (`CadreNode`), injected so the service is testable without a full node.
80
80
  */
81
81
  export interface StrandAddrServiceOptions {
82
- /** Membership gate: is the remote peer a `CadrePeer` member of this cadre? */
83
- isMember(remotePeerId: string): Promise<boolean>;
84
82
  /**
85
83
  * The local strand instance's dialable strand-network multiaddrs, ordered
86
84
  * signaling-first. Returns `[]` when the strand is not running / has no live
@@ -90,8 +88,9 @@ export interface StrandAddrServiceOptions {
90
88
  getStrandMultiaddrs(strandId: string): string[];
91
89
  /**
92
90
  * Called when a member's request carries a `delegatePeerId` — the derived
93
- * transport peerId its strand-`strandId` node runs as. Invoked only AFTER the
94
- * `isMember` gate passed, with a validated (parseable, non-self) peerId.
91
+ * transport peerId its strand-`strandId` node runs as, with a validated
92
+ * (parseable, non-self) peerId. Every caller is an authorized member: the
93
+ * protocol guard admitted it before this handler ran.
95
94
  * `CadreNode` injects its delegate-admission grant recorder so this node's
96
95
  * connection gate — and thus its circuit-relay server, when it runs one —
97
96
  * admits that peerId (see `delegate-admission.ts`). Optional: absent, an
@@ -114,10 +113,10 @@ export interface StrandAddrServiceOptions {
114
113
 
115
114
  /**
116
115
  * Receiver side of the strand-address RPC. Registers a `STRAND_ADDR_PROTOCOL`
117
- * handler on the control node and, for each inbound {@link StrandAddrRequest},
118
- * gates on cadre membership, then replies with the local strand instance's live
119
- * multiaddrs — `ok` with an empty list when the strand is not running, `refused`
120
- * for a non-member, `unavailable` when it could not answer at all.
116
+ * handler on the control node and, for each inbound {@link StrandAddrRequest}
117
+ * (from a peer the protocol guard has already admitted as an authorized member),
118
+ * replies with the local strand instance's live multiaddrs — `ok` with an empty
119
+ * list when the strand is not running, `unavailable` when it could not answer at all.
121
120
  */
122
121
  export class StrandAddrService {
123
122
  private readonly options: StrandAddrServiceOptions;
@@ -175,8 +174,8 @@ export class StrandAddrService {
175
174
  * (over {@link maxConcurrent}, reply without looking up any address), a read
176
175
  * timeout (a peer that never half-closes is aborted inside {@link readFrame}/
177
176
  * `readStreamToEnd`), and the existing malformed/oversized-frame guard. A
178
- * lookup that throws — the membership read failing, say — is `unavailable` too,
179
- * never a `refused` or an empty `ok` the asker would wait ten minutes on.
177
+ * lookup that throws is `unavailable` too, never an empty `ok` the asker would
178
+ * wait ten minutes on.
180
179
  */
181
180
  private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
182
181
  log('Incoming strand-addr request from: %s', remotePeerId);
@@ -215,22 +214,14 @@ export class StrandAddrService {
215
214
  * decision matrix can be unit-tested directly (mirrors wake's
216
215
  * `processWakeRequest`).
217
216
  *
218
- * - Non-member sender → `refused`, no delegate grant.
219
- * - Member request carrying a `delegatePeerId` → recorded via
217
+ * - Request carrying a `delegatePeerId` → recorded via
220
218
  * {@link StrandAddrServiceOptions.onDelegateAnnounce} before the lookup.
221
219
  * - Strand not running locally → `ok` with empty `multiaddrs` (`getStrandMultiaddrs` → `[]`).
222
- * - Member + running strand → `ok` with the strand's live, signaling-first multiaddrs.
220
+ * - Running strand → `ok` with the strand's live, signaling-first multiaddrs.
223
221
  *
224
- * A throwing `isMember` propagates; {@link handleStream} answers it `unavailable`.
222
+ * A throwing lookup propagates; {@link handleStream} answers it `unavailable`.
225
223
  */
226
224
  async processAddrRequest(request: StrandAddrRequest, remotePeerId: string): Promise<StrandAddrResponse> {
227
- // Control-network membership is the v1 authorization: only this party's
228
- // cadre peers may ask us for a strand address (or announce a delegate).
229
- if (!(await this.options.isMember(remotePeerId))) {
230
- log('Refusing strand-addr from non-member %s', remotePeerId);
231
- return addrlessResponse('refused', request.strandId);
232
- }
233
-
234
225
  this.recordDelegateAnnounce(request, remotePeerId);
235
226
 
236
227
  const multiaddrs = this.options.getStrandMultiaddrs(request.strandId);
@@ -303,11 +294,11 @@ export interface CollectStrandAddrsOptions {
303
294
  * - `empty` — status `ok` with no address: the sibling does not run the strand
304
295
  * right now, a normal steady state.
305
296
  * - `unavailable` — the sibling replied that it could not answer.
306
- * - `refused` — the sibling does not (yet) count the asker as an authorized member.
307
- * - `unreachable` — no reply at all: every dial target failed or timed out, or
308
- * the reply was malformed.
297
+ * - `unreachable` — no reply at all: every dial target failed or timed out, the
298
+ * reply was malformed, or the sibling's protocol guard reset the stream because
299
+ * it does not (yet) count the asker as an authorized member.
309
300
  */
310
- export type StrandAddrOutcome = 'answered' | 'empty' | 'unavailable' | 'refused' | 'unreachable';
301
+ export type StrandAddrOutcome = 'answered' | 'empty' | 'unavailable' | 'unreachable';
311
302
 
312
303
  /** Result of {@link collectStrandAddrs}. */
313
304
  export interface StrandAddrCollection {
@@ -525,7 +516,7 @@ async function sendStrandAddr(
525
516
  return response;
526
517
  }
527
518
 
528
- const STRAND_ADDR_STATUSES: ReadonlySet<string> = new Set<StrandAddrStatus>(['ok', 'unavailable', 'refused']);
519
+ const STRAND_ADDR_STATUSES: ReadonlySet<string> = new Set<StrandAddrStatus>(['ok', 'unavailable']);
529
520
 
530
521
  /**
531
522
  * Shape check on a decoded reply: the responder is another machine, so a reply