instar 1.3.1255 → 1.3.1256
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/core/ChromeCdpReloginBrowser.d.ts +110 -2
- package/dist/core/ChromeCdpReloginBrowser.d.ts.map +1 -1
- package/dist/core/ChromeCdpReloginBrowser.js +524 -58
- package/dist/core/ChromeCdpReloginBrowser.js.map +1 -1
- package/dist/core/PasskeyBrowserPolicy.d.ts +61 -0
- package/dist/core/PasskeyBrowserPolicy.d.ts.map +1 -0
- package/dist/core/PasskeyBrowserPolicy.js +92 -0
- package/dist/core/PasskeyBrowserPolicy.js.map +1 -0
- package/dist/core/PasskeyWebAuthnFixture.d.ts +12 -0
- package/dist/core/PasskeyWebAuthnFixture.d.ts.map +1 -0
- package/dist/core/PasskeyWebAuthnFixture.js +113 -0
- package/dist/core/PasskeyWebAuthnFixture.js.map +1 -0
- package/dist/data/standards-guard-index.json +1 -1
- package/dist/data/standards-guard-index.meta.json +2 -2
- package/dist/data/standards-registry.meta.json +1 -1
- package/package.json +1 -1
- package/src/data/builtin-manifest.json +2 -2
- package/src/data/standards-guard-index.json +1 -1
- package/src/data/standards-guard-index.meta.json +2 -2
- package/src/data/standards-registry.meta.json +1 -1
- package/upgrades/1.3.1256.md +46 -0
- package/upgrades/side-effects/passkey-inc3-browser.md +216 -0
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { WebSocket } from 'ws';
|
|
5
5
|
import { SafeFsExecutor } from './SafeFsExecutor.js';
|
|
6
|
+
import { GOOGLE_ORIGIN_POLICY, PASSKEY_SESSION_CHROME_ARGS, isGoogleOrigin, isRpFamilyOrigin, mayAddCredential, mustRemoveCredentialBefore, } from './PasskeyBrowserPolicy.js';
|
|
6
7
|
/** Closed structural floor for OpenAI's device confirmation; consent prose can never enter this class. */
|
|
7
8
|
export function isClosedOpenAiDeviceApproval(input) {
|
|
8
9
|
return input.origin === 'https://auth.openai.com'
|
|
@@ -24,6 +25,65 @@ const CHROME_EXECUTABLE_CANDIDATES = [
|
|
|
24
25
|
export function resolveChromeExecutable() {
|
|
25
26
|
return CHROME_EXECUTABLE_CANDIDATES.find((candidate) => fs.existsSync(candidate)) ?? null;
|
|
26
27
|
}
|
|
28
|
+
class WsTransport {
|
|
29
|
+
socket;
|
|
30
|
+
constructor(socket) {
|
|
31
|
+
this.socket = socket;
|
|
32
|
+
}
|
|
33
|
+
get open() { return this.socket.readyState === WebSocket.OPEN; }
|
|
34
|
+
send(text) { this.socket.send(text); }
|
|
35
|
+
onMessage(cb) { this.socket.on('message', (raw) => cb(raw.toString())); }
|
|
36
|
+
onClose(cb) { this.socket.on('close', cb); }
|
|
37
|
+
close() { this.socket.close(); }
|
|
38
|
+
}
|
|
39
|
+
/** CDP over Chrome's `--remote-debugging-pipe`: NUL-delimited JSON on fds 3 (write) / 4 (read). */
|
|
40
|
+
class PipeTransport {
|
|
41
|
+
writer;
|
|
42
|
+
buffer = '';
|
|
43
|
+
closed = false;
|
|
44
|
+
messageCbs = [];
|
|
45
|
+
closeCbs = [];
|
|
46
|
+
constructor(writer, reader) {
|
|
47
|
+
this.writer = writer;
|
|
48
|
+
reader.setEncoding('utf8');
|
|
49
|
+
reader.on('data', (chunk) => {
|
|
50
|
+
this.buffer += chunk;
|
|
51
|
+
let idx;
|
|
52
|
+
while ((idx = this.buffer.indexOf('\0')) >= 0) {
|
|
53
|
+
const message = this.buffer.slice(0, idx);
|
|
54
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
55
|
+
for (const cb of this.messageCbs)
|
|
56
|
+
cb(message);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
const onEnd = () => { if (!this.closed) {
|
|
60
|
+
this.closed = true;
|
|
61
|
+
for (const cb of this.closeCbs)
|
|
62
|
+
cb();
|
|
63
|
+
} };
|
|
64
|
+
reader.on('end', onEnd);
|
|
65
|
+
reader.on('close', onEnd);
|
|
66
|
+
reader.on('error', onEnd);
|
|
67
|
+
writer.on('error', onEnd);
|
|
68
|
+
}
|
|
69
|
+
get open() { return !this.closed && !this.writer.destroyed; }
|
|
70
|
+
send(text) { this.writer.write(text + '\0'); }
|
|
71
|
+
onMessage(cb) { this.messageCbs.push(cb); }
|
|
72
|
+
onClose(cb) { this.closeCbs.push(cb); }
|
|
73
|
+
close() { this.closed = true; try {
|
|
74
|
+
this.writer.end();
|
|
75
|
+
}
|
|
76
|
+
catch { /* @silent-fallback-ok — pipe already gone; the process teardown below is authoritative */ } }
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Whether a SECONDARY page target (a popup) tolerates the credential being present:
|
|
80
|
+
* every frame is either the holder origin or outside the relying-party family. A target
|
|
81
|
+
* still paused at about:blank has no RP-family frame and passes; one whose top-level
|
|
82
|
+
* frame sits on another RP-family origin fails.
|
|
83
|
+
*/
|
|
84
|
+
function mayAddSecondaryTarget(frames, policy) {
|
|
85
|
+
return frames.every((f) => f.origin === policy.holderOrigin || !isRpFamilyOrigin(f.origin, policy));
|
|
86
|
+
}
|
|
27
87
|
/**
|
|
28
88
|
* Narrow Chrome DevTools implementation of ReloginBrowserPort.
|
|
29
89
|
* It returns only a closed page classification; raw DOM, URLs-with-query,
|
|
@@ -35,10 +95,30 @@ export class ChromeCdpReloginBrowser {
|
|
|
35
95
|
headless;
|
|
36
96
|
launchTimeoutMs;
|
|
37
97
|
operationTimeoutMs;
|
|
98
|
+
passkeyMode;
|
|
99
|
+
policy;
|
|
38
100
|
child = null;
|
|
39
|
-
|
|
101
|
+
transport = null;
|
|
40
102
|
requestId = 0;
|
|
41
103
|
pending = new Map();
|
|
104
|
+
eventHandlers = new Map();
|
|
105
|
+
/** Pipe mode: every attached target by sessionId. */
|
|
106
|
+
sessions = new Map();
|
|
107
|
+
mainSessionId = null;
|
|
108
|
+
mainSessionReady = null;
|
|
109
|
+
/** The credential currently loaded into the authenticators, or null. */
|
|
110
|
+
credential = null;
|
|
111
|
+
/**
|
|
112
|
+
* Every credential id this browser has EVER loaded into an authenticator. Removal
|
|
113
|
+
* sweeps these from every authenticator regardless of the in-memory flag, so a
|
|
114
|
+
* target that received the key during a prepare/remove interleave can never keep it.
|
|
115
|
+
*/
|
|
116
|
+
knownCredentialIds = new Set();
|
|
117
|
+
/** Serialises add / remove / prepare-time load so they can never interleave. */
|
|
118
|
+
credentialLock = Promise.resolve();
|
|
119
|
+
assertedCredentialIds = new Set();
|
|
120
|
+
/** True if a Google-origin document navigation was ever seen before an attach completed. */
|
|
121
|
+
navigationBeforeAttach = false;
|
|
42
122
|
constructor(options) {
|
|
43
123
|
this.chromePath = options.chromePath ?? resolveChromeExecutable()
|
|
44
124
|
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
@@ -46,14 +126,23 @@ export class ChromeCdpReloginBrowser {
|
|
|
46
126
|
this.headless = options.headless ?? false;
|
|
47
127
|
this.launchTimeoutMs = Math.max(1_000, Math.min(30_000, options.launchTimeoutMs ?? 10_000));
|
|
48
128
|
this.operationTimeoutMs = Math.max(1_000, Math.min(30_000, options.operationTimeoutMs ?? 10_000));
|
|
129
|
+
this.passkeyMode = options.passkeyMode ?? false;
|
|
130
|
+
this.policy = options.originPolicy ?? GOOGLE_ORIGIN_POLICY;
|
|
49
131
|
}
|
|
132
|
+
// ── Lifecycle ────────────────────────────────────────────────────────
|
|
50
133
|
async open(url) {
|
|
51
|
-
if (this.
|
|
134
|
+
if (this.transport)
|
|
52
135
|
throw new Error('relogin-browser-already-open');
|
|
53
136
|
if (!fs.existsSync(this.chromePath))
|
|
54
137
|
throw new Error('chrome-not-installed');
|
|
55
138
|
fs.mkdirSync(this.userDataDir, { recursive: true, mode: 0o700 });
|
|
56
139
|
fs.chmodSync(this.userDataDir, 0o700);
|
|
140
|
+
if (this.passkeyMode)
|
|
141
|
+
return this.openPipe(url);
|
|
142
|
+
return this.openTcp(url);
|
|
143
|
+
}
|
|
144
|
+
/** Legacy transport: TCP debugging port + a page-level WebSocket (non-passkey sessions). */
|
|
145
|
+
async openTcp(url) {
|
|
57
146
|
const portFile = path.join(this.userDataDir, 'DevToolsActivePort');
|
|
58
147
|
if (fs.existsSync(portFile)) {
|
|
59
148
|
SafeFsExecutor.safeUnlinkSync(portFile, { operation: 'ChromeCdpReloginBrowser stale DevToolsActivePort cleanup' });
|
|
@@ -82,11 +171,327 @@ export class ChromeCdpReloginBrowser {
|
|
|
82
171
|
});
|
|
83
172
|
if (!target.webSocketDebuggerUrl)
|
|
84
173
|
throw new Error('chrome-target-missing-websocket');
|
|
85
|
-
await this.
|
|
174
|
+
await this.connectWs(target.webSocketDebuggerUrl);
|
|
86
175
|
await this.send('Page.enable');
|
|
87
176
|
await this.send('Runtime.enable');
|
|
88
177
|
await this.wait(500);
|
|
89
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Passkey transport: the debugging pipe. The initial about:blank page is
|
|
181
|
+
* auto-attached and PREPARED (authenticator, interception, service-worker
|
|
182
|
+
* bypass) before the first navigation; popups are paused until prepared.
|
|
183
|
+
*/
|
|
184
|
+
async openPipe(url) {
|
|
185
|
+
const args = [
|
|
186
|
+
`--user-data-dir=${this.userDataDir}`,
|
|
187
|
+
...PASSKEY_SESSION_CHROME_ARGS,
|
|
188
|
+
'--no-first-run',
|
|
189
|
+
'--no-default-browser-check',
|
|
190
|
+
'--disable-sync',
|
|
191
|
+
'--disable-component-update',
|
|
192
|
+
'about:blank',
|
|
193
|
+
];
|
|
194
|
+
if (this.headless)
|
|
195
|
+
args.unshift('--headless=new');
|
|
196
|
+
const child = spawn(this.chromePath, args, { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
|
|
197
|
+
this.child = child;
|
|
198
|
+
const writer = child.stdio[3];
|
|
199
|
+
const reader = child.stdio[4];
|
|
200
|
+
if (!writer || !reader)
|
|
201
|
+
throw new Error('chrome-pipe-unavailable');
|
|
202
|
+
this.attachTransport(new PipeTransport(writer, reader));
|
|
203
|
+
let resolveReady;
|
|
204
|
+
const promise = new Promise((r) => { resolveReady = r; });
|
|
205
|
+
this.mainSessionReady = { resolve: resolveReady, promise };
|
|
206
|
+
this.on('Target.attachedToTarget', (params) => this.onAttached(params));
|
|
207
|
+
this.on('Target.detachedFromTarget', (params) => { this.sessions.delete(String(params.sessionId)); });
|
|
208
|
+
this.on('Fetch.requestPaused', (params, sessionId) => this.onRequestPaused(params, sessionId));
|
|
209
|
+
this.on('WebAuthn.credentialAsserted', (params) => {
|
|
210
|
+
const cred = params.credential;
|
|
211
|
+
if (cred?.credentialId)
|
|
212
|
+
this.assertedCredentialIds.add(cred.credentialId);
|
|
213
|
+
});
|
|
214
|
+
this.on('Page.frameRequestedNavigation', (params, sessionId) => {
|
|
215
|
+
// Diagnostic only: a navigation observed on a session that was never prepared.
|
|
216
|
+
const s = sessionId ? this.sessions.get(sessionId) : undefined;
|
|
217
|
+
if (s && !s.authenticatorId && typeof params.url === 'string' && params.url !== 'about:blank')
|
|
218
|
+
this.navigationBeforeAttach = true;
|
|
219
|
+
});
|
|
220
|
+
await this.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
|
|
221
|
+
await Promise.race([
|
|
222
|
+
promise,
|
|
223
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('chrome-launch-timeout')), this.launchTimeoutMs)),
|
|
224
|
+
]);
|
|
225
|
+
await this.send('Page.navigate', { url }, this.mainSessionId ?? undefined);
|
|
226
|
+
await this.wait(500);
|
|
227
|
+
}
|
|
228
|
+
async onAttached(params) {
|
|
229
|
+
const sessionId = String(params.sessionId);
|
|
230
|
+
const info = (params.targetInfo ?? {});
|
|
231
|
+
const waiting = params.waitingForDebugger === true;
|
|
232
|
+
const type = info.type ?? 'other';
|
|
233
|
+
const session = { targetId: String(info.targetId ?? ''), type };
|
|
234
|
+
this.sessions.set(sessionId, session);
|
|
235
|
+
try {
|
|
236
|
+
if (type === 'page' || type === 'iframe' || type === 'webview') {
|
|
237
|
+
await this.prepareSession(sessionId, session);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch { /* @silent-fallback-ok — a target we could not prepare is resumed below so the browser never wedges; the credential is only ever added to PREPARED authenticators */ }
|
|
241
|
+
if (waiting) {
|
|
242
|
+
try {
|
|
243
|
+
await this.send('Runtime.runIfWaitingForDebugger', {}, sessionId);
|
|
244
|
+
}
|
|
245
|
+
catch { /* @silent-fallback-ok — target may have gone away */ }
|
|
246
|
+
}
|
|
247
|
+
if (type === 'page' && !this.mainSessionId) {
|
|
248
|
+
this.mainSessionId = sessionId;
|
|
249
|
+
this.mainSessionReady?.resolve();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/** Everything a target needs BEFORE it is allowed to navigate. */
|
|
253
|
+
async prepareSession(sessionId, session) {
|
|
254
|
+
await this.send('Page.enable', {}, sessionId);
|
|
255
|
+
await this.send('Runtime.enable', {}, sessionId);
|
|
256
|
+
await this.send('Network.enable', {}, sessionId);
|
|
257
|
+
await this.send('Network.setBypassServiceWorker', { bypass: true }, sessionId);
|
|
258
|
+
await this.send('Fetch.enable', { patterns: [{ urlPattern: '*', resourceType: 'Document', requestStage: 'Request' }] }, sessionId);
|
|
259
|
+
await this.send('WebAuthn.enable', { enableUI: false }, sessionId);
|
|
260
|
+
const auth = await this.send('WebAuthn.addVirtualAuthenticator', {
|
|
261
|
+
options: {
|
|
262
|
+
protocol: 'ctap2', ctap2Version: 'ctap2_1', transport: 'internal',
|
|
263
|
+
hasResidentKey: true, hasUserVerification: true, isUserVerified: true,
|
|
264
|
+
automaticPresenceSimulation: true,
|
|
265
|
+
},
|
|
266
|
+
}, sessionId);
|
|
267
|
+
// The authenticator becomes VISIBLE to removal and receives the credential in one
|
|
268
|
+
// locked step: a concurrent removeCredential() either runs entirely before (so the
|
|
269
|
+
// credential is null here and nothing is loaded) or entirely after (so this
|
|
270
|
+
// authenticator is in its sweep). A target is still paused at about:blank here.
|
|
271
|
+
await this.withCredentialLock(async () => {
|
|
272
|
+
session.authenticatorId = String(auth.authenticatorId);
|
|
273
|
+
if (this.credential) {
|
|
274
|
+
await this.send('WebAuthn.addCredential', { authenticatorId: session.authenticatorId, credential: this.credential }, sessionId);
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
try {
|
|
278
|
+
const tree = await this.send('Page.getFrameTree', {}, sessionId);
|
|
279
|
+
session.mainFrameId = tree.frameTree?.frame?.id;
|
|
280
|
+
}
|
|
281
|
+
catch { /* @silent-fallback-ok — an OOPIF target has no page frame tree; its documents are never top-level */ }
|
|
282
|
+
// Nested auto-attach so cross-origin iframes of THIS target get the same preparation.
|
|
283
|
+
await this.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, sessionId);
|
|
284
|
+
}
|
|
285
|
+
async onRequestPaused(params, sessionId) {
|
|
286
|
+
const requestId = String(params.requestId);
|
|
287
|
+
const cont = () => this.send('Fetch.continueRequest', { requestId }, sessionId).catch(() => { });
|
|
288
|
+
if (params.resourceType !== 'Document') {
|
|
289
|
+
await cont();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const session = sessionId ? this.sessions.get(sessionId) : undefined;
|
|
293
|
+
const request = (params.request ?? {});
|
|
294
|
+
const url = request.url ?? '';
|
|
295
|
+
// Unknown frame identity (no session record, or Page.getFrameTree failed) is treated
|
|
296
|
+
// as TOP-LEVEL so the stricter rule applies — over-remove, never under-remove.
|
|
297
|
+
const topLevel = session?.type === 'iframe' ? false
|
|
298
|
+
: (session?.mainFrameId === undefined || params.frameId === session.mainFrameId);
|
|
299
|
+
if ((this.credential || this.knownCredentialIds.size > 0) && mustRemoveCredentialBefore({ url, topLevel }, this.policy)) {
|
|
300
|
+
try {
|
|
301
|
+
await this.removeCredential();
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Fail CLOSED: if the credential cannot be removed, the document must not load.
|
|
305
|
+
await this.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, sessionId).catch(() => { });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
await cont();
|
|
310
|
+
}
|
|
311
|
+
// ── Passkey operations (spec §3.5 port additions) ─────────────────────
|
|
312
|
+
/**
|
|
313
|
+
* Add the credential to every prepared authenticator. Allowed only while the
|
|
314
|
+
* main target's frames satisfy the origin policy (top-level on the holder
|
|
315
|
+
* origin, no other RP-family frame). The value stays inside this process.
|
|
316
|
+
*/
|
|
317
|
+
async addCredential(credential) {
|
|
318
|
+
if (!this.passkeyMode)
|
|
319
|
+
throw new Error('passkey-mode-required');
|
|
320
|
+
await this.withCredentialLock(async () => {
|
|
321
|
+
// EVERY page target (the main page AND every auto-attached popup) must satisfy
|
|
322
|
+
// the policy — a popup that moved to another RP-family origin earlier (and had
|
|
323
|
+
// the key removed then) must not be re-armed by an add on the main page.
|
|
324
|
+
for (const [sid, s] of this.sessions) {
|
|
325
|
+
if (s.type !== 'page' || !s.authenticatorId)
|
|
326
|
+
continue;
|
|
327
|
+
const frames = await this.frameOrigins(sid);
|
|
328
|
+
if (sid === this.mainSessionId) {
|
|
329
|
+
if (!mayAddCredential(frames, this.policy))
|
|
330
|
+
throw new Error('passkey-origin-not-allowed');
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
// A secondary target may be blank (still paused), on the holder origin, or on an
|
|
334
|
+
// origin OUTSIDE the relying-party family (it cannot claim the key). What it may
|
|
335
|
+
// never be is on another RP-family origin — in any of its frames.
|
|
336
|
+
if (!mayAddSecondaryTarget(frames, this.policy))
|
|
337
|
+
throw new Error('passkey-origin-not-allowed');
|
|
338
|
+
}
|
|
339
|
+
this.credential = { ...credential };
|
|
340
|
+
this.knownCredentialIds.add(credential.credentialId);
|
|
341
|
+
for (const [sessionId, s] of this.sessions) {
|
|
342
|
+
if (!s.authenticatorId)
|
|
343
|
+
continue;
|
|
344
|
+
await this.send('WebAuthn.addCredential', { authenticatorId: s.authenticatorId, credential: this.credential }, sessionId);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Remove every credential this browser ever loaded from EVERY authenticator, and
|
|
350
|
+
* forget the current one. Does not trust the in-memory flag: the sweep runs whenever
|
|
351
|
+
* any id is known, so no target can keep a key the flag says is gone. Idempotent.
|
|
352
|
+
*/
|
|
353
|
+
async removeCredential() {
|
|
354
|
+
if (!this.credential && this.knownCredentialIds.size === 0)
|
|
355
|
+
return;
|
|
356
|
+
await this.withCredentialLock(async () => {
|
|
357
|
+
let firstError = null;
|
|
358
|
+
for (const [sessionId, s] of this.sessions) {
|
|
359
|
+
if (!s.authenticatorId)
|
|
360
|
+
continue;
|
|
361
|
+
for (const credentialId of this.knownCredentialIds) {
|
|
362
|
+
try {
|
|
363
|
+
await this.send('WebAuthn.removeCredential', { authenticatorId: s.authenticatorId, credentialId }, sessionId);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
// A target that already went away, or an authenticator that never held this
|
|
367
|
+
// id, is fine; anything else is reported after the sweep completes.
|
|
368
|
+
if (!(err instanceof Error && /target|session|closed|detached|not found|unknown credential/i.test(err.message)))
|
|
369
|
+
firstError ??= err;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
this.credential = null;
|
|
374
|
+
if (firstError)
|
|
375
|
+
throw firstError instanceof Error ? firstError : new Error(String(firstError));
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/** Run `fn` with exclusive access to the credential state (add / remove / prepare-time load). */
|
|
379
|
+
withCredentialLock(fn) {
|
|
380
|
+
const run = this.credentialLock.then(fn, fn);
|
|
381
|
+
this.credentialLock = run.then(() => undefined, () => undefined);
|
|
382
|
+
return run;
|
|
383
|
+
}
|
|
384
|
+
/** Whether the credential is currently loaded into the authenticators. */
|
|
385
|
+
hasCredential() { return this.credential !== null; }
|
|
386
|
+
/** True once Google (or the fixture) actually asked for and received an assertion from this credential. */
|
|
387
|
+
observedAssertion(credentialId) { return this.assertedCredentialIds.has(credentialId); }
|
|
388
|
+
/** Export every credential in the MAIN target's authenticator (used at mint). */
|
|
389
|
+
async exportCredentials() {
|
|
390
|
+
if (!this.passkeyMode || !this.mainSessionId)
|
|
391
|
+
throw new Error('passkey-mode-required');
|
|
392
|
+
const s = this.sessions.get(this.mainSessionId);
|
|
393
|
+
if (!s?.authenticatorId)
|
|
394
|
+
throw new Error('authenticator-missing');
|
|
395
|
+
const out = await this.send('WebAuthn.getCredentials', { authenticatorId: s.authenticatorId }, this.mainSessionId);
|
|
396
|
+
return (out.credentials ?? []).map((c) => ({ ...c }));
|
|
397
|
+
}
|
|
398
|
+
/** Credential COUNT per prepared target (never material) — proves every attached target's authenticator is loaded. */
|
|
399
|
+
async credentialCountsByTarget() {
|
|
400
|
+
const counts = [];
|
|
401
|
+
for (const [sid, s] of this.sessions) {
|
|
402
|
+
if (!s.authenticatorId || s.type !== 'page')
|
|
403
|
+
continue;
|
|
404
|
+
try {
|
|
405
|
+
const out = await this.send('WebAuthn.getCredentials', { authenticatorId: s.authenticatorId }, sid);
|
|
406
|
+
counts.push((out.credentials ?? []).length);
|
|
407
|
+
}
|
|
408
|
+
catch { /* @silent-fallback-ok — a target that closed holds nothing */ }
|
|
409
|
+
}
|
|
410
|
+
return counts;
|
|
411
|
+
}
|
|
412
|
+
/** Number of credentials the MAIN authenticator holds (a count, never material). */
|
|
413
|
+
async credentialCount() {
|
|
414
|
+
return (await this.exportCredentials()).length;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Compare the signed-in identity shown on the current page with the expected
|
|
418
|
+
* email INSIDE the browser; only the boolean leaves. Structural first
|
|
419
|
+
* (`data-email` / `data-identifier` / `aria-label`), then an exact-text element.
|
|
420
|
+
*/
|
|
421
|
+
async readSignedInIdentityMatches(expected) {
|
|
422
|
+
return this.evaluate(`(() => {
|
|
423
|
+
const expected = ${JSON.stringify(expected)}.trim().toLowerCase();
|
|
424
|
+
if (!expected) return false;
|
|
425
|
+
const attrNodes = Array.from(document.querySelectorAll('[data-email],[data-identifier],[aria-label]'));
|
|
426
|
+
for (const n of attrNodes) {
|
|
427
|
+
for (const a of ['data-email','data-identifier','aria-label']) {
|
|
428
|
+
const v = (n.getAttribute(a) || '').trim().toLowerCase();
|
|
429
|
+
if (v === expected) return true;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const all = Array.from(document.querySelectorAll('div,span,p,li,td,a,button'));
|
|
433
|
+
return all.some((n) => n.children.length === 0 && (n.textContent || '').trim().toLowerCase() === expected);
|
|
434
|
+
})()`);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Whether any target was seen navigating before its authenticator was attached. A
|
|
438
|
+
* DIAGNOSTIC only: it observes from `Page.enable` onward, so it can confirm a breach
|
|
439
|
+
* it saw but not prove absence. The guarantee itself is Chrome's
|
|
440
|
+
* `waitForDebuggerOnStart` pause, which the popup test exercises.
|
|
441
|
+
*/
|
|
442
|
+
sawNavigationBeforeAttach() { return this.navigationBeforeAttach; }
|
|
443
|
+
/**
|
|
444
|
+
* Run an expression in the main page. FIXTURE-ONLY: refused under the production
|
|
445
|
+
* Google policy so no caller can turn the repair browser into an arbitrary-evaluate
|
|
446
|
+
* surface on a real account page.
|
|
447
|
+
*/
|
|
448
|
+
async runInPage(expression) {
|
|
449
|
+
this.assertFixturePolicy('run-in-page');
|
|
450
|
+
return this.evaluate(expression);
|
|
451
|
+
}
|
|
452
|
+
assertFixturePolicy(op) {
|
|
453
|
+
if (this.policy.holderOrigin === GOOGLE_ORIGIN_POLICY.holderOrigin || isGoogleOrigin(this.policy.holderOrigin)) {
|
|
454
|
+
throw new Error(`${op}-fixture-only`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/** Origins of every frame in ONE page target (its own tree plus its out-of-process iframes). */
|
|
458
|
+
async frameOrigins(pageSessionId = this.mainSessionId) {
|
|
459
|
+
const tree = await this.send('Page.getFrameTree', {}, pageSessionId ?? undefined);
|
|
460
|
+
const out = [];
|
|
461
|
+
const walk = (node, top) => {
|
|
462
|
+
if (!node?.frame)
|
|
463
|
+
return;
|
|
464
|
+
const origin = node.frame.securityOrigin && node.frame.securityOrigin !== '://' ? node.frame.securityOrigin : (() => { try {
|
|
465
|
+
return new URL(node.frame.url ?? '').origin;
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return '';
|
|
469
|
+
} })();
|
|
470
|
+
out.push({ origin, topLevel: top });
|
|
471
|
+
for (const child of (node.childFrames ?? []))
|
|
472
|
+
walk(child, false);
|
|
473
|
+
};
|
|
474
|
+
walk(tree.frameTree, true);
|
|
475
|
+
// Out-of-process iframes are separate targets: include them as subframes.
|
|
476
|
+
for (const [sid, s] of this.sessions) {
|
|
477
|
+
if (s.type !== 'iframe' || sid === this.mainSessionId)
|
|
478
|
+
continue;
|
|
479
|
+
try {
|
|
480
|
+
const t = await this.send('Page.getFrameTree', {}, sid);
|
|
481
|
+
const f = t.frameTree?.frame;
|
|
482
|
+
const origin = f?.securityOrigin && f.securityOrigin !== '://' ? f.securityOrigin : (() => { try {
|
|
483
|
+
return new URL(f?.url ?? '').origin;
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return '';
|
|
487
|
+
} })();
|
|
488
|
+
out.push({ origin, topLevel: false });
|
|
489
|
+
}
|
|
490
|
+
catch { /* @silent-fallback-ok — a vanished OOPIF cannot hold the credential */ }
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
// ── ReloginBrowserPort ───────────────────────────────────────────────
|
|
90
495
|
async snapshot(expectedIdentity) {
|
|
91
496
|
return this.evaluate(`(() => {
|
|
92
497
|
const expected = ${JSON.stringify(expectedIdentity)}.trim().toLowerCase();
|
|
@@ -136,7 +541,9 @@ export class ChromeCdpReloginBrowser {
|
|
|
136
541
|
})()`);
|
|
137
542
|
}
|
|
138
543
|
async chooseExpectedAccount(expectedIdentity) {
|
|
139
|
-
|
|
544
|
+
// A REAL input click (spec §3.5): Google's choice-list items do not respond
|
|
545
|
+
// to a script-level element.click().
|
|
546
|
+
await this.clickReal(`(() => {
|
|
140
547
|
const expected = ${JSON.stringify(expectedIdentity)}.trim().toLowerCase();
|
|
141
548
|
const nodes = Array.from(document.querySelectorAll('[data-email],[data-identifier],button,[role="button"],a,[role="link"],li'));
|
|
142
549
|
const identities = (node) => {
|
|
@@ -146,9 +553,9 @@ export class ChromeCdpReloginBrowser {
|
|
|
146
553
|
};
|
|
147
554
|
const matching = nodes.filter((node) => identities(node).includes(expected));
|
|
148
555
|
const actionable = matching.filter((node) => !matching.some((child) => child !== node && node.contains(child)));
|
|
149
|
-
if (actionable.length !== 1 || !(actionable[0] instanceof HTMLElement)) return
|
|
150
|
-
actionable[0]
|
|
151
|
-
})()
|
|
556
|
+
if (actionable.length !== 1 || !(actionable[0] instanceof HTMLElement)) return null;
|
|
557
|
+
return actionable[0];
|
|
558
|
+
})()`);
|
|
152
559
|
}
|
|
153
560
|
async fillPublic(field, value) {
|
|
154
561
|
const selector = field === 'email'
|
|
@@ -166,15 +573,19 @@ export class ChromeCdpReloginBrowser {
|
|
|
166
573
|
const pattern = action === 'next' ? '^(next|continue)$'
|
|
167
574
|
: action === 'google' ? '(continue|sign in|log in) with google'
|
|
168
575
|
: '^(allow|authorize|approve|continue)$';
|
|
169
|
-
await this.
|
|
576
|
+
await this.clickReal(`(() => {
|
|
170
577
|
const re = new RegExp(${JSON.stringify(pattern)}, 'i');
|
|
171
578
|
const nodes = Array.from(document.querySelectorAll('button,[role="button"],a,[role="link"],input[type="submit"]'));
|
|
172
579
|
const node = nodes.find((entry) => re.test(((entry.textContent || entry.getAttribute('value') || '')).trim()));
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
})()`, true);
|
|
580
|
+
return node instanceof HTMLElement ? node : null;
|
|
581
|
+
})()`);
|
|
176
582
|
await this.wait(500);
|
|
177
583
|
}
|
|
584
|
+
/** Click a fixture/test element by CSS selector with a REAL pointer event. FIXTURE-ONLY (see runInPage). */
|
|
585
|
+
async clickSelector(selector) {
|
|
586
|
+
this.assertFixturePolicy('click-selector');
|
|
587
|
+
await this.clickReal(`(() => { const n = document.querySelector(${JSON.stringify(selector)}); return n instanceof HTMLElement ? n : null; })()`);
|
|
588
|
+
}
|
|
178
589
|
async readPasteCode() {
|
|
179
590
|
return this.evaluate(`(() => {
|
|
180
591
|
const candidates = Array.from(document.querySelectorAll('code,pre,[data-testid*="code" i]'))
|
|
@@ -187,19 +598,22 @@ export class ChromeCdpReloginBrowser {
|
|
|
187
598
|
}
|
|
188
599
|
async close() {
|
|
189
600
|
const child = this.child;
|
|
190
|
-
|
|
601
|
+
this.credential = null;
|
|
602
|
+
if (this.transport?.open) {
|
|
191
603
|
try {
|
|
192
604
|
await this.send('Browser.close');
|
|
193
605
|
}
|
|
194
606
|
catch { /* @silent-fallback-ok — process may already be gone; local socket/process teardown continues below */ }
|
|
195
607
|
}
|
|
196
|
-
this.
|
|
197
|
-
this.
|
|
608
|
+
this.transport?.close();
|
|
609
|
+
this.transport = null;
|
|
198
610
|
for (const entry of this.pending.values()) {
|
|
199
611
|
clearTimeout(entry.timer);
|
|
200
612
|
entry.reject(new Error('relogin-browser-closed'));
|
|
201
613
|
}
|
|
202
614
|
this.pending.clear();
|
|
615
|
+
this.sessions.clear();
|
|
616
|
+
this.mainSessionId = null;
|
|
203
617
|
if (child && child.exitCode === null && child.signalCode === null) {
|
|
204
618
|
child.kill('SIGTERM');
|
|
205
619
|
if (!await this.waitForChildExit(child, 5_000)) {
|
|
@@ -209,6 +623,7 @@ export class ChromeCdpReloginBrowser {
|
|
|
209
623
|
}
|
|
210
624
|
this.child = null;
|
|
211
625
|
}
|
|
626
|
+
// ── Internals ────────────────────────────────────────────────────────
|
|
212
627
|
async waitForChildExit(child, timeoutMs) {
|
|
213
628
|
if (child.exitCode !== null || child.signalCode !== null)
|
|
214
629
|
return true;
|
|
@@ -231,29 +646,63 @@ export class ChromeCdpReloginBrowser {
|
|
|
231
646
|
finish(true);
|
|
232
647
|
});
|
|
233
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* Real pointer click: resolve the element with `finderExpression` (must
|
|
651
|
+
* evaluate to an HTMLElement or null), scroll it into view, read its centre,
|
|
652
|
+
* and dispatch mouse pressed/released through the Input domain.
|
|
653
|
+
*/
|
|
654
|
+
async clickReal(finderExpression) {
|
|
655
|
+
const point = await this.evaluate(`(() => {
|
|
656
|
+
const el = (${finderExpression});
|
|
657
|
+
if (!(el instanceof HTMLElement)) return null;
|
|
658
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
659
|
+
const r = el.getBoundingClientRect();
|
|
660
|
+
if (r.width === 0 || r.height === 0) return null;
|
|
661
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
662
|
+
})()`);
|
|
663
|
+
if (!point)
|
|
664
|
+
throw new Error('browser-element-not-found');
|
|
665
|
+
const sid = this.mainSessionId ?? undefined;
|
|
666
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point.x, y: point.y }, sid);
|
|
667
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: point.x, y: point.y, button: 'left', clickCount: 1 }, sid);
|
|
668
|
+
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: point.x, y: point.y, button: 'left', clickCount: 1 }, sid);
|
|
669
|
+
}
|
|
234
670
|
async fillAndSubmit(selector, value) {
|
|
235
|
-
const
|
|
671
|
+
const filled = await this.evaluate(`(() => {
|
|
236
672
|
const node = document.querySelector(${JSON.stringify(selector)});
|
|
237
673
|
if (!(node instanceof HTMLInputElement)) return false;
|
|
238
674
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
239
675
|
setter?.call(node, ${JSON.stringify(value)});
|
|
240
676
|
node.dispatchEvent(new Event('input', { bubbles: true }));
|
|
241
677
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
678
|
+
// Read back: never submit a field whose value did not stick (measured 2026-09-21).
|
|
679
|
+
return node.value.length === ${JSON.stringify(value)}.length;
|
|
680
|
+
})()`, true);
|
|
681
|
+
if (!filled)
|
|
682
|
+
throw new Error('browser-element-not-found');
|
|
683
|
+
try {
|
|
684
|
+
await this.clickReal(`(() => {
|
|
685
|
+
const buttons = Array.from(document.querySelectorAll('button,[role="button"],a,[role="link"],input[type="submit"]'));
|
|
686
|
+
const submit = buttons.find((entry) => /^(next|continue|sign in|verify|submit)$/i
|
|
687
|
+
.test(((entry.textContent || entry.getAttribute('value') || '')).trim()));
|
|
688
|
+
return submit instanceof HTMLElement ? submit : null;
|
|
689
|
+
})()`);
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
// No submit button: fall back to the form's own submit.
|
|
693
|
+
await this.evaluate(`(() => {
|
|
694
|
+
const node = document.querySelector(${JSON.stringify(selector)});
|
|
695
|
+
const form = node instanceof HTMLElement ? node.closest('form') : null;
|
|
696
|
+
if (form instanceof HTMLFormElement) { form.requestSubmit(); return true; }
|
|
697
|
+
return true;
|
|
698
|
+
})()`);
|
|
699
|
+
}
|
|
251
700
|
await this.wait(500);
|
|
252
701
|
}
|
|
253
702
|
async evaluate(expression, requireTruthy = false) {
|
|
254
|
-
const result = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
|
703
|
+
const result = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, this.mainSessionId ?? undefined);
|
|
255
704
|
const remote = result.result;
|
|
256
|
-
if (!remote ||
|
|
705
|
+
if (!remote || result.exceptionDetails)
|
|
257
706
|
throw new Error('browser-evaluation-failed');
|
|
258
707
|
if (requireTruthy && !remote.value)
|
|
259
708
|
throw new Error('browser-element-not-found');
|
|
@@ -274,60 +723,77 @@ export class ChromeCdpReloginBrowser {
|
|
|
274
723
|
}
|
|
275
724
|
throw new Error('chrome-launch-timeout');
|
|
276
725
|
}
|
|
277
|
-
async
|
|
726
|
+
async connectWs(url) {
|
|
278
727
|
const socket = new WebSocket(url, { origin: 'http://127.0.0.1' });
|
|
279
|
-
|
|
280
|
-
|
|
728
|
+
await new Promise((resolve, reject) => {
|
|
729
|
+
const timer = setTimeout(() => reject(new Error('cdp-connect-timeout')), this.operationTimeoutMs);
|
|
730
|
+
socket.once('open', () => { clearTimeout(timer); resolve(); });
|
|
731
|
+
socket.once('error', (error) => { clearTimeout(timer); reject(error); });
|
|
732
|
+
});
|
|
733
|
+
this.attachTransport(new WsTransport(socket));
|
|
734
|
+
}
|
|
735
|
+
attachTransport(transport) {
|
|
736
|
+
this.transport = transport;
|
|
737
|
+
transport.onMessage((raw) => {
|
|
281
738
|
let message;
|
|
282
739
|
try {
|
|
283
|
-
message = JSON.parse(raw
|
|
740
|
+
message = JSON.parse(raw);
|
|
284
741
|
}
|
|
285
742
|
catch {
|
|
286
743
|
return;
|
|
287
744
|
}
|
|
288
|
-
if (message.id
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
745
|
+
if (message.id !== undefined) {
|
|
746
|
+
const entry = this.pending.get(message.id);
|
|
747
|
+
if (!entry)
|
|
748
|
+
return;
|
|
749
|
+
this.pending.delete(message.id);
|
|
750
|
+
clearTimeout(entry.timer);
|
|
751
|
+
if (message.error)
|
|
752
|
+
entry.reject(new Error(message.error.message ?? 'cdp-error'));
|
|
753
|
+
else
|
|
754
|
+
entry.resolve(message.result ?? {});
|
|
292
755
|
return;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
756
|
+
}
|
|
757
|
+
if (message.method) {
|
|
758
|
+
for (const handler of this.eventHandlers.get(message.method) ?? []) {
|
|
759
|
+
try {
|
|
760
|
+
const out = handler(message.params ?? {}, message.sessionId);
|
|
761
|
+
if (out && typeof out.catch === 'function')
|
|
762
|
+
out.catch(() => { });
|
|
763
|
+
}
|
|
764
|
+
catch { /* @silent-fallback-ok — an event handler must never take the transport down */ }
|
|
765
|
+
}
|
|
766
|
+
}
|
|
299
767
|
});
|
|
300
|
-
|
|
768
|
+
transport.onClose(() => {
|
|
301
769
|
for (const [id, entry] of this.pending) {
|
|
302
770
|
this.pending.delete(id);
|
|
303
771
|
clearTimeout(entry.timer);
|
|
304
772
|
entry.reject(new Error('cdp-closed'));
|
|
305
773
|
}
|
|
306
774
|
});
|
|
307
|
-
await new Promise((resolve, reject) => {
|
|
308
|
-
const timer = setTimeout(() => reject(new Error('cdp-connect-timeout')), this.operationTimeoutMs);
|
|
309
|
-
socket.once('open', () => { clearTimeout(timer); resolve(); });
|
|
310
|
-
socket.once('error', (error) => { clearTimeout(timer); reject(error); });
|
|
311
|
-
});
|
|
312
775
|
}
|
|
313
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
776
|
+
on(method, handler) {
|
|
777
|
+
const list = this.eventHandlers.get(method) ?? [];
|
|
778
|
+
list.push(handler);
|
|
779
|
+
this.eventHandlers.set(method, list);
|
|
780
|
+
}
|
|
781
|
+
send(method, params = {}, sessionId) {
|
|
782
|
+
const transport = this.transport;
|
|
783
|
+
if (!transport || !transport.open)
|
|
316
784
|
return Promise.reject(new Error('cdp-not-connected'));
|
|
317
785
|
const id = ++this.requestId;
|
|
318
786
|
return new Promise((resolve, reject) => {
|
|
319
787
|
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`cdp-timeout:${method}`)); }, this.operationTimeoutMs);
|
|
320
788
|
this.pending.set(id, { resolve, reject, timer });
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (!entry)
|
|
326
|
-
return;
|
|
789
|
+
try {
|
|
790
|
+
transport.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
327
793
|
this.pending.delete(id);
|
|
328
|
-
clearTimeout(
|
|
329
|
-
reject(error);
|
|
330
|
-
}
|
|
794
|
+
clearTimeout(timer);
|
|
795
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
796
|
+
}
|
|
331
797
|
});
|
|
332
798
|
}
|
|
333
799
|
}
|