twinerd-browser-vault 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: twinerd-browser-vault
3
+ Version: 0.1.0
4
+ Summary: Browser profile snapshots, session cookie migration, and credential vault for Chrome, Chromium, and Firefox
5
+ Author-email: Tomasz Sapletta <info@softreck.com>
6
+ License: Proprietary
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # twinerd-browser-vault
11
+
12
+ Browser profile snapshots, session cookie migration, and credential vault management for Chrome, Chromium, and Firefox.
13
+
14
+ ## Features
15
+
16
+ - **Profile Discovery & Inspection**: Locates and analyzes profile state and size for Firefox, Chromium, Google Chrome, Brave, Edge, Opera, and Vivaldi.
17
+ - **Session Continuity (Cookie Sync)**: Migrates authenticated session cookies from Firefox (`cookies.sqlite`) into Chromium/Chrome SQLite `Cookies` table (mapping 1601-epoch timestamps).
18
+ - **Profile Snapshotting & Restore**: Clean backups omitting ephemeral caches, temporary locks, and GPU cache files.
19
+ - **Credential & Password Extraction**: Extracts credentials from isolated Firefox profiles (`logins.json` and external decrypt tools) with host and provider matching.
20
+ - **Zero Mandatory External Dependencies**: Built with Python 3.10+ standard library (`sqlite3`, `pathlib`, `shutil`, `urllib.parse`).
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install -e /path/to/twinerd-browser-vault
26
+ ```
27
+
28
+ ## CLI Usage
29
+
30
+ ```bash
31
+ # Detect browser profiles on current system
32
+ twinerd-browser-vault detect
33
+
34
+ # Inspect a profile directory
35
+ twinerd-browser-vault inspect firefox ~/.mozilla/firefox/default
36
+
37
+ # Snapshot profile omitting cache
38
+ twinerd-browser-vault snapshot chrome --account dev --dest ~/snapshots/
39
+
40
+ # Migrate session cookies from Firefox to Chromium for an account
41
+ twinerd-browser-vault sync-cookies --home /home/worker1 --provider anthropic
42
+ ```
43
+
44
+ ## Python API
45
+
46
+ ```python
47
+ from twinerd_browser_vault import (
48
+ detect_available_profiles,
49
+ sync_firefox_cookies_to_chromium,
50
+ create_profile_snapshot,
51
+ )
52
+
53
+ # Sync cookies
54
+ result = sync_firefox_cookies_to_chromium(
55
+ home=Path("/home/worker1"),
56
+ provider="chatgpt",
57
+ only_missing=True,
58
+ )
59
+ ```
@@ -0,0 +1,50 @@
1
+ # twinerd-browser-vault
2
+
3
+ Browser profile snapshots, session cookie migration, and credential vault management for Chrome, Chromium, and Firefox.
4
+
5
+ ## Features
6
+
7
+ - **Profile Discovery & Inspection**: Locates and analyzes profile state and size for Firefox, Chromium, Google Chrome, Brave, Edge, Opera, and Vivaldi.
8
+ - **Session Continuity (Cookie Sync)**: Migrates authenticated session cookies from Firefox (`cookies.sqlite`) into Chromium/Chrome SQLite `Cookies` table (mapping 1601-epoch timestamps).
9
+ - **Profile Snapshotting & Restore**: Clean backups omitting ephemeral caches, temporary locks, and GPU cache files.
10
+ - **Credential & Password Extraction**: Extracts credentials from isolated Firefox profiles (`logins.json` and external decrypt tools) with host and provider matching.
11
+ - **Zero Mandatory External Dependencies**: Built with Python 3.10+ standard library (`sqlite3`, `pathlib`, `shutil`, `urllib.parse`).
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pip install -e /path/to/twinerd-browser-vault
17
+ ```
18
+
19
+ ## CLI Usage
20
+
21
+ ```bash
22
+ # Detect browser profiles on current system
23
+ twinerd-browser-vault detect
24
+
25
+ # Inspect a profile directory
26
+ twinerd-browser-vault inspect firefox ~/.mozilla/firefox/default
27
+
28
+ # Snapshot profile omitting cache
29
+ twinerd-browser-vault snapshot chrome --account dev --dest ~/snapshots/
30
+
31
+ # Migrate session cookies from Firefox to Chromium for an account
32
+ twinerd-browser-vault sync-cookies --home /home/worker1 --provider anthropic
33
+ ```
34
+
35
+ ## Python API
36
+
37
+ ```python
38
+ from twinerd_browser_vault import (
39
+ detect_available_profiles,
40
+ sync_firefox_cookies_to_chromium,
41
+ create_profile_snapshot,
42
+ )
43
+
44
+ # Sync cookies
45
+ result = sync_firefox_cookies_to_chromium(
46
+ home=Path("/home/worker1"),
47
+ provider="chatgpt",
48
+ only_missing=True,
49
+ )
50
+ ```
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "twinerd-browser-vault"
7
+ version = "0.1.0"
8
+ description = "Browser profile snapshots, session cookie migration, and credential vault for Chrome, Chromium, and Firefox"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Proprietary" }
12
+ authors = [
13
+ { name = "Tomasz Sapletta", email = "info@softreck.com" }
14
+ ]
15
+ dependencies = []
16
+
17
+ [project.scripts]
18
+ twinerd-browser-vault = "twinerd_browser_vault.cli:main"
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["src"]
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,59 @@
1
+ """twinerd_browser_vault — Browser profile snapshots, session cookie migration, and credential vault."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from twinerd_browser_vault.models import (
6
+ PasswordRecord,
7
+ CookieRecord,
8
+ ProfileMetadata,
9
+ )
10
+ from twinerd_browser_vault.profiles import (
11
+ detect_available_profiles,
12
+ get_profile_size,
13
+ format_size,
14
+ inspect_profile,
15
+ create_profile_snapshot,
16
+ restore_profile_snapshot,
17
+ )
18
+ from twinerd_browser_vault.cookies import (
19
+ sync_firefox_cookies_to_chromium,
20
+ read_firefox_cookies,
21
+ write_chromium_cookies,
22
+ find_firefox_cookies_db,
23
+ find_chromium_cookies_db,
24
+ DEFAULT_SYNC_HOST_MARKERS,
25
+ PROVIDER_COOKIE_HOST_MARKERS,
26
+ )
27
+ from twinerd_browser_vault.passwords import (
28
+ extract_login_hosts,
29
+ load_isolated_firefox_profiles,
30
+ read_firefox_logins_json,
31
+ decrypt_firefox_logins,
32
+ FALLBACK_HOSTS,
33
+ )
34
+
35
+ __all__ = [
36
+ "PasswordRecord",
37
+ "CookieRecord",
38
+ "ProfileMetadata",
39
+ "detect_available_profiles",
40
+ "get_profile_size",
41
+ "format_size",
42
+ "inspect_profile",
43
+ "create_profile_snapshot",
44
+ "restore_profile_snapshot",
45
+ "sync_firefox_cookies_to_chromium",
46
+ "read_firefox_cookies",
47
+ "write_chromium_cookies",
48
+ "find_firefox_cookies_db",
49
+ "find_chromium_cookies_db",
50
+ "DEFAULT_SYNC_HOST_MARKERS",
51
+ "PROVIDER_COOKIE_HOST_MARKERS",
52
+ "extract_login_hosts",
53
+ "load_isolated_firefox_profiles",
54
+ "read_firefox_logins_json",
55
+ "decrypt_firefox_logins",
56
+ "FALLBACK_HOSTS",
57
+ ]
58
+
59
+ __version__ = "0.1.0"
@@ -0,0 +1,131 @@
1
+ """twinerd_browser_vault.cli — Command-line interface for twinerd-browser-vault."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Sequence
10
+
11
+ from twinerd_browser_vault.profiles import (
12
+ detect_available_profiles,
13
+ inspect_profile,
14
+ create_profile_snapshot,
15
+ restore_profile_snapshot,
16
+ )
17
+ from twinerd_browser_vault.cookies import (
18
+ sync_firefox_cookies_to_chromium,
19
+ read_firefox_cookies,
20
+ find_firefox_cookies_db,
21
+ DEFAULT_SYNC_HOST_MARKERS,
22
+ )
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(
27
+ prog="twinerd-browser-vault",
28
+ description="Browser profile snapshotting, session cookie migration, and credential vault for Twinerd",
29
+ )
30
+ sub = parser.add_subparsers(dest="command", required=True)
31
+
32
+ # detect
33
+ p_detect = sub.add_parser("detect", help="Detect installed browser profiles on system or account home")
34
+ p_detect.add_argument("--home", type=Path, default=None, help="Custom home directory")
35
+
36
+ # inspect
37
+ p_inspect = sub.add_parser("inspect", help="Inspect size and database state of a profile directory")
38
+ p_inspect.add_argument("browser", help="Browser type (firefox, chromium, chrome, etc.)")
39
+ p_inspect.add_argument("path", type=Path, help="Profile directory path")
40
+
41
+ # snapshot
42
+ p_snap = sub.add_parser("snapshot", help="Create a clean snapshot of a browser profile")
43
+ p_snap.add_argument("browser", help="Browser type (firefox, chromium, chrome)")
44
+ p_snap.add_argument("--account", default="default", help="Account ID / tag")
45
+ p_snap.add_argument("--dest", type=Path, required=True, help="Destination directory for snapshots")
46
+ p_snap.add_argument("--home", type=Path, default=None, help="Custom home directory")
47
+
48
+ # restore
49
+ p_restore = sub.add_parser("restore", help="Restore a profile snapshot to a target directory")
50
+ p_restore.add_argument("snapshot", type=Path, help="Snapshot directory path")
51
+ p_restore.add_argument("target", type=Path, help="Target profile directory")
52
+ p_restore.add_argument("--no-backup", action="store_true", help="Do not backup existing target directory")
53
+
54
+ # sync-cookies
55
+ p_sync = sub.add_parser("sync-cookies", help="Migrate session cookies from Firefox to Chromium/Chrome")
56
+ p_sync.add_argument("--home", type=Path, required=True, help="Account home directory containing .mozilla and .config")
57
+ p_sync.add_argument("--provider", default=None, help="Specific LLM provider (chatgpt, anthropic, google, etc.)")
58
+ p_sync.add_argument("--all-hosts", action="store_true", help="Sync all matching common LLM hosts")
59
+ p_sync.add_argument("--force-overwrite", action="store_true", help="Overwrite existing cookies instead of only missing")
60
+
61
+ # list-cookies
62
+ p_list_c = sub.add_parser("list-cookies", help="List available session cookies from Firefox profile")
63
+ p_list_c.add_argument("--home", type=Path, required=True, help="Account home directory")
64
+
65
+ return parser
66
+
67
+
68
+ def main(argv: Sequence[str] | None = None) -> int:
69
+ parser = build_parser()
70
+ args = parser.parse_args(argv)
71
+
72
+ if args.command == "detect":
73
+ profiles = detect_available_profiles(home=args.home)
74
+ print(f"Detected {len(profiles)} browser profile(s):")
75
+ for b, p in profiles.items():
76
+ meta = inspect_profile(b, p)
77
+ print(f" • {b.ljust(10)}: {p} ({meta.size_human})")
78
+ return 0
79
+
80
+ if args.command == "inspect":
81
+ meta = inspect_profile(args.browser, args.path)
82
+ print(f"Browser: {meta.browser}")
83
+ print(f"Path: {meta.path}")
84
+ print(f"Size: {meta.size_human} ({meta.size_bytes} bytes)")
85
+ print(f"Cookies DB: {meta.cookie_db or 'None'}")
86
+ print(f"Logins DB: {meta.login_db or 'None'}")
87
+ return 0
88
+
89
+ if args.command == "snapshot":
90
+ res = create_profile_snapshot(args.browser, args.account, args.dest, home=args.home)
91
+ if res:
92
+ print(f"Snapshot created successfully: {res}")
93
+ return 0
94
+ else:
95
+ print("Failed to create profile snapshot.", file=sys.stderr)
96
+ return 1
97
+
98
+ if args.command == "restore":
99
+ ok = restore_profile_snapshot(args.snapshot, args.target, backup_existing=not args.no_backup)
100
+ if ok:
101
+ print(f"Snapshot restored successfully to: {args.target}")
102
+ return 0
103
+ else:
104
+ print("Failed to restore profile snapshot.", file=sys.stderr)
105
+ return 1
106
+
107
+ if args.command == "sync-cookies":
108
+ res = sync_firefox_cookies_to_chromium(
109
+ home=args.home,
110
+ provider=args.provider,
111
+ only_missing=not args.force_overwrite,
112
+ )
113
+ print(json.dumps(res, indent=2))
114
+ return 0 if res.get("status") == "ok" else 1
115
+
116
+ if args.command == "list-cookies":
117
+ ff_db = find_firefox_cookies_db(args.home)
118
+ if not ff_db:
119
+ print(f"No Firefox cookies.sqlite found in {args.home}", file=sys.stderr)
120
+ return 1
121
+ cookies = read_firefox_cookies(ff_db, host_filter=DEFAULT_SYNC_HOST_MARKERS)
122
+ print(f"Found {len(cookies)} matching session cookies:")
123
+ for c in cookies:
124
+ print(f" [{c.host}] {c.name} (secure={c.is_secure}, httponly={c.is_httponly})")
125
+ return 0
126
+
127
+ return 0
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())
@@ -0,0 +1,274 @@
1
+ """twinerd_browser_vault.cookies — Session cookie migration and sync across Firefox and Chromium/Chrome."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import sqlite3
8
+ import tempfile
9
+ from pathlib import Path
10
+ from typing import Any, Sequence, Optional
11
+
12
+ from twinerd_browser_vault.models import CookieRecord
13
+
14
+ # Chromium expires_utc: microseconds since 1601-01-01 UTC.
15
+ CHROME_EPOCH_OFFSET_S = 11644473600
16
+
17
+ DEFAULT_SYNC_HOST_MARKERS = (
18
+ "openai.com",
19
+ "chatgpt.com",
20
+ "anthropic.com",
21
+ "claude.ai",
22
+ "google.com",
23
+ "google.pl",
24
+ "accounts.google.com",
25
+ "x.ai",
26
+ "deepseek.com",
27
+ "github.com",
28
+ "openrouter.ai",
29
+ "mistral.ai",
30
+ )
31
+
32
+ PROVIDER_COOKIE_HOST_MARKERS: dict[str, tuple[str, ...]] = {
33
+ "chatgpt": ("chatgpt.com", "openai.com", "auth0.openai.com"),
34
+ "anthropic": ("anthropic.com", "claude.ai"),
35
+ "google": ("google.com", "google.pl", "accounts.google.com", "gemini.google.com"),
36
+ "xai": ("x.ai", "console.x.ai"),
37
+ "deepseek": ("deepseek.com", "platform.deepseek.com"),
38
+ "github-copilot": ("github.com", "api.github.com"),
39
+ "openrouter": ("openrouter.ai", "openrouter.com"),
40
+ "mistral": ("mistral.ai", "console.mistral.ai"),
41
+ }
42
+
43
+
44
+ def chrome_expires(unix_expiry: int) -> int:
45
+ """Converts a Unix epoch second timestamp into Chrome 1601 epoch microsecond timestamp."""
46
+ if unix_expiry <= 0:
47
+ return 0
48
+ return int((unix_expiry + CHROME_EPOCH_OFFSET_S) * 1_000_000)
49
+
50
+
51
+ def host_matches(host: str, markers: Sequence[str]) -> bool:
52
+ """Checks if a host matches any of the given host markers."""
53
+ lowered = host.lstrip(".").lower()
54
+ return any(marker.lower() in lowered or lowered.endswith(marker.lower()) for marker in markers)
55
+
56
+
57
+ def markers_for_provider(provider: str | None) -> tuple[str, ...]:
58
+ if not provider:
59
+ return DEFAULT_SYNC_HOST_MARKERS
60
+ return PROVIDER_COOKIE_HOST_MARKERS.get(provider.lower(), DEFAULT_SYNC_HOST_MARKERS)
61
+
62
+
63
+ def find_firefox_cookies_db(home: Path) -> Path | None:
64
+ firefox = home / ".mozilla" / "firefox"
65
+ if not firefox.is_dir():
66
+ return None
67
+ best: tuple[int, Path] | None = None
68
+ for cookies in firefox.glob("*/cookies.sqlite"):
69
+ try:
70
+ size = cookies.stat().st_size
71
+ except OSError:
72
+ continue
73
+ if best is None or size > best[0]:
74
+ best = (size, cookies)
75
+ return best[1] if best else None
76
+
77
+
78
+ def find_chromium_cookies_db(home: Path) -> Path:
79
+ chromium = home / ".config" / "chromium" / "Default" / "Cookies"
80
+ chrome = home / ".config" / "google-chrome" / "Default" / "Cookies"
81
+ if chromium.is_file() or (home / ".config" / "chromium" / "Default").is_dir():
82
+ return chromium
83
+ if chrome.is_file() or (home / ".config" / "google-chrome" / "Default").is_dir():
84
+ return chrome
85
+ return chromium
86
+
87
+
88
+ def ensure_chromium_cookies_db(path: Path) -> None:
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ if path.is_file():
91
+ return
92
+ con = sqlite3.connect(str(path))
93
+ con.execute(
94
+ """
95
+ CREATE TABLE IF NOT EXISTS cookies (
96
+ creation_utc INTEGER NOT NULL,
97
+ host_key TEXT NOT NULL,
98
+ top_frame_site_key TEXT NOT NULL DEFAULT '',
99
+ name TEXT NOT NULL,
100
+ value TEXT NOT NULL,
101
+ encrypted_value BLOB NOT NULL DEFAULT X'',
102
+ path TEXT NOT NULL,
103
+ expires_utc INTEGER NOT NULL,
104
+ is_secure INTEGER NOT NULL,
105
+ is_httponly INTEGER NOT NULL,
106
+ last_access_utc INTEGER NOT NULL,
107
+ has_expires INTEGER NOT NULL DEFAULT 1,
108
+ is_persistent INTEGER NOT NULL DEFAULT 1,
109
+ priority INTEGER NOT NULL DEFAULT 1,
110
+ samesite INTEGER NOT NULL DEFAULT -1,
111
+ source_scheme INTEGER NOT NULL DEFAULT 0,
112
+ source_port INTEGER NOT NULL DEFAULT -1,
113
+ last_update_utc INTEGER NOT NULL DEFAULT 0,
114
+ source_type INTEGER NOT NULL DEFAULT 0,
115
+ has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0
116
+ )
117
+ """
118
+ )
119
+ con.execute(
120
+ "CREATE UNIQUE INDEX IF NOT EXISTS cookies_unique_index "
121
+ "ON cookies(host_key, top_frame_site_key, name, path, source_scheme, source_port)"
122
+ )
123
+ con.commit()
124
+ con.close()
125
+
126
+
127
+ def read_firefox_cookies(
128
+ db_path: Path,
129
+ host_filter: Optional[Sequence[str]] = None,
130
+ ) -> list[CookieRecord]:
131
+ """Reads cookies from Firefox cookies.sqlite, using a temporary snapshot to avoid SQLite locking."""
132
+ if not db_path.is_file():
133
+ return []
134
+
135
+ cookies: list[CookieRecord] = []
136
+ with tempfile.TemporaryDirectory() as td:
137
+ snap = Path(td) / "cookies.sqlite"
138
+ shutil.copy2(db_path, snap)
139
+ con = sqlite3.connect(str(snap))
140
+ try:
141
+ cur = con.cursor()
142
+ cur.execute(
143
+ """
144
+ SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite
145
+ FROM moz_cookies
146
+ """
147
+ )
148
+ for row in cur.fetchall():
149
+ name, val, host, path, exp, sec, http_only, ss = row
150
+ if host_filter and not host_matches(host, host_filter):
151
+ continue
152
+ cookies.append(
153
+ CookieRecord(
154
+ name=name,
155
+ value=val,
156
+ host=host,
157
+ path=path,
158
+ expiry=exp or 0,
159
+ is_secure=bool(sec),
160
+ is_httponly=bool(http_only),
161
+ samesite=int(ss) if ss is not None else -1,
162
+ )
163
+ )
164
+ finally:
165
+ con.close()
166
+ return cookies
167
+
168
+
169
+ def write_chromium_cookies(
170
+ db_path: Path,
171
+ cookies: list[CookieRecord],
172
+ only_missing: bool = True,
173
+ ) -> tuple[int, int]:
174
+ """Writes cookies into Chromium SQLite Cookies table.
175
+
176
+ Returns:
177
+ (inserted_count, skipped_count)
178
+ """
179
+ ensure_chromium_cookies_db(db_path)
180
+ con = sqlite3.connect(str(db_path))
181
+ inserted = 0
182
+ skipped = 0
183
+ try:
184
+ cur = con.cursor()
185
+ for c in cookies:
186
+ if only_missing:
187
+ cur.execute(
188
+ "SELECT 1 FROM cookies WHERE host_key = ? AND name = ? AND path = ? LIMIT 1",
189
+ (c.host, c.name, c.path),
190
+ )
191
+ if cur.fetchone():
192
+ skipped += 1
193
+ continue
194
+
195
+ now_chrome = chrome_expires(int(db_path.stat().st_mtime if db_path.exists() else 0))
196
+ exp_chrome = chrome_expires(c.expiry)
197
+
198
+ cur.execute(
199
+ """
200
+ INSERT OR REPLACE INTO cookies (
201
+ creation_utc, host_key, top_frame_site_key, name, value, encrypted_value,
202
+ path, expires_utc, is_secure, is_httponly, last_access_utc,
203
+ has_expires, is_persistent, priority, samesite, source_scheme, source_port,
204
+ last_update_utc, source_type, has_cross_site_ancestor
205
+ ) VALUES (
206
+ ?, ?, '', ?, ?, X'',
207
+ ?, ?, ?, ?, ?,
208
+ 1, 1, 1, ?, 2, 443,
209
+ ?, 0, 0
210
+ )
211
+ """,
212
+ (
213
+ now_chrome,
214
+ c.host,
215
+ c.name,
216
+ c.value,
217
+ c.path,
218
+ exp_chrome,
219
+ 1 if c.is_secure else 0,
220
+ 1 if c.is_httponly else 0,
221
+ now_chrome,
222
+ c.samesite,
223
+ now_chrome,
224
+ ),
225
+ )
226
+ inserted += 1
227
+ con.commit()
228
+ finally:
229
+ con.close()
230
+
231
+ return inserted, skipped
232
+
233
+
234
+ def sync_firefox_cookies_to_chromium(
235
+ home: Path,
236
+ provider: Optional[str] = None,
237
+ host_markers: Optional[Sequence[str]] = None,
238
+ only_missing: bool = True,
239
+ ) -> dict[str, Any]:
240
+ """Synchronizes Firefox session cookies into Chromium/Chrome."""
241
+ ff_db = find_firefox_cookies_db(home)
242
+ if not ff_db:
243
+ return {
244
+ "status": "skipped",
245
+ "reason": "no_firefox_profile",
246
+ "inserted": 0,
247
+ "skipped_existing": 0,
248
+ "hosts": [],
249
+ }
250
+
251
+ chrom_db = find_chromium_cookies_db(home)
252
+ markers = host_markers or markers_for_provider(provider)
253
+
254
+ cookies = read_firefox_cookies(ff_db, host_filter=markers)
255
+ matched_hosts = sorted(list({c.host.lstrip(".") for c in cookies}))
256
+
257
+ if not cookies:
258
+ return {
259
+ "status": "ok",
260
+ "inserted": 0,
261
+ "skipped_existing": 0,
262
+ "hosts": [],
263
+ "reason": "no_matching_cookies",
264
+ }
265
+
266
+ inserted, skipped = write_chromium_cookies(chrom_db, cookies, only_missing=only_missing)
267
+
268
+ return {
269
+ "status": "ok",
270
+ "inserted": inserted,
271
+ "skipped_existing": skipped,
272
+ "hosts": matched_hosts,
273
+ "provider": provider,
274
+ }
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, asdict
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class PasswordRecord:
10
+ """Represents a credential record extracted from a browser password manager."""
11
+ provider: str
12
+ host: str
13
+ username: str
14
+ password: str
15
+ profile: Path
16
+
17
+ def to_dict(self) -> dict[str, Any]:
18
+ d = asdict(self)
19
+ d["profile"] = str(self.profile)
20
+ return d
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class CookieRecord:
25
+ """Represents a browser session cookie."""
26
+ name: str
27
+ value: str
28
+ host: str
29
+ path: str
30
+ expiry: int
31
+ is_secure: bool
32
+ is_httponly: bool
33
+ samesite: int = -1
34
+
35
+ def to_dict(self) -> dict[str, Any]:
36
+ return asdict(self)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ProfileMetadata:
41
+ """Metadata regarding a browser profile on disk."""
42
+ browser: str
43
+ path: Path
44
+ size_bytes: int
45
+ size_human: str
46
+ cookie_db: Path | None = None
47
+ login_db: Path | None = None
@@ -0,0 +1,166 @@
1
+ """twinerd_browser_vault.passwords — Credential extraction and parsing from browser password stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import configparser
6
+ import json
7
+ import logging
8
+ import os
9
+ import re
10
+ import shlex
11
+ import shutil
12
+ import subprocess
13
+ from pathlib import Path
14
+ from typing import Any, Sequence, Optional
15
+ from urllib.parse import urlparse
16
+
17
+ from twinerd_browser_vault.models import PasswordRecord
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ DECRYPT_TIMEOUT_SECONDS = 20
22
+
23
+ FALLBACK_HOSTS: dict[str, tuple[str, ...]] = {
24
+ "chatgpt": ("chatgpt.com", "openai.com", "platform.openai.com"),
25
+ "anthropic": ("anthropic.com", "claude.ai", "console.anthropic.com"),
26
+ "google": ("accounts.google.com", "gemini.google.com"),
27
+ "github-copilot": ("github.com", "github.com/login"),
28
+ "azure-openai": ("portal.azure.com", "azure.com"),
29
+ "openrouter": ("openrouter.ai", "openrouter.com"),
30
+ "xai": ("x.ai", "console.x.ai"),
31
+ "deepseek": ("deepseek.com", "platform.deepseek.com"),
32
+ "mistral": ("mistral.ai", "console.mistral.ai"),
33
+ "groq": ("groq.com", "console.groq.com"),
34
+ "cohere": ("cohere.com", "dashboard.cohere.com"),
35
+ }
36
+
37
+
38
+ def url_host(value: str) -> str:
39
+ """Extracts hostname from an URL or hostname string."""
40
+ candidate = value.strip()
41
+ if not candidate:
42
+ return ""
43
+ if "://" not in candidate:
44
+ candidate = f"https://{candidate}"
45
+ try:
46
+ return (urlparse(candidate).hostname or "").lower()
47
+ except ValueError:
48
+ return ""
49
+
50
+
51
+ def extract_login_hosts(login: dict[str, Any]) -> list[str]:
52
+ """Extracts all potential hostnames from a login record dictionary."""
53
+ hosts: list[str] = []
54
+ for key in ("hostname", "formSubmitURL", "formSubmitUrl", "httpRealm", "origin", "url", "URL"):
55
+ host = url_host(str(login.get(key, "") or ""))
56
+ if host and host not in hosts:
57
+ hosts.append(host)
58
+ return hosts
59
+
60
+
61
+ def is_inside(root: Path, candidate: Path) -> bool:
62
+ try:
63
+ candidate.relative_to(root)
64
+ except ValueError:
65
+ return False
66
+ return True
67
+
68
+
69
+ def load_isolated_firefox_profiles(home: Path) -> list[Path]:
70
+ """Return only profiles contained within the account's Firefox directory."""
71
+ firefox_root = (home / ".mozilla" / "firefox").resolve()
72
+ if not firefox_root.is_dir():
73
+ return []
74
+
75
+ def accepted(candidate: Path) -> Path | None:
76
+ resolved = candidate.expanduser().resolve()
77
+ return resolved if resolved.is_dir() and is_inside(firefox_root, resolved) else None
78
+
79
+ profiles_ini = firefox_root / "profiles.ini"
80
+ if not profiles_ini.is_file():
81
+ return sorted(
82
+ profile
83
+ for item in firefox_root.iterdir()
84
+ if item.is_dir() and not item.name.endswith(".ini")
85
+ if (profile := accepted(item)) is not None
86
+ )
87
+
88
+ parser = configparser.ConfigParser(interpolation=None)
89
+ parser.optionxform = str
90
+ parser.read(profiles_ini, encoding="utf-8")
91
+ profiles: list[Path] = []
92
+ for section in parser.sections():
93
+ if not section.startswith("Profile") or not parser.has_option(section, "Path"):
94
+ continue
95
+ configured = parser.get(section, "Path", fallback="").strip()
96
+ if not configured:
97
+ continue
98
+ relative = parser.getboolean(section, "IsRelative", fallback=True)
99
+ candidate = firefox_root / configured if relative else Path(configured)
100
+ profile = accepted(candidate)
101
+ if profile is not None and profile not in profiles:
102
+ profiles.append(profile)
103
+ return profiles
104
+
105
+
106
+ def read_firefox_logins_json(profile_path: Path) -> list[dict[str, Any]]:
107
+ """Reads raw encrypted login records from logins.json if present."""
108
+ logins_file = profile_path / "logins.json"
109
+ if not logins_file.is_file():
110
+ return []
111
+ try:
112
+ data = json.loads(logins_file.read_text(encoding="utf-8"))
113
+ if isinstance(data, dict):
114
+ return data.get("logins", [])
115
+ except Exception as e:
116
+ log.warning(f"Error reading logins.json in {profile_path}: {e}")
117
+ return []
118
+
119
+
120
+ def decrypt_firefox_logins(
121
+ profile_path: Path,
122
+ decrypt_tool: Optional[str] = None,
123
+ provider: Optional[str] = None,
124
+ ) -> list[PasswordRecord]:
125
+ """Decrypts Firefox logins using an available external tool (like firefox-decrypt / firefox_decrypt)."""
126
+ tool = decrypt_tool or shutil.which("firefox-decrypt") or shutil.which("firefox_decrypt")
127
+ if not tool or not Path(tool).is_file():
128
+ return []
129
+
130
+ cmd = [tool, "-f", "json", str(profile_path)]
131
+ try:
132
+ res = subprocess.run(
133
+ cmd,
134
+ capture_output=True,
135
+ text=True,
136
+ timeout=DECRYPT_TIMEOUT_SECONDS,
137
+ )
138
+ if res.returncode != 0:
139
+ return []
140
+
141
+ data = json.loads(res.stdout)
142
+ records_raw = data.get("logins", data) if isinstance(data, dict) else data
143
+ if not isinstance(records_raw, list):
144
+ return []
145
+
146
+ records: list[PasswordRecord] = []
147
+ for item in records_raw:
148
+ if not isinstance(item, dict):
149
+ continue
150
+ h = url_host(str(item.get("hostname", item.get("host", item.get("url", ""))) or ""))
151
+ u = str(item.get("username", item.get("user", item.get("login", ""))) or "").strip()
152
+ p = str(item.get("password", item.get("passwd", item.get("pass", ""))) or "").strip()
153
+ if h and u and p:
154
+ records.append(
155
+ PasswordRecord(
156
+ provider=provider or "custom",
157
+ host=h,
158
+ username=u,
159
+ password=p,
160
+ profile=profile_path,
161
+ )
162
+ )
163
+ return records
164
+ except Exception as e:
165
+ log.warning(f"Failed decrypting profile {profile_path}: {e}")
166
+ return []
@@ -0,0 +1,204 @@
1
+ """twinerd_browser_vault.profiles — Browser profile discovery, size calculation, and snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from twinerd_browser_vault.models import ProfileMetadata
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ # Standard browser profile locations on Linux
17
+ BROWSER_PROFILE_PATHS = {
18
+ "firefox": {
19
+ "config": Path(".mozilla") / "firefox",
20
+ "cache": Path(".cache") / "mozilla" / "firefox",
21
+ },
22
+ "chromium": {
23
+ "config": Path(".config") / "chromium",
24
+ "cache": Path(".cache") / "chromium",
25
+ },
26
+ "chrome": {
27
+ "config": Path(".config") / "google-chrome",
28
+ "cache": Path(".cache") / "google-chrome",
29
+ },
30
+ "brave": {
31
+ "config": Path(".config") / "BraveSoftware" / "Brave-Browser",
32
+ "cache": Path(".cache") / "BraveSoftware" / "Brave-Browser",
33
+ },
34
+ "edge": {
35
+ "config": Path(".config") / "microsoft-edge",
36
+ "cache": Path(".cache") / "microsoft-edge",
37
+ },
38
+ "opera": {
39
+ "config": Path(".config") / "opera",
40
+ "cache": Path(".cache") / "opera",
41
+ },
42
+ "vivaldi": {
43
+ "config": Path(".config") / "vivaldi",
44
+ "cache": Path(".cache") / "vivaldi",
45
+ },
46
+ }
47
+
48
+ CONTAINER_PROFILE_PATHS = {
49
+ "firefox": "/home/browser/.mozilla/firefox",
50
+ "chromium": "/home/browser/.config/chromium",
51
+ "chrome": "/home/browser/.config/google-chrome",
52
+ }
53
+
54
+
55
+ def detect_available_profiles(home: Optional[Path] = None) -> dict[str, Path]:
56
+ """Detect available browser profiles on the given or host home system.
57
+
58
+ Returns:
59
+ Dictionary mapping browser name to profile config path.
60
+ """
61
+ base_home = home or Path.home()
62
+ available = {}
63
+ for browser_name, paths in BROWSER_PROFILE_PATHS.items():
64
+ cfg_path = base_home / paths["config"]
65
+ if cfg_path.exists():
66
+ available[browser_name] = cfg_path
67
+ return available
68
+
69
+
70
+ def get_profile_size(profile_path: Path) -> int:
71
+ """Get total size of a browser profile in bytes."""
72
+ if not profile_path.exists():
73
+ return 0
74
+
75
+ total = 0
76
+ for item in profile_path.rglob("*"):
77
+ try:
78
+ if item.is_file(follow_symlinks=False):
79
+ total += item.stat().st_size
80
+ except (OSError, PermissionError):
81
+ pass
82
+ return total
83
+
84
+
85
+ def format_size(size_bytes: int) -> str:
86
+ """Format bytes to human-readable size."""
87
+ size_flt = float(size_bytes)
88
+ for unit in ["B", "KB", "MB", "GB"]:
89
+ if size_flt < 1024:
90
+ return f"{size_flt:.1f} {unit}"
91
+ size_flt /= 1024.0
92
+ return f"{size_flt:.1f} TB"
93
+
94
+
95
+ def inspect_profile(browser: str, profile_path: Path) -> ProfileMetadata:
96
+ """Inspects a browser profile directory, calculating size and locating key databases."""
97
+ size_b = get_profile_size(profile_path)
98
+ cookie_db = None
99
+ login_db = None
100
+
101
+ if browser == "firefox":
102
+ for cand in profile_path.glob("*/cookies.sqlite"):
103
+ cookie_db = cand
104
+ break
105
+ for cand in profile_path.glob("*/logins.json"):
106
+ login_db = cand
107
+ break
108
+ else:
109
+ for cand in [
110
+ profile_path / "Default" / "Cookies",
111
+ profile_path / "Cookies",
112
+ ]:
113
+ if cand.is_file():
114
+ cookie_db = cand
115
+ break
116
+ for cand in [
117
+ profile_path / "Default" / "Login Data",
118
+ profile_path / "Login Data",
119
+ ]:
120
+ if cand.is_file():
121
+ login_db = cand
122
+ break
123
+
124
+ return ProfileMetadata(
125
+ browser=browser,
126
+ path=profile_path,
127
+ size_bytes=size_b,
128
+ size_human=format_size(size_b),
129
+ cookie_db=cookie_db,
130
+ login_db=login_db,
131
+ )
132
+
133
+
134
+ def create_profile_snapshot(
135
+ browser: str,
136
+ account_id: str,
137
+ snapshot_dir: Path,
138
+ home: Optional[Path] = None,
139
+ ) -> Optional[Path]:
140
+ """Create a snapshot of a browser profile for an account."""
141
+ base_home = home or Path.home()
142
+ if browser not in BROWSER_PROFILE_PATHS:
143
+ log.warning(f"Unknown browser: {browser}")
144
+ return None
145
+
146
+ source_path = base_home / BROWSER_PROFILE_PATHS[browser]["config"]
147
+ if not source_path.exists():
148
+ log.warning(f"Browser profile not found: {browser} ({source_path})")
149
+ return None
150
+
151
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
152
+ dest_path = snapshot_dir / f"{account_id}-{browser}-{timestamp}"
153
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
154
+
155
+ try:
156
+ shutil.copytree(
157
+ source_path,
158
+ dest_path,
159
+ symlinks=False,
160
+ ignore=shutil.ignore_patterns(
161
+ "Cache",
162
+ "cache",
163
+ "GPUCache",
164
+ "Code Cache",
165
+ "*.tmp",
166
+ "*lock*",
167
+ ".parentlock",
168
+ ),
169
+ )
170
+ log.info(f"Snapshot created: {dest_path}")
171
+ return dest_path
172
+ except Exception as e:
173
+ log.error(f"Failed to create snapshot: {e}")
174
+ if dest_path.exists():
175
+ shutil.rmtree(dest_path, ignore_errors=True)
176
+ return None
177
+
178
+
179
+ def restore_profile_snapshot(
180
+ snapshot_path: Path,
181
+ target_dir: Path,
182
+ backup_existing: bool = True,
183
+ ) -> bool:
184
+ """Restore a profile snapshot to a target directory."""
185
+ if not snapshot_path.exists() or not snapshot_path.is_dir():
186
+ log.error(f"Snapshot path does not exist: {snapshot_path}")
187
+ return False
188
+
189
+ if target_dir.exists() and backup_existing:
190
+ backup_path = target_dir.with_name(f"{target_dir.name}-backup-{int(datetime.now().timestamp())}")
191
+ try:
192
+ shutil.move(str(target_dir), str(backup_path))
193
+ log.info(f"Existing profile backed up to: {backup_path}")
194
+ except Exception as e:
195
+ log.error(f"Failed to backup existing profile: {e}")
196
+ return False
197
+
198
+ try:
199
+ shutil.copytree(snapshot_path, target_dir)
200
+ log.info(f"Snapshot restored to: {target_dir}")
201
+ return True
202
+ except Exception as e:
203
+ log.error(f"Failed to restore snapshot: {e}")
204
+ return False
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: twinerd-browser-vault
3
+ Version: 0.1.0
4
+ Summary: Browser profile snapshots, session cookie migration, and credential vault for Chrome, Chromium, and Firefox
5
+ Author-email: Tomasz Sapletta <info@softreck.com>
6
+ License: Proprietary
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ # twinerd-browser-vault
11
+
12
+ Browser profile snapshots, session cookie migration, and credential vault management for Chrome, Chromium, and Firefox.
13
+
14
+ ## Features
15
+
16
+ - **Profile Discovery & Inspection**: Locates and analyzes profile state and size for Firefox, Chromium, Google Chrome, Brave, Edge, Opera, and Vivaldi.
17
+ - **Session Continuity (Cookie Sync)**: Migrates authenticated session cookies from Firefox (`cookies.sqlite`) into Chromium/Chrome SQLite `Cookies` table (mapping 1601-epoch timestamps).
18
+ - **Profile Snapshotting & Restore**: Clean backups omitting ephemeral caches, temporary locks, and GPU cache files.
19
+ - **Credential & Password Extraction**: Extracts credentials from isolated Firefox profiles (`logins.json` and external decrypt tools) with host and provider matching.
20
+ - **Zero Mandatory External Dependencies**: Built with Python 3.10+ standard library (`sqlite3`, `pathlib`, `shutil`, `urllib.parse`).
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ pip install -e /path/to/twinerd-browser-vault
26
+ ```
27
+
28
+ ## CLI Usage
29
+
30
+ ```bash
31
+ # Detect browser profiles on current system
32
+ twinerd-browser-vault detect
33
+
34
+ # Inspect a profile directory
35
+ twinerd-browser-vault inspect firefox ~/.mozilla/firefox/default
36
+
37
+ # Snapshot profile omitting cache
38
+ twinerd-browser-vault snapshot chrome --account dev --dest ~/snapshots/
39
+
40
+ # Migrate session cookies from Firefox to Chromium for an account
41
+ twinerd-browser-vault sync-cookies --home /home/worker1 --provider anthropic
42
+ ```
43
+
44
+ ## Python API
45
+
46
+ ```python
47
+ from twinerd_browser_vault import (
48
+ detect_available_profiles,
49
+ sync_firefox_cookies_to_chromium,
50
+ create_profile_snapshot,
51
+ )
52
+
53
+ # Sync cookies
54
+ result = sync_firefox_cookies_to_chromium(
55
+ home=Path("/home/worker1"),
56
+ provider="chatgpt",
57
+ only_missing=True,
58
+ )
59
+ ```
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/twinerd_browser_vault/__init__.py
4
+ src/twinerd_browser_vault/cli.py
5
+ src/twinerd_browser_vault/cookies.py
6
+ src/twinerd_browser_vault/models.py
7
+ src/twinerd_browser_vault/passwords.py
8
+ src/twinerd_browser_vault/profiles.py
9
+ src/twinerd_browser_vault.egg-info/PKG-INFO
10
+ src/twinerd_browser_vault.egg-info/SOURCES.txt
11
+ src/twinerd_browser_vault.egg-info/dependency_links.txt
12
+ src/twinerd_browser_vault.egg-info/entry_points.txt
13
+ src/twinerd_browser_vault.egg-info/top_level.txt
14
+ tests/test_cookies.py
15
+ tests/test_passwords.py
16
+ tests/test_profiles.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ twinerd-browser-vault = twinerd_browser_vault.cli:main
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from pathlib import Path
5
+
6
+ from twinerd_browser_vault.cookies import (
7
+ sync_firefox_cookies_to_chromium,
8
+ read_firefox_cookies,
9
+ write_chromium_cookies,
10
+ chrome_expires,
11
+ host_matches,
12
+ )
13
+ from twinerd_browser_vault.models import CookieRecord
14
+
15
+
16
+ def _create_mock_ff_cookies(home: Path) -> Path:
17
+ profile = home / ".mozilla" / "firefox" / "abcd.default"
18
+ profile.mkdir(parents=True)
19
+ db = profile / "cookies.sqlite"
20
+ con = sqlite3.connect(str(db))
21
+ con.execute(
22
+ """
23
+ CREATE TABLE moz_cookies (
24
+ id INTEGER PRIMARY KEY,
25
+ name TEXT,
26
+ value TEXT,
27
+ host TEXT,
28
+ path TEXT,
29
+ expiry INTEGER,
30
+ isSecure INTEGER,
31
+ isHttpOnly INTEGER,
32
+ sameSite INTEGER
33
+ )
34
+ """
35
+ )
36
+ con.execute(
37
+ "INSERT INTO moz_cookies (name, value, host, path, expiry, isSecure, isHttpOnly, sameSite) "
38
+ "VALUES (?, ?, ?, ?, ?, 1, 1, 0)",
39
+ ("sessionKey", "secret-claude-session", ".claude.ai", "/", 2000000000),
40
+ )
41
+ con.execute(
42
+ "INSERT INTO moz_cookies (name, value, host, path, expiry, isSecure, isHttpOnly, sameSite) "
43
+ "VALUES (?, ?, ?, ?, ?, 1, 1, 0)",
44
+ ("other", "ignore-me", ".example.com", "/", 2000000000),
45
+ )
46
+ con.commit()
47
+ con.close()
48
+ return db
49
+
50
+
51
+ def test_host_matches():
52
+ assert host_matches(".claude.ai", ["claude.ai"])
53
+ assert host_matches("subdomain.openai.com", ["openai.com"])
54
+ assert not host_matches("evil-phishing.com", ["openai.com"])
55
+
56
+
57
+ def test_chrome_expires():
58
+ assert chrome_expires(0) == 0
59
+ # Unix timestamp > 0 should convert to 1601 epoch
60
+ exp = chrome_expires(1700000000)
61
+ assert exp > 1700000000 * 1_000_000
62
+
63
+
64
+ def test_read_and_write_cookies(tmp_path: Path):
65
+ home = tmp_path / "acct"
66
+ home.mkdir()
67
+ ff_db = _create_mock_ff_cookies(home)
68
+
69
+ cookies = read_firefox_cookies(ff_db, host_filter=["claude.ai"])
70
+ assert len(cookies) == 1
71
+ assert cookies[0].name == "sessionKey"
72
+ assert cookies[0].value == "secret-claude-session"
73
+
74
+ chrom_db = home / ".config" / "chromium" / "Default" / "Cookies"
75
+ inserted, skipped = write_chromium_cookies(chrom_db, cookies, only_missing=True)
76
+ assert inserted == 1
77
+ assert skipped == 0
78
+
79
+ # Second write with only_missing=True should skip
80
+ inserted2, skipped2 = write_chromium_cookies(chrom_db, cookies, only_missing=True)
81
+ assert inserted2 == 0
82
+ assert skipped2 == 1
83
+
84
+
85
+ def test_sync_firefox_cookies_to_chromium_flow(tmp_path: Path):
86
+ home = tmp_path / "acct"
87
+ home.mkdir()
88
+ _create_mock_ff_cookies(home)
89
+
90
+ res = sync_firefox_cookies_to_chromium(home, provider="anthropic", only_missing=True)
91
+ assert res["status"] == "ok"
92
+ assert res["inserted"] == 1
93
+ assert res["skipped_existing"] == 0
94
+ assert "claude.ai" in res["hosts"]
95
+ assert "example.com" not in res["hosts"]
96
+
97
+ # Verify directly in SQLite
98
+ chrome_db = home / ".config" / "chromium" / "Default" / "Cookies"
99
+ assert chrome_db.is_file()
100
+ con = sqlite3.connect(str(chrome_db))
101
+ row = con.execute("SELECT name, value, host_key FROM cookies WHERE host_key LIKE '%claude%'").fetchone()
102
+ con.close()
103
+ assert row is not None
104
+ assert row[0] == "sessionKey"
105
+ assert row[1] == "secret-claude-session"
106
+
107
+ # Idempotent second sync
108
+ res2 = sync_firefox_cookies_to_chromium(home, provider="anthropic", only_missing=True)
109
+ assert res2["inserted"] == 0
110
+ assert res2["skipped_existing"] == 1
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from twinerd_browser_vault.passwords import (
7
+ url_host,
8
+ extract_login_hosts,
9
+ load_isolated_firefox_profiles,
10
+ read_firefox_logins_json,
11
+ decrypt_firefox_logins,
12
+ )
13
+ from twinerd_browser_vault.models import PasswordRecord
14
+
15
+
16
+ def test_url_host():
17
+ assert url_host("https://chatgpt.com/auth/login") == "chatgpt.com"
18
+ assert url_host("api.anthropic.com") == "api.anthropic.com"
19
+ assert url_host("https://admin:secret@host.example.com:8443/test") == "host.example.com"
20
+
21
+
22
+ def test_extract_login_hosts():
23
+ login = {
24
+ "hostname": "https://accounts.google.com/signin",
25
+ "formSubmitURL": "https://accounts.google.com/CheckCookie",
26
+ "httpRealm": None,
27
+ }
28
+ hosts = extract_login_hosts(login)
29
+ assert hosts == ["accounts.google.com"]
30
+
31
+
32
+ def test_load_isolated_firefox_profiles(tmp_path: Path):
33
+ home = tmp_path / "home"
34
+ ff = home / ".mozilla" / "firefox"
35
+ ff.mkdir(parents=True)
36
+
37
+ prof1 = ff / "random.default"
38
+ prof1.mkdir()
39
+ prof2 = ff / "random.dev-edition"
40
+ prof2.mkdir()
41
+
42
+ profiles = load_isolated_firefox_profiles(home)
43
+ assert len(profiles) == 2
44
+ assert prof1.resolve() in profiles
45
+ assert prof2.resolve() in profiles
46
+
47
+
48
+ def test_decrypt_firefox_logins_with_mock_tool(tmp_path: Path):
49
+ prof = tmp_path / "prof"
50
+ prof.mkdir()
51
+
52
+ # Create dummy decrypt tool
53
+ tool = tmp_path / "mock-decrypt"
54
+ payload = json.dumps([
55
+ {
56
+ "hostname": "https://chatgpt.com",
57
+ "username": "user@example.com",
58
+ "password": "secret-password",
59
+ }
60
+ ])
61
+ tool.write_text(f"#!/bin/sh\nprintf '%s\\n' '{payload}'\n", encoding="utf-8")
62
+ tool.chmod(0o755)
63
+
64
+ records = decrypt_firefox_logins(prof, decrypt_tool=str(tool), provider="chatgpt")
65
+ assert len(records) == 1
66
+ assert records[0].provider == "chatgpt"
67
+ assert records[0].host == "chatgpt.com"
68
+ assert records[0].username == "user@example.com"
69
+ assert records[0].password == "secret-password"
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from twinerd_browser_vault.profiles import (
6
+ detect_available_profiles,
7
+ get_profile_size,
8
+ format_size,
9
+ inspect_profile,
10
+ create_profile_snapshot,
11
+ restore_profile_snapshot,
12
+ )
13
+
14
+
15
+ def test_format_size():
16
+ assert format_size(500) == "500.0 B"
17
+ assert format_size(2048) == "2.0 KB"
18
+ assert format_size(1048576 * 5) == "5.0 MB"
19
+
20
+
21
+ def test_profile_snapshot_and_restore(tmp_path: Path):
22
+ home = tmp_path / "home"
23
+ chrome_profile = home / ".config" / "google-chrome" / "Default"
24
+ chrome_profile.mkdir(parents=True)
25
+ (chrome_profile / "Preferences").write_text('{"test": true}', encoding="utf-8")
26
+ (chrome_profile / "History").write_text("dummy-history", encoding="utf-8")
27
+
28
+ # Detect
29
+ detected = detect_available_profiles(home=home)
30
+ assert "chrome" in detected
31
+ assert detected["chrome"] == home / ".config" / "google-chrome"
32
+
33
+ # Size
34
+ size = get_profile_size(detected["chrome"])
35
+ assert size > 0
36
+
37
+ # Inspect
38
+ meta = inspect_profile("chrome", detected["chrome"])
39
+ assert meta.browser == "chrome"
40
+ assert meta.size_bytes == size
41
+
42
+ # Snapshot
43
+ snapshots_dir = tmp_path / "snapshots"
44
+ snap = create_profile_snapshot("chrome", "acc1", snapshots_dir, home=home)
45
+ assert snap is not None
46
+ assert snap.is_dir()
47
+ assert (snap / "Default" / "Preferences").is_file()
48
+
49
+ # Restore to another directory
50
+ target = tmp_path / "restored" / ".config" / "google-chrome"
51
+ target.parent.mkdir(parents=True)
52
+ ok = restore_profile_snapshot(snap, target)
53
+ assert ok is True
54
+ assert (target / "Default" / "Preferences").is_file()