assertify-mcp 0.6.3__py3-none-any.whl
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.
- assertify/__init__.py +1 -0
- assertify/__main__.py +3 -0
- assertify/browser.py +1791 -0
- assertify/cli.py +120 -0
- assertify/files.py +177 -0
- assertify/human.py +95 -0
- assertify/icon.png +0 -0
- assertify/install.py +107 -0
- assertify/native.py +259 -0
- assertify/prompt.py +75 -0
- assertify/relay.py +629 -0
- assertify/safety.py +252 -0
- assertify/server.py +495 -0
- assertify/storage.py +287 -0
- assertify/vault.py +157 -0
- assertify_mcp-0.6.3.dist-info/METADATA +414 -0
- assertify_mcp-0.6.3.dist-info/RECORD +20 -0
- assertify_mcp-0.6.3.dist-info/WHEEL +4 -0
- assertify_mcp-0.6.3.dist-info/entry_points.txt +3 -0
- assertify_mcp-0.6.3.dist-info/licenses/LICENSE +21 -0
assertify/storage.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""What a login keeps besides cookies: localStorage, sessionStorage and IndexedDB (apps like
|
|
2
|
+
WhatsApp Web and Teams keep their sign-in there), plus per-login browser settings.
|
|
3
|
+
|
|
4
|
+
A saved state is Playwright's storage_state ({"cookies", "origins"}) with extra keys that
|
|
5
|
+
Playwright doesn't know about, so they're split off before a context is made from it."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
# Runs in a page (in an isolated world, see native.isolated_eval): [origin, localStorage,
|
|
12
|
+
# sessionStorage, IndexedDB], each null when it couldn't be read (keep what was saved before).
|
|
13
|
+
# IndexedDB comes back as {dbs, skipped}. Values that JSON can't hold (dates, binary data, files,
|
|
14
|
+
# Maps, exportable encryption keys...) are wrapped as {"$t": kind, ...} (see RESTORE_IDB_JS).
|
|
15
|
+
# A database holding something that can't be copied at all (encryption keys the browser
|
|
16
|
+
# won't export) is left out whole and named in `skipped`, rather than saved half-broken.
|
|
17
|
+
STORAGE_JS = r"""
|
|
18
|
+
async (withIdb) => {
|
|
19
|
+
const entries = (get) => { try { return Object.entries(get()).map(([name, value]) => ({name, value})); } catch (e) { return null; } };
|
|
20
|
+
const out = [location.origin, entries(() => localStorage), entries(() => sessionStorage), null];
|
|
21
|
+
if (!withIdb || !self.indexedDB || !indexedDB.databases) return out;
|
|
22
|
+
const plain = (v, depth) => {
|
|
23
|
+
if (v === null || typeof v === 'string' || typeof v === 'boolean') return true;
|
|
24
|
+
if (typeof v === 'number') return Number.isFinite(v) && !Object.is(v, -0);
|
|
25
|
+
if (depth > 40 || typeof v !== 'object') return false;
|
|
26
|
+
if (Array.isArray(v)) return v.every((x) => plain(x, depth + 1));
|
|
27
|
+
const proto = Object.getPrototypeOf(v);
|
|
28
|
+
if ((proto !== Object.prototype && proto !== null) || Object.hasOwn(v, '$t')) return false;
|
|
29
|
+
return Object.values(v).every((x) => plain(x, depth + 1));
|
|
30
|
+
};
|
|
31
|
+
class Skip extends Error {}
|
|
32
|
+
const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i += 0x8000) s += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000)); return btoa(s); };
|
|
33
|
+
const VIEWS = 'Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Uint32Array Float32Array Float64Array BigInt64Array BigUint64Array DataView'.split(' ');
|
|
34
|
+
const enc = async (v, seen) => {
|
|
35
|
+
if (plain(v, 0)) return v;
|
|
36
|
+
if (typeof v === 'number') return {$t: 'num', v: Object.is(v, -0) ? '-0' : String(v)};
|
|
37
|
+
if (v === undefined) return {$t: 'undef'};
|
|
38
|
+
if (typeof v === 'bigint') return {$t: 'big', v: String(v)};
|
|
39
|
+
if (typeof v !== 'object') throw new Skip(typeof v);
|
|
40
|
+
if (seen.has(v)) throw new Skip('a value that contains itself');
|
|
41
|
+
seen.add(v);
|
|
42
|
+
try {
|
|
43
|
+
const tag = Object.prototype.toString.call(v).slice(8, -1);
|
|
44
|
+
if (Array.isArray(v)) { const a = []; for (const x of v) a.push(await enc(x, seen)); return a; }
|
|
45
|
+
if (tag === 'Date') return {$t: 'date', v: String(v.getTime())};
|
|
46
|
+
if (tag === 'RegExp') return {$t: 're', s: v.source, f: v.flags};
|
|
47
|
+
if (tag === 'ArrayBuffer') return {$t: 'ab', b: b64(new Uint8Array(v))};
|
|
48
|
+
if (VIEWS.includes(tag)) return {$t: 'ta', k: tag, b: b64(new Uint8Array(v.buffer, v.byteOffset, v.byteLength))};
|
|
49
|
+
if (tag === 'Map') { const m = []; for (const [k, x] of v) m.push([await enc(k, seen), await enc(x, seen)]); return {$t: 'map', v: m}; }
|
|
50
|
+
if (tag === 'Set') { const a = []; for (const x of v) a.push(await enc(x, seen)); return {$t: 'set', v: a}; }
|
|
51
|
+
if (tag === 'Number' || tag === 'String' || tag === 'Boolean') return {$t: 'box', v: await enc(v.valueOf(), seen)};
|
|
52
|
+
if (tag === 'Blob' || tag === 'File') {
|
|
53
|
+
const o = {$t: 'blob', type: v.type, b: b64(new Uint8Array(await v.arrayBuffer()))};
|
|
54
|
+
if (tag === 'File') { o.name = v.name; o.lm = v.lastModified; }
|
|
55
|
+
return o;
|
|
56
|
+
}
|
|
57
|
+
if (tag === 'CryptoKey') {
|
|
58
|
+
if (!v.extractable) throw new Skip("encryption keys the browser won't let be copied");
|
|
59
|
+
const a = v.algorithm, alg = {name: a.name};
|
|
60
|
+
if (a.hash) alg.hash = a.hash.name || a.hash;
|
|
61
|
+
if (a.namedCurve) alg.namedCurve = a.namedCurve;
|
|
62
|
+
if (a.name === 'HMAC' && a.length) alg.length = a.length;
|
|
63
|
+
return {$t: 'key', jwk: await crypto.subtle.exportKey('jwk', v), alg, use: v.usages};
|
|
64
|
+
}
|
|
65
|
+
const proto = Object.getPrototypeOf(v);
|
|
66
|
+
if (proto !== Object.prototype && proto !== null) throw new Skip(tag + ' values');
|
|
67
|
+
const o = {};
|
|
68
|
+
for (const k of Object.keys(v)) o[k] = await enc(v[k], seen);
|
|
69
|
+
return Object.hasOwn(o, '$t') ? {$t: 'obj', v: o} : o;
|
|
70
|
+
} finally { seen.delete(v); }
|
|
71
|
+
};
|
|
72
|
+
const req = (r) => new Promise((ok, fail) => { r.onsuccess = () => ok(r.result); r.onerror = () => fail(r.error); });
|
|
73
|
+
const DB_LIMIT = 25 * 1024 * 1024, LIMIT = 60 * 1024 * 1024;
|
|
74
|
+
let total = 0;
|
|
75
|
+
const dump = async () => {
|
|
76
|
+
const dbs = [], skipped = [];
|
|
77
|
+
for (const info of await indexedDB.databases()) {
|
|
78
|
+
if (!info.name || !info.version) continue;
|
|
79
|
+
const db = await req(indexedDB.open(info.name));
|
|
80
|
+
try {
|
|
81
|
+
const names = [...db.objectStoreNames];
|
|
82
|
+
const raw = [];
|
|
83
|
+
if (names.length) { // read everything in one transaction, then encode (awaiting would end it)
|
|
84
|
+
const tx = db.transaction(names, 'readonly');
|
|
85
|
+
for (const name of names) {
|
|
86
|
+
const st = tx.objectStore(name);
|
|
87
|
+
const s = {name, autoIncrement: st.autoIncrement, indexes: [...st.indexNames].map((n) => {
|
|
88
|
+
const ix = st.index(n), d = {name: ix.name, unique: ix.unique, multiEntry: ix.multiEntry};
|
|
89
|
+
if (Array.isArray(ix.keyPath)) d.keyPathArray = ix.keyPath; else d.keyPath = ix.keyPath;
|
|
90
|
+
return d; })};
|
|
91
|
+
if (Array.isArray(st.keyPath)) s.keyPathArray = st.keyPath; else if (st.keyPath !== null) s.keyPath = st.keyPath;
|
|
92
|
+
raw.push([s, st.keyPath === null, ...(await Promise.all([req(st.getAllKeys()), req(st.getAll())]))]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
let size = 0;
|
|
96
|
+
const stores = [];
|
|
97
|
+
for (const [s, outOfLine, keys, values] of raw) {
|
|
98
|
+
const records = [];
|
|
99
|
+
for (let i = 0; i < keys.length; i++) {
|
|
100
|
+
const rec = {value: await enc(values[i], new Set())};
|
|
101
|
+
if (outOfLine) rec.key = await enc(keys[i], new Set());
|
|
102
|
+
size += JSON.stringify(rec).length;
|
|
103
|
+
if (size > DB_LIMIT || total + size > LIMIT) throw new Skip('too big to copy (over 25 MB)');
|
|
104
|
+
records.push(rec);
|
|
105
|
+
}
|
|
106
|
+
stores.push({...s, records});
|
|
107
|
+
}
|
|
108
|
+
total += size;
|
|
109
|
+
dbs.push({name: info.name, version: info.version, stores});
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (!(e instanceof Skip)) throw e;
|
|
112
|
+
skipped.push({name: info.name, why: e.message});
|
|
113
|
+
} finally { db.close(); }
|
|
114
|
+
}
|
|
115
|
+
return {dbs, skipped};
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
out[3] = await Promise.race([dump(), new Promise((_, fail) => setTimeout(() => fail(new Error('slow')), 20000))]);
|
|
119
|
+
} catch (e) {}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
# Runs in a page of the site: writes saved databases (from STORAGE_JS) back. Returns the problems.
|
|
125
|
+
# Used on a blank page of the site before the agent's first visit, and on an open tab after the
|
|
126
|
+
# user logs in again (it then replaces the records of those stores).
|
|
127
|
+
RESTORE_IDB_JS = r"""
|
|
128
|
+
async (dbs) => {
|
|
129
|
+
const bytes = (b) => Uint8Array.from(atob(b), (c) => c.charCodeAt(0));
|
|
130
|
+
const dec = async (v) => {
|
|
131
|
+
if (v === null || typeof v !== 'object') return v;
|
|
132
|
+
if (Array.isArray(v)) { const a = []; for (const x of v) a.push(await dec(x)); return a; }
|
|
133
|
+
const obj = async (src) => { const o = {}; for (const k of Object.keys(src)) o[k] = await dec(src[k]); return o; };
|
|
134
|
+
switch (v.$t) {
|
|
135
|
+
case undefined: return obj(v);
|
|
136
|
+
case 'obj': return obj(v.v);
|
|
137
|
+
case 'num': return Number(v.v);
|
|
138
|
+
case 'undef': return undefined;
|
|
139
|
+
case 'big': return BigInt(v.v);
|
|
140
|
+
case 'date': return new Date(Number(v.v));
|
|
141
|
+
case 're': return new RegExp(v.s, v.f);
|
|
142
|
+
case 'ab': return bytes(v.b).buffer;
|
|
143
|
+
case 'ta': { const u = bytes(v.b); return v.k === 'DataView' ? new DataView(u.buffer) : new self[v.k](u.buffer); }
|
|
144
|
+
case 'map': { const m = new Map(); for (const [k, x] of v.v) m.set(await dec(k), await dec(x)); return m; }
|
|
145
|
+
case 'set': { const s = new Set(); for (const x of v.v) s.add(await dec(x)); return s; }
|
|
146
|
+
case 'box': return Object(await dec(v.v));
|
|
147
|
+
case 'blob': return v.name !== undefined ? new File([bytes(v.b)], v.name, {type: v.type, lastModified: v.lm})
|
|
148
|
+
: new Blob([bytes(v.b)], {type: v.type});
|
|
149
|
+
case 'key': return crypto.subtle.importKey('jwk', v.jwk, v.alg, true, v.use);
|
|
150
|
+
default: throw new Error('unknown saved value ' + v.$t);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
const req = (r) => new Promise((ok, fail) => { r.onsuccess = () => ok(r.result); r.onerror = () => fail(r.error); });
|
|
154
|
+
const problems = [];
|
|
155
|
+
for (const db of dbs) {
|
|
156
|
+
try {
|
|
157
|
+
const stores = [];
|
|
158
|
+
for (const st of db.stores) {
|
|
159
|
+
const recs = [];
|
|
160
|
+
for (const r of st.records) recs.push([await dec(r.value), 'key' in r, 'key' in r ? await dec(r.key) : undefined]);
|
|
161
|
+
stores.push([st, recs]);
|
|
162
|
+
}
|
|
163
|
+
const open = indexedDB.open(db.name, db.version);
|
|
164
|
+
open.onupgradeneeded = () => {
|
|
165
|
+
const d = open.result;
|
|
166
|
+
for (const st of db.stores) {
|
|
167
|
+
if (d.objectStoreNames.contains(st.name)) continue;
|
|
168
|
+
const os = d.createObjectStore(st.name, {autoIncrement: !!st.autoIncrement, keyPath: st.keyPathArray ?? st.keyPath ?? null});
|
|
169
|
+
for (const ix of st.indexes || []) os.createIndex(ix.name, ix.keyPathArray ?? ix.keyPath, {unique: !!ix.unique, multiEntry: !!ix.multiEntry});
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
let d;
|
|
173
|
+
try { d = await req(open); } catch (e) {
|
|
174
|
+
if (!e || e.name !== 'VersionError') throw e;
|
|
175
|
+
d = await req(indexedDB.open(db.name)); // the site already upgraded it: fill the stores it has
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const names = stores.map(([st]) => st.name).filter((n) => d.objectStoreNames.contains(n));
|
|
179
|
+
if (names.length) {
|
|
180
|
+
const tx = d.transaction(names, 'readwrite');
|
|
181
|
+
const done = new Promise((ok, fail) => { tx.oncomplete = ok; tx.onerror = () => fail(tx.error); tx.onabort = () => fail(tx.error); });
|
|
182
|
+
for (const [st, recs] of stores) {
|
|
183
|
+
if (!names.includes(st.name)) continue;
|
|
184
|
+
const os = tx.objectStore(st.name);
|
|
185
|
+
os.clear();
|
|
186
|
+
for (const [value, hasKey, key] of recs) hasKey ? os.put(value, key) : os.put(value);
|
|
187
|
+
}
|
|
188
|
+
await done;
|
|
189
|
+
}
|
|
190
|
+
} finally { d.close(); }
|
|
191
|
+
} catch (e) {
|
|
192
|
+
problems.push(db.name + ': ' + ((e && e.message) || e));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return problems;
|
|
196
|
+
}
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
# Extra keys kept next to Playwright's state in the vault. Never part of meta: list_profiles shows meta.
|
|
200
|
+
EXTRA_KEYS = ("session_storage", "indexed_db", "http_credentials", "ignore_https_errors")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def split(state: dict | None) -> tuple[dict | None, dict]:
|
|
204
|
+
"""Saved state -> (what Playwright accepts as storage_state, the extras).
|
|
205
|
+
IndexedDB is restored by Assertify itself (RESTORE_IDB_JS), so it moves to extras["indexed_db"].
|
|
206
|
+
Logins saved by older versions may hold Playwright's own encoding ("valueEncoded"); only those
|
|
207
|
+
stay with Playwright, which knows how to read them."""
|
|
208
|
+
if not state:
|
|
209
|
+
return None, {}
|
|
210
|
+
extras = {k: state[k] for k in EXTRA_KEYS if k in state}
|
|
211
|
+
idb = dict(extras.get("indexed_db") or {})
|
|
212
|
+
origins = []
|
|
213
|
+
for o in state.get("origins", []):
|
|
214
|
+
dbs = o.get("indexedDB")
|
|
215
|
+
if dbs and not _playwright_encoded(dbs):
|
|
216
|
+
idb.setdefault(o["origin"], dbs)
|
|
217
|
+
o = {k: v for k, v in o.items() if k != "indexedDB"}
|
|
218
|
+
elif dbs and o["origin"] in idb:
|
|
219
|
+
o = {k: v for k, v in o.items() if k != "indexedDB"}
|
|
220
|
+
origins.append(o)
|
|
221
|
+
if idb:
|
|
222
|
+
extras["indexed_db"] = idb
|
|
223
|
+
return {"cookies": state.get("cookies", []), "origins": origins}, extras
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _playwright_encoded(dbs: list) -> bool:
|
|
227
|
+
return any(
|
|
228
|
+
"valueEncoded" in r or "keyEncoded" in r for db in dbs for st in db.get("stores", []) for r in st["records"]
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def merge_idb(old: list | None, dump: dict) -> list:
|
|
233
|
+
"""A new IndexedDB dump of one site, keeping the earlier copy of any database it had to skip."""
|
|
234
|
+
skipped = {s["name"] for s in dump.get("skipped", [])}
|
|
235
|
+
dbs = clean_idb(dump.get("dbs", []))
|
|
236
|
+
names = {d["name"] for d in dbs}
|
|
237
|
+
return dbs + [d for d in old or [] if d["name"] in skipped and d["name"] not in names]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def merge(pw_state: dict, extras: dict) -> dict:
|
|
241
|
+
return {**pw_state, **{k: v for k, v in extras.items() if v not in (None, {}, [])}}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def clean_idb(dbs: list) -> list:
|
|
245
|
+
"""A page's missing keyPath comes back as None; Playwright wants the key left out."""
|
|
246
|
+
for db in dbs:
|
|
247
|
+
for st in db.get("stores", []):
|
|
248
|
+
for d in (st, *st.get("indexes", [])):
|
|
249
|
+
for k in ("keyPath", "keyPathArray"):
|
|
250
|
+
if d.get(k) is None:
|
|
251
|
+
d.pop(k, None)
|
|
252
|
+
return dbs
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def summary(state: dict) -> dict:
|
|
256
|
+
"""How much a saved login holds besides cookies, for list_profiles (counts only, never values)."""
|
|
257
|
+
origins = state.get("origins", [])
|
|
258
|
+
out = {
|
|
259
|
+
"local_storage_items": sum(len(o.get("localStorage", [])) for o in origins),
|
|
260
|
+
"indexed_db_databases": sum(len(o.get("indexedDB", [])) for o in origins)
|
|
261
|
+
+ sum(len(v) for v in (state.get("indexed_db") or {}).values()),
|
|
262
|
+
"session_storage_items": sum(len(v) for v in (state.get("session_storage") or {}).values()),
|
|
263
|
+
}
|
|
264
|
+
return {k: v for k, v in out.items() if v}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def context_options(extras: dict) -> dict:
|
|
268
|
+
"""new_context() keyword arguments for a login's browser settings."""
|
|
269
|
+
opts: dict = {}
|
|
270
|
+
if extras.get("http_credentials"):
|
|
271
|
+
opts["http_credentials"] = extras["http_credentials"]
|
|
272
|
+
if extras.get("ignore_https_errors"):
|
|
273
|
+
opts["ignore_https_errors"] = True
|
|
274
|
+
return opts
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def session_storage_script(saved: dict[str, list]) -> str | None:
|
|
278
|
+
"""An init script that puts saved sessionStorage back in each new tab of those sites.
|
|
279
|
+
It only fills a tab whose sessionStorage is still empty, so it never overwrites the app's own."""
|
|
280
|
+
data = {o: {i["name"]: i["value"] for i in items} for o, items in (saved or {}).items() if items}
|
|
281
|
+
if not data:
|
|
282
|
+
return None
|
|
283
|
+
return (
|
|
284
|
+
"(() => { try { const d = " + json.dumps(data) + "[location.origin];"
|
|
285
|
+
" if (!d || sessionStorage.length) return;"
|
|
286
|
+
" for (const [k, v] of Object.entries(d)) sessionStorage.setItem(k, v); } catch (e) {} })();"
|
|
287
|
+
)
|
assertify/vault.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Encrypted on-disk storage for browser sessions.
|
|
2
|
+
|
|
3
|
+
Each profile is one file: ~/.assertify-mcp/profiles/<name>.enc
|
|
4
|
+
Contents (after decryption) are JSON: {"meta": {...}, "state": <Playwright storage_state>}.
|
|
5
|
+
|
|
6
|
+
Encryption: AES-256-GCM. The profile name is bound in as associated data,
|
|
7
|
+
so an encrypted file can't be renamed to impersonate another profile.
|
|
8
|
+
|
|
9
|
+
Key source (first match wins):
|
|
10
|
+
1. ASSERTIFY_PASSPHRASE env var -> scrypt-derived key (salt stored in vault dir)
|
|
11
|
+
2. OS keychain (macOS Keychain / Windows Credential Locker / Secret Service)
|
|
12
|
+
via `keyring`; a random key is generated on first use.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import secrets
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
26
|
+
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
|
|
27
|
+
|
|
28
|
+
from . import storage
|
|
29
|
+
|
|
30
|
+
MAGIC = b"AS1"
|
|
31
|
+
KEYRING_SERVICE = "assertify-mcp"
|
|
32
|
+
KEYRING_USER = "master-key"
|
|
33
|
+
NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.@+-]{0,63}$") # emails work as names
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class VaultError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def vault_home() -> Path:
|
|
41
|
+
home = Path(os.environ.get("ASSERTIFY_HOME", Path.home() / ".assertify-mcp"))
|
|
42
|
+
(home / "profiles").mkdir(parents=True, exist_ok=True)
|
|
43
|
+
os.chmod(home, 0o700)
|
|
44
|
+
os.chmod(home / "profiles", 0o700)
|
|
45
|
+
return home
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_key() -> bytes:
|
|
49
|
+
passphrase = os.environ.get("ASSERTIFY_PASSPHRASE")
|
|
50
|
+
if passphrase:
|
|
51
|
+
salt_file = vault_home() / "salt"
|
|
52
|
+
if not salt_file.exists():
|
|
53
|
+
salt_file.write_bytes(secrets.token_bytes(16))
|
|
54
|
+
os.chmod(salt_file, 0o600)
|
|
55
|
+
kdf = Scrypt(salt=salt_file.read_bytes(), length=32, n=2**15, r=8, p=1)
|
|
56
|
+
return kdf.derive(passphrase.encode())
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
import keyring
|
|
60
|
+
|
|
61
|
+
stored = keyring.get_password(KEYRING_SERVICE, KEYRING_USER)
|
|
62
|
+
if stored is None:
|
|
63
|
+
stored = secrets.token_hex(32)
|
|
64
|
+
keyring.set_password(KEYRING_SERVICE, KEYRING_USER, stored)
|
|
65
|
+
return bytes.fromhex(stored)
|
|
66
|
+
except Exception as e: # no usable keychain (e.g. headless Linux)
|
|
67
|
+
raise VaultError("No OS keychain available. Set ASSERTIFY_PASSPHRASE to use a passphrase instead.") from e
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Vault:
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
self.dir = vault_home() / "profiles"
|
|
73
|
+
self._key: bytes | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def key(self) -> bytes:
|
|
77
|
+
if self._key is None:
|
|
78
|
+
self._key = _load_key()
|
|
79
|
+
return self._key
|
|
80
|
+
|
|
81
|
+
def _path(self, name: str) -> Path:
|
|
82
|
+
if not NAME_RE.match(name):
|
|
83
|
+
raise VaultError(f"Invalid profile name {name!r}: use letters, digits, '_', '-', '.', '@', '+' (max 64).")
|
|
84
|
+
return self.dir / f"{name}.enc"
|
|
85
|
+
|
|
86
|
+
def save(self, name: str, state: dict, meta: dict[str, Any]) -> None:
|
|
87
|
+
path = self._path(name)
|
|
88
|
+
meta = {**meta, "name": name, "updated_at": time.time()}
|
|
89
|
+
meta.setdefault("created_at", meta["updated_at"])
|
|
90
|
+
plaintext = json.dumps({"meta": meta, "state": state}).encode()
|
|
91
|
+
nonce = secrets.token_bytes(12)
|
|
92
|
+
blob = MAGIC + nonce + AESGCM(self.key).encrypt(nonce, plaintext, name.encode())
|
|
93
|
+
tmp = path.with_suffix(".tmp")
|
|
94
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
95
|
+
with os.fdopen(fd, "wb") as f:
|
|
96
|
+
f.write(blob)
|
|
97
|
+
os.replace(tmp, path) # atomic: never leaves a half-written profile
|
|
98
|
+
|
|
99
|
+
def load(self, name: str) -> tuple[dict, dict]:
|
|
100
|
+
path = self._path(name)
|
|
101
|
+
if not path.exists():
|
|
102
|
+
raise VaultError(f"No saved session named {name!r}. Log in first.")
|
|
103
|
+
blob = path.read_bytes()
|
|
104
|
+
if not blob.startswith(MAGIC):
|
|
105
|
+
raise VaultError(f"{path} is not an Assertify file.")
|
|
106
|
+
nonce, ct = blob[3:15], blob[15:]
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(AESGCM(self.key).decrypt(nonce, ct, name.encode()))
|
|
109
|
+
except Exception as e:
|
|
110
|
+
raise VaultError(f"Could not decrypt {name!r} (wrong key/passphrase or file tampered).") from e
|
|
111
|
+
return data["state"], data["meta"]
|
|
112
|
+
|
|
113
|
+
def list(self, site: str | None = None) -> list[dict]:
|
|
114
|
+
"""All saved logins, or only those for one site ("google.com", "gmail", "linkedin")."""
|
|
115
|
+
out = []
|
|
116
|
+
for p in sorted(self.dir.glob("*.enc")):
|
|
117
|
+
try:
|
|
118
|
+
state, meta = self.load(p.stem)
|
|
119
|
+
meta = {**meta, "cookies": len(state.get("cookies", [])), **storage.summary(state)}
|
|
120
|
+
except VaultError as e:
|
|
121
|
+
meta = {"name": p.stem, "error": str(e)}
|
|
122
|
+
out.append(meta)
|
|
123
|
+
if site:
|
|
124
|
+
out = [m for m in out if _matches_site(m, site)]
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
def exists(self, name: str) -> bool:
|
|
128
|
+
return self._path(name).exists()
|
|
129
|
+
|
|
130
|
+
def rename(self, old: str, new: str, overwrite: bool = False) -> None:
|
|
131
|
+
"""Re-encrypt under the new name (the name is bound into the ciphertext)."""
|
|
132
|
+
if old == new:
|
|
133
|
+
return
|
|
134
|
+
case_only = old.lower() == new.lower() # same file on macOS/Windows, which ignore case
|
|
135
|
+
if not case_only and self.exists(new) and not overwrite:
|
|
136
|
+
raise VaultError(f"A profile named {new!r} already exists.")
|
|
137
|
+
state, meta = self.load(old)
|
|
138
|
+
if case_only:
|
|
139
|
+
self.delete(old)
|
|
140
|
+
self.save(new, state, meta)
|
|
141
|
+
if not case_only:
|
|
142
|
+
self.delete(old)
|
|
143
|
+
|
|
144
|
+
def delete(self, name: str) -> bool:
|
|
145
|
+
path = self._path(name)
|
|
146
|
+
if path.exists():
|
|
147
|
+
path.unlink()
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _matches_site(meta: dict, site: str) -> bool:
|
|
153
|
+
q = site.lower().removeprefix("https://").removeprefix("http://").removeprefix("www.").strip("/")
|
|
154
|
+
for d in [meta.get("site") or "", *(meta.get("allowed_domains") or [])]:
|
|
155
|
+
if d and (q == d or d.endswith("." + q) or q.endswith("." + d) or q in d.split(".")):
|
|
156
|
+
return True
|
|
157
|
+
return q in (meta.get("start_url") or "")
|