hallpass-browser 0.0.0-stage → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/plugins/marketplace.json +12 -0
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.codex-mcp.json +9 -0
- package/.codex-plugin/plugin.json +27 -0
- package/.mcp.json +9 -0
- package/CHANGELOG.md +89 -0
- package/GEMINI.md +10 -0
- package/LICENSE +21 -0
- package/README.md +113 -2
- package/ROADMAP.md +74 -0
- package/SECURITY.md +104 -0
- package/bin/hallpass +3 -0
- package/bin/hallpass.mjs +349 -0
- package/bin/postinstall.mjs +12 -0
- package/gemini-extension.json +13 -0
- package/package.json +72 -4
- package/skills/hallpass/SKILL.md +160 -0
- package/src/ab.mjs +216 -0
- package/src/act.mjs +226 -0
- package/src/audit.mjs +57 -0
- package/src/install.mjs +96 -0
- package/src/keystore.mjs +88 -0
- package/src/login.mjs +480 -0
- package/src/mcp.mjs +173 -0
- package/src/origin.mjs +61 -0
- package/src/paths.mjs +17 -0
- package/src/policy.mjs +95 -0
- package/src/portable.mjs +41 -0
- package/src/probe.mjs +73 -0
- package/src/prompt.mjs +42 -0
- package/src/redact.mjs +30 -0
- package/src/refs.mjs +53 -0
- package/src/request.mjs +150 -0
- package/src/sessions.mjs +65 -0
- package/src/setup.mjs +133 -0
- package/src/sysexe.mjs +22 -0
- package/src/totp.mjs +51 -0
- package/src/ui.mjs +124 -0
- package/src/upload.mjs +97 -0
- package/src/vault.mjs +85 -0
package/src/keystore.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Seals the vault master key to the OS user account. The key never touches argv or disk in the clear.
|
|
2
|
+
// Windows: DPAPI (CurrentUser scope) via PowerShell, data passed over stdin.
|
|
3
|
+
// macOS: login Keychain via `security`. (untested - see SECURITY.md)
|
|
4
|
+
// Linux: libsecret via `secret-tool`, stdin only. (untested - see SECURITY.md)
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { home } from './paths.mjs';
|
|
10
|
+
import { sysExe } from './sysexe.mjs';
|
|
11
|
+
|
|
12
|
+
const need = (n) => sysExe(n) || (() => { throw new Error(`required system program "${n}" not found in a trusted location`); })();
|
|
13
|
+
|
|
14
|
+
const ENTROPY = 'hallpass-v1';
|
|
15
|
+
|
|
16
|
+
function ps(mode, b64) {
|
|
17
|
+
const script = `
|
|
18
|
+
$ErrorActionPreference='Stop'
|
|
19
|
+
Add-Type -AssemblyName System.Security
|
|
20
|
+
$b=[Convert]::FromBase64String(([Console]::In.ReadToEnd()).Trim())
|
|
21
|
+
$e=[Text.Encoding]::UTF8.GetBytes('${ENTROPY}')
|
|
22
|
+
$r=[Security.Cryptography.ProtectedData]::${mode}($b,$e,[Security.Cryptography.DataProtectionScope]::CurrentUser)
|
|
23
|
+
[Console]::Out.Write([Convert]::ToBase64String($r))`;
|
|
24
|
+
const enc = Buffer.from(script, 'utf16le').toString('base64');
|
|
25
|
+
const r = spawnSync(need('powershell'), ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], { input: b64, encoding: 'utf8', windowsHide: true });
|
|
26
|
+
if (r.status !== 0) throw new Error('DPAPI ' + mode.toLowerCase() + ' failed: ' + (r.stderr || '').trim().split('\n')[0]);
|
|
27
|
+
return r.stdout.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const wrappedPath = () => path.join(home(), 'master.key.dpapi');
|
|
31
|
+
|
|
32
|
+
const backends = {
|
|
33
|
+
win32: {
|
|
34
|
+
name: 'Windows DPAPI (CurrentUser)',
|
|
35
|
+
load() { const p = wrappedPath(); return fs.existsSync(p) ? Buffer.from(ps('Unprotect', fs.readFileSync(p, 'utf8')), 'base64') : null; },
|
|
36
|
+
store(key) { fs.writeFileSync(wrappedPath(), ps('Protect', key.toString('base64')), { mode: 0o600 }); },
|
|
37
|
+
},
|
|
38
|
+
darwin: {
|
|
39
|
+
name: 'macOS Keychain',
|
|
40
|
+
load() {
|
|
41
|
+
const r = spawnSync(need('security'), ['find-generic-password', '-s', 'hallpass', '-a', 'master', '-w'], { encoding: 'utf8' });
|
|
42
|
+
return r.status === 0 ? Buffer.from(r.stdout.trim(), 'hex') : null;
|
|
43
|
+
},
|
|
44
|
+
store(key) {
|
|
45
|
+
// `security` has no stdin mode for -w; the key is briefly visible in argv once, at vault creation only.
|
|
46
|
+
const r = spawnSync(need('security'), ['add-generic-password', '-U', '-s', 'hallpass', '-a', 'master', '-w', key.toString('hex')], { encoding: 'utf8' });
|
|
47
|
+
if (r.status !== 0) throw new Error('keychain store failed');
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
linux: {
|
|
51
|
+
name: 'libsecret (secret-tool)',
|
|
52
|
+
load() {
|
|
53
|
+
const r = spawnSync(need('secret-tool'), ['lookup', 'service', 'hallpass', 'account', 'master'], { encoding: 'utf8' });
|
|
54
|
+
return r.status === 0 && r.stdout.trim() ? Buffer.from(r.stdout.trim(), 'hex') : null;
|
|
55
|
+
},
|
|
56
|
+
store(key) {
|
|
57
|
+
const r = spawnSync(need('secret-tool'), ['store', '--label=hallpass master key', 'service', 'hallpass', 'account', 'master'], { input: key.toString('hex'), encoding: 'utf8' });
|
|
58
|
+
if (r.status !== 0) throw new Error('secret-tool store failed (is libsecret-tools installed and a keyring unlocked?)');
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Explicit opt-in for containers/CI/servers with no OS keystore. The master key sits in a 0600 file next to the vault:
|
|
64
|
+
// it keeps other OS users out, and nothing else. Never chosen automatically.
|
|
65
|
+
const fileBackend = {
|
|
66
|
+
name: 'file (opt-in; protects against other OS users only, NOT against code running as you)',
|
|
67
|
+
load() { const p = path.join(home(), 'master.key'); return fs.existsSync(p) ? Buffer.from(fs.readFileSync(p, 'utf8').trim(), 'hex') : null; },
|
|
68
|
+
store(key) { fs.writeFileSync(path.join(home(), 'master.key'), key.toString('hex'), { mode: 0o600 }); },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function backend() {
|
|
72
|
+
if (process.env.HALLPASS_KEYSTORE === 'file') return fileBackend;
|
|
73
|
+
const b = backends[process.platform];
|
|
74
|
+
if (!b) throw new Error(`unsupported platform ${process.platform}`);
|
|
75
|
+
return b;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let cached = null;
|
|
79
|
+
export function masterKey({ create = true } = {}) {
|
|
80
|
+
if (cached) return cached;
|
|
81
|
+
const b = backend();
|
|
82
|
+
const existing = b.load();
|
|
83
|
+
if (existing) return (cached = existing);
|
|
84
|
+
if (!create) return null;
|
|
85
|
+
const key = crypto.randomBytes(32);
|
|
86
|
+
b.store(key);
|
|
87
|
+
return (cached = key);
|
|
88
|
+
}
|
package/src/login.mjs
ADDED
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// The login engine. This is the ONLY module that decrypts credentials, and it never returns them:
|
|
2
|
+
// every value that leaves this file has been through the Redactor, and page text is never returned.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import * as vault from './vault.mjs';
|
|
7
|
+
import { Browser, systemBrowser, signInManually } from './ab.mjs';
|
|
8
|
+
import { probeScript, tagSel } from './probe.mjs';
|
|
9
|
+
import { Redactor } from './redact.mjs';
|
|
10
|
+
import { isAllowed, safeUrl as rawSafeUrl, globToRegExp, parseOrigin, sameSite } from './origin.mjs';
|
|
11
|
+
import { totp, secondsLeft, parseTotpInput } from './totp.mjs';
|
|
12
|
+
import { profilesDir, stateDir } from './paths.mjs';
|
|
13
|
+
import { checkEligibility, authorizeUse } from './policy.mjs';
|
|
14
|
+
import { audit } from './audit.mjs';
|
|
15
|
+
import { ensureReady } from './setup.mjs';
|
|
16
|
+
import { notify } from './ui.mjs';
|
|
17
|
+
import { resolveSecrets, resolveRef, looksLikeCode, SecretUnavailable } from './refs.mjs';
|
|
18
|
+
|
|
19
|
+
// Everything that flows back to the model and originates from the page is untrusted text: cap it, strip control chars.
|
|
20
|
+
const safeUrl = (u) => rawSafeUrl(u).slice(0, 200);
|
|
21
|
+
const clean = (s, n = 80) => String(s || '').replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, n);
|
|
22
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
export const sessionName = (name) => `hp-${name}`;
|
|
24
|
+
|
|
25
|
+
// "https://*.a.com" -> "*.a.com", "https://b.com:8443" -> "b.com" (agent-browser --allowed-domains syntax)
|
|
26
|
+
const toDomain = (a) => (a.startsWith('https://*.') ? '*.' + a.slice('https://*.'.length).replace(/:\d+$/, '') : new URL(a).hostname);
|
|
27
|
+
|
|
28
|
+
// ---------- run context ----------
|
|
29
|
+
// Profile/session key for a site that has no vault entry. Human logins persist here, so later headless
|
|
30
|
+
// opens of the same site reuse the session without any stored credential.
|
|
31
|
+
export const siteKey = (url) => ('site-' + new URL(url).hostname).replace(/[^a-z0-9.-]/gi, '-').toLowerCase().slice(0, 63);
|
|
32
|
+
|
|
33
|
+
function makeCtx(name, o) {
|
|
34
|
+
if (!vault.exists(name)) return makeGeneric(name, o);
|
|
35
|
+
// Secrets are NOT loaded here. They are decrypted (and any password manager asked) only when a credential must
|
|
36
|
+
// actually be typed, after policy/approval: see ctx.authorize().
|
|
37
|
+
const ctx = buildCtx(name, vault.meta(name), null, new Redactor(), o);
|
|
38
|
+
ctx.authorize = authorizer(ctx, o);
|
|
39
|
+
return ctx;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function authorizer(ctx, o) {
|
|
43
|
+
let done = false;
|
|
44
|
+
return async () => {
|
|
45
|
+
if (done) return null;
|
|
46
|
+
if (!ctx.preAuthorized) {
|
|
47
|
+
const pol = authorizeUse(ctx.name, { target: ctx.openUrl || o.target || ctx.meta.url, approver: o._approver });
|
|
48
|
+
if (pol) return pol;
|
|
49
|
+
}
|
|
50
|
+
try { ctx.secrets = resolveSecrets(vault.unseal(ctx.name)); } // may call a password manager CLI
|
|
51
|
+
catch (e) {
|
|
52
|
+
if (e instanceof SecretUnavailable) return { status: 'failed', name: ctx.name, session: ctx.session, reason: 'secret_unavailable', detail: e.message };
|
|
53
|
+
throw e;
|
|
54
|
+
}
|
|
55
|
+
ctx.redactor.add(ctx.secrets.password);
|
|
56
|
+
if (ctx.secrets.totp) ctx.redactor.add(ctx.secrets.totp.secret);
|
|
57
|
+
ctx.authorizedAt = new Date().toISOString();
|
|
58
|
+
done = true;
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// No vault entry: nothing to decrypt, nothing will ever be typed.
|
|
64
|
+
function makeGeneric(key, o) {
|
|
65
|
+
let url = o.url;
|
|
66
|
+
if (!url) { try { url = JSON.parse(fs.readFileSync(path.join(stateDir(), key + '.json'), 'utf8')).url; } catch { /* none */ } }
|
|
67
|
+
if (!url) throw new Error(`no credential or open site named "${key}" (try: hallpass open <url>)`);
|
|
68
|
+
const meta = { url, allowed: [new URL(url).origin], selectors: {}, successUrl: null, successText: null };
|
|
69
|
+
return buildCtx(key, meta, null, new Redactor(), o, true);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const writeState = (ctx, phase, extra = {}) =>
|
|
73
|
+
fs.writeFileSync(path.join(stateDir(), ctx.name + '.json'), JSON.stringify({ phase, url: rawSafeUrl(ctx.openUrl || ctx.meta.url), human: !!ctx.humanMode, at: new Date().toISOString(), ...(ctx.authorizedAt ? { authorizedAt: ctx.authorizedAt } : {}), ...extra }));
|
|
74
|
+
|
|
75
|
+
function buildCtx(name, meta, secrets, redactor, o, generic = false) {
|
|
76
|
+
const ctx = {
|
|
77
|
+
generic, name, meta, secrets, redactor, o,
|
|
78
|
+
allowed: meta.allowed,
|
|
79
|
+
authorize: async () => null, // credential-less contexts never need secrets
|
|
80
|
+
nonce: crypto.randomBytes(8).toString('hex'),
|
|
81
|
+
session: sessionName(name),
|
|
82
|
+
profile: path.join(profilesDir(), name),
|
|
83
|
+
trace: [],
|
|
84
|
+
};
|
|
85
|
+
ctx.reset = () => { ctx.st = { fills: { user: 0, pass: 0, otp: 0 }, filledAny: false, stable: 0, idle: 0, capSeen: 0, wallSeen: 0, nullProbes: 0, blank: 0, badFills: 0, lastFill: null }; };
|
|
86
|
+
ctx.reset();
|
|
87
|
+
ctx.mk = (headed) => new Browser({
|
|
88
|
+
session: ctx.session, profile: ctx.profile, headed, redactor,
|
|
89
|
+
allowedDomains: meta.lockNavigation ? meta.allowed.map(toDomain) : null,
|
|
90
|
+
});
|
|
91
|
+
return ctx;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const note = (ctx, msg) => { ctx.trace.push(`${new Date().toISOString().slice(11, 19)} ${ctx.redactor.scrub(msg)}`); };
|
|
95
|
+
const base = (ctx) => ({ name: ctx.name, session: ctx.session, mode: ctx.browser?.headed ? 'headed' : 'headless' });
|
|
96
|
+
|
|
97
|
+
// via: vault = hallpass typed the credentials; human = the user logged in; session = an existing saved session / no login needed
|
|
98
|
+
const ok = (ctx, p) => ({
|
|
99
|
+
status: 'logged_in', ...base(ctx), via: ctx.humanMode ? (ctx.walled ? 'human' : 'session') : (ctx.st.filledAny ? 'vault' : 'session'),
|
|
100
|
+
url: safeUrl(p.url), title: clean(ctx.redactor.scrub(p.title || '')), use: `hallpass do ${ctx.session} snapshot -i`,
|
|
101
|
+
});
|
|
102
|
+
const LOCKING = new Set(['bad_credentials', 'bad_totp']);
|
|
103
|
+
const readState = (name) => { try { return JSON.parse(fs.readFileSync(path.join(stateDir(), name + '.json'), 'utf8')); } catch { return null; } };
|
|
104
|
+
export const clearState = (name) => { try { fs.rmSync(path.join(stateDir(), name + '.json')); } catch { /* none */ } };
|
|
105
|
+
const fail = (ctx, reason, detail) => (LOCKING.has(reason) && writeState(ctx, 'failed', { reason }), { status: 'failed', ...base(ctx), reason, detail: ctx.redactor.scrub(detail), trace: ctx.trace.slice(-8) });
|
|
106
|
+
const needsHuman = (ctx, reason) => {
|
|
107
|
+
const hints = {
|
|
108
|
+
captcha: 'A CAPTCHA / bot check is showing.',
|
|
109
|
+
mfa: 'The site wants a second factor that hallpass cannot supply (push approval, SMS/email code, security key).',
|
|
110
|
+
mfa_code: 'The site wants a verification code and this entry has no TOTP secret.',
|
|
111
|
+
unknown_page: 'hallpass does not recognise this page.',
|
|
112
|
+
login: 'This site needs the user to log in.',
|
|
113
|
+
redirected: `The site sent us to ${ctx.landed} instead of the page requested (${safeUrl(ctx.openUrl || '')}). That is typically a public landing page shown to logged-out visitors, so we are probably not logged in. If it is actually the right page, the user can just confirm.`,
|
|
114
|
+
};
|
|
115
|
+
ctx.keepFilled = true; // a person may be about to finish this form in the visible window
|
|
116
|
+
writeState(ctx, 'needs_human', { reason });
|
|
117
|
+
const where = ctx.browser?.headed ? 'A Chrome window is open on it.' : 'No window is open (headless): re-run without --headless to get a visible one.';
|
|
118
|
+
return {
|
|
119
|
+
status: 'needs_human', reason, ...base(ctx), url: safeUrl(ctx.last?.url || ''),
|
|
120
|
+
message: `${hints[reason] || reason} ${where} Ask the user to complete it in that window, then run: hallpass resume ${ctx.name}`,
|
|
121
|
+
resume: `hallpass resume ${ctx.name}`,
|
|
122
|
+
trace: ctx.trace.slice(-6),
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// ---------- don't leave typed secrets lying in the page ----------
|
|
127
|
+
// A single-page app keeps the typed password in its field after a rejected attempt, and "get value" returns it in the
|
|
128
|
+
// clear to anyone driving the same browser. So when we are done typing, empty the secret fields (unless a person is
|
|
129
|
+
// mid-handoff in a visible window and may still need them).
|
|
130
|
+
const CLEAR_SCRIPT = "(() => { document.querySelectorAll('input[type=password], input[autocomplete=one-time-code]').forEach((i) => { i.value = ''; i.dispatchEvent(new Event('input', { bubbles: true })); }); return JSON.stringify({ ok: true }); })()";
|
|
131
|
+
function scrubTypedSecrets(ctx) {
|
|
132
|
+
try {
|
|
133
|
+
if (!ctx || !ctx.st?.filledAny || ctx.keepFilled || !ctx.browser) return;
|
|
134
|
+
if (!ctx.browser.alive()) return; // never launch a browser just to clean up
|
|
135
|
+
ctx.browser.evalJson(CLEAR_SCRIPT);
|
|
136
|
+
note(ctx, 'cleared typed secret fields');
|
|
137
|
+
} catch { /* best effort */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------- page probe ----------
|
|
141
|
+
function probe(ctx) {
|
|
142
|
+
return ctx.browser.evalJson(probeScript({ nonce: ctx.nonce, selectors: ctx.meta.selectors || {}, successText: ctx.meta.successText || null }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function differsFromLogin(ctx, url) {
|
|
146
|
+
try {
|
|
147
|
+
const a = new URL(url), b = new URL(ctx.meta.url);
|
|
148
|
+
return a.origin !== b.origin || a.pathname.replace(/\/$/, '') !== b.pathname.replace(/\/$/, '');
|
|
149
|
+
} catch { return true; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function totpCode(ctx) {
|
|
153
|
+
if (ctx.secrets.totpRef) { // password manager supplies the current code (or the seed)
|
|
154
|
+
const v = resolveRef(ctx.secrets.totpRef, 'totp').trim();
|
|
155
|
+
ctx.redactor.add(v);
|
|
156
|
+
if (looksLikeCode(v)) return v;
|
|
157
|
+
const cfg2 = parseTotpInput(v);
|
|
158
|
+
const code2 = totp(cfg2);
|
|
159
|
+
ctx.redactor.add(code2);
|
|
160
|
+
return code2;
|
|
161
|
+
}
|
|
162
|
+
const cfg = ctx.secrets.totp;
|
|
163
|
+
const left = secondsLeft(cfg);
|
|
164
|
+
if (left < 4) await sleep((left + 1) * 1000); // never submit a code that is about to expire
|
|
165
|
+
const code = totp(cfg);
|
|
166
|
+
ctx.redactor.add(code); // a one-time code echoed back by any tool is still scrubbed
|
|
167
|
+
return code;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function fill(ctx, p, kinds) {
|
|
171
|
+
const st = ctx.st;
|
|
172
|
+
const cmds = [];
|
|
173
|
+
for (const k of kinds) {
|
|
174
|
+
const value = k === 'user' ? ctx.secrets.username : k === 'pass' ? ctx.secrets.password : await totpCode(ctx);
|
|
175
|
+
cmds.push(['fill', tagSel(ctx.nonce, k), value]);
|
|
176
|
+
}
|
|
177
|
+
cmds.push(p.submit === 'click' ? ['click', tagSel(ctx.nonce, 'submit')] : ['press', 'Enter']);
|
|
178
|
+
const r = ctx.browser.batch(cmds);
|
|
179
|
+
if (!r.ok) {
|
|
180
|
+
st.badFills++;
|
|
181
|
+
note(ctx, `fill ${kinds.join('+')} did not apply (page likely changed): ${String(r.error || '').slice(0, 120)}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
for (const k of kinds) st.fills[k]++;
|
|
185
|
+
st.filledAny = true;
|
|
186
|
+
st.lastFill = { sig: sigOf(p), at: Date.now() };
|
|
187
|
+
note(ctx, `filled ${kinds.join('+')} and ${p.submit === 'click' ? 'clicked submit' : 'pressed Enter'}`);
|
|
188
|
+
await sleep(1200);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const sigOf = (p) => { try { return `${new URL(p.url).origin}${new URL(p.url).pathname}|${+p.user}${+p.pass}${+p.otp}`; } catch { return p.url; } };
|
|
192
|
+
|
|
193
|
+
// ---------- the state machine ----------
|
|
194
|
+
// wait=false: return needs_human as soon as a person is required.
|
|
195
|
+
// wait=true: keep polling until the person is done (and carry on automating afterwards).
|
|
196
|
+
// noFill=true: never type anything; just watch for the logged-in state (manual login).
|
|
197
|
+
async function drive(ctx, { wait, deadline, noFill = false }) {
|
|
198
|
+
const st = ctx.st;
|
|
199
|
+
while (Date.now() < deadline) {
|
|
200
|
+
const p = probe(ctx);
|
|
201
|
+
if (!p) {
|
|
202
|
+
// no answer from the page: closed window, crashed tab, or still navigating
|
|
203
|
+
if (++st.nullProbes >= 12) return fail(ctx, 'browser_closed', 'The browser window was closed or stopped responding. Run hallpass open <url> again.');
|
|
204
|
+
await sleep(700);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
st.nullProbes = 0;
|
|
208
|
+
ctx.last = p;
|
|
209
|
+
if (!/^https?:/i.test(p.url)) { // about:blank, chrome-error:// (site unreachable), ... is never a logged-in state
|
|
210
|
+
if (++st.blank >= 6) return fail(ctx, 'page_unreachable', 'The browser is not showing a web page (blank, or a browser error page). The site may be unreachable or offline.');
|
|
211
|
+
await sleep(1000);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
st.blank = 0;
|
|
215
|
+
note(ctx, `probe ${safeUrl(p.url)} user=${+p.user} pass=${+p.pass} otp=${+p.otp} captcha=${+p.captcha} mfa=${+p.mfa} err=${+p.error}`);
|
|
216
|
+
|
|
217
|
+
// Origin pinning only matters when something secret is about to be typed.
|
|
218
|
+
if (!noFill && !isAllowed(p.url, ctx.allowed)) {
|
|
219
|
+
if (!wait) return fail(ctx, 'origin_not_allowed', `The page is on ${safeUrl(p.url)}, which is not an approved origin for "${ctx.name}". Nothing was typed. If this is an expected SSO/IdP hop, re-add the entry with --origin for it.`);
|
|
220
|
+
await sleep(1500); // a person is driving through an identity provider; never autofill there
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A lone email/text box is only a login step when the page looks like a login, a password field is beside it,
|
|
225
|
+
// we already submitted something, or the user told us its selector. Otherwise it is just a form on a logged-in page
|
|
226
|
+
// (invite box, newsletter, search) and must never receive the username.
|
|
227
|
+
const userStep = p.user && (p.loginish || p.pass || st.filledAny || !!ctx.meta.selectors?.username);
|
|
228
|
+
const noFields = !userStep && !p.pass && !p.otp;
|
|
229
|
+
// Credential-less opens have no "login URL" to compare with, so a login-looking URL/page is the signal instead.
|
|
230
|
+
const urlOk = ctx.meta.successUrl ? globToRegExp(ctx.meta.successUrl).test(safeUrl(p.url))
|
|
231
|
+
: ctx.humanMode ? !p.loginish
|
|
232
|
+
: (st.filledAny || differsFromLogin(ctx, p.url));
|
|
233
|
+
if (!p.captcha && !p.mfa && (p.successText === true || (noFields && urlOk && p.successText !== false))) {
|
|
234
|
+
// SPAs often paint a blank shell before redirecting to /login, so human-mode opens demand a longer calm period
|
|
235
|
+
if (++st.stable >= (ctx.humanMode ? 3 : 2)) {
|
|
236
|
+
// Logged-out visitors are often bounced to a different site's marketing page (Play Console -> google.play/business).
|
|
237
|
+
// Landing on another origin than the one requested is not proof of a session: ask the person to confirm.
|
|
238
|
+
// (After the person has acted - wait mode - we accept wherever they ended up.)
|
|
239
|
+
if (ctx.humanMode && !wait && ctx.openUrl && !sameSite(p.url, ctx.openUrl)) {
|
|
240
|
+
ctx.landed = safeUrl(p.url);
|
|
241
|
+
return needsHuman(ctx, 'redirected');
|
|
242
|
+
}
|
|
243
|
+
return ok(ctx, p);
|
|
244
|
+
}
|
|
245
|
+
await sleep(1200);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
st.stable = 0;
|
|
249
|
+
|
|
250
|
+
if (p.captcha) {
|
|
251
|
+
if (++st.capSeen < 2) { await sleep(3000); continue; } // many bot checks (Turnstile, "just a moment") clear on their own
|
|
252
|
+
if (!wait) return needsHuman(ctx, 'captcha');
|
|
253
|
+
await sleep(2000);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
st.capSeen = 0;
|
|
257
|
+
|
|
258
|
+
if (noFill) {
|
|
259
|
+
// Human mode: never type. A login wall (fields, login-looking page, MFA) means "ask the person".
|
|
260
|
+
const wall = userStep || p.pass || p.otp || p.mfa || p.loginish;
|
|
261
|
+
if (wall) {
|
|
262
|
+
ctx.walled = true;
|
|
263
|
+
if (!wait && ++st.wallSeen >= 2) return needsHuman(ctx, p.mfa ? 'mfa' : 'login'); // 2 looks: don't trip on a transient redirect
|
|
264
|
+
} else st.wallSeen = 0;
|
|
265
|
+
await sleep(wall && !wait ? 1200 : 2000);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (p.error && st.filledAny && (p.pass || p.otp || userStep) && !wait) {
|
|
270
|
+
return fail(ctx, 'bad_credentials', 'The site is showing an error after the credentials were submitted. Not retrying, to avoid locking the account. Check the stored username/password (hallpass add NAME --force).');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// let the page react to our last submit before touching it again
|
|
274
|
+
if (st.lastFill && st.lastFill.sig === sigOf(p) && Date.now() - st.lastFill.at < 8000) { await sleep(800); continue; }
|
|
275
|
+
|
|
276
|
+
if (p.otp) {
|
|
277
|
+
if (!ctx.meta.hasTotp) { if (!wait) return needsHuman(ctx, 'mfa_code'); await sleep(2000); continue; }
|
|
278
|
+
if (st.fills.otp >= 2) return fail(ctx, 'bad_totp', 'The one-time code was rejected twice. The TOTP secret may be wrong, or this prompt wants an email/SMS code instead.');
|
|
279
|
+
{ const denied = await ctx.authorize(); if (denied) return denied; }
|
|
280
|
+
await fill(ctx, p, ['otp']);
|
|
281
|
+
} else if (p.pass || userStep) {
|
|
282
|
+
const kinds = [userStep && 'user', p.pass && 'pass'].filter(Boolean);
|
|
283
|
+
const spent = kinds.find((k) => st.fills[k] >= 2);
|
|
284
|
+
if (spent) return fail(ctx, spent === 'pass' ? 'bad_credentials' : 'form_loop', `The ${spent === 'pass' ? 'password' : 'username'} step appeared again after being submitted twice.`);
|
|
285
|
+
{ const denied = await ctx.authorize(); if (denied) return denied; }
|
|
286
|
+
await fill(ctx, p, kinds);
|
|
287
|
+
} else if (p.mfa) {
|
|
288
|
+
if (!wait) return needsHuman(ctx, 'mfa');
|
|
289
|
+
await sleep(2000);
|
|
290
|
+
} else {
|
|
291
|
+
st.idle++;
|
|
292
|
+
if (st.idle >= 8) { if (!wait) return needsHuman(ctx, 'unknown_page'); st.idle = 0; }
|
|
293
|
+
await sleep(1000);
|
|
294
|
+
}
|
|
295
|
+
if (st.badFills >= 5) return fail(ctx, 'fill_error', 'Could not apply the fill commands to the page repeatedly. The login form may be inside an iframe or use a custom control; add selectors with hallpass add --username-selector/--password-selector/--submit-selector.');
|
|
296
|
+
}
|
|
297
|
+
return fail(ctx, 'timeout', 'Gave up waiting for the login to complete.');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------- single-flight lock ----------
|
|
301
|
+
function lock(name) {
|
|
302
|
+
const f = path.join(stateDir(), name + '.lock');
|
|
303
|
+
if (fs.existsSync(f)) {
|
|
304
|
+
const pid = Number(fs.readFileSync(f, 'utf8'));
|
|
305
|
+
let alive = false;
|
|
306
|
+
try { process.kill(pid, 0); alive = pid !== process.pid; } catch { /* stale */ }
|
|
307
|
+
if (alive) throw new Error(`another hallpass command is already running for "${name}" (pid ${pid})`);
|
|
308
|
+
}
|
|
309
|
+
fs.writeFileSync(f, String(process.pid));
|
|
310
|
+
return () => { try { fs.rmSync(f); } catch { /* ignore */ } };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function openFailed(ctx, opened) {
|
|
314
|
+
const why = clean(opened.json?.error || opened.error || opened.stderr || 'could not open the page', 200);
|
|
315
|
+
ctx.browser.close(); // do not leave a dead session behind
|
|
316
|
+
return fail(ctx, 'browser_error', why);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------- public API ----------
|
|
320
|
+
async function loginImpl(name, o = {}) {
|
|
321
|
+
const notReady = ensureReady(); // agent-browser + Chrome are installed on first use
|
|
322
|
+
if (notReady) return { name, ...notReady };
|
|
323
|
+
const release = lock(name);
|
|
324
|
+
let ctx;
|
|
325
|
+
try {
|
|
326
|
+
// A rejected password is never retried automatically, not even by a later command: repeated bad attempts
|
|
327
|
+
// are exactly what locks real accounts. Only a human (add --force / unlock) clears this.
|
|
328
|
+
const prev = readState(name);
|
|
329
|
+
if (prev?.phase === 'failed' && LOCKING.has(prev.reason)) {
|
|
330
|
+
return { status: 'failed', name, session: sessionName(name), reason: 'credential_locked', detail: `The last login for "${name}" was rejected (${prev.reason}), so hallpass will not try again until the credential is fixed. Ask the user to run: hallpass add ${name} --force (or hallpass unlock ${name} if they fixed it on the site).` };
|
|
331
|
+
}
|
|
332
|
+
const pol = checkEligibility(name); // expiry only; approval / use-count / decryption wait until a credential is actually typed
|
|
333
|
+
if (pol) return pol;
|
|
334
|
+
ctx = makeCtx(name, o);
|
|
335
|
+
const deadline = () => Date.now() + (o.timeout ?? 90) * 1000;
|
|
336
|
+
ctx.browser = ctx.mk(o.headed === true || o.manual === true);
|
|
337
|
+
const start = o.target || ctx.meta.url; // a deep link works too: the site bounces to its login and back
|
|
338
|
+
const opened = ctx.browser.open(start);
|
|
339
|
+
if (!opened.ok) return openFailed(ctx, opened);
|
|
340
|
+
|
|
341
|
+
if (o.manual) {
|
|
342
|
+
const r = await drive(ctx, { wait: true, noFill: true, deadline: Date.now() + (o.wait ?? 600) * 1000 });
|
|
343
|
+
return r;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
let r = await drive(ctx, { wait: false, deadline: deadline() });
|
|
347
|
+
if (r.status === 'needs_human' && !ctx.browser.headed && o.headed !== false) {
|
|
348
|
+
// Hand over: same profile, visible window, replay up to the challenge so the person lands right on it.
|
|
349
|
+
note(ctx, 'handoff: relaunching headed');
|
|
350
|
+
ctx.browser.close();
|
|
351
|
+
ctx.browser = ctx.mk(true);
|
|
352
|
+
ctx.reset();
|
|
353
|
+
const again = ctx.browser.open(start);
|
|
354
|
+
if (!again.ok) return fail(ctx, 'browser_error', again.error || again.stderr || 'could not open headed browser');
|
|
355
|
+
r = await drive(ctx, { wait: false, deadline: deadline() });
|
|
356
|
+
}
|
|
357
|
+
if (r.status === 'needs_human' && o.wait) r = await drive(ctx, { wait: true, deadline: Date.now() + o.wait * 1000 });
|
|
358
|
+
if (r.status === 'logged_in') {
|
|
359
|
+
writeState(ctx, 'logged_in');
|
|
360
|
+
// land on what the caller asked for, if the login bounced somewhere else
|
|
361
|
+
if (o.target && safeUrl(r.url) !== safeUrl(o.target)) { ctx.browser.open(o.target); r.url = safeUrl(o.target); }
|
|
362
|
+
}
|
|
363
|
+
return r;
|
|
364
|
+
} finally { scrubTypedSecrets(ctx); release(); }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// hallpass open <url>: works for any site, with or without a stored credential.
|
|
368
|
+
// mode auto: vault entry for this origin -> headless login. No entry -> headless attempt with the site's persistent
|
|
369
|
+
// profile; if a login wall appears, switch to a visible window and ask the person to log in.
|
|
370
|
+
// mode headed: visible window, the PERSON logs in (nothing is ever typed for them).
|
|
371
|
+
// mode headless: never opens a window; a login wall is reported as needs_human.
|
|
372
|
+
async function openSiteImpl(url, o = {}) {
|
|
373
|
+
const notReady = ensureReady();
|
|
374
|
+
if (notReady) return notReady;
|
|
375
|
+
if (typeof url === 'string' && !/^[a-z][a-z0-9+.-]*:\/\//i.test(url.trim())) {
|
|
376
|
+
// bare "play.google.com/console" works; local dev servers default to http
|
|
377
|
+
url = (/^(localhost|127\.\d+\.\d+\.\d+|\[::1\])(:\d+)?(\/|$)/i.test(url.trim()) ? 'http://' : 'https://') + url.trim();
|
|
378
|
+
}
|
|
379
|
+
const origin = parseOrigin(url);
|
|
380
|
+
if (!origin) return { status: 'failed', reason: 'bad_url', detail: 'URL must be https:// (http:// only for localhost)' };
|
|
381
|
+
const mode = o.mode || 'auto';
|
|
382
|
+
if (o.as && !vault.exists(o.as)) return { status: 'failed', reason: 'no_such_credential', detail: `no credential named "${o.as}"` };
|
|
383
|
+
const matches = o.as ? [o.as] : vault.findByUrl(url);
|
|
384
|
+
if (matches.length > 1) {
|
|
385
|
+
// Never guess which account to log in as: wrong guesses can lock accounts or act as the wrong identity.
|
|
386
|
+
return { status: 'failed', reason: 'ambiguous_credential', candidates: matches, detail: `${matches.length} stored credentials match ${origin}. Ask the user which account, then re-run with --as <name> (or --headed to let the user log in).` };
|
|
387
|
+
}
|
|
388
|
+
const entry = matches[0] || null;
|
|
389
|
+
if (entry && mode !== 'headed') return loginImpl(entry, { target: url, headed: mode === 'headless' ? false : undefined, timeout: o.timeout, wait: o.wait });
|
|
390
|
+
|
|
391
|
+
const key = entry || siteKey(url);
|
|
392
|
+
const release = lock(key);
|
|
393
|
+
try {
|
|
394
|
+
const ctx = makeCtx(key, { url });
|
|
395
|
+
ctx.humanMode = true;
|
|
396
|
+
ctx.openUrl = url;
|
|
397
|
+
const deadline = () => Date.now() + (o.timeout ?? 60) * 1000;
|
|
398
|
+
ctx.browser = ctx.mk(mode === 'headed');
|
|
399
|
+
const opened = ctx.browser.open(url);
|
|
400
|
+
if (!opened.ok) return openFailed(ctx, opened);
|
|
401
|
+
let r = await drive(ctx, { wait: false, noFill: true, deadline: deadline() });
|
|
402
|
+
const googleHost = (u) => /^https?:\/\/([^/]+\.)?(google\.com|google\.play)(\/|:|$)/i.test(u || '');
|
|
403
|
+
if (r.status === 'needs_human' && mode !== 'headless' && (googleHost(url) || googleHost(r.url)) && systemBrowser()) {
|
|
404
|
+
// Google rejects sign-in in an automated browser: the person signs in in a plain window on the same profile first.
|
|
405
|
+
note(ctx, 'google sign-in: plain window for the user, then reopening automated');
|
|
406
|
+
ctx.browser.close();
|
|
407
|
+
notify('Hallpass needs you', 'Sign in to Google in the Chrome window, then close that window to continue.');
|
|
408
|
+
await signInManually(ctx.profile, 'https://accounts.google.com/ServiceLogin?continue=' + encodeURIComponent(url));
|
|
409
|
+
ctx.browser = ctx.mk(true);
|
|
410
|
+
ctx.reset();
|
|
411
|
+
const again = ctx.browser.open(url);
|
|
412
|
+
if (!again.ok) return fail(ctx, 'browser_error', again.error || again.stderr || 'could not reopen the browser after sign-in');
|
|
413
|
+
r = await drive(ctx, { wait: false, noFill: true, deadline: deadline() });
|
|
414
|
+
} else if (r.status === 'needs_human' && !ctx.browser.headed && mode !== 'headless') {
|
|
415
|
+
note(ctx, 'login wall: relaunching headed for the user');
|
|
416
|
+
ctx.browser.close();
|
|
417
|
+
ctx.browser = ctx.mk(true);
|
|
418
|
+
ctx.reset();
|
|
419
|
+
const again = ctx.browser.open(url);
|
|
420
|
+
if (!again.ok) return fail(ctx, 'browser_error', again.error || again.stderr || 'could not open headed browser');
|
|
421
|
+
r = await drive(ctx, { wait: false, noFill: true, deadline: deadline() });
|
|
422
|
+
}
|
|
423
|
+
if (r.status === 'needs_human' && o.wait) r = await drive(ctx, { wait: true, noFill: true, deadline: Date.now() + o.wait * 1000 });
|
|
424
|
+
if (r.status === 'logged_in') writeState(ctx, 'logged_in');
|
|
425
|
+
return r;
|
|
426
|
+
} finally { release(); }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function resumeImpl(name, o = {}) {
|
|
430
|
+
const release = lock(name);
|
|
431
|
+
let ctx;
|
|
432
|
+
try {
|
|
433
|
+
let prev = {};
|
|
434
|
+
try { prev = JSON.parse(fs.readFileSync(path.join(stateDir(), name + '.json'), 'utf8')); } catch { /* none */ }
|
|
435
|
+
ctx = makeCtx(name, {});
|
|
436
|
+
ctx.humanMode = !!prev.human || ctx.generic;
|
|
437
|
+
ctx.walled = prev.phase === 'needs_human'; // the person was asked to act, so a success now is "via human"
|
|
438
|
+
// Typing credentials from resume is only a continuation of a login that passed policy moments ago.
|
|
439
|
+
const continuation = !ctx.humanMode && prev.phase === 'needs_human' && Date.now() - Date.parse(prev.at) < 30 * 60 * 1000;
|
|
440
|
+
const watchOnly = ctx.humanMode || !continuation;
|
|
441
|
+
ctx.preAuthorized = !!(continuation && prev.authorizedAt && Date.now() - Date.parse(prev.authorizedAt) < 30 * 60 * 1000);
|
|
442
|
+
ctx.browser = ctx.mk(true);
|
|
443
|
+
if (!ctx.browser.alive()) return fail(ctx, 'no_session', `No open browser session for "${name}". Run: hallpass ${ctx.generic ? 'open <url>' : 'login ' + name}`);
|
|
444
|
+
// human-mode sessions: the person is logging in, so we only watch; never type over them
|
|
445
|
+
const r = await drive(ctx, { wait: true, noFill: watchOnly, deadline: Date.now() + (o.timeout ?? 600) * 1000 });
|
|
446
|
+
if (r.status === 'logged_in') writeState(ctx, 'logged_in');
|
|
447
|
+
return r;
|
|
448
|
+
} finally { scrubTypedSecrets(ctx); release(); }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// every public entry point is logged (no secrets: names, origin+path, outcomes) and nudges the user when a window needs them
|
|
452
|
+
const audited = (event, fn) => async (...args) => {
|
|
453
|
+
const r = await fn(...args);
|
|
454
|
+
audit(event, { name: r.name, target: typeof args[0] === 'string' && /^https?:/i.test(args[0]) ? safeUrl(args[0]) : undefined, status: r.status, reason: r.reason, via: r.via, mode: r.mode, url: r.url });
|
|
455
|
+
if (r.status === 'needs_human' && r.mode === 'headed') notify('Hallpass needs you', 'A browser window is waiting for you to sign in or complete a check.');
|
|
456
|
+
return r;
|
|
457
|
+
};
|
|
458
|
+
export const login = audited('login', loginImpl);
|
|
459
|
+
export const openSite = audited('open', openSiteImpl);
|
|
460
|
+
export const resume = audited('resume', resumeImpl);
|
|
461
|
+
|
|
462
|
+
export function status(name) {
|
|
463
|
+
const meta = vault.exists(name) ? vault.meta(name) : { allowed: null };
|
|
464
|
+
const redactor = new Redactor();
|
|
465
|
+
const b = new Browser({ session: sessionName(name), profile: path.join(profilesDir(), name), redactor });
|
|
466
|
+
const alive = b.alive();
|
|
467
|
+
let url = null;
|
|
468
|
+
if (alive) url = safeUrl(b.run(['get', 'url']).json?.data?.url || '');
|
|
469
|
+
let phase = null;
|
|
470
|
+
try { phase = JSON.parse(fs.readFileSync(path.join(stateDir(), name + '.json'), 'utf8')); } catch { /* none */ }
|
|
471
|
+
return { name, session: sessionName(name), browser_running: alive, url, last: phase, allowed_origins: meta.allowed };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function logout(name, { wipe = false } = {}) {
|
|
475
|
+
if (!vault.validName(name)) throw new Error('invalid name');
|
|
476
|
+
const b = new Browser({ session: sessionName(name), profile: path.join(profilesDir(), name), redactor: new Redactor() });
|
|
477
|
+
b.close();
|
|
478
|
+
if (wipe) fs.rmSync(path.join(profilesDir(), name), { recursive: true, force: true });
|
|
479
|
+
return { name, closed: true, profile_wiped: wipe };
|
|
480
|
+
}
|