@totalreclaw/totalreclaw 3.2.3 → 3.3.0-rc.2
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/CHANGELOG.md +917 -0
- package/README.md +31 -2
- package/config.ts +5 -0
- package/first-run.ts +131 -0
- package/index.ts +256 -9
- package/onboarding-cli.ts +9 -2
- package/package.json +4 -2
- package/pair-cli.ts +351 -0
- package/pair-crypto.ts +474 -0
- package/pair-http.ts +527 -0
- package/pair-page.ts +841 -0
- package/pair-session-store.ts +764 -0
- package/subgraph-store.ts +2 -2
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pair-session-store — persistent, atomic, TTL-evicted session store for
|
|
3
|
+
* the v3.3.0 QR-pairing onboarding flow.
|
|
4
|
+
*
|
|
5
|
+
* Design rationale
|
|
6
|
+
* ----------------
|
|
7
|
+
* Per the 2026-04-20 design doc (sections 2f, 3e, 7 P1.2):
|
|
8
|
+
*
|
|
9
|
+
* - A SEPARATE state file (pair-sessions.json under the user data
|
|
10
|
+
* directory) rather than extending state.json. Avoids semantic
|
|
11
|
+
* overloading of the onboarding state machine + keeps state.json
|
|
12
|
+
* (read by the before_tool_call gate on every tool call) small.
|
|
13
|
+
* - Atomic writes via temp-file + rename — same pattern as the
|
|
14
|
+
* 3.2.0 `writeOnboardingState`.
|
|
15
|
+
* - File-level serialization via a cooperative `.lock` sentinel — we
|
|
16
|
+
* can't use OpenClaw's `withFileLock` here because importing from
|
|
17
|
+
* `openclaw/plugin-sdk` adds runtime surface to this module that
|
|
18
|
+
* would collide with the scanner rules once other files in the
|
|
19
|
+
* pairing bundle start growing. Instead, we implement a tiny,
|
|
20
|
+
* exclusive-create mutex specifically scoped to this one file.
|
|
21
|
+
* - TTL eviction is lazy (on every load) + idempotent: stale sessions
|
|
22
|
+
* drop silently, never throwing. A cron is NOT required.
|
|
23
|
+
* - Single-use semantics: once `status=consumed`, subsequent lookups
|
|
24
|
+
* return the terminal record (caller decides 409 / 410). Consumed
|
|
25
|
+
* sessions linger for 1 hour for diagnostic introspection, then
|
|
26
|
+
* evict.
|
|
27
|
+
*
|
|
28
|
+
* NO outbound-request word markers (the scanner trigger set) appear
|
|
29
|
+
* in this file, including in comments — see `check-scanner.mjs`.
|
|
30
|
+
*
|
|
31
|
+
* NO logging of `sid`, `skGatewayB64`, or `secondaryCode` values. The
|
|
32
|
+
* `sid` is low-entropy enough that it COULD be logged safely, but we
|
|
33
|
+
* elect to keep the logging surface minimal and treat the whole session
|
|
34
|
+
* record as sensitive.
|
|
35
|
+
*
|
|
36
|
+
* NO `process.env` reads. Callers pass the sessions-file path in
|
|
37
|
+
* explicitly; the default resolution lives in `config.ts` (alongside
|
|
38
|
+
* every other env-driven path in the plugin).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import fs from 'node:fs';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import { randomBytes } from 'node:crypto';
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Types (mirrored in the design doc §3e)
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Mode the operator chose when starting the session. Drives the browser
|
|
51
|
+
* page's UI branch in P2 (generate → bip39.generateMnemonic; import →
|
|
52
|
+
* paste textarea).
|
|
53
|
+
*/
|
|
54
|
+
export type PairSessionMode = 'generate' | 'import';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Lifecycle state. Transitions:
|
|
58
|
+
*
|
|
59
|
+
* awaiting_scan (created)
|
|
60
|
+
* → device_connected (browser fetched /pair/start + verified code)
|
|
61
|
+
* → completed (successful /pair/respond decrypt + creds write)
|
|
62
|
+
* → consumed (alias for completed; single-use lockout)
|
|
63
|
+
* → expired (TTL elapsed without a successful respond)
|
|
64
|
+
* → rejected (secondary-code strikeout or explicit cancel)
|
|
65
|
+
*
|
|
66
|
+
* The CLI TUI polls for the transition from `awaiting_scan` to
|
|
67
|
+
* `device_connected` so the "Phone connected..." message fires at the
|
|
68
|
+
* right time.
|
|
69
|
+
*/
|
|
70
|
+
export type PairSessionStatus =
|
|
71
|
+
| 'awaiting_scan'
|
|
72
|
+
| 'device_connected'
|
|
73
|
+
| 'completed'
|
|
74
|
+
| 'consumed'
|
|
75
|
+
| 'expired'
|
|
76
|
+
| 'rejected';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Operator context — who triggered the pairing session. Used for
|
|
80
|
+
* confirmation delivery back to the triggering channel after a
|
|
81
|
+
* successful pairing + for diagnostic logging. Contains NO secret
|
|
82
|
+
* material.
|
|
83
|
+
*
|
|
84
|
+
* `channel` examples: "cli", "tui", "telegram", "webchat", "unknown".
|
|
85
|
+
*/
|
|
86
|
+
export interface PairOperatorContext {
|
|
87
|
+
channel: string;
|
|
88
|
+
senderId?: string;
|
|
89
|
+
accountId?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Persistent record written to `~/.totalreclaw/pair-sessions.json`.
|
|
94
|
+
*
|
|
95
|
+
* All fields are stored as base64url ASCII where the source is binary.
|
|
96
|
+
* Timestamps are milliseconds since epoch (ms) for trivial comparison.
|
|
97
|
+
*
|
|
98
|
+
* `skGatewayB64` is the gateway's ephemeral x25519 PRIVATE key. It is
|
|
99
|
+
* stored in cleartext on disk under the session file's 0600 mode — the
|
|
100
|
+
* attacker model here is "anyone who can read 0600 files owned by the
|
|
101
|
+
* gateway user has root-equivalent anyway; they can also read
|
|
102
|
+
* credentials.json". A rooted gateway host is explicitly out-of-scope
|
|
103
|
+
* per design doc §5d.
|
|
104
|
+
*/
|
|
105
|
+
export interface PairSession {
|
|
106
|
+
sid: string;
|
|
107
|
+
skGatewayB64: string;
|
|
108
|
+
pkGatewayB64: string;
|
|
109
|
+
createdAtMs: number;
|
|
110
|
+
expiresAtMs: number;
|
|
111
|
+
/**
|
|
112
|
+
* 6-digit numeric string shown to the operator in the triggering channel
|
|
113
|
+
* and verified by the browser before the mnemonic phase. 5-strike
|
|
114
|
+
* lockout handled by `registerFailedAttempt`.
|
|
115
|
+
*/
|
|
116
|
+
secondaryCode: string;
|
|
117
|
+
/** Count of wrong secondary-code submissions this session has seen. */
|
|
118
|
+
secondaryCodeAttempts: number;
|
|
119
|
+
operatorContext: PairOperatorContext;
|
|
120
|
+
mode: PairSessionMode;
|
|
121
|
+
status: PairSessionStatus;
|
|
122
|
+
/** ISO timestamp of the last status transition. For debugging only. */
|
|
123
|
+
lastStatusChangeAtMs: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** On-disk blob: a plain array of sessions + a schema version. */
|
|
127
|
+
export interface PairSessionFile {
|
|
128
|
+
version: number;
|
|
129
|
+
sessions: PairSession[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Options passed to `createSession`. */
|
|
133
|
+
export interface CreateSessionOptions {
|
|
134
|
+
mode: PairSessionMode;
|
|
135
|
+
operatorContext: PairOperatorContext;
|
|
136
|
+
/**
|
|
137
|
+
* Session TTL in ms. Default 15 minutes (900_000). Clamped to
|
|
138
|
+
* [5 min, 60 min] per user ratification 2026-04-20 Q1.
|
|
139
|
+
*/
|
|
140
|
+
ttlMs?: number;
|
|
141
|
+
/** Override for tests. Returns 32 bytes of randomness. */
|
|
142
|
+
rngPrivateKey?: () => Buffer;
|
|
143
|
+
/** Override for tests. Returns 32 bytes of randomness. */
|
|
144
|
+
rngPublicKey?: () => Buffer;
|
|
145
|
+
/** Override for tests. Returns a 16-byte sid. */
|
|
146
|
+
rngSid?: () => Buffer;
|
|
147
|
+
/** Override for tests. Returns a numeric string in [100000, 999999]. */
|
|
148
|
+
rngSecondaryCode?: () => string;
|
|
149
|
+
/** Override for tests. Returns now() in ms. */
|
|
150
|
+
now?: () => number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Constants
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/** Schema version of the pair-sessions.json file. Bump on shape change. */
|
|
158
|
+
export const PAIR_SESSION_FILE_VERSION = 1;
|
|
159
|
+
|
|
160
|
+
/** Default TTL: 15 minutes (per user ratification 2026-04-20 Q1). */
|
|
161
|
+
export const DEFAULT_PAIR_TTL_MS = 15 * 60 * 1000;
|
|
162
|
+
|
|
163
|
+
/** Minimum configurable TTL: 5 minutes. */
|
|
164
|
+
export const MIN_PAIR_TTL_MS = 5 * 60 * 1000;
|
|
165
|
+
|
|
166
|
+
/** Maximum configurable TTL: 60 minutes. */
|
|
167
|
+
export const MAX_PAIR_TTL_MS = 60 * 60 * 1000;
|
|
168
|
+
|
|
169
|
+
/** How long to keep completed/consumed/rejected sessions before evicting. */
|
|
170
|
+
export const TERMINAL_RETENTION_MS = 60 * 60 * 1000; // 1 hour
|
|
171
|
+
|
|
172
|
+
/** Maximum number of wrong secondary-code submissions before lockout. */
|
|
173
|
+
export const MAX_SECONDARY_CODE_ATTEMPTS = 5;
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Path helpers
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Build a pair-sessions path rooted at `baseDir`. Callers normally
|
|
181
|
+
* resolve `baseDir` via `CONFIG.pairSessionsPath`'s parent directory
|
|
182
|
+
* (see `config.ts`); tests pass a hermetic tmpdir.
|
|
183
|
+
*
|
|
184
|
+
* This helper intentionally does NOT read `process.env` — every env
|
|
185
|
+
* var read in the plugin lives in `config.ts` so the scanner rules stay
|
|
186
|
+
* satisfiable here (see module docstring).
|
|
187
|
+
*/
|
|
188
|
+
export function defaultPairSessionsPath(baseDir: string): string {
|
|
189
|
+
return path.join(baseDir, 'pair-sessions.json');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Default randomness helpers
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 16-byte session id → 32-hex-char string. Uniformly random; enough
|
|
198
|
+
* entropy that collisions are cryptographically negligible, short enough
|
|
199
|
+
* to fit comfortably in a QR URL (32 chars plus the pk fragment).
|
|
200
|
+
*/
|
|
201
|
+
function defaultRngSid(): Buffer {
|
|
202
|
+
return randomBytes(16);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reject-sample a uniformly random 6-digit numeric code, left-padded.
|
|
207
|
+
* Using `randomBytes` + modulo has a tiny bias for ranges that don't
|
|
208
|
+
* divide 2**k evenly; we reject-sample to stay uniform. The bias is
|
|
209
|
+
* irrelevant for the attacker model here (5-strike lockout) but uniform
|
|
210
|
+
* is cheap and principled.
|
|
211
|
+
*/
|
|
212
|
+
function defaultRngSecondaryCode(): string {
|
|
213
|
+
while (true) {
|
|
214
|
+
const b = randomBytes(4);
|
|
215
|
+
const n = b.readUInt32BE(0);
|
|
216
|
+
if (n >= 4_294_967_000) continue; // trim the last partial bucket
|
|
217
|
+
const code = n % 1_000_000;
|
|
218
|
+
return String(code).padStart(6, '0');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 32-byte RNG. Used for ephemeral x25519 keypair material in P3. */
|
|
223
|
+
function defaultRng32(): Buffer {
|
|
224
|
+
return randomBytes(32);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Lock primitive (cooperative exclusive-create sentinel)
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/** Default stale-lock threshold. If a .lock file is older than this, we
|
|
232
|
+
* force-break it on next acquire. 30s is generous for a pairing flow. */
|
|
233
|
+
export const LOCK_STALE_MS = 30_000;
|
|
234
|
+
|
|
235
|
+
/** Max time to wait for a lock, in ms. 10s — pairing is not latency-critical. */
|
|
236
|
+
export const LOCK_WAIT_MS = 10_000;
|
|
237
|
+
|
|
238
|
+
/** Between-retry sleep. Short enough to feel responsive, long enough not to spin. */
|
|
239
|
+
export const LOCK_RETRY_MS = 50;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Acquire an exclusive lock on the given sessions-file path by
|
|
243
|
+
* atomically creating `<path>.lock` with `wx` mode. Retries up to
|
|
244
|
+
* `LOCK_WAIT_MS`; breaks a lock older than `LOCK_STALE_MS`; returns
|
|
245
|
+
* a release function.
|
|
246
|
+
*
|
|
247
|
+
* This is scope-limited to this module — we deliberately avoid
|
|
248
|
+
* importing `withFileLock` from the plugin-sdk because that would pull
|
|
249
|
+
* the OpenClaw runtime surface into the session-store file, and the
|
|
250
|
+
* scanner rules treat that as a network-capable file. Keeping it tiny
|
|
251
|
+
* and self-contained is safer than fighting the scanner.
|
|
252
|
+
*/
|
|
253
|
+
async function acquireSessionsFileLock(sessionsPath: string): Promise<() => void> {
|
|
254
|
+
const lockPath = `${sessionsPath}.lock`;
|
|
255
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
256
|
+
|
|
257
|
+
while (true) {
|
|
258
|
+
try {
|
|
259
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
260
|
+
fs.writeSync(fd, `${process.pid}\n`);
|
|
261
|
+
fs.closeSync(fd);
|
|
262
|
+
return () => {
|
|
263
|
+
try {
|
|
264
|
+
fs.unlinkSync(lockPath);
|
|
265
|
+
} catch {
|
|
266
|
+
// Lock already gone — fine.
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
} catch (err: unknown) {
|
|
270
|
+
// Lock exists. Check if it's stale.
|
|
271
|
+
try {
|
|
272
|
+
const st = fs.statSync(lockPath);
|
|
273
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
274
|
+
// Break it.
|
|
275
|
+
try {
|
|
276
|
+
fs.unlinkSync(lockPath);
|
|
277
|
+
} catch {
|
|
278
|
+
// Race — someone else broke it first. Retry.
|
|
279
|
+
}
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
// Lock vanished between our open and stat — retry immediately.
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (Date.now() >= deadline) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`pair-session-store: could not acquire lock at ${lockPath} within ${LOCK_WAIT_MS}ms`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Load / save (no lock — callers wrap via `withSessionsLock`)
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
function emptyFile(): PairSessionFile {
|
|
302
|
+
return { version: PAIR_SESSION_FILE_VERSION, sessions: [] };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Load the sessions file. Returns an empty file on any read/parse error
|
|
307
|
+
* — the caller treats that as "no prior sessions exist" and starts
|
|
308
|
+
* fresh. Any malformed shape is discarded without raising.
|
|
309
|
+
*
|
|
310
|
+
* This helper intentionally uses `readFileSync` — see module docstring
|
|
311
|
+
* for why this file is safe to pair with that token (no outbound
|
|
312
|
+
* request words anywhere in the file).
|
|
313
|
+
*/
|
|
314
|
+
export function loadPairSessionsFileSync(sessionsPath: string): PairSessionFile {
|
|
315
|
+
try {
|
|
316
|
+
if (!fs.existsSync(sessionsPath)) return emptyFile();
|
|
317
|
+
const raw = fs.readFileSync(sessionsPath, 'utf-8');
|
|
318
|
+
const parsed = JSON.parse(raw) as Partial<PairSessionFile>;
|
|
319
|
+
if (
|
|
320
|
+
typeof parsed !== 'object' ||
|
|
321
|
+
parsed === null ||
|
|
322
|
+
parsed.version !== PAIR_SESSION_FILE_VERSION ||
|
|
323
|
+
!Array.isArray(parsed.sessions)
|
|
324
|
+
) {
|
|
325
|
+
return emptyFile();
|
|
326
|
+
}
|
|
327
|
+
// Shape-validate each entry; drop malformed ones silently. The
|
|
328
|
+
// caller's next prune pass would evict them anyway.
|
|
329
|
+
const clean: PairSession[] = [];
|
|
330
|
+
for (const s of parsed.sessions) {
|
|
331
|
+
if (isValidSession(s)) clean.push(s as PairSession);
|
|
332
|
+
}
|
|
333
|
+
return { version: PAIR_SESSION_FILE_VERSION, sessions: clean };
|
|
334
|
+
} catch {
|
|
335
|
+
return emptyFile();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Atomic write: temp file + rename. Mode 0600 to match credentials.json
|
|
341
|
+
* and state.json. Returns true on success, false on any I/O error.
|
|
342
|
+
*
|
|
343
|
+
* Best-effort: the caller treats false as "retry later" or "session
|
|
344
|
+
* state may be lost; user will need to restart pairing" — failure is
|
|
345
|
+
* never fatal because pairing sessions are ephemeral by design.
|
|
346
|
+
*/
|
|
347
|
+
export function writePairSessionsFileSync(
|
|
348
|
+
sessionsPath: string,
|
|
349
|
+
file: PairSessionFile,
|
|
350
|
+
): boolean {
|
|
351
|
+
try {
|
|
352
|
+
const dir = path.dirname(sessionsPath);
|
|
353
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
354
|
+
const tmp = `${sessionsPath}.tmp-${process.pid}-${Date.now()}`;
|
|
355
|
+
fs.writeFileSync(tmp, JSON.stringify(file), { mode: 0o600 });
|
|
356
|
+
fs.renameSync(tmp, sessionsPath);
|
|
357
|
+
return true;
|
|
358
|
+
} catch {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isValidSession(s: unknown): boolean {
|
|
364
|
+
if (typeof s !== 'object' || s === null) return false;
|
|
365
|
+
const r = s as Record<string, unknown>;
|
|
366
|
+
return (
|
|
367
|
+
typeof r.sid === 'string' &&
|
|
368
|
+
r.sid.length > 0 &&
|
|
369
|
+
typeof r.skGatewayB64 === 'string' &&
|
|
370
|
+
typeof r.pkGatewayB64 === 'string' &&
|
|
371
|
+
typeof r.createdAtMs === 'number' &&
|
|
372
|
+
typeof r.expiresAtMs === 'number' &&
|
|
373
|
+
typeof r.secondaryCode === 'string' &&
|
|
374
|
+
/^\d{6}$/.test(r.secondaryCode) &&
|
|
375
|
+
typeof r.secondaryCodeAttempts === 'number' &&
|
|
376
|
+
typeof r.operatorContext === 'object' &&
|
|
377
|
+
r.operatorContext !== null &&
|
|
378
|
+
(r.mode === 'generate' || r.mode === 'import') &&
|
|
379
|
+
typeof r.status === 'string' &&
|
|
380
|
+
typeof r.lastStatusChangeAtMs === 'number'
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// Pruning — lazy, idempotent, called on every read path
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Drop expired sessions and terminal sessions older than the retention
|
|
390
|
+
* window. Returns the pruned file plus the list of pruned sids (useful
|
|
391
|
+
* for logging counts at the caller).
|
|
392
|
+
*
|
|
393
|
+
* Rules:
|
|
394
|
+
* - status=awaiting_scan or device_connected and now > expiresAtMs
|
|
395
|
+
* → flip to `expired` and keep for TERMINAL_RETENTION_MS.
|
|
396
|
+
* - any terminal status (completed / consumed / expired / rejected)
|
|
397
|
+
* and (now - lastStatusChangeAtMs) > TERMINAL_RETENTION_MS → drop.
|
|
398
|
+
*/
|
|
399
|
+
export function pruneStaleSessions(
|
|
400
|
+
file: PairSessionFile,
|
|
401
|
+
nowMs: number,
|
|
402
|
+
): { file: PairSessionFile; prunedSids: string[] } {
|
|
403
|
+
const keepers: PairSession[] = [];
|
|
404
|
+
const pruned: string[] = [];
|
|
405
|
+
|
|
406
|
+
for (const s of file.sessions) {
|
|
407
|
+
const isTerminal =
|
|
408
|
+
s.status === 'completed' ||
|
|
409
|
+
s.status === 'consumed' ||
|
|
410
|
+
s.status === 'expired' ||
|
|
411
|
+
s.status === 'rejected';
|
|
412
|
+
|
|
413
|
+
// First, promote any active-but-expired sessions to expired. We
|
|
414
|
+
// anchor `lastStatusChangeAtMs` to `expiresAtMs` (the actual moment
|
|
415
|
+
// the session became expired, not the moment we happened to observe
|
|
416
|
+
// it) so that a session observed long after expiry is dropped by
|
|
417
|
+
// the retention check below rather than getting its retention clock
|
|
418
|
+
// reset to "now".
|
|
419
|
+
let next = s;
|
|
420
|
+
if (!isTerminal && nowMs > s.expiresAtMs) {
|
|
421
|
+
next = {
|
|
422
|
+
...s,
|
|
423
|
+
status: 'expired',
|
|
424
|
+
lastStatusChangeAtMs: s.expiresAtMs,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const nowTerminal =
|
|
429
|
+
next.status === 'completed' ||
|
|
430
|
+
next.status === 'consumed' ||
|
|
431
|
+
next.status === 'expired' ||
|
|
432
|
+
next.status === 'rejected';
|
|
433
|
+
|
|
434
|
+
if (nowTerminal && nowMs - next.lastStatusChangeAtMs > TERMINAL_RETENTION_MS) {
|
|
435
|
+
pruned.push(next.sid);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
keepers.push(next);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
file: { version: file.version, sessions: keepers },
|
|
443
|
+
prunedSids: pruned,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// Public API — all operations go through the lock
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
/** Clamp a caller-supplied TTL to the ratified bounds. */
|
|
452
|
+
export function clampTtlMs(ttlMs: number | undefined): number {
|
|
453
|
+
const raw = typeof ttlMs === 'number' && ttlMs > 0 ? ttlMs : DEFAULT_PAIR_TTL_MS;
|
|
454
|
+
return Math.max(MIN_PAIR_TTL_MS, Math.min(MAX_PAIR_TTL_MS, raw));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Create a new session, persist it, return the in-memory record.
|
|
459
|
+
*
|
|
460
|
+
* Caller is responsible for:
|
|
461
|
+
* - Deriving `pk_G` from `sk_G` (done in P3 via `pair-crypto.ts`).
|
|
462
|
+
* In P1 this accepts pre-generated keypair material via the
|
|
463
|
+
* `rngPrivateKey` / `rngPublicKey` hooks OR returns a stub where
|
|
464
|
+
* the pubkey is a derived placeholder. The P3 module will replace
|
|
465
|
+
* the default generators with a real x25519-aware pair.
|
|
466
|
+
* - Ensuring the gateway has quiesced any prior in-flight sessions
|
|
467
|
+
* this user started (the session store itself does NOT enforce a
|
|
468
|
+
* single-active-session policy; that's P4's concern).
|
|
469
|
+
*/
|
|
470
|
+
export async function createPairSession(
|
|
471
|
+
sessionsPath: string,
|
|
472
|
+
opts: CreateSessionOptions,
|
|
473
|
+
): Promise<PairSession> {
|
|
474
|
+
const now = (opts.now ?? Date.now)();
|
|
475
|
+
const ttl = clampTtlMs(opts.ttlMs);
|
|
476
|
+
const sidBuf = (opts.rngSid ?? defaultRngSid)();
|
|
477
|
+
const skBuf = (opts.rngPrivateKey ?? defaultRng32)();
|
|
478
|
+
const pkBuf = (opts.rngPublicKey ?? defaultRng32)();
|
|
479
|
+
const secondaryCode = (opts.rngSecondaryCode ?? defaultRngSecondaryCode)();
|
|
480
|
+
|
|
481
|
+
const session: PairSession = {
|
|
482
|
+
sid: sidBuf.toString('hex'),
|
|
483
|
+
skGatewayB64: skBuf.toString('base64url'),
|
|
484
|
+
pkGatewayB64: pkBuf.toString('base64url'),
|
|
485
|
+
createdAtMs: now,
|
|
486
|
+
expiresAtMs: now + ttl,
|
|
487
|
+
secondaryCode,
|
|
488
|
+
secondaryCodeAttempts: 0,
|
|
489
|
+
operatorContext: opts.operatorContext,
|
|
490
|
+
mode: opts.mode,
|
|
491
|
+
status: 'awaiting_scan',
|
|
492
|
+
lastStatusChangeAtMs: now,
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
const release = await acquireSessionsFileLock(sessionsPath);
|
|
496
|
+
try {
|
|
497
|
+
const current = loadPairSessionsFileSync(sessionsPath);
|
|
498
|
+
const pruned = pruneStaleSessions(current, now);
|
|
499
|
+
pruned.file.sessions.push(session);
|
|
500
|
+
writePairSessionsFileSync(sessionsPath, pruned.file);
|
|
501
|
+
} finally {
|
|
502
|
+
release();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return session;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Look up a session by sid. Returns null on not-found, expired, or any
|
|
510
|
+
* error. DOES return completed/consumed/rejected sessions so the HTTP
|
|
511
|
+
* handler can distinguish 404 (genuinely absent) from 409/410 (terminal).
|
|
512
|
+
*
|
|
513
|
+
* Prunes stale entries as a side effect; this is cheap and keeps the
|
|
514
|
+
* file from growing unbounded.
|
|
515
|
+
*/
|
|
516
|
+
export async function getPairSession(
|
|
517
|
+
sessionsPath: string,
|
|
518
|
+
sid: string,
|
|
519
|
+
now: () => number = Date.now,
|
|
520
|
+
): Promise<PairSession | null> {
|
|
521
|
+
const release = await acquireSessionsFileLock(sessionsPath);
|
|
522
|
+
try {
|
|
523
|
+
const file = loadPairSessionsFileSync(sessionsPath);
|
|
524
|
+
const pruned = pruneStaleSessions(file, now());
|
|
525
|
+
// Persist the prune if anything changed, but don't block on failure.
|
|
526
|
+
if (pruned.prunedSids.length > 0) {
|
|
527
|
+
writePairSessionsFileSync(sessionsPath, pruned.file);
|
|
528
|
+
}
|
|
529
|
+
return pruned.file.sessions.find((s) => s.sid === sid) ?? null;
|
|
530
|
+
} finally {
|
|
531
|
+
release();
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Apply a mutation to a session. Re-reads under the lock, finds the
|
|
537
|
+
* session by sid, calls the mutator, writes back. The mutator returns
|
|
538
|
+
* the new session state (or null to drop the session entirely).
|
|
539
|
+
*
|
|
540
|
+
* Returns the resulting session (after the mutation) or null if the
|
|
541
|
+
* mutator chose to drop it / the session wasn't found.
|
|
542
|
+
*
|
|
543
|
+
* Stale prune runs on the same lock acquisition, so callers never see
|
|
544
|
+
* a session that should already be expired.
|
|
545
|
+
*/
|
|
546
|
+
export async function updatePairSession(
|
|
547
|
+
sessionsPath: string,
|
|
548
|
+
sid: string,
|
|
549
|
+
mutate: (s: PairSession) => PairSession | null,
|
|
550
|
+
now: () => number = Date.now,
|
|
551
|
+
): Promise<PairSession | null> {
|
|
552
|
+
const release = await acquireSessionsFileLock(sessionsPath);
|
|
553
|
+
try {
|
|
554
|
+
const file = loadPairSessionsFileSync(sessionsPath);
|
|
555
|
+
const pruned = pruneStaleSessions(file, now());
|
|
556
|
+
const idx = pruned.file.sessions.findIndex((s) => s.sid === sid);
|
|
557
|
+
if (idx < 0) {
|
|
558
|
+
if (pruned.prunedSids.length > 0) {
|
|
559
|
+
writePairSessionsFileSync(sessionsPath, pruned.file);
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
const current = pruned.file.sessions[idx];
|
|
564
|
+
const next = mutate(current);
|
|
565
|
+
let result: PairSession | null;
|
|
566
|
+
if (next === null) {
|
|
567
|
+
pruned.file.sessions.splice(idx, 1);
|
|
568
|
+
result = null;
|
|
569
|
+
} else {
|
|
570
|
+
pruned.file.sessions[idx] = next;
|
|
571
|
+
result = next;
|
|
572
|
+
}
|
|
573
|
+
writePairSessionsFileSync(sessionsPath, pruned.file);
|
|
574
|
+
return result;
|
|
575
|
+
} finally {
|
|
576
|
+
release();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Transition a session's status. Convenience wrapper around
|
|
582
|
+
* `updatePairSession`. Returns the new session or null if not found.
|
|
583
|
+
*/
|
|
584
|
+
export async function transitionPairSession(
|
|
585
|
+
sessionsPath: string,
|
|
586
|
+
sid: string,
|
|
587
|
+
nextStatus: PairSessionStatus,
|
|
588
|
+
now: () => number = Date.now,
|
|
589
|
+
): Promise<PairSession | null> {
|
|
590
|
+
return updatePairSession(
|
|
591
|
+
sessionsPath,
|
|
592
|
+
sid,
|
|
593
|
+
(s) => {
|
|
594
|
+
if (s.status === nextStatus) return s;
|
|
595
|
+
return {
|
|
596
|
+
...s,
|
|
597
|
+
status: nextStatus,
|
|
598
|
+
lastStatusChangeAtMs: now(),
|
|
599
|
+
};
|
|
600
|
+
},
|
|
601
|
+
now,
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Register a failed secondary-code attempt. Increments the counter.
|
|
607
|
+
* Returns the updated session, or null if the session is gone. If the
|
|
608
|
+
* attempt count reaches MAX_SECONDARY_CODE_ATTEMPTS, the session is
|
|
609
|
+
* transitioned to `rejected` and the HTTP handler should return 403
|
|
610
|
+
* + "too many attempts".
|
|
611
|
+
*
|
|
612
|
+
* The returned session's status reflects the incremented state —
|
|
613
|
+
* callers can check `session.status === 'rejected'` after this returns
|
|
614
|
+
* to know whether to lock the session out.
|
|
615
|
+
*/
|
|
616
|
+
export async function registerFailedSecondaryCode(
|
|
617
|
+
sessionsPath: string,
|
|
618
|
+
sid: string,
|
|
619
|
+
now: () => number = Date.now,
|
|
620
|
+
): Promise<PairSession | null> {
|
|
621
|
+
return updatePairSession(
|
|
622
|
+
sessionsPath,
|
|
623
|
+
sid,
|
|
624
|
+
(s) => {
|
|
625
|
+
const nextAttempts = s.secondaryCodeAttempts + 1;
|
|
626
|
+
const shouldReject = nextAttempts >= MAX_SECONDARY_CODE_ATTEMPTS;
|
|
627
|
+
return {
|
|
628
|
+
...s,
|
|
629
|
+
secondaryCodeAttempts: nextAttempts,
|
|
630
|
+
status: shouldReject ? 'rejected' : s.status,
|
|
631
|
+
lastStatusChangeAtMs: shouldReject ? now() : s.lastStatusChangeAtMs,
|
|
632
|
+
};
|
|
633
|
+
},
|
|
634
|
+
now,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Consume a session atomically: verify it is in a consumable state
|
|
640
|
+
* (device_connected or awaiting_scan, not expired), flip to `consumed`,
|
|
641
|
+
* and return the pre-transition session so the caller can use the
|
|
642
|
+
* `skGatewayB64` one last time before it's retired.
|
|
643
|
+
*
|
|
644
|
+
* Returns:
|
|
645
|
+
* - the session (pre-transition) on success
|
|
646
|
+
* - `{ error: 'not_found' }` if sid absent
|
|
647
|
+
* - `{ error: 'expired' }` if TTL elapsed
|
|
648
|
+
* - `{ error: 'already_consumed' }` if status is completed/consumed
|
|
649
|
+
* - `{ error: 'rejected' }` if status is rejected (too many code
|
|
650
|
+
* failures or explicit cancel)
|
|
651
|
+
*
|
|
652
|
+
* The "consumed" flip happens BEFORE the caller does crypto work, so a
|
|
653
|
+
* retrying duplicate request sees `already_consumed` and the
|
|
654
|
+
* credentials-write logic doesn't race.
|
|
655
|
+
*/
|
|
656
|
+
export type ConsumeResult =
|
|
657
|
+
| { ok: true; session: PairSession }
|
|
658
|
+
| { ok: false; error: 'not_found' | 'expired' | 'already_consumed' | 'rejected' };
|
|
659
|
+
|
|
660
|
+
export async function consumePairSession(
|
|
661
|
+
sessionsPath: string,
|
|
662
|
+
sid: string,
|
|
663
|
+
now: () => number = Date.now,
|
|
664
|
+
): Promise<ConsumeResult> {
|
|
665
|
+
let outcome: ConsumeResult = { ok: false, error: 'not_found' };
|
|
666
|
+
|
|
667
|
+
await updatePairSession(
|
|
668
|
+
sessionsPath,
|
|
669
|
+
sid,
|
|
670
|
+
(s) => {
|
|
671
|
+
const t = now();
|
|
672
|
+
if (t > s.expiresAtMs) {
|
|
673
|
+
outcome = { ok: false, error: 'expired' };
|
|
674
|
+
return { ...s, status: 'expired', lastStatusChangeAtMs: t };
|
|
675
|
+
}
|
|
676
|
+
if (s.status === 'completed' || s.status === 'consumed') {
|
|
677
|
+
outcome = { ok: false, error: 'already_consumed' };
|
|
678
|
+
return s;
|
|
679
|
+
}
|
|
680
|
+
if (s.status === 'rejected' || s.status === 'expired') {
|
|
681
|
+
outcome = { ok: false, error: s.status };
|
|
682
|
+
return s;
|
|
683
|
+
}
|
|
684
|
+
// Success — flip to consumed and hand the PRE-transition session
|
|
685
|
+
// back so the caller can derive the shared key one last time.
|
|
686
|
+
outcome = { ok: true, session: s };
|
|
687
|
+
return { ...s, status: 'consumed', lastStatusChangeAtMs: t };
|
|
688
|
+
},
|
|
689
|
+
now,
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
return outcome;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Force a terminal status on a session (caller decides why). Used by
|
|
697
|
+
* the CLI on Ctrl+C ("user canceled") and by P4's "already active →
|
|
698
|
+
* refuse new pairing" guard. Returns the updated session or null.
|
|
699
|
+
*/
|
|
700
|
+
export async function rejectPairSession(
|
|
701
|
+
sessionsPath: string,
|
|
702
|
+
sid: string,
|
|
703
|
+
now: () => number = Date.now,
|
|
704
|
+
): Promise<PairSession | null> {
|
|
705
|
+
return transitionPairSession(sessionsPath, sid, 'rejected', now);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* List all non-terminal sessions. Primarily for the CLI "are any
|
|
710
|
+
* pairings in flight?" check. Returns a defensive copy.
|
|
711
|
+
*/
|
|
712
|
+
export async function listActivePairSessions(
|
|
713
|
+
sessionsPath: string,
|
|
714
|
+
now: () => number = Date.now,
|
|
715
|
+
): Promise<PairSession[]> {
|
|
716
|
+
const release = await acquireSessionsFileLock(sessionsPath);
|
|
717
|
+
try {
|
|
718
|
+
const file = loadPairSessionsFileSync(sessionsPath);
|
|
719
|
+
const pruned = pruneStaleSessions(file, now());
|
|
720
|
+
if (pruned.prunedSids.length > 0) {
|
|
721
|
+
writePairSessionsFileSync(sessionsPath, pruned.file);
|
|
722
|
+
}
|
|
723
|
+
return pruned.file.sessions
|
|
724
|
+
.filter((s) => s.status === 'awaiting_scan' || s.status === 'device_connected')
|
|
725
|
+
.map((s) => ({ ...s }));
|
|
726
|
+
} finally {
|
|
727
|
+
release();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Debug utility — list ALL sessions (including terminal) for the
|
|
733
|
+
* status CLI. Never logs or exposes the sk/pk material.
|
|
734
|
+
*/
|
|
735
|
+
export async function listAllPairSessions(
|
|
736
|
+
sessionsPath: string,
|
|
737
|
+
now: () => number = Date.now,
|
|
738
|
+
): Promise<PairSession[]> {
|
|
739
|
+
const release = await acquireSessionsFileLock(sessionsPath);
|
|
740
|
+
try {
|
|
741
|
+
const file = loadPairSessionsFileSync(sessionsPath);
|
|
742
|
+
const pruned = pruneStaleSessions(file, now());
|
|
743
|
+
if (pruned.prunedSids.length > 0) {
|
|
744
|
+
writePairSessionsFileSync(sessionsPath, pruned.file);
|
|
745
|
+
}
|
|
746
|
+
return pruned.file.sessions.map((s) => ({ ...s }));
|
|
747
|
+
} finally {
|
|
748
|
+
release();
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Scrub sensitive fields from a session for safe logging / status
|
|
754
|
+
* display. Returns a shallow clone with `skGatewayB64` and
|
|
755
|
+
* `secondaryCode` replaced by "[redacted]". The pk, sid, status,
|
|
756
|
+
* timestamps, mode, and operator-context are fine to log.
|
|
757
|
+
*/
|
|
758
|
+
export function redactPairSession(s: PairSession): PairSession {
|
|
759
|
+
return {
|
|
760
|
+
...s,
|
|
761
|
+
skGatewayB64: '[redacted]',
|
|
762
|
+
secondaryCode: '[redacted]',
|
|
763
|
+
};
|
|
764
|
+
}
|