@mmmbuto/nexuscrew 0.8.43 → 0.8.45

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.
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+ // Reverse-port pool and append-only ledger. The ledger is deliberately
3
+ // separate from nodes.json: a node store is rewritten often, whereas reusing a
4
+ // released SSH permitlisten is unsafe until an operator changes that policy.
5
+ // Its anchor lives in nodes.json (validated by the caller) so a deleted or
6
+ // truncated ledger can never make a retired pool allocatable again.
7
+ const crypto = require('node:crypto');
8
+ const fs = require('node:fs');
9
+ const os = require('node:os');
10
+ const path = require('node:path');
11
+
12
+ const REVERSE_PORT_BASE = 44001;
13
+ const REVERSE_POOL_SIZE = 3;
14
+ const REVERSE_POOL_STRIDE = 100;
15
+ const LEDGER_VERSION = 1;
16
+ const DIGEST_RE = /^[a-f0-9]{64}$/;
17
+ const EPOCH_RE = /^[a-f0-9]{32,64}$/;
18
+ const ENTRY_TYPES = new Set(['allocated', 'retired', 'quarantined']);
19
+
20
+ function isPort(port) {
21
+ return Number.isInteger(port) && port >= 1 && port <= 65535;
22
+ }
23
+
24
+ function reversePoolForBase(base) {
25
+ if (!isPort(base)) return null;
26
+ const slots = Array.from({ length: REVERSE_POOL_SIZE }, (_, index) => base + (index * REVERSE_POOL_STRIDE));
27
+ return slots.every(isPort) ? slots : null;
28
+ }
29
+
30
+ function validReversePoolBase(base) {
31
+ return Array.isArray(reversePoolForBase(base));
32
+ }
33
+
34
+ function defaultLedgerPath(home = os.homedir()) {
35
+ return path.join(home, '.nexuscrew', 'reverse-pool-ledger.json');
36
+ }
37
+
38
+ function genesisDigest(epoch) {
39
+ return crypto.createHash('sha256').update(`nexuscrew-reverse-pool-ledger/v${LEDGER_VERSION}\0${epoch}`).digest('hex');
40
+ }
41
+
42
+ function digestEntry(entry) {
43
+ return crypto.createHash('sha256').update(JSON.stringify({
44
+ seq: entry.seq,
45
+ type: entry.type,
46
+ base: entry.base,
47
+ at: entry.at,
48
+ prevDigest: entry.prevDigest,
49
+ })).digest('hex');
50
+ }
51
+
52
+ function parseEntry(entry, previous, expectedSeq) {
53
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
54
+ const keys = Object.keys(entry);
55
+ if (keys.length !== 6 || keys.some((key) => !['seq', 'type', 'base', 'at', 'prevDigest', 'digest'].includes(key))) return null;
56
+ if (!Number.isSafeInteger(entry.seq) || entry.seq !== expectedSeq || !ENTRY_TYPES.has(entry.type)
57
+ || !validReversePoolBase(entry.base) || !Number.isSafeInteger(entry.at) || entry.at < 0
58
+ || !DIGEST_RE.test(String(entry.prevDigest || '')) || !DIGEST_RE.test(String(entry.digest || ''))
59
+ || entry.prevDigest !== previous || digestEntry(entry) !== entry.digest) return null;
60
+ return { seq: entry.seq, type: entry.type, base: entry.base, at: entry.at, prevDigest: entry.prevDigest, digest: entry.digest };
61
+ }
62
+
63
+ function parseLedger(raw) {
64
+ try {
65
+ const source = typeof raw === 'string' ? JSON.parse(raw) : raw;
66
+ if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
67
+ const keys = Object.keys(source);
68
+ if (keys.length !== 3 || keys.some((key) => !['version', 'epoch', 'entries'].includes(key))) return null;
69
+ if (source.version !== LEDGER_VERSION || !EPOCH_RE.test(String(source.epoch || ''))
70
+ || !Array.isArray(source.entries) || source.entries.length > 65535) return null;
71
+ let previous = genesisDigest(source.epoch);
72
+ const entries = [];
73
+ for (let index = 0; index < source.entries.length; index += 1) {
74
+ const entry = parseEntry(source.entries[index], previous, index + 1);
75
+ if (!entry) return null;
76
+ previous = entry.digest;
77
+ entries.push(entry);
78
+ }
79
+ return { version: LEDGER_VERSION, epoch: source.epoch, entries };
80
+ } catch (_) { return null; }
81
+ }
82
+
83
+ function emptyLedger(epoch = crypto.randomBytes(16).toString('hex')) {
84
+ if (!EPOCH_RE.test(epoch)) throw new Error('ledger epoch non valido');
85
+ return { version: LEDGER_VERSION, epoch, entries: [] };
86
+ }
87
+
88
+ function ledgerHead(ledger) {
89
+ const parsed = parseLedger(ledger);
90
+ if (!parsed) return null;
91
+ const last = parsed.entries.at(-1);
92
+ return {
93
+ epoch: parsed.epoch,
94
+ seq: last ? last.seq : 0,
95
+ digest: last ? last.digest : genesisDigest(parsed.epoch),
96
+ };
97
+ }
98
+
99
+ function appendLedger(ledger, { type, base, at = Date.now() } = {}) {
100
+ const parsed = parseLedger(ledger);
101
+ if (!parsed) throw new Error('reverse pool ledger non valido');
102
+ if (!ENTRY_TYPES.has(type) || !validReversePoolBase(base) || !Number.isSafeInteger(at) || at < 0) {
103
+ throw new Error('entry reverse pool ledger non valida');
104
+ }
105
+ const head = ledgerHead(parsed);
106
+ const entry = { seq: head.seq + 1, type, base, at, prevDigest: head.digest };
107
+ entry.digest = digestEntry(entry);
108
+ return { ...parsed, entries: [...parsed.entries, entry] };
109
+ }
110
+
111
+ function parseAnchor(anchor) {
112
+ if (!anchor || typeof anchor !== 'object' || Array.isArray(anchor)) return null;
113
+ if (Object.keys(anchor).length !== 3 || !EPOCH_RE.test(String(anchor.epoch || ''))
114
+ || !Number.isSafeInteger(anchor.seq) || anchor.seq < 0 || !DIGEST_RE.test(String(anchor.digest || ''))) return null;
115
+ return { epoch: anchor.epoch, seq: anchor.seq, digest: anchor.digest };
116
+ }
117
+
118
+ // A ledger can legitimately be ahead of the anchor after an interruption
119
+ // between its atomic write and nodes.json's atomic anchor update. That is
120
+ // conservative and is reconciled by returning advanceAnchor. Any missing
121
+ // prefix is unsafe and blocks only fresh pool allocation; existing tunnels are
122
+ // intentionally not affected.
123
+ function validateLedgerAnchor(ledger, anchor) {
124
+ const parsed = parseLedger(ledger);
125
+ const expected = parseAnchor(anchor);
126
+ if (!parsed) return { ok: false, code: 'reverse-pool-ledger-invalid', allocationBlocked: true };
127
+ if (!expected) return { ok: false, code: 'reverse-pool-anchor-missing', allocationBlocked: true };
128
+ if (parsed.epoch !== expected.epoch) return { ok: false, code: 'reverse-pool-anchor-epoch-mismatch', allocationBlocked: true };
129
+ const head = ledgerHead(parsed);
130
+ if (head.seq < expected.seq) return { ok: false, code: 'reverse-pool-ledger-behind-anchor', allocationBlocked: true };
131
+ const prefix = expected.seq === 0
132
+ ? { epoch: parsed.epoch, seq: 0, digest: genesisDigest(parsed.epoch) }
133
+ : parsed.entries[expected.seq - 1];
134
+ if (!prefix || prefix.digest !== expected.digest) return { ok: false, code: 'reverse-pool-anchor-prefix-mismatch', allocationBlocked: true };
135
+ if (head.seq > expected.seq) return { ok: true, code: 'reverse-pool-anchor-advance', allocationBlocked: false, advanceAnchor: head };
136
+ return { ok: true, code: 'reverse-pool-anchor-current', allocationBlocked: false, anchor: head };
137
+ }
138
+
139
+ function loadLedger(ledgerPath) {
140
+ try {
141
+ const st = fs.lstatSync(ledgerPath);
142
+ if (!st.isFile() || st.isSymbolicLink()) return null;
143
+ return parseLedger(fs.readFileSync(ledgerPath, 'utf8'));
144
+ } catch (_) { return null; }
145
+ }
146
+
147
+ function atomicWriteLedger(ledgerPath, ledger) {
148
+ const parsed = parseLedger(ledger);
149
+ if (!parsed) throw new Error('reverse pool ledger non valido');
150
+ try {
151
+ if (fs.lstatSync(ledgerPath).isSymbolicLink()) throw new Error('reverse pool ledger target e\' un symlink');
152
+ } catch (error) {
153
+ if (error.code !== 'ENOENT') throw error;
154
+ }
155
+ const dir = path.dirname(ledgerPath);
156
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
157
+ const tmp = path.join(dir, `.${path.basename(ledgerPath)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
158
+ try {
159
+ fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
160
+ fs.chmodSync(tmp, 0o600);
161
+ fs.renameSync(tmp, ledgerPath);
162
+ } catch (error) {
163
+ try { fs.unlinkSync(tmp); } catch (_) {}
164
+ throw error;
165
+ }
166
+ return parsed;
167
+ }
168
+
169
+ function usedPorts(nodes = [], ledger = null) {
170
+ const ports = new Set();
171
+ for (const node of nodes || []) {
172
+ if (isPort(node && node.localPort)) ports.add(node.localPort);
173
+ if (isPort(node && node.reversePort)) ports.add(node.reversePort);
174
+ const pool = node && node.reversePool;
175
+ if (pool && validReversePoolBase(pool.base)) for (const port of reversePoolForBase(pool.base)) ports.add(port);
176
+ }
177
+ const parsed = parseLedger(ledger);
178
+ for (const entry of (parsed && parsed.entries) || []) {
179
+ // An allocation is permanent for this ledger's epoch. Keeping every
180
+ // allocated base reserved is what prevents a removed peer's old SSH key
181
+ // from binding a newly assigned pool.
182
+ if (entry.type === 'allocated' && validReversePoolBase(entry.base)) {
183
+ for (const port of reversePoolForBase(entry.base)) ports.add(port);
184
+ }
185
+ }
186
+ return ports;
187
+ }
188
+
189
+ function nextReversePool(nodes = [], ledger = null, { start = REVERSE_PORT_BASE } = {}) {
190
+ const occupied = usedPorts(nodes, ledger);
191
+ for (let base = start; validReversePoolBase(base); base += 1) {
192
+ const slots = reversePoolForBase(base);
193
+ if (slots.every((port) => !occupied.has(port))) return { base, slots };
194
+ }
195
+ throw new Error('nessun reverse port pool disponibile');
196
+ }
197
+
198
+ async function allocateAvailableReversePool(nodes = [], ledger = null, {
199
+ start = REVERSE_PORT_BASE,
200
+ canBind = async () => true,
201
+ } = {}) {
202
+ const occupied = usedPorts(nodes, ledger);
203
+ for (let base = start; validReversePoolBase(base); base += 1) {
204
+ const slots = reversePoolForBase(base);
205
+ if (!slots.every((port) => !occupied.has(port))) continue;
206
+ let available = true;
207
+ for (const port of slots) {
208
+ if (!(await canBind(port))) { available = false; break; }
209
+ }
210
+ if (available) return { base, slots };
211
+ }
212
+ throw new Error('nessun reverse port pool disponibile');
213
+ }
214
+
215
+ module.exports = {
216
+ REVERSE_PORT_BASE, REVERSE_POOL_SIZE, REVERSE_POOL_STRIDE, LEDGER_VERSION,
217
+ reversePoolForBase, validReversePoolBase, defaultLedgerPath,
218
+ genesisDigest, digestEntry, parseLedger, emptyLedger, ledgerHead, appendLedger,
219
+ parseAnchor, validateLedgerAnchor, loadLedger, atomicWriteLedger,
220
+ usedPorts, nextReversePool, allocateAvailableReversePool,
221
+ };
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ // Pure state transitions for the three-slot reverse pool. SSH side effects
4
+ // live outside this module: callers reserve, prove the candidate, commit on
5
+ // the hub, then let the peer drain the old sidecar. Keeping the transitions
6
+ // pure makes crash/replay cases explicit and testable.
7
+ const crypto = require('node:crypto');
8
+
9
+ const LEASE_MS = 60_000;
10
+ const GRACE_MS = 30_000;
11
+
12
+ function clone(pool) { return JSON.parse(JSON.stringify(pool)); }
13
+ function validSlot(pool, slot) { return Number.isInteger(slot) && pool && Array.isArray(pool.slots) && slot >= 0 && slot < pool.slots.length; }
14
+
15
+ function nextReadySlot(pool) {
16
+ if (!pool || !Array.isArray(pool.slots)) return null;
17
+ return pool.slots.findIndex((slot, index) => index !== pool.activeSlot && slot && slot.state === 'ready');
18
+ }
19
+
20
+ function prepareRotation(pool, { slot = nextReadySlot(pool), now = Date.now(), leaseId = crypto.randomBytes(16).toString('hex'), leaseMs = LEASE_MS } = {}) {
21
+ if (!pool || pool.verification !== 'verified' || !Array.isArray(pool.verifiedSlots)
22
+ || pool.verifiedSlots.length !== pool.slots.length || pool.rotation?.phase !== 'active' || !validSlot(pool, slot)
23
+ || pool.slots[slot].state !== 'ready' || !Number.isSafeInteger(now) || !Number.isSafeInteger(leaseMs) || leaseMs < 1
24
+ || typeof leaseId !== 'string' || !/^[a-f0-9]{32,64}$/.test(leaseId)) return null;
25
+ const next = clone(pool);
26
+ const generation = next.activeGeneration + 1;
27
+ next.slots[slot] = { ...next.slots[slot], state: 'reserved', generation };
28
+ next.rotation = { phase: 'prepared', generation, slot, leaseId, expiresAt: now + leaseMs };
29
+ return next;
30
+ }
31
+
32
+ function abortPrepared(pool, { now = Date.now() } = {}) {
33
+ if (!pool || pool.rotation?.phase !== 'prepared' || !Number.isSafeInteger(now)) return null;
34
+ const next = clone(pool);
35
+ const slot = next.rotation.slot;
36
+ next.slots[slot] = { ...next.slots[slot], state: 'ready' };
37
+ next.rotation = { phase: 'active', generation: next.activeGeneration, slot: next.activeSlot };
38
+ return next;
39
+ }
40
+
41
+ function commitRotation(pool, { leaseId, now = Date.now(), graceMs = GRACE_MS } = {}) {
42
+ if (!pool || pool.rotation?.phase !== 'prepared' || typeof leaseId !== 'string' || leaseId !== pool.rotation.leaseId
43
+ || !Number.isSafeInteger(now) || now > pool.rotation.expiresAt || !Number.isSafeInteger(graceMs) || graceMs < 0) return null;
44
+ const next = clone(pool);
45
+ const oldSlot = next.activeSlot;
46
+ const oldGeneration = next.activeGeneration;
47
+ const slot = next.rotation.slot;
48
+ next.slots[oldSlot] = { ...next.slots[oldSlot], state: 'draining' };
49
+ next.slots[slot] = { ...next.slots[slot], state: 'active', generation: next.rotation.generation };
50
+ next.activeSlot = slot;
51
+ next.activeGeneration = next.rotation.generation;
52
+ next.rotation = {
53
+ phase: 'switched', generation: next.activeGeneration, slot,
54
+ oldSlot, oldGeneration, graceUntil: now + graceMs,
55
+ };
56
+ return next;
57
+ }
58
+
59
+ function settleGrace(pool, { now = Date.now() } = {}) {
60
+ if (!pool || pool.rotation?.phase !== 'switched' || !Number.isSafeInteger(now) || now < pool.rotation.graceUntil) return null;
61
+ const next = clone(pool);
62
+ const oldSlot = next.rotation.oldSlot;
63
+ if (next.slots[oldSlot]?.state === 'draining') next.slots[oldSlot] = { ...next.slots[oldSlot], state: 'ready' };
64
+ next.rotation = { phase: 'active', generation: next.activeGeneration, slot: next.activeSlot };
65
+ return next;
66
+ }
67
+
68
+ function quarantineSlot(pool, { slot, now = Date.now() } = {}) {
69
+ if (!pool || !validSlot(pool, slot) || slot === pool.activeSlot || !Number.isSafeInteger(now)) return null;
70
+ const next = clone(pool);
71
+ next.slots[slot] = { ...next.slots[slot], state: 'quarantined' };
72
+ if (next.rotation?.phase === 'prepared' && next.rotation.slot === slot) {
73
+ next.rotation = { phase: 'abandoned', generation: next.activeGeneration, slot: next.activeSlot };
74
+ }
75
+ return next;
76
+ }
77
+
78
+ module.exports = { LEASE_MS, GRACE_MS, nextReadySlot, prepareRotation, abortPrepared, commitRotation, settleGrace, quarantineSlot };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+ // Per-slot loopback listeners owned by this NexusCrew process. They run the
3
+ // same Express application as the primary listener, but expose a distinct TCP
4
+ // destination to every -R. That physical distinction is what makes an
5
+ // old-port -> new-port relay unable to obtain a slot MAC.
6
+ const http = require('node:http');
7
+ const proof = require('./reverse-slot-proof.js');
8
+
9
+ function listen(server, options) {
10
+ return new Promise((resolve, reject) => {
11
+ server.once('error', reject);
12
+ server.once('listening', () => {
13
+ server.removeListener('error', reject);
14
+ resolve(server.address());
15
+ });
16
+ server.listen(options);
17
+ });
18
+ }
19
+
20
+ function close(server) {
21
+ return new Promise((resolve) => {
22
+ try { server.close(() => resolve()); } catch (_) { resolve(); }
23
+ });
24
+ }
25
+
26
+ function createReverseSlotListeners({ app, createServerImpl = http.createServer, diagnostics } = {}) {
27
+ if (typeof app !== 'function') throw new Error('reverse slot listeners richiede app HTTP');
28
+ const listeners = new Map(); // local target port -> owned server + immutable expected tuple
29
+
30
+ async function open({ nodeName, remotePort, generation, instanceId, secret }) {
31
+ if (typeof nodeName !== 'string' || !proof.newProbe({ remotePort, generation, instanceId }, () => Buffer.alloc(24))) {
32
+ throw new Error('reverse slot listener spec non valida');
33
+ }
34
+ if (typeof secret !== 'string' || !secret) throw new Error('reverse slot listener credential mancante');
35
+ const server = createServerImpl(app);
36
+ let address;
37
+ try { address = await listen(server, { host: '127.0.0.1', port: 0, exclusive: true }); }
38
+ catch (error) { try { server.close(); } catch (_) {} throw error; }
39
+ const localPort = address && address.port;
40
+ if (!Number.isInteger(localPort)) { await close(server); throw new Error('reverse slot listener non ha una porta locale'); }
41
+ listeners.set(localPort, {
42
+ server, nodeName, secret,
43
+ expected: { remotePort, generation, instanceId },
44
+ });
45
+ diagnostics?.record?.('info', 'reverse-pool', 'REVERSE_SLOT_LISTENER_READY', 'Reverse slot listener ready', {
46
+ node: nodeName, remotePort, generation,
47
+ });
48
+ return { localPort, remotePort, generation };
49
+ }
50
+
51
+ async function closePort(localPort) {
52
+ const entry = listeners.get(localPort);
53
+ if (!entry) return false;
54
+ listeners.delete(localPort);
55
+ await close(entry.server);
56
+ return true;
57
+ }
58
+
59
+ async function closeAll() {
60
+ await Promise.all([...listeners.keys()].map(closePort));
61
+ }
62
+
63
+ function respond(req, res) {
64
+ const localPort = req && req.socket && req.socket.localPort;
65
+ const entry = listeners.get(localPort);
66
+ if (!entry) return false;
67
+ const body = req.body || {};
68
+ const response = proof.respondSlotProof({ secret: entry.secret, expected: entry.expected, request: body });
69
+ if (!response.ok) {
70
+ res.status(409).json({ error: 'reverse slot proof non valida', code: response.code });
71
+ } else {
72
+ res.json(response);
73
+ }
74
+ return true;
75
+ }
76
+
77
+ return { open, closePort, closeAll, respond, size: () => listeners.size };
78
+ }
79
+
80
+ module.exports = { createReverseSlotListeners };
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+ // No-bearer ownership proof for one reverse slot. The local listener knows
3
+ // which remote -R slot targets it; a request relayed from another slot therefore
4
+ // reaches a listener with different expectedPort and is rejected before a MAC
5
+ // is emitted. The hub sends only public challenge material to an untrusted
6
+ // loopback listener, never the peer credential itself.
7
+ const crypto = require('node:crypto');
8
+
9
+ const PROBE_ID_RE = /^[A-Za-z0-9_-]{16,128}$/;
10
+ const NONCE_RE = /^[A-Za-z0-9_-]{16,128}$/;
11
+ const INSTANCE_RE = /^[a-f0-9]{16,64}$/;
12
+
13
+ function isPort(port) { return Number.isInteger(port) && port >= 1 && port <= 65535; }
14
+ function safeEqual(a, b) {
15
+ const aa = Buffer.from(String(a || ''));
16
+ const bb = Buffer.from(String(b || ''));
17
+ return aa.length === bb.length && crypto.timingSafeEqual(aa, bb);
18
+ }
19
+
20
+ function parseTuple(value) {
21
+ if (!value || typeof value !== 'object' || Array.isArray(value)
22
+ || Object.keys(value).some((key) => !['probeId', 'nonce', 'dialedPort', 'generation', 'instanceId'].includes(key))
23
+ || !PROBE_ID_RE.test(String(value.probeId || '')) || !NONCE_RE.test(String(value.nonce || ''))
24
+ || !isPort(value.dialedPort) || !Number.isSafeInteger(value.generation) || value.generation < 1
25
+ || !INSTANCE_RE.test(String(value.instanceId || ''))) return null;
26
+ return {
27
+ probeId: value.probeId, nonce: value.nonce, dialedPort: value.dialedPort,
28
+ generation: value.generation, instanceId: value.instanceId,
29
+ };
30
+ }
31
+
32
+ function canonicalTuple(tuple) {
33
+ const parsed = parseTuple(tuple);
34
+ return parsed ? `nexuscrew-reverse-slot-proof/v1\0${JSON.stringify(parsed)}` : null;
35
+ }
36
+
37
+ function signSlotProof(secret, tuple) {
38
+ const canonical = canonicalTuple(tuple);
39
+ if (typeof secret !== 'string' || !secret || !canonical) return null;
40
+ return crypto.createHmac('sha256', secret).update(canonical).digest('base64url');
41
+ }
42
+
43
+ function expectedTuple(expected, request) {
44
+ const tuple = parseTuple(request && {
45
+ probeId: request.probeId,
46
+ nonce: request.nonce,
47
+ dialedPort: request.dialedPort,
48
+ generation: request.generation,
49
+ instanceId: request.instanceId,
50
+ });
51
+ if (!tuple || !expected || tuple.dialedPort !== expected.remotePort
52
+ || tuple.generation !== expected.generation || tuple.instanceId !== expected.instanceId) return null;
53
+ return tuple;
54
+ }
55
+
56
+ function respondSlotProof({ secret, expected, request }) {
57
+ const tuple = expectedTuple(expected, request);
58
+ const mac = tuple && signSlotProof(secret, tuple);
59
+ return mac ? { ok: true, ...tuple, mac } : { ok: false, code: 'reverse-slot-proof-mismatch' };
60
+ }
61
+
62
+ function verifySlotProof({ secret, expected, challenge, response }) {
63
+ const issued = expectedTuple(expected, challenge);
64
+ const tuple = expectedTuple(expected, response);
65
+ const issuedCanonical = canonicalTuple(issued);
66
+ const responseCanonical = canonicalTuple(tuple);
67
+ if (!issued || !tuple || !issuedCanonical || !responseCanonical
68
+ || !safeEqual(issuedCanonical, responseCanonical)
69
+ || !response || typeof response.mac !== 'string') {
70
+ return { owned: false, code: 'reverse-slot-proof-mismatch' };
71
+ }
72
+ const expectedMac = signSlotProof(secret, tuple);
73
+ if (!expectedMac || !safeEqual(expectedMac, response.mac)) return { owned: false, code: 'reverse-slot-proof-invalid' };
74
+ return { owned: true, code: 'reverse-slot-owned', tuple };
75
+ }
76
+
77
+ function newProbe(expected, randomBytes = crypto.randomBytes) {
78
+ if (!expected || !isPort(expected.remotePort) || !Number.isSafeInteger(expected.generation)
79
+ || expected.generation < 1 || !INSTANCE_RE.test(String(expected.instanceId || ''))) return null;
80
+ return {
81
+ probeId: randomBytes(18).toString('base64url'),
82
+ nonce: randomBytes(24).toString('base64url'),
83
+ dialedPort: expected.remotePort,
84
+ generation: expected.generation,
85
+ instanceId: expected.instanceId,
86
+ };
87
+ }
88
+
89
+ async function probeReverseSlot({ port, secret, expected, fetchImpl = fetch, timeoutMs = 1500, randomBytes } = {}) {
90
+ if (!isPort(port) || typeof secret !== 'string' || !secret) return { owned: false, code: 'reverse-slot-proof-invalid-input' };
91
+ const probe = newProbe(expected, randomBytes);
92
+ if (!probe) return { owned: false, code: 'reverse-slot-proof-invalid-input' };
93
+ const ctrl = new AbortController();
94
+ const timer = setTimeout(() => ctrl.abort(), Math.max(1, Math.min(timeoutMs, 5000)));
95
+ try {
96
+ const response = await fetchImpl(`http://127.0.0.1:${port}/reverse-slot-proof`, {
97
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(probe), signal: ctrl.signal,
98
+ });
99
+ if (!response || response.status !== 200) return { owned: false, code: 'reverse-slot-proof-unavailable' };
100
+ const body = await response.json().catch(() => null);
101
+ return verifySlotProof({ secret, expected, challenge: probe, response: body });
102
+ } catch (_) { return { owned: false, code: 'reverse-slot-proof-unavailable' }; }
103
+ finally { clearTimeout(timer); }
104
+ }
105
+
106
+ module.exports = {
107
+ parseTuple, canonicalTuple, signSlotProof, respondSlotProof, verifySlotProof, newProbe, probeReverseSlot,
108
+ };