@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.
- package/dist/cadre-invite-protocol.d.ts +348 -0
- package/dist/cadre-invite-protocol.js +605 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- package/dist/cadre-node.d.ts +523 -180
- package/dist/cadre-node.js +921 -293
- package/dist/cadre-node.js.map +1 -1
- package/dist/claim-proof.d.ts +45 -0
- package/dist/claim-proof.js +132 -0
- package/dist/claim-proof.js.map +1 -0
- package/dist/control-authorization.d.ts +80 -19
- package/dist/control-authorization.js +55 -13
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +514 -97
- package/dist/control-database.js +1033 -195
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +142 -0
- package/dist/control-protocol-guard.js +293 -0
- package/dist/control-protocol-guard.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +801 -1067
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +4 -0
- package/dist/control-stream.js +22 -7
- package/dist/control-stream.js.map +1 -1
- package/dist/delegate-admission.d.ts +1 -1
- package/dist/delegate-admission.js +1 -1
- package/dist/device-token.d.ts +11 -9
- package/dist/device-token.js +14 -12
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +15 -0
- package/dist/ed25519-key.js +28 -1
- package/dist/ed25519-key.js.map +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.js +27 -9
- package/dist/index.js.map +1 -1
- package/dist/link-budget.d.ts +16 -29
- package/dist/link-budget.js +16 -31
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +190 -151
- package/dist/membership-connection-gater.js +235 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +19 -0
- package/dist/node-claim-payload.js +120 -0
- package/dist/node-claim-payload.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +22 -0
- package/dist/owner-anchor-sync.js +143 -0
- package/dist/owner-anchor-sync.js.map +1 -0
- package/dist/peer-authorization.d.ts +113 -18
- package/dist/peer-authorization.js +225 -29
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-dial.d.ts +19 -0
- package/dist/peer-dial.js +22 -1
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +54 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +13 -9
- package/dist/peer-record.js +16 -12
- package/dist/peer-record.js.map +1 -1
- package/dist/pending-join-runner.d.ts +66 -58
- package/dist/pending-join-runner.js +136 -145
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/push-fanout.d.ts +8 -2
- package/dist/push-fanout.js +8 -2
- package/dist/push-fanout.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +147 -122
- package/dist/seed-bootstrap.js +284 -269
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +98 -37
- package/dist/seed-trust-policy.js +188 -2
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +24 -26
- package/dist/strand-addr-protocol.js +19 -26
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-formation-protocol.js +1 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-revocation-enforcer.d.ts +11 -11
- package/dist/strand-revocation-enforcer.js +11 -11
- package/dist/strand-wake-protocol.d.ts +13 -16
- package/dist/strand-wake-protocol.js +13 -20
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/trusted-owner-store.d.ts +35 -5
- package/dist/trusted-owner-store.js +50 -8
- package/dist/trusted-owner-store.js.map +1 -1
- package/dist/types.d.ts +302 -87
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- package/src/cadre-invite-protocol.ts +864 -0
- package/src/cadre-node.ts +9381 -8686
- package/src/claim-proof.ts +147 -0
- package/src/control-authorization.ts +203 -117
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +1216 -218
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +812 -1078
- package/src/control-stream.ts +26 -7
- package/src/delegate-admission.ts +1 -1
- package/src/device-token.ts +14 -10
- package/src/ed25519-key.ts +28 -1
- package/src/index.ts +84 -10
- package/src/link-budget.ts +16 -32
- package/src/membership-connection-gater.ts +637 -570
- package/src/node-claim-payload.ts +125 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +22 -1
- package/src/peer-join-backfill.ts +56 -11
- package/src/peer-record.ts +16 -11
- package/src/pending-join-runner.ts +729 -735
- package/src/push-fanout.ts +8 -2
- package/src/seed-bootstrap.ts +344 -321
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- package/src/strand-formation-protocol.ts +1 -1
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +13 -23
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2471 -2242
package/src/seed-trust-policy.ts
CHANGED
|
@@ -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
|
|
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 `
|
|
19
|
-
* been anchored yet.
|
|
20
|
-
* 3.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* own rows into the replicated control DB
|
|
27
|
-
*
|
|
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
|
|
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
|
|
94
|
-
*
|
|
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
|
-
*
|
|
119
|
-
* multiaddrs — `ok` with an empty
|
|
120
|
-
*
|
|
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
|
|
179
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
* -
|
|
220
|
+
* - Running strand → `ok` with the strand's live, signaling-first multiaddrs.
|
|
223
221
|
*
|
|
224
|
-
* A throwing
|
|
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
|
-
* - `
|
|
307
|
-
*
|
|
308
|
-
* the
|
|
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' | '
|
|
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'
|
|
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
|
|
@@ -28,7 +28,7 @@ import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
|
28
28
|
import { verifyFormationConsent } from './peer-authorization.js';
|
|
29
29
|
// seed-bootstrap imports neither this protocol nor the manager/solicitation layers,
|
|
30
30
|
// so this import introduces no cycle.
|
|
31
|
-
import { ed25519PublicKeyB64FromPeerId } from './
|
|
31
|
+
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
32
32
|
import { trailingPeerId } from './peer-record.js';
|
|
33
33
|
import {
|
|
34
34
|
formationDeadlines,
|