@twinsearth/w2m-dsh-plugin 1.1.3 → 1.1.15
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +105 -0
- package/codex/mcp-servers.toml +1 -1
- package/package.json +11 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +17 -0
- package/src/plugin/account-join.mjs +86 -0
- package/src/plugin/join-degradation.mjs +34 -0
- package/src/plugin/tools.mjs +57 -7
- package/src/relay/admission.mjs +250 -0
- package/src/relay/join-challenge.mjs +92 -0
- package/src/relay/server.mjs +1496 -1426
- package/src/relay/state.mjs +46 -0
- package/src/util/account-identity.mjs +75 -0
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Fleet admission: who may join a fleet, who may be removed, and by how many votes.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this is a state machine and not a route handler
|
|
6
|
+
*
|
|
7
|
+
* Every rule the operator set is a *time window* or a *quorum*, and both are the kind of logic that reads correctly in
|
|
8
|
+
* a route handler and is wrong at the edges: a decision that arrives one millisecond after the automatic one, a vote
|
|
9
|
+
* whose quorum changes because a voter went offline mid-vote, a removal of the last member. None of that needs a socket,
|
|
10
|
+
* so none of it is tested through one -- the routes are thin wrappers over this file.
|
|
11
|
+
*
|
|
12
|
+
* ## The four operator decisions this encodes (2026-10-17)
|
|
13
|
+
*
|
|
14
|
+
* 1. the OLD mechanism stays the default; this one is the fallback, so nothing here is on any startup path yet;
|
|
15
|
+
* 2. `autoApproveMs = 10_000` -- but **never** with zero members online, because an empty fleet cannot consent;
|
|
16
|
+
* 3. a fleet of 2 needs 1 vote (= 50%), 3 or more needs >= 50% rounded up;
|
|
17
|
+
* 4. the quorum denominator is **currently online old members only**, so the set is **frozen when the vote opens**.
|
|
18
|
+
*
|
|
19
|
+
* ## Clock
|
|
20
|
+
*
|
|
21
|
+
* Every decision is stamped with `now()`, which the caller supplies. The default is `Date.now`; the tests pass a
|
|
22
|
+
* clock they control, and production uses the relay's clock. **No client clock is ever trusted** -- three machines in
|
|
23
|
+
* this fleet disagreed by tens of seconds, and a vote that opens or closes on a client clock is a vote an attacker
|
|
24
|
+
* schedules.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Request states. A request that is not `approved` grants nothing. */
|
|
28
|
+
export const ADMISSION = Object.freeze({
|
|
29
|
+
PENDING: 'pending',
|
|
30
|
+
APPROVED: 'approved',
|
|
31
|
+
REJECTED: 'rejected',
|
|
32
|
+
EXPIRED: 'expired',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** Operator decision: 10s, and only while at least one member is online. */
|
|
36
|
+
export const AUTO_APPROVE_MS = 10_000;
|
|
37
|
+
/** A request nobody decided expires, so a machine cannot wait forever on a fleet that is gone. */
|
|
38
|
+
export const REQUEST_TTL_MS = 24 * 60 * 60 * 1000;
|
|
39
|
+
/** Removal is immediate inside this window; after it, a vote is required. */
|
|
40
|
+
export const KICK_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
41
|
+
/** How long a removal vote stays open. */
|
|
42
|
+
export const VOTE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The quorum, as the operator stated it: 3+ old members needs >= 50% rounded up; a fleet of 2 needs **one** vote.
|
|
46
|
+
*
|
|
47
|
+
* `Math.ceil(n / 2)` would demand 2 votes from a two-member fleet, which makes removal impossible -- the operator
|
|
48
|
+
* called that out explicitly ("2 人小编队一人就行").
|
|
49
|
+
*/
|
|
50
|
+
export function neededVotes(onlineOldMembers) {
|
|
51
|
+
const n = Number.isInteger(onlineOldMembers) && onlineOldMembers > 0 ? onlineOldMembers : 0;
|
|
52
|
+
if (n === 0) return 0;
|
|
53
|
+
if (n <= 2) return 1;
|
|
54
|
+
return Math.ceil(n / 2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {{now?: () => number, autoApproveMs?: number}} [options]
|
|
59
|
+
*/
|
|
60
|
+
export function createAdmission(options = {}) {
|
|
61
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
62
|
+
const autoApproveMs = Number.isInteger(options.autoApproveMs) ? options.autoApproveMs : AUTO_APPROVE_MS;
|
|
63
|
+
|
|
64
|
+
/** machine_id -> {joined_at_ms, fleet_id} for every APPROVED member. */
|
|
65
|
+
const members = new Map();
|
|
66
|
+
let joinSeq = 0;
|
|
67
|
+
/** request_id -> request record. */
|
|
68
|
+
const requests = new Map();
|
|
69
|
+
/** removal_id -> vote record. */
|
|
70
|
+
const removals = new Map();
|
|
71
|
+
let seq = 0;
|
|
72
|
+
const nextId = (prefix) => { seq += 1; return prefix + '-' + String(seq).padStart(4, '0'); };
|
|
73
|
+
|
|
74
|
+
const onlineMembers = () => [...members.keys()].filter((id) => members.get(id).online === true);
|
|
75
|
+
|
|
76
|
+
function seedFounder(machine_id, fleet_id = 'fleet-1', online = true) {
|
|
77
|
+
joinSeq += 1;
|
|
78
|
+
members.set(machine_id, { joined_at_ms: now(), fleet_id, online, join_seq: joinSeq });
|
|
79
|
+
return memberOf(machine_id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function memberOf(machine_id) {
|
|
83
|
+
const m = members.get(machine_id);
|
|
84
|
+
return m === undefined ? null : { machine_id, joined_at_ms: m.joined_at_ms, fleet_id: m.fleet_id, online: m.online === true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A pending request grants nothing: this is what the dispatch path asks. */
|
|
88
|
+
function isMember(machine_id) { return members.has(machine_id); }
|
|
89
|
+
|
|
90
|
+
function setOnline(machine_id, online) {
|
|
91
|
+
const m = members.get(machine_id);
|
|
92
|
+
if (m === undefined) return false;
|
|
93
|
+
m.online = online === true;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requestAdmission({ machine_id, machine_name = null, account_fingerprint = null, fleet_id = 'fleet-1' }) {
|
|
98
|
+
if (typeof machine_id !== 'string' || machine_id === '') throw new Error('machine_id is required');
|
|
99
|
+
if (members.has(machine_id)) return { ok: true, already_member: true, state: ADMISSION.APPROVED, request_id: null };
|
|
100
|
+
// One open request per machine: re-requesting returns the same record rather than creating a queue.
|
|
101
|
+
for (const r of requests.values()) {
|
|
102
|
+
if (r.machine_id === machine_id && r.state === ADMISSION.PENDING) return { ok: true, request_id: r.request_id, state: r.state, replayed: true };
|
|
103
|
+
}
|
|
104
|
+
// A fleet with no members has nobody to approve, so the first machine founds it.
|
|
105
|
+
if (members.size === 0) {
|
|
106
|
+
joinSeq += 1;
|
|
107
|
+
members.set(machine_id, { joined_at_ms: now(), fleet_id, online: true, join_seq: joinSeq });
|
|
108
|
+
return { ok: true, state: ADMISSION.APPROVED, request_id: null, founder: true };
|
|
109
|
+
}
|
|
110
|
+
const request_id = nextId('adm');
|
|
111
|
+
requests.set(request_id, {
|
|
112
|
+
request_id, machine_id, machine_name, account_fingerprint, fleet_id,
|
|
113
|
+
state: ADMISSION.PENDING, requested_at_ms: now(), decided_at_ms: null,
|
|
114
|
+
decided_by: null, auto: false,
|
|
115
|
+
poll_token: randomBytes(16).toString('hex'),
|
|
116
|
+
});
|
|
117
|
+
return { ok: true, request_id, state: ADMISSION.PENDING, poll_token: requests.get(request_id).poll_token };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getRequest(request_id) {
|
|
121
|
+
const r = requests.get(request_id);
|
|
122
|
+
return r === undefined ? null : { ...r };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Approve or reject. `auto` is recorded, because "nobody was watching" and "a person said yes" are different facts.
|
|
127
|
+
*/
|
|
128
|
+
function decide(request_id, { decision, decided_by = null, auto = false }) {
|
|
129
|
+
const r = requests.get(request_id);
|
|
130
|
+
if (r === undefined) return { ok: false, code: 'NOT_FOUND' };
|
|
131
|
+
if (r.state !== ADMISSION.PENDING) return { ok: false, code: 'ALREADY_DECIDED', state: r.state };
|
|
132
|
+
if (decision !== 'approve' && decision !== 'reject') return { ok: false, code: 'BAD_DECISION' };
|
|
133
|
+
if (auto === true && onlineMembers().length === 0) {
|
|
134
|
+
// An empty fleet cannot consent. This is the one rule that must not be relaxed for convenience.
|
|
135
|
+
return { ok: false, code: 'NO_ONLINE_MEMBER' };
|
|
136
|
+
}
|
|
137
|
+
r.state = decision === 'approve' ? ADMISSION.APPROVED : ADMISSION.REJECTED;
|
|
138
|
+
r.decided_at_ms = now();
|
|
139
|
+
r.decided_by = decided_by;
|
|
140
|
+
r.auto = auto === true;
|
|
141
|
+
if (r.state === ADMISSION.APPROVED) {
|
|
142
|
+
joinSeq += 1;
|
|
143
|
+
members.set(r.machine_id, { joined_at_ms: r.decided_at_ms, fleet_id: r.fleet_id, online: true, join_seq: joinSeq });
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, request_id, state: r.state, auto: r.auto };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Tick: applies the automatic approval and the request TTL. Called by the caller's timer, not by a setTimeout here,
|
|
150
|
+
* so a test controls time completely.
|
|
151
|
+
*/
|
|
152
|
+
function tick() {
|
|
153
|
+
const t = now();
|
|
154
|
+
const decided = [];
|
|
155
|
+
for (const r of requests.values()) {
|
|
156
|
+
if (r.state !== ADMISSION.PENDING) continue;
|
|
157
|
+
const age = t - r.requested_at_ms;
|
|
158
|
+
if (age >= REQUEST_TTL_MS) { r.state = ADMISSION.EXPIRED; r.decided_at_ms = t; decided.push({ request_id: r.request_id, state: r.state }); continue; }
|
|
159
|
+
if (age >= autoApproveMs && onlineMembers().length > 0) {
|
|
160
|
+
r.state = ADMISSION.APPROVED; r.decided_at_ms = t; r.decided_by = 'auto'; r.auto = true;
|
|
161
|
+
joinSeq += 1;
|
|
162
|
+
members.set(r.machine_id, { joined_at_ms: t, fleet_id: r.fleet_id, online: true, join_seq: joinSeq });
|
|
163
|
+
decided.push({ request_id: r.request_id, state: r.state, auto: true });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return decided;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Remove a member.
|
|
171
|
+
*
|
|
172
|
+
* Inside KICK_WINDOW_MS of joining, any online member may do it immediately. After that, a single machine may only
|
|
173
|
+
* OPEN a vote: the quorum set is frozen here, at open time, so a voter going offline mid-vote cannot silently lower
|
|
174
|
+
* the bar (or raise it).
|
|
175
|
+
*/
|
|
176
|
+
function remove(target_machine_id, { requested_by, reason = null }) {
|
|
177
|
+
const target = members.get(target_machine_id);
|
|
178
|
+
if (target === undefined) return { ok: false, code: 'NOT_A_MEMBER' };
|
|
179
|
+
if (requested_by === target_machine_id) return { ok: false, code: 'CANNOT_REMOVE_SELF' };
|
|
180
|
+
if (!members.has(requested_by)) return { ok: false, code: 'NOT_A_MEMBER' };
|
|
181
|
+
if (members.size <= 1) return { ok: false, code: 'LAST_MEMBER' };
|
|
182
|
+
|
|
183
|
+
const age = now() - target.joined_at_ms;
|
|
184
|
+
// Insertion order, not timestamps: two machines approved inside the same millisecond are equal on the clock but
|
|
185
|
+
// still have an order, and a quorum that depends on a sub-millisecond race is not a quorum.
|
|
186
|
+
const oldMembers = [...members.keys()].filter((id) => id !== target_machine_id
|
|
187
|
+
&& (members.get(id).join_seq ?? 0) < (target.join_seq ?? 0));
|
|
188
|
+
const snapshot = oldMembers.filter((id) => members.get(id).online === true);
|
|
189
|
+
|
|
190
|
+
if (age <= KICK_WINDOW_MS) {
|
|
191
|
+
members.delete(target_machine_id);
|
|
192
|
+
return { ok: true, immediate: true, removed: target_machine_id };
|
|
193
|
+
}
|
|
194
|
+
const removal_id = 'rmv-' + String(seq += 1).padStart(4, '0');
|
|
195
|
+
const record = {
|
|
196
|
+
removal_id, target_machine_id, requested_by, reason,
|
|
197
|
+
snapshot: [...snapshot], recent_votes: snapshot.slice(0, 1),
|
|
198
|
+
votes: new Map(), state: 'open', opened_at_ms: now(), need: neededVotes(snapshot.length),
|
|
199
|
+
};
|
|
200
|
+
// The requestor counts as the first yes; the operator's "a single machine may initiate" implies that much.
|
|
201
|
+
if (record.recent_votes.includes(requested_by)) record.votes.set(requested_by, 'yes');
|
|
202
|
+
removals.set(removal_id, record);
|
|
203
|
+
return {
|
|
204
|
+
ok: true, immediate: false, removal_id, need: record.need,
|
|
205
|
+
snapshot: [...record.snapshot], votes: record.votes.size,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Vote on an OPEN removal. Only the frozen snapshot may vote; the target never may. */
|
|
210
|
+
function vote(removal_id, { machine_id, vote: choice }) {
|
|
211
|
+
const r = removals.get(removal_id);
|
|
212
|
+
if (r === undefined) return { ok: false, code: 'NOT_FOUND' };
|
|
213
|
+
if (r.state !== 'open') return { ok: false, code: 'CLOSED', state: r.state };
|
|
214
|
+
if (now() - r.opened_at_ms > VOTE_WINDOW_MS) { r.state = 'expired'; return { ok: false, code: 'VOTE_WINDOW_OVER' }; }
|
|
215
|
+
if (machine_id === r.target_machine_id) return { ok: false, code: 'TARGET_CANNOT_VOTE' };
|
|
216
|
+
if (!r.snapshot.includes(machine_id)) return { ok: false, code: 'NOT_IN_SNAPSHOT' };
|
|
217
|
+
if (choice !== 'yes' && choice !== 'no') return { ok: false, code: 'BAD_VOTE' };
|
|
218
|
+
r.votes.set(machine_id, choice);
|
|
219
|
+
const yes = [...r.votes.values()].filter((v) => v === 'yes').length;
|
|
220
|
+
const no = [...r.votes.values()].filter((v) => v === 'no').length;
|
|
221
|
+
if (yes >= r.need) {
|
|
222
|
+
r.state = 'carried';
|
|
223
|
+
if (members.size > 1) members.delete(r.target_machine_id);
|
|
224
|
+
return { ok: true, state: 'carried', yes, no, need: r.need, removed: r.target_machine_id };
|
|
225
|
+
}
|
|
226
|
+
// Once enough no votes exist that yes can no longer reach the bar, it is decided.
|
|
227
|
+
if (no > r.snapshot.length - r.need) { r.state = 'failed'; return { ok: true, state: 'failed', yes, no, need: r.need }; }
|
|
228
|
+
return { ok: true, state: 'open', yes, no, need: r.need };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function getRemoval(removal_id) {
|
|
232
|
+
const r = removals.get(removal_id);
|
|
233
|
+
return r === undefined ? null : { removal_id: r.removal_id, target_machine_id: r.target_machine_id, state: r.state, need: r.need, snapshot: [...r.snapshot], yes: [...r.votes.values()].filter((v) => v === 'yes').length, no: [...r.votes.values()].filter((v) => v === 'no').length, votes: r.votes.size };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Poll without a device token: the asking machine has none. The token is the credential, and it is not guessable from the id. */
|
|
237
|
+
function poll(request_id, poll_token) {
|
|
238
|
+
const r = requests.get(request_id);
|
|
239
|
+
if (r === undefined || typeof poll_token !== 'string' || poll_token === '' || r.poll_token !== poll_token) {
|
|
240
|
+
return { ok: false, code: 'NOT_FOUND' };
|
|
241
|
+
}
|
|
242
|
+
return { ok: true, request_id, state: r.state };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function pendingFor() {
|
|
246
|
+
return [...requests.values()].filter((r) => r.state === ADMISSION.PENDING).map((r) => ({ ...r }));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { requestAdmission, decide, tick, getRequest, pendingFor, poll, remove, vote, getRemoval, isMember, memberOf, setOnline, seedFounder, _members: members };
|
|
250
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exchange that lets an UNPAIRED machine prove an account it cannot authenticate as a device.
|
|
3
|
+
*
|
|
4
|
+
* ## The shape
|
|
5
|
+
*
|
|
6
|
+
* 1. the machine asks for a challenge, naming the account fingerprint;
|
|
7
|
+
* 2. the relay answers with a one-time nonce and an expiry;
|
|
8
|
+
* 3. the machine signs \`challenge|nonce|fingerprint|machine_id\` with the account's derived private key;
|
|
9
|
+
* 4. the relay verifies with the public key registered when that account's first machine paired.
|
|
10
|
+
*
|
|
11
|
+
* ## The three properties, and why each is a test
|
|
12
|
+
*
|
|
13
|
+
* - **One-time.** A challenge is consumed by the first attempt, success or failure, because a retryable nonce is a replay
|
|
14
|
+
* window. Consuming on failure means an attacker who guessed wrong must fetch a new challenge -- which is rate limited.
|
|
15
|
+
* - **Expiring.** Five minutes, on the relay's clock. A nonce that lives for ever is a signature that can be reused at
|
|
16
|
+
* leisure.
|
|
17
|
+
* - **Rate limited per fingerprint.** Not per address: the thing being protected is a fingerprint, and an attacker with
|
|
18
|
+
* many addresses must not get many guesses.
|
|
19
|
+
*
|
|
20
|
+
* The signature payload binds the machine_id as well as the fingerprint: a valid signature harvested for one machine must
|
|
21
|
+
* not admit another.
|
|
22
|
+
*/
|
|
23
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
24
|
+
import { verifyChallenge } from '../util/account-identity.mjs';
|
|
25
|
+
|
|
26
|
+
export const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
27
|
+
export const CHALLENGE_MAX_PER_MINUTE = 5;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {{now?: () => number, ttlMs?: number, maxPerMinute?: number,
|
|
31
|
+
* publicKeyFor: (fingerprint: string) => (string|null)}} options
|
|
32
|
+
*/
|
|
33
|
+
export function createJoinChallenges(options = {}) {
|
|
34
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
35
|
+
const ttlMs = Number.isInteger(options.ttlMs) ? options.ttlMs : CHALLENGE_TTL_MS;
|
|
36
|
+
const maxPerMinute = Number.isInteger(options.maxPerMinute) ? options.maxPerMinute : CHALLENGE_MAX_PER_MINUTE;
|
|
37
|
+
const publicKeyFor = typeof options.publicKeyFor === 'function' ? options.publicKeyFor : () => null;
|
|
38
|
+
|
|
39
|
+
/** challenge_id -> { fingerprint, nonce, expires_at_ms } */
|
|
40
|
+
const open = new Map();
|
|
41
|
+
/** fingerprint -> [issue times] */
|
|
42
|
+
const issued = new Map();
|
|
43
|
+
|
|
44
|
+
const prune = () => {
|
|
45
|
+
const t = now();
|
|
46
|
+
for (const [id, c] of open) if (c.expires_at_ms <= t) open.delete(id);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The exact bytes signed. Exported so the client and the tests cannot drift from the relay. */
|
|
50
|
+
const signingPayload = (challenge) => [challenge.challenge_id, challenge.nonce, challenge.fingerprint, challenge.machine_id].join('|');
|
|
51
|
+
|
|
52
|
+
function issue(fingerprint, machineId = '') {
|
|
53
|
+
if (typeof fingerprint !== 'string' || fingerprint === '') return { ok: false, code: 'BAD_FINGERPRINT' };
|
|
54
|
+
if (publicKeyFor(fingerprint) === null) return { ok: false, code: 'FINGERPRINT_UNREGISTERED' };
|
|
55
|
+
prune();
|
|
56
|
+
const recent = (issued.get(fingerprint) ?? []).filter((at) => now() - at < 60_000);
|
|
57
|
+
if (recent.length >= maxPerMinute) { issued.set(fingerprint, recent); return { ok: false, code: 'RATE_LIMITED' }; }
|
|
58
|
+
recent.push(now());
|
|
59
|
+
issued.set(fingerprint, recent);
|
|
60
|
+
const challenge_id = createHash('sha256').update(fingerprint).update(String(randomBytes(16).toString('hex'))).update(String(now())).digest('hex').slice(0, 32);
|
|
61
|
+
const challenge = {
|
|
62
|
+
challenge_id, nonce: randomBytes(16).toString('hex'), fingerprint,
|
|
63
|
+
machine_id: typeof machineId === 'string' ? machineId : '', expires_at_ms: now() + ttlMs,
|
|
64
|
+
};
|
|
65
|
+
open.set(challenge_id, challenge);
|
|
66
|
+
return { ok: true, challenge_id, nonce: challenge.nonce, expires_at_ms: challenge.expires_at_ms };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Verify a signed challenge. Consumes the challenge on ANY attempt.
|
|
71
|
+
*
|
|
72
|
+
* @param {{challenge_id: string, machine_id: string, signature: string}} request
|
|
73
|
+
* @returns {{ok: true, fingerprint: string} | {ok: false, code: string}}
|
|
74
|
+
*/
|
|
75
|
+
function consume(request) {
|
|
76
|
+
prune();
|
|
77
|
+
const challenge = open.get(request?.challenge_id);
|
|
78
|
+
if (challenge === undefined) return { ok: false, code: 'CHALLENGE_INVALID' };
|
|
79
|
+
open.delete(challenge.challenge_id); // one attempt, success or not
|
|
80
|
+
if (challenge.expires_at_ms <= now()) return { ok: false, code: 'CHALLENGE_EXPIRED' };
|
|
81
|
+
if (typeof request.machine_id !== 'string' || request.machine_id === '') return { ok: false, code: 'BAD_REQUEST' };
|
|
82
|
+
// The machine that signs must be the machine that asked: issuing for one id and signing for another is a forgery.
|
|
83
|
+
if (challenge.machine_id !== '' && challenge.machine_id !== request.machine_id) return { ok: false, code: 'MACHINE_MISMATCH' };
|
|
84
|
+
const pem = publicKeyFor(challenge.fingerprint);
|
|
85
|
+
if (pem === null) return { ok: false, code: 'FINGERPRINT_UNREGISTERED' };
|
|
86
|
+
const payload = signingPayload({ ...challenge, machine_id: request.machine_id });
|
|
87
|
+
if (verifyChallenge(pem, payload, request.signature) !== true) return { ok: false, code: 'SIGNATURE_INVALID' };
|
|
88
|
+
return { ok: true, fingerprint: challenge.fingerprint };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { issue, consume, signingPayload, _open: open };
|
|
92
|
+
}
|