From 1e1239e8ad5e8f7e58edeacb132b3951ae12aff5 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 05:46:20 +0000 Subject: [PATCH 01/19] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/browser-commander/issues/69 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitkeep b/.gitkeep index 3811e7a..9b4a961 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,3 +1,4 @@ # .gitkeep file auto-generated at 2026-05-10T11:15:41.449Z for PR creation at branch issue-51-f2a446afa3b9 for issue https://github.com/link-foundation/browser-commander/issues/51 # Updated: 2026-06-28T20:40:17.765Z -# Updated: 2026-08-01T16:08:23.376Z \ No newline at end of file +# Updated: 2026-08-01T16:08:23.376Z +# Updated: 2026-08-02T05:46:19.964Z \ No newline at end of file From 875d023fe5582462247107d09a54772fc1d07f01 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 05:55:52 +0000 Subject: [PATCH 02/19] test(js): define installed cookie import behavior --- js/package-lock.json | 412 ++++++++++++++++- js/package.json | 3 +- js/tests/unit/browser/browser-cookies.test.js | 427 ++++++++++++++++++ 3 files changed, 836 insertions(+), 6 deletions(-) create mode 100644 js/tests/unit/browser/browser-cookies.test.js diff --git a/js/package-lock.json b/js/package-lock.json index f827c83..61b8569 100644 --- a/js/package-lock.json +++ b/js/package-lock.json @@ -9,6 +9,7 @@ "version": "0.13.0", "license": "UNLICENSE", "dependencies": { + "better-sqlite3": "^12.11.1", "log-lazy": "^1.0.4", "test-anywhere": "^0.9.1" }, @@ -1352,6 +1353,26 @@ "bare-path": "^3.0.0" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/basic-ftp": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", @@ -1375,6 +1396,40 @@ "node": ">=4" } }, + "node_modules/better-sqlite3": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/blamer": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/blamer/-/blamer-1.0.7.tgz", @@ -1420,6 +1475,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -1528,6 +1607,12 @@ "dev": true, "license": "MIT" }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/chromium-bidi": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-11.0.0.tgz", @@ -1803,6 +1888,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1835,6 +1944,15 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1534754", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1534754.tgz", @@ -1888,7 +2006,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -2288,6 +2405,15 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/extendable-error": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", @@ -2414,6 +2540,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2465,6 +2597,12 @@ "dev": true, "license": "ISC" }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -2598,6 +2736,12 @@ "node": ">= 14" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gitignore-to-glob": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/gitignore-to-glob/-/gitignore-to-glob-0.3.0.tgz", @@ -2808,6 +2952,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2845,6 +3009,18 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -3728,6 +3904,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3741,6 +3929,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -3761,6 +3958,12 @@ "node": ">=10" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -3791,6 +3994,12 @@ "url": "https://github.com/sindresorhus/nano-spawn?sponsor=1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -3808,6 +4017,18 @@ "node": ">= 0.4.0" } }, + "node_modules/node-abi": { + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-sarif-builder": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-2.0.3.tgz", @@ -3887,7 +4108,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -4194,6 +4414,61 @@ "node": ">=18" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4420,7 +4695,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -4526,6 +4800,30 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/read-yaml-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", @@ -4566,6 +4864,20 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -4722,6 +5034,26 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4733,7 +5065,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4772,6 +5103,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4930,6 +5306,15 @@ "text-decoder": "^1.1.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -5132,6 +5517,18 @@ "dev": true, "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5194,6 +5591,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -5275,7 +5678,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { diff --git a/js/package.json b/js/package.json index 3ee4094..e0e4fc5 100644 --- a/js/package.json +++ b/js/package.json @@ -63,6 +63,7 @@ } }, "dependencies": { + "better-sqlite3": "^12.11.1", "log-lazy": "^1.0.4", "test-anywhere": "^0.9.1" }, @@ -72,8 +73,8 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "husky": "^9.1.7", - "jsdoc": "^4.0.4", "jscpd": "^4.0.5", + "jsdoc": "^4.0.4", "lint-staged": "^16.2.6", "playwright": "^1.56.1", "prettier": "^3.6.2", diff --git a/js/tests/unit/browser/browser-cookies.test.js b/js/tests/unit/browser/browser-cookies.test.js new file mode 100644 index 0000000..b707e29 --- /dev/null +++ b/js/tests/unit/browser/browser-cookies.test.js @@ -0,0 +1,427 @@ +import assert from 'node:assert'; +import { + createCipheriv, + createHash, + pbkdf2Sync, + randomBytes, +} from 'node:crypto'; +import { + chmod, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import Database from 'better-sqlite3'; + +import { + clearBrowserCookieMemoryCache, + decryptChromiumCookie, + listBrowserProfiles, + readBrowserCookies, + readBrowserCookiesWithDependencies, +} from '../../../src/browser/browser-cookies.js'; +import { + listBrowserProfiles as publicListBrowserProfiles, + readBrowserCookies as publicReadBrowserCookies, +} from '../../../src/index.js'; + +const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600; + +function chromeExpires(unixSeconds) { + return (unixSeconds + CHROME_EPOCH_OFFSET_SECONDS) * 1_000_000; +} + +function encryptCbcCookie({ host, value, password, prefix = 'v11' }) { + const key = pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1'); + const plaintext = Buffer.concat([ + createHash('sha256').update(host).digest(), + Buffer.from(value), + ]); + const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, 0x20)); + return Buffer.concat([ + Buffer.from(prefix), + cipher.update(plaintext), + cipher.final(), + ]); +} + +function encryptGcmCookie({ host, value, key, prefix = 'v10' }) { + const nonce = randomBytes(12); + const plaintext = Buffer.concat([ + createHash('sha256').update(host).digest(), + Buffer.from(value), + ]); + const cipher = createCipheriv('aes-256-gcm', key, nonce); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return Buffer.concat([ + Buffer.from(prefix), + nonce, + ciphertext, + cipher.getAuthTag(), + ]); +} + +async function createChromiumProfile({ homeDir, rows, profile = 'Default' }) { + const root = path.join(homeDir, '.config', 'google-chrome'); + const profilePath = path.join(root, profile); + const cookiePath = path.join(profilePath, 'Network', 'Cookies'); + await mkdir(path.dirname(cookiePath), { recursive: true }); + await writeFile( + path.join(root, 'Local State'), + JSON.stringify({ + profile: { + last_used: profile, + info_cache: { [profile]: { name: 'Primary profile' } }, + }, + }) + ); + + const database = new Database(cookiePath); + database.exec(` + CREATE TABLE meta (key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR); + INSERT INTO meta (key, value) VALUES ('version', '24'); + CREATE TABLE cookies ( + host_key TEXT NOT NULL, + name TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + encrypted_value BLOB NOT NULL DEFAULT '', + path TEXT NOT NULL, + expires_utc INTEGER NOT NULL, + is_secure INTEGER NOT NULL, + is_httponly INTEGER NOT NULL, + samesite INTEGER NOT NULL + ); + `); + const insert = database.prepare(` + INSERT INTO cookies ( + host_key, name, value, encrypted_value, path, expires_utc, + is_secure, is_httponly, samesite + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const row of rows) { + insert.run( + row.host, + row.name, + row.value ?? '', + row.encryptedValue ?? Buffer.alloc(0), + row.path ?? '/', + row.expiresUtc ?? 0, + row.secure ? 1 : 0, + row.httpOnly ? 1 : 0, + row.sameSite ?? -1 + ); + } + database.close(); + return { cookiePath, profilePath, root }; +} + +async function createFirefoxProfile({ homeDir, rows }) { + const root = path.join(homeDir, '.mozilla', 'firefox'); + const profileName = 'fixture.default-release'; + const profilePath = path.join(root, profileName); + const cookiePath = path.join(profilePath, 'cookies.sqlite'); + await mkdir(profilePath, { recursive: true }); + await writeFile( + path.join(root, 'profiles.ini'), + `[Profile0]\nName=default-release\nIsRelative=1\nPath=${profileName}\nDefault=1\n` + ); + const database = new Database(cookiePath); + database.exec(` + CREATE TABLE moz_cookies ( + name TEXT, + value TEXT, + host TEXT, + path TEXT, + expiry INTEGER, + isSecure INTEGER, + isHttpOnly INTEGER, + sameSite INTEGER + ); + `); + const insert = database.prepare(` + INSERT INTO moz_cookies + (name, value, host, path, expiry, isSecure, isHttpOnly, sameSite) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const row of rows) { + insert.run( + row.name, + row.value, + row.host, + row.path ?? '/', + row.expiry ?? 0, + row.secure ? 1 : 0, + row.httpOnly ? 1 : 0, + row.sameSite ?? 0 + ); + } + database.close(); + return { cookiePath, profilePath, root }; +} + +describe('installed browser cookie import', () => { + let temporaryDirectory; + + afterEach(async () => { + clearBrowserCookieMemoryCache(); + if (temporaryDirectory) { + await chmod(temporaryDirectory, 0o700); + await rm(temporaryDirectory, { recursive: true, force: true }); + temporaryDirectory = undefined; + } + }); + + it('exports both public helpers', () => { + assert.equal(publicListBrowserProfiles, listBrowserProfiles); + assert.equal(publicReadBrowserCookies, readBrowserCookies); + }); + + it('discovers named Chromium and Firefox profiles', async () => { + temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'browser-commander-cookie-profiles-') + ); + const chromium = await createChromiumProfile({ + homeDir: temporaryDirectory, + rows: [], + }); + const firefox = await createFirefoxProfile({ + homeDir: temporaryDirectory, + rows: [], + }); + + const profiles = await listBrowserProfiles({ + platform: 'linux', + homeDir: temporaryDirectory, + environment: {}, + }); + + assert.deepEqual(profiles, [ + { + browser: 'chrome', + name: 'Default', + displayName: 'Primary profile', + path: chromium.profilePath, + isDefault: true, + }, + { + browser: 'firefox', + name: 'default-release', + displayName: 'default-release', + path: firefox.profilePath, + isDefault: true, + }, + ]); + }); + + it('decrypts Chromium CBC cookies and returns the engine cookie shape', async () => { + temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'browser-commander-cookie-read-') + ); + const password = 'fixture safe storage password'; + const host = '.example.com'; + await createChromiumProfile({ + homeDir: temporaryDirectory, + rows: [ + { + host, + name: 'SID', + encryptedValue: encryptCbcCookie({ + host, + value: 'decrypted-session', + password, + }), + expiresUtc: chromeExpires(2_000_000_000), + secure: true, + httpOnly: true, + sameSite: 2, + }, + { + host: '.other.test', + name: 'ignored', + value: 'plain', + }, + ], + }); + + let credentialReads = 0; + const cookies = await readBrowserCookiesWithDependencies( + { + browser: 'chrome', + domainFilter: 'example.com', + cache: { + dir: path.join(temporaryDirectory, 'cache'), + ttlMinutes: 60, + }, + }, + { + platform: 'linux', + homeDir: temporaryDirectory, + environment: {}, + readSafeStoragePassword: async () => { + credentialReads += 1; + return password; + }, + } + ); + + assert.deepEqual(cookies, [ + { + name: 'SID', + value: 'decrypted-session', + domain: host, + path: '/', + expires: 2_000_000_000, + httpOnly: true, + secure: true, + sameSite: 'Strict', + }, + ]); + assert.equal(credentialReads, 1); + }); + + it('reads Firefox cookies without touching the credential provider', async () => { + temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'browser-commander-firefox-cookie-') + ); + await createFirefoxProfile({ + homeDir: temporaryDirectory, + rows: [ + { + host: '.example.org', + name: 'firefox-session', + value: 'plain-value', + expiry: 2_000_000_001, + secure: true, + sameSite: 1, + }, + ], + }); + + const cookies = await readBrowserCookiesWithDependencies( + { browser: 'firefox' }, + { + platform: 'linux', + homeDir: temporaryDirectory, + environment: {}, + readSafeStoragePassword: async () => { + throw new Error('Firefox must not read an OS credential'); + }, + } + ); + + assert.deepEqual(cookies, [ + { + name: 'firefox-session', + value: 'plain-value', + domain: '.example.org', + path: '/', + expires: 2_000_000_001, + httpOnly: false, + secure: true, + sameSite: 'Lax', + }, + ]); + }); + + it('reuses the owner-only credential cache across memory-cache resets', async () => { + temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'browser-commander-cookie-cache-') + ); + const password = 'cached safe storage password'; + const rows = ['.example.com', '.example.org'].map((host, index) => ({ + host, + name: `session-${index}`, + encryptedValue: encryptCbcCookie({ + host, + value: `value-${index}`, + password, + }), + })); + await createChromiumProfile({ homeDir: temporaryDirectory, rows }); + const cacheDir = path.join(temporaryDirectory, 'cache'); + let credentialReads = 0; + const dependencies = { + platform: 'linux', + homeDir: temporaryDirectory, + environment: {}, + readSafeStoragePassword: async () => { + credentialReads += 1; + return password; + }, + }; + + await readBrowserCookiesWithDependencies( + { + browser: 'chrome', + domainFilter: 'example.com', + cache: { dir: cacheDir, ttlMinutes: 60 }, + }, + dependencies + ); + clearBrowserCookieMemoryCache(); + await readBrowserCookiesWithDependencies( + { + browser: 'chrome', + domainFilter: 'example.org', + cache: { dir: cacheDir, ttlMinutes: 60 }, + }, + dependencies + ); + + assert.equal(credentialReads, 1); + const cacheFiles = await readdir(cacheDir); + const credentialFile = cacheFiles.find((name) => + name.startsWith('credential-') + ); + assert.ok(credentialFile); + const credentialPath = path.join(cacheDir, credentialFile); + assert.equal((await stat(credentialPath)).mode & 0o777, 0o600); + const cached = JSON.parse(await readFile(credentialPath, 'utf8')); + assert.equal(cached.kind, 'derived-key'); + assert.equal( + cached.key, + pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1').toString('base64') + ); + assert.equal(JSON.stringify(cached).includes(password), false); + }); + + it('decrypts Windows AES-GCM data and rejects app-bound v20 data', () => { + const key = randomBytes(32); + const host = '.example.net'; + const encryptedValue = encryptGcmCookie({ + host, + value: 'windows-session', + key, + }); + + assert.equal( + decryptChromiumCookie({ + encryptedValue, + host, + databaseVersion: 24, + platform: 'win32', + key, + }), + 'windows-session' + ); + assert.throws( + () => + decryptChromiumCookie({ + encryptedValue: Buffer.from('v20app-bound-cookie'), + host, + databaseVersion: 24, + platform: 'win32', + key, + }), + /app-bound.*cannot be decrypted outside the browser/i + ); + }); +}); From a23c0fa34b36e2f8fa417f14566e7f3fddaf3a70 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:03:06 +0000 Subject: [PATCH 03/19] feat(js): import cookies from installed browsers --- README.md | 5 + js/.changeset/installed-browser-cookies.md | 5 + js/README.md | 79 ++++- js/src/browser/browser-cookie-cache.js | 232 ++++++++++++++ js/src/browser/browser-cookie-credentials.js | 140 ++++++++ js/src/browser/browser-cookie-crypto.js | 113 +++++++ js/src/browser/browser-cookies.js | 318 +++++++++++++++++++ js/src/browser/browser-profiles.js | 280 ++++++++++++++++ js/src/exports.js | 4 + 9 files changed, 1174 insertions(+), 2 deletions(-) create mode 100644 js/.changeset/installed-browser-cookies.md create mode 100644 js/src/browser/browser-cookie-cache.js create mode 100644 js/src/browser/browser-cookie-credentials.js create mode 100644 js/src/browser/browser-cookie-crypto.js create mode 100644 js/src/browser/browser-cookies.js create mode 100644 js/src/browser/browser-profiles.js diff --git a/README.md b/README.md index 2bfba47..242476c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ CDP. The JavaScript package also provides `launchAndConnectRealBrowser()` to find and start an installed Chrome, Edge, Brave, or Chromium with a safe, dedicated automation profile before attaching. +All implementations also expose installed-browser profile discovery and local +cookie import for Chrome, Edge, Brave, Chromium, and Firefox. Cookie values are +returned in the automation-engine shape and cached locally with owner-only +permissions so platform credential stores are touched at most once per TTL. + ## Core Concept: Page State Machine Browser Commander manages the browser as a state machine with two states: diff --git a/js/.changeset/installed-browser-cookies.md b/js/.changeset/installed-browser-cookies.md new file mode 100644 index 0000000..3852f52 --- /dev/null +++ b/js/.changeset/installed-browser-cookies.md @@ -0,0 +1,5 @@ +--- +'browser-commander': minor +--- + +Add installed-browser profile discovery and privacy-preserving cookie import for Chrome, Edge, Brave, Chromium, and Firefox, including OS credential caching and Playwright/Puppeteer-compatible output. diff --git a/js/README.md b/js/README.md index de34134..8ae22a7 100644 --- a/js/README.md +++ b/js/README.md @@ -138,8 +138,41 @@ const connection = await launchAndConnectRealBrowser({ await connection.browser.close(); ``` -Cookie seeding copies only cookies you explicitly provide; the helper does not -read, decrypt, or expose cookies from a browser's default profile. +Cookie seeding copies only cookies you explicitly provide. To seed a dedicated +profile from one of your installed browser profiles, use the explicit local +cookie-import helper: + +```javascript +import { + launchAndConnectRealBrowser, + listBrowserProfiles, + readBrowserCookies, +} from 'browser-commander'; + +console.log(await listBrowserProfiles({ browser: 'chrome' })); + +const cookies = await readBrowserCookies({ + browser: 'chrome', // chrome, edge, brave, chromium, or firefox + profile: 'Default', + domainFilter: 'example.com', + cache: { ttlMinutes: 60 }, +}); + +const connection = await launchAndConnectRealBrowser({ + engine: 'playwright', + channel: 'chrome', + userDataDir: '/tmp/my-dedicated-profile', + seedCookies: cookies, +}); +``` + +The import stays on the local machine and runs only when called. It never sends +cookie data anywhere. Decrypted result and derived-key cache files are stored +under `~/.browser-commander/cookie-cache/` with owner-only permissions. The +default 60-minute TTL and a cross-process lock ensure that concurrent or repeated +scripts touch Keychain, libsecret/KWallet, or DPAPI at most once per TTL window. +Set `refresh: true` to force a new read, customize `cache.dir`/`ttlMinutes`, or +set `cache: false` to opt out of disk caching. Reuse a saved authenticated session by passing Playwright-compatible storage state as a JSON file path or object. Cookies and localStorage are restored for @@ -370,6 +403,48 @@ default browser-profile paths, protects its remote-debugging arguments, and returns the spawned `browserProcess`, resolved `cdpEndpoint`, executable path, and profile path alongside `{ browser, page }`. +### listBrowserProfiles(options) + +Discover cookie-bearing profiles for Chrome, Edge, Brave, Chromium, and Firefox. +Pass an optional `browser` to narrow discovery. Each result contains +`{ browser, name, displayName, path, isDefault }`. + +### readBrowserCookies(options) + +Read cookies from an installed browser profile and return the exact +`{ name, value, domain, path, expires, httpOnly, secure, sameSite }` shape used by +Playwright and Puppeteer: + +```javascript +const cookies = await readBrowserCookies({ + browser: 'firefox', + profile: 'default-release', // optional; the default profile is selected first + domainFilter: 'example.com', // optional substring match + cache: { dir: './private-cookie-cache', ttlMinutes: 30 }, + refresh: false, +}); +``` + +Platform support: + +| Browser family | macOS | Linux | Windows | +| ----------------------------- | ------------------------------------ | --------------------------------------------------------------------------- | --------------------------------------------- | +| Chrome, Edge, Brave, Chromium | Keychain + AES-128-CBC (`v10`/`v11`) | libsecret/KWallet + AES-128-CBC (`v11`), or the Chromium `v10` fallback key | DPAPI-protected AES-256-GCM key (`v10`/`v11`) | +| Firefox | `cookies.sqlite` | `cookies.sqlite` | `cookies.sqlite` | + +Firefox cookie values are stored directly in its local cookie database. Chromium +database version 24 domain hashes and Chrome's 1601-based timestamps are handled +automatically. Current Windows Chromium can use app-bound `v20` encryption, +which intentionally requires the browser's privileged elevation service and +cannot be decrypted by an ordinary external process. The helper reports that +boundary instead of bypassing it; use a browser-supported export or an existing +Browser Commander storage-state file for those cookies. Set +`ignoreDecryptionErrors: true` only when returning the remaining decryptable +cookies is acceptable. + +Treat imported cookies like passwords: keep cache directories private, use a +short TTL, never commit them, and seed only a dedicated automation profile. + ### saveStorageState(page, filePath) Save the current cookies and localStorage in Playwright's portable storage diff --git a/js/src/browser/browser-cookie-cache.js b/js/src/browser/browser-cookie-cache.js new file mode 100644 index 0000000..592ae90 --- /dev/null +++ b/js/src/browser/browser-cookie-cache.js @@ -0,0 +1,232 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + chmod, + mkdir, + open, + readFile, + rename, + rm, + stat, +} from 'node:fs/promises'; +import path from 'node:path'; + +const DEFAULT_TTL_MINUTES = 60; +const LOCK_STALE_MILLISECONDS = 30_000; +const LOCK_WAIT_MILLISECONDS = 30_000; +const credentialPromises = new Map(); + +function hash(value) { + return createHash('sha256').update(value).digest('hex'); +} + +export function clearBrowserCookieMemoryCache() { + credentialPromises.clear(); +} + +export function normalizeCookieCache(cache, homeDir, ttlMinutes) { + if (cache === false) { + return { enabled: false }; + } + const selectedTtl = ttlMinutes ?? cache?.ttlMinutes ?? DEFAULT_TTL_MINUTES; + if (!Number.isFinite(selectedTtl) || selectedTtl < 0) { + throw new RangeError( + 'cookie cache ttlMinutes must be a non-negative number' + ); + } + return { + enabled: true, + dir: path.resolve( + cache?.dir ?? path.join(homeDir, '.browser-commander', 'cookie-cache') + ), + ttlMilliseconds: selectedTtl * 60_000, + }; +} + +async function ensureCacheDirectory(cacheDir) { + await mkdir(cacheDir, { recursive: true, mode: 0o700 }); + await chmod(cacheDir, 0o700).catch(() => {}); +} + +async function readFreshJson(filePath, ttlMilliseconds, now) { + try { + const value = JSON.parse(await readFile(filePath, 'utf8')); + const age = now() - value.savedAt; + return age >= 0 && age <= ttlMilliseconds ? value : null; + } catch { + return null; + } +} + +async function writeOwnerOnlyJson(filePath, value) { + const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + const handle = await open(temporaryPath, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(value)}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporaryPath, filePath); + } catch (error) { + await rm(temporaryPath, { force: true }); + throw error; + } + await chmod(filePath, 0o600).catch(() => {}); +} + +function cachePath(cacheDir, kind, identity) { + return path.join(cacheDir, `${kind}-${hash(identity)}.json`); +} + +export async function readCookieResultCache({ + cache, + identity, + refresh, + now = Date.now, +}) { + if (!cache.enabled || refresh) { + return null; + } + const cached = await readFreshJson( + cachePath(cache.dir, 'cookies', identity), + cache.ttlMilliseconds, + now + ); + return cached?.kind === 'cookies' && Array.isArray(cached.cookies) + ? cached.cookies + : null; +} + +export async function writeCookieResultCache({ + cache, + identity, + cookies, + now = Date.now, +}) { + if (!cache.enabled) { + return; + } + await ensureCacheDirectory(cache.dir); + await writeOwnerOnlyJson(cachePath(cache.dir, 'cookies', identity), { + version: 1, + kind: 'cookies', + savedAt: now(), + cookies, + }); +} + +async function removeStaleLock(lockPath, now) { + try { + const lockStat = await stat(lockPath); + if (now() - lockStat.mtimeMs > LOCK_STALE_MILLISECONDS) { + await rm(lockPath, { force: true }); + } + } catch { + // Another process may have released it already. + } +} + +async function wait(milliseconds) { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} + +async function acquireCredentialLock(lockPath, cachedPath, options) { + const startedAt = options.now(); + while (options.now() - startedAt <= LOCK_WAIT_MILLISECONDS) { + try { + return await open(lockPath, 'wx', 0o600); + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + const cached = await readFreshJson( + cachedPath, + options.ttlMilliseconds, + options.now + ); + if ( + cached?.kind === 'derived-key' && + (!options.refresh || cached.savedAt !== options.initialSavedAt) + ) { + return { cached }; + } + await removeStaleLock(lockPath, options.now); + await wait(50); + } + } + throw new Error('timed out waiting for another cookie credential reader'); +} + +async function loadOrCreateCredential({ + cache, + identity, + refresh, + create, + now, + metadata, +}) { + if (!cache.enabled) { + return create(); + } + await ensureCacheDirectory(cache.dir); + const cachedPath = cachePath(cache.dir, 'credential', identity); + const lockPath = `${cachedPath}.lock`; + const initial = await readFreshJson(cachedPath, cache.ttlMilliseconds, now); + if (!refresh && initial?.kind === 'derived-key') { + return Buffer.from(initial.key, 'base64'); + } + + const acquired = await acquireCredentialLock(lockPath, cachedPath, { + initialSavedAt: initial?.savedAt, + now, + refresh, + ttlMilliseconds: cache.ttlMilliseconds, + }); + if (acquired.cached) { + return Buffer.from(acquired.cached.key, 'base64'); + } + + try { + const afterLock = await readFreshJson( + cachedPath, + cache.ttlMilliseconds, + now + ); + if ( + afterLock?.kind === 'derived-key' && + (!refresh || afterLock.savedAt !== initial?.savedAt) + ) { + return Buffer.from(afterLock.key, 'base64'); + } + const key = Buffer.from(await create()); + await writeOwnerOnlyJson(cachedPath, { + version: 1, + kind: 'derived-key', + savedAt: now(), + key: key.toString('base64'), + ...metadata, + }); + return key; + } finally { + await acquired.close(); + await rm(lockPath, { force: true }); + } +} + +export async function getCachedCredential(options) { + const memoryIdentity = `${options.cache.dir ?? 'disabled'}:${options.identity}`; + if (!options.refresh && credentialPromises.has(memoryIdentity)) { + return credentialPromises.get(memoryIdentity); + } + const promise = loadOrCreateCredential({ now: Date.now, ...options }); + credentialPromises.set(memoryIdentity, promise); + try { + return await promise; + } catch (error) { + if (credentialPromises.get(memoryIdentity) === promise) { + credentialPromises.delete(memoryIdentity); + } + throw error; + } +} diff --git a/js/src/browser/browser-cookie-credentials.js b/js/src/browser/browser-cookie-credentials.js new file mode 100644 index 0000000..323053a --- /dev/null +++ b/js/src/browser/browser-cookie-credentials.js @@ -0,0 +1,140 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; + +const execFile = promisify(execFileCallback); + +const SAFE_STORAGE = { + brave: { application: 'brave', service: 'Brave Safe Storage' }, + chrome: { application: 'chrome', service: 'Chrome Safe Storage' }, + chromium: { application: 'chromium', service: 'Chromium Safe Storage' }, + edge: { + application: 'microsoft-edge', + service: 'Microsoft Edge Safe Storage', + }, +}; + +async function runCredentialCommand(command, args, environment) { + const { stdout } = await execFile(command, args, { + encoding: 'utf8', + env: environment, + maxBuffer: 1024 * 1024, + windowsHide: true, + }); + return stdout.trim(); +} + +async function readLinuxSafeStoragePassword(browser, environment) { + const identity = SAFE_STORAGE[browser]; + try { + const password = await runCredentialCommand( + 'secret-tool', + ['lookup', 'application', identity.application], + environment + ); + if (password) { + return password; + } + } catch { + // Try KWallet before reporting that v11 key storage is unavailable. + } + try { + const password = await runCredentialCommand( + 'kwallet-query', + [ + '-r', + identity.service, + '-f', + `${identity.application} Keys`, + 'kdewallet', + ], + environment + ); + if (password) { + return password; + } + } catch { + // Fall through to the actionable error below. + } + throw new Error( + `Could not read ${identity.service} from libsecret or KWallet; install secret-tool or unlock the browser key store` + ); +} + +/** Read a Chromium Safe Storage password from the platform credential store. */ +export async function readSafeStoragePassword({ + browser, + platform = process.platform, + environment = process.env, +}) { + const identity = SAFE_STORAGE[browser]; + if (!identity) { + throw new Error(`No Safe Storage identity is known for ${browser}`); + } + if (platform === 'darwin') { + const password = await runCredentialCommand( + 'security', + ['find-generic-password', '-w', '-s', identity.service], + environment + ); + if (!password) { + throw new Error(`${identity.service} returned an empty password`); + } + return password; + } + if (platform === 'linux') { + return readLinuxSafeStoragePassword(browser, environment); + } + throw new Error(`Safe Storage passwords are not used on ${platform}`); +} + +const DPAPI_SCRIPT = [ + '$inputBytes=[Convert]::FromBase64String($args[0])', + '$outputBytes=[Security.Cryptography.ProtectedData]::Unprotect(', + ' $inputBytes,$null,', + ' [Security.Cryptography.DataProtectionScope]::CurrentUser)', + '[Convert]::ToBase64String($outputBytes)', +].join(';'); + +/** Decrypt bytes with Windows DPAPI in the current user's security context. */ +export async function decryptWindowsDpapi( + encryptedValue, + { environment = process.env } = {} +) { + const output = await runCredentialCommand( + 'powershell.exe', + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-Command', + DPAPI_SCRIPT, + Buffer.from(encryptedValue).toString('base64'), + ], + environment + ); + return Buffer.from(output, 'base64'); +} + +/** Recover the legacy AES-GCM key from a Chromium Local State file. */ +export async function readWindowsEncryptionKey({ + localStatePath, + environment = process.env, + decryptDpapi = decryptWindowsDpapi, +}) { + let state; + try { + state = JSON.parse(await readFile(localStatePath, 'utf8')); + } catch (error) { + throw new Error(`Could not read Chromium Local State: ${error.message}`); + } + const encoded = state.os_crypt?.encrypted_key; + if (!encoded) { + throw new Error('Chromium Local State has no os_crypt.encrypted_key'); + } + const encryptedKey = Buffer.from(encoded, 'base64'); + if (encryptedKey.subarray(0, 5).toString('ascii') !== 'DPAPI') { + throw new Error('Chromium Local State key does not have a DPAPI prefix'); + } + return decryptDpapi(encryptedKey.subarray(5), { environment }); +} diff --git a/js/src/browser/browser-cookie-crypto.js b/js/src/browser/browser-cookie-crypto.js new file mode 100644 index 0000000..9f4748c --- /dev/null +++ b/js/src/browser/browser-cookie-crypto.js @@ -0,0 +1,113 @@ +import { + createDecipheriv, + createHash, + pbkdf2Sync, + timingSafeEqual, +} from 'node:crypto'; +import { TextDecoder } from 'node:util'; + +const CHROMIUM_CBC_IV = Buffer.alloc(16, 0x20); +const DOMAIN_HASH_BYTES = 32; + +export function deriveChromiumCookieKey(password, platform) { + if (platform !== 'darwin' && platform !== 'linux') { + throw new Error(`CBC cookie keys are not used on ${platform}`); + } + return pbkdf2Sync( + password, + 'saltysalt', + platform === 'darwin' ? 1003 : 1, + 16, + 'sha1' + ); +} + +function removeDomainHash(plaintext, host, databaseVersion) { + if (databaseVersion < 24) { + return plaintext; + } + if (plaintext.length < DOMAIN_HASH_BYTES) { + throw new Error('decrypted cookie is missing its domain hash'); + } + const expectedHash = createHash('sha256').update(host).digest(); + const actualHash = plaintext.subarray(0, DOMAIN_HASH_BYTES); + if (!timingSafeEqual(actualHash, expectedHash)) { + throw new Error('decrypted cookie domain hash does not match its host'); + } + return plaintext.subarray(DOMAIN_HASH_BYTES); +} + +function decodeCookieValue(plaintext) { + return new TextDecoder('utf-8', { fatal: true }).decode(plaintext); +} + +function decryptCbcCookie(encryptedValue, key) { + const decipher = createDecipheriv('aes-128-cbc', key, CHROMIUM_CBC_IV); + return Buffer.concat([ + decipher.update(encryptedValue.subarray(3)), + decipher.final(), + ]); +} + +function decryptGcmCookie(encryptedValue, key) { + const payload = encryptedValue.subarray(3); + if (payload.length < 12 + 16) { + throw new Error('encrypted AES-GCM cookie is truncated'); + } + const nonce = payload.subarray(0, 12); + const authenticationTag = payload.subarray(-16); + const ciphertext = payload.subarray(12, -16); + const decipher = createDecipheriv('aes-256-gcm', key, nonce); + decipher.setAuthTag(authenticationTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +/** Decrypt a Chromium cookie encrypted_value using an already recovered key. */ +export function decryptChromiumCookie({ + encryptedValue, + host, + databaseVersion = 0, + platform = process.platform, + key, +}) { + const encrypted = Buffer.from(encryptedValue); + const prefix = encrypted.subarray(0, 3).toString('ascii'); + if (prefix === 'v20') { + throw new Error( + 'Windows app-bound v20 cookies cannot be decrypted outside the browser; use a browser-supported export or a previously saved storage state' + ); + } + if (prefix !== 'v10' && prefix !== 'v11') { + throw new Error( + 'cookie does not use a supported Chromium encryption prefix' + ); + } + if (!Buffer.isBuffer(key)) { + throw new TypeError('a recovered cookie encryption key is required'); + } + const plaintext = + platform === 'win32' + ? decryptGcmCookie(encrypted, key) + : decryptCbcCookie(encrypted, key); + return decodeCookieValue(removeDomainHash(plaintext, host, databaseVersion)); +} + +export function chromiumSameSite(value) { + if (Number(value) === 2) { + return 'Strict'; + } + if (Number(value) === 1) { + return 'Lax'; + } + return 'None'; +} + +export function firefoxSameSite(value) { + if (Number(value) === 2) { + return 'Strict'; + } + if (Number(value) === 1) { + return 'Lax'; + } + return 'None'; +} diff --git a/js/src/browser/browser-cookies.js b/js/src/browser/browser-cookies.js new file mode 100644 index 0000000..dc61ba9 --- /dev/null +++ b/js/src/browser/browser-cookies.js @@ -0,0 +1,318 @@ +import path from 'node:path'; +import os from 'node:os'; +import { TextDecoder } from 'node:util'; + +import Database from 'better-sqlite3'; + +import { + clearBrowserCookieMemoryCache, + getCachedCredential, + normalizeCookieCache, + readCookieResultCache, + writeCookieResultCache, +} from './browser-cookie-cache.js'; +import { + chromiumSameSite, + decryptChromiumCookie, + deriveChromiumCookieKey, + firefoxSameSite, +} from './browser-cookie-crypto.js'; +import { + decryptWindowsDpapi, + readSafeStoragePassword, + readWindowsEncryptionKey, +} from './browser-cookie-credentials.js'; +import { + findCookieDatabase, + listBrowserProfiles, + normalizeCookieBrowser, + resolveBrowserProfile, +} from './browser-profiles.js'; + +const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600n; +const MICROSECONDS_PER_SECOND = 1_000_000n; + +function toNumber(value) { + return typeof value === 'bigint' ? Number(value) : Number(value ?? 0); +} + +function chromiumExpires(value) { + const microseconds = BigInt(value ?? 0); + if (microseconds === 0n) { + return -1; + } + return Number( + microseconds / MICROSECONDS_PER_SECOND - CHROME_EPOCH_OFFSET_SECONDS + ); +} + +function firefoxExpires(value) { + const expires = toNumber(value); + return expires > 0 ? expires : -1; +} + +function queryRows(database, query, domainFilter) { + const statement = database.prepare(query).safeIntegers(); + return domainFilter ? statement.all(`%${domainFilter}%`) : statement.all(); +} + +function readDatabaseVersion(database) { + try { + const row = database + .prepare("SELECT value FROM meta WHERE key = 'version'") + .get(); + return Number(row?.value ?? 0); + } catch { + return 0; + } +} + +function readFirefoxRows(database, domainFilter) { + return queryRows( + database, + `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite + FROM moz_cookies + ${domainFilter ? 'WHERE host LIKE ?' : ''} + ORDER BY host, name, path`, + domainFilter + ); +} + +function readChromiumRows(database, domainFilter) { + return queryRows( + database, + `SELECT host_key, name, value, encrypted_value, path, expires_utc, + is_secure, is_httponly, samesite + FROM cookies + ${domainFilter ? 'WHERE host_key LIKE ?' : ''} + ORDER BY host_key, name, path`, + domainFilter + ); +} + +function mapFirefoxRows(rows) { + return rows.map((row) => ({ + name: row.name, + value: row.value, + domain: row.host, + path: row.path || '/', + expires: firefoxExpires(row.expiry), + httpOnly: Boolean(row.isHttpOnly), + secure: Boolean(row.isSecure), + sameSite: firefoxSameSite(row.sameSite), + })); +} + +function encryptionPrefix(encryptedValue) { + return Buffer.from(encryptedValue).subarray(0, 3).toString('ascii'); +} + +function chromiumKeyForPrefix(context, prefix) { + if (context.platform === 'linux' && prefix === 'v10') { + return deriveChromiumCookieKey('peanuts', 'linux'); + } + if (context.platform === 'linux' || context.platform === 'darwin') { + return getCachedCredential({ + cache: context.cache, + identity: `${context.browser}:${context.platform}:safe-storage`, + refresh: context.refresh, + now: context.now, + metadata: { + browser: context.browser, + platform: context.platform, + source: 'safe-storage', + }, + create: async () => + deriveChromiumCookieKey( + await context.readSafeStoragePassword({ + browser: context.browser, + platform: context.platform, + environment: context.environment, + }), + context.platform + ), + }); + } + if (context.platform === 'win32') { + return getCachedCredential({ + cache: context.cache, + identity: `${context.browser}:win32:legacy-aes-key`, + refresh: context.refresh, + now: context.now, + metadata: { + browser: context.browser, + platform: context.platform, + source: 'dpapi', + }, + create: () => + context.readWindowsEncryptionKey({ + localStatePath: path.join( + path.dirname(context.profile.path), + 'Local State' + ), + environment: context.environment, + decryptDpapi: context.decryptWindowsDpapi, + }), + }); + } + throw new Error( + `Chromium cookie decryption is unsupported on ${context.platform}` + ); +} + +async function decryptChromiumRow(row, databaseVersion, context) { + if (row.value) { + return row.value; + } + const encryptedValue = Buffer.from(row.encrypted_value); + if (encryptedValue.length === 0) { + return ''; + } + const prefix = encryptionPrefix(encryptedValue); + if (context.platform === 'win32' && prefix !== 'v10' && prefix !== 'v11') { + if (prefix === 'v20') { + return decryptChromiumCookie({ + encryptedValue, + host: row.host_key, + databaseVersion, + platform: context.platform, + key: Buffer.alloc(32), + }); + } + const plaintext = await context.decryptWindowsDpapi(encryptedValue, { + environment: context.environment, + }); + return new TextDecoder('utf8', { fatal: true }).decode(plaintext); + } + const key = await chromiumKeyForPrefix(context, prefix); + return decryptChromiumCookie({ + encryptedValue, + host: row.host_key, + databaseVersion, + platform: context.platform, + key, + }); +} + +async function mapChromiumRows(rows, databaseVersion, context) { + const cookies = []; + for (const row of rows) { + try { + cookies.push({ + name: row.name, + value: await decryptChromiumRow(row, databaseVersion, context), + domain: row.host_key, + path: row.path || '/', + expires: chromiumExpires(row.expires_utc), + httpOnly: Boolean(row.is_httponly), + secure: Boolean(row.is_secure), + sameSite: chromiumSameSite(row.samesite), + }); + } catch (error) { + if (!context.ignoreDecryptionErrors) { + throw new Error( + `Could not decrypt cookie ${row.name} for ${row.host_key}: ${error.message}` + ); + } + } + } + return cookies; +} + +function openCookieDatabase(cookiePath) { + try { + return new Database(cookiePath, { fileMustExist: true, readonly: true }); + } catch (error) { + throw new Error(`Could not open browser cookie database: ${error.message}`); + } +} + +/** + * Read cookies from an installed browser profile in Playwright/Puppeteer shape. + * Use dependency injection only for deterministic platform and credential tests. + */ +export async function readBrowserCookiesWithDependencies( + options, + dependencies = {} +) { + if (!options || typeof options !== 'object') { + throw new TypeError('readBrowserCookies requires an options object'); + } + const browser = normalizeCookieBrowser(options.browser); + const platform = dependencies.platform ?? process.platform; + const homeDir = dependencies.homeDir ?? os.homedir(); + const environment = dependencies.environment ?? process.env; + const profile = await resolveBrowserProfile({ + browser, + profile: options.profile, + platform, + homeDir, + environment, + }); + const cookiePath = await findCookieDatabase(browser, profile.path, platform); + if (!cookiePath) { + throw new Error(`No cookie database exists in ${profile.path}`); + } + const cache = normalizeCookieCache( + options.cache, + homeDir, + options.ttlMinutes + ); + const identity = JSON.stringify({ + browser, + profile: profile.path, + domainFilter: options.domainFilter ?? null, + }); + const now = dependencies.now ?? Date.now; + const cachedCookies = await readCookieResultCache({ + cache, + identity, + refresh: options.refresh === true, + now, + }); + if (cachedCookies) { + return cachedCookies; + } + + const database = openCookieDatabase(cookiePath); + let cookies; + try { + if (browser === 'firefox') { + cookies = mapFirefoxRows(readFirefoxRows(database, options.domainFilter)); + } else { + const databaseVersion = readDatabaseVersion(database); + const rows = readChromiumRows(database, options.domainFilter); + cookies = await mapChromiumRows(rows, databaseVersion, { + browser, + cache, + decryptWindowsDpapi: + dependencies.decryptWindowsDpapi ?? decryptWindowsDpapi, + environment, + ignoreDecryptionErrors: options.ignoreDecryptionErrors === true, + now, + platform, + profile, + readSafeStoragePassword: + dependencies.readSafeStoragePassword ?? readSafeStoragePassword, + readWindowsEncryptionKey: + dependencies.readWindowsEncryptionKey ?? readWindowsEncryptionKey, + refresh: options.refresh === true, + }); + } + } finally { + database.close(); + } + await writeCookieResultCache({ cache, identity, cookies, now }); + return cookies; +} + +/** Read cookies from an installed Chrome, Edge, Brave, Chromium, or Firefox. */ +export function readBrowserCookies(options) { + return readBrowserCookiesWithDependencies(options); +} + +export { + clearBrowserCookieMemoryCache, + decryptChromiumCookie, + listBrowserProfiles, +}; diff --git a/js/src/browser/browser-profiles.js b/js/src/browser/browser-profiles.js new file mode 100644 index 0000000..98962d8 --- /dev/null +++ b/js/src/browser/browser-profiles.js @@ -0,0 +1,280 @@ +import { access, readdir, readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +export const SUPPORTED_COOKIE_BROWSERS = [ + 'chrome', + 'edge', + 'brave', + 'chromium', + 'firefox', +]; + +function platformPath(platform) { + return platform === 'win32' ? path.win32 : path; +} + +export function normalizeCookieBrowser(browser) { + const normalized = browser === 'msedge' ? 'edge' : browser; + if (!SUPPORTED_COOKIE_BROWSERS.includes(normalized)) { + throw new Error( + `Unsupported browser: ${browser}. Expected one of ${SUPPORTED_COOKIE_BROWSERS.join(', ')}` + ); + } + return normalized; +} + +export function browserProfileRoot( + browser, + { + platform = process.platform, + homeDir = os.homedir(), + environment = process.env, + } = {} +) { + const normalizedBrowser = normalizeCookieBrowser(browser); + const pathApi = platformPath(platform); + if (platform === 'darwin') { + const support = pathApi.join(homeDir, 'Library', 'Application Support'); + const roots = { + brave: pathApi.join(support, 'BraveSoftware', 'Brave-Browser'), + chrome: pathApi.join(support, 'Google', 'Chrome'), + chromium: pathApi.join(support, 'Chromium'), + edge: pathApi.join(support, 'Microsoft Edge'), + firefox: pathApi.join(support, 'Firefox'), + }; + return roots[normalizedBrowser]; + } + if (platform === 'win32') { + const localAppData = + environment.LOCALAPPDATA ?? pathApi.join(homeDir, 'AppData', 'Local'); + const roamingAppData = + environment.APPDATA ?? pathApi.join(homeDir, 'AppData', 'Roaming'); + const roots = { + brave: pathApi.join( + localAppData, + 'BraveSoftware', + 'Brave-Browser', + 'User Data' + ), + chrome: pathApi.join(localAppData, 'Google', 'Chrome', 'User Data'), + chromium: pathApi.join(localAppData, 'Chromium', 'User Data'), + edge: pathApi.join(localAppData, 'Microsoft', 'Edge', 'User Data'), + firefox: pathApi.join(roamingAppData, 'Mozilla', 'Firefox'), + }; + return roots[normalizedBrowser]; + } + const roots = { + brave: pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser'), + chrome: pathApi.join(homeDir, '.config', 'google-chrome'), + chromium: pathApi.join(homeDir, '.config', 'chromium'), + edge: pathApi.join(homeDir, '.config', 'microsoft-edge'), + firefox: pathApi.join(homeDir, '.mozilla', 'firefox'), + }; + return roots[normalizedBrowser]; +} + +async function pathExists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +async function readJson(filePath) { + try { + return JSON.parse(await readFile(filePath, 'utf8')); + } catch { + return {}; + } +} + +function chromiumCookiePaths(profilePath, pathApi = path) { + return [ + pathApi.join(profilePath, 'Network', 'Cookies'), + pathApi.join(profilePath, 'Cookies'), + ]; +} + +export async function findCookieDatabase(browser, profilePath, platform) { + const pathApi = platformPath(platform ?? process.platform); + if (normalizeCookieBrowser(browser) === 'firefox') { + const candidate = pathApi.join(profilePath, 'cookies.sqlite'); + return (await pathExists(candidate)) ? candidate : null; + } + for (const candidate of chromiumCookiePaths(profilePath, pathApi)) { + if (await pathExists(candidate)) { + return candidate; + } + } + return null; +} + +async function listChromiumProfiles(browser, root, platform) { + if (!(await pathExists(root))) { + return []; + } + const pathApi = platformPath(platform); + const localState = await readJson(pathApi.join(root, 'Local State')); + const infoCache = localState.profile?.info_cache ?? {}; + const names = new Set(Object.keys(infoCache)); + try { + const entries = await readdir(root, { withFileTypes: true }); + for (const entry of entries) { + if ( + entry.isDirectory() && + (entry.name === 'Default' || entry.name.startsWith('Profile ')) + ) { + names.add(entry.name); + } + } + } catch { + return []; + } + + const profiles = []; + for (const name of names) { + const profilePath = pathApi.join(root, name); + if (!(await findCookieDatabase(browser, profilePath, platform))) { + continue; + } + profiles.push({ + browser, + name, + displayName: infoCache[name]?.name ?? name, + path: profilePath, + isDefault: + name === (localState.profile?.last_used ?? 'Default') || + (names.size === 1 && name === 'Default'), + }); + } + return profiles.sort( + (left, right) => + Number(right.isDefault) - Number(left.isDefault) || + left.name.localeCompare(right.name) + ); +} + +function parseIni(text) { + const sections = []; + let current; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + const sectionMatch = /^\[([^\]]+)]$/.exec(line); + if (sectionMatch) { + current = { section: sectionMatch[1] }; + sections.push(current); + continue; + } + const separator = line.indexOf('='); + if (current && separator > 0 && !line.startsWith(';')) { + current[line.slice(0, separator)] = line.slice(separator + 1); + } + } + return sections; +} + +async function listFirefoxProfiles(root, platform) { + if (!(await pathExists(root))) { + return []; + } + const pathApi = platformPath(platform); + let sections = []; + try { + sections = parseIni( + await readFile(pathApi.join(root, 'profiles.ini'), 'utf8') + ); + } catch { + const profilesRoot = pathApi.join(root, 'Profiles'); + try { + const entries = await readdir(profilesRoot, { withFileTypes: true }); + sections = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + section: 'Profile', + Name: entry.name, + Path: entry.name, + ProfilesRoot: profilesRoot, + })); + } catch { + return []; + } + } + + const profiles = []; + for (const section of sections.filter(({ section }) => + section.startsWith('Profile') + )) { + if (!section.Path) { + continue; + } + const relativeRoot = section.ProfilesRoot ?? root; + const profilePath = + section.IsRelative === '0' + ? section.Path + : pathApi.resolve(relativeRoot, section.Path); + if (!(await findCookieDatabase('firefox', profilePath, platform))) { + continue; + } + const displayName = section.Name ?? pathApi.basename(profilePath); + profiles.push({ + browser: 'firefox', + name: displayName, + displayName, + path: profilePath, + isDefault: section.Default === '1', + }); + } + return profiles.sort( + (left, right) => + Number(right.isDefault) - Number(left.isDefault) || + left.name.localeCompare(right.name) + ); +} + +/** Discover cookie-bearing profiles from installed browsers. */ +export async function listBrowserProfiles({ + browser, + platform = process.platform, + homeDir = os.homedir(), + environment = process.env, +} = {}) { + const browsers = browser + ? [normalizeCookieBrowser(browser)] + : SUPPORTED_COOKIE_BROWSERS; + const profiles = []; + for (const candidate of browsers) { + const root = browserProfileRoot(candidate, { + platform, + homeDir, + environment, + }); + profiles.push( + ...(candidate === 'firefox' + ? await listFirefoxProfiles(root, platform) + : await listChromiumProfiles(candidate, root, platform)) + ); + } + return profiles; +} + +export async function resolveBrowserProfile(options) { + const browser = normalizeCookieBrowser(options.browser); + const profiles = await listBrowserProfiles({ ...options, browser }); + const requested = options.profile; + const selected = requested + ? profiles.find( + ({ name, displayName, path: profilePath }) => + requested === name || + requested === displayName || + requested === platformPath(options.platform).basename(profilePath) + ) + : (profiles.find(({ isDefault }) => isDefault) ?? profiles[0]); + if (!selected) { + const detail = requested ? ` profile "${requested}"` : ' profile'; + throw new Error(`Could not find a cookie database for ${browser}${detail}`); + } + return selected; +} diff --git a/js/src/exports.js b/js/src/exports.js index 93f891f..63172bf 100644 --- a/js/src/exports.js +++ b/js/src/exports.js @@ -43,6 +43,10 @@ export { // Re-export browser management export { connectBrowser } from './browser/connector.js'; +export { + listBrowserProfiles, + readBrowserCookies, +} from './browser/browser-cookies.js'; export { launchAndConnectRealBrowser } from './browser/real-browser.js'; export { launchBrowser } from './browser/launcher.js'; export { saveStorageState } from './browser/storage-state.js'; From c25f592ce586dddf0abba0273c2aaed84fbc8742 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:05:42 +0000 Subject: [PATCH 04/19] test(python): define installed cookie import behavior --- python/pyproject.toml | 1 + .../unit/browser/test_browser_cookies.py | 336 ++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 python/tests/unit/browser/test_browser_cookies.py diff --git a/python/pyproject.toml b/python/pyproject.toml index e0f96c7..cc1f72b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -32,6 +32,7 @@ classifiers = [ "Topic :: Software Development :: Testing", "Topic :: Software Development :: Libraries :: Python Modules", ] +dependencies = ["cryptography>=42.0.0"] [project.optional-dependencies] playwright = ["playwright>=1.40.0"] diff --git a/python/tests/unit/browser/test_browser_cookies.py b/python/tests/unit/browser/test_browser_cookies.py new file mode 100644 index 0000000..21fd515 --- /dev/null +++ b/python/tests/unit/browser/test_browser_cookies.py @@ -0,0 +1,336 @@ +"""Regression tests for importing installed-browser cookies.""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +import stat +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from browser_commander import ( + BrowserCookieCacheOptions, + BrowserCookieReadOptions, +) +from browser_commander import ( + list_browser_profiles as public_list_browser_profiles, +) +from browser_commander import ( + read_browser_cookies as public_read_browser_cookies, +) +from browser_commander.browser import ( + list_browser_profiles as browser_list_browser_profiles, +) +from browser_commander.browser.browser_cookies import ( + BrowserProfile, + clear_browser_cookie_memory_cache, + decrypt_chromium_cookie, + list_browser_profiles, + read_browser_cookies, + read_browser_cookies_with_dependencies, +) + +CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600 + + +def _derive_key(password: str, *, iterations: int = 1) -> bytes: + return PBKDF2HMAC( + algorithm=hashes.SHA1(), + length=16, + salt=b"saltysalt", + iterations=iterations, + ).derive(password.encode()) + + +def _encrypt_cbc_cookie(host: str, value: str, password: str) -> bytes: + plaintext = hashlib.sha256(host.encode()).digest() + value.encode() + padder = padding.PKCS7(128).padder() + padded = padder.update(plaintext) + padder.finalize() + encryptor = Cipher( + algorithms.AES(_derive_key(password)), modes.CBC(b" " * 16) + ).encryptor() + return b"v11" + encryptor.update(padded) + encryptor.finalize() + + +def _encrypt_gcm_cookie(host: str, value: str, key: bytes) -> bytes: + nonce = b"fixture-nonce" + encryptor = Cipher(algorithms.AES(key), modes.GCM(nonce)).encryptor() + plaintext = hashlib.sha256(host.encode()).digest() + value.encode() + ciphertext = encryptor.update(plaintext) + encryptor.finalize() + return b"v10" + nonce + ciphertext + encryptor.tag + + +def _create_chromium_profile( + home_dir: Path, rows: list[dict[str, Any]], profile: str = "Default" +) -> Path: + root = home_dir / ".config" / "google-chrome" + profile_path = root / profile + cookie_path = profile_path / "Network" / "Cookies" + cookie_path.parent.mkdir(parents=True) + (root / "Local State").write_text( + json.dumps( + { + "profile": { + "last_used": profile, + "info_cache": {profile: {"name": "Primary profile"}}, + } + } + ), + encoding="utf-8", + ) + with sqlite3.connect(cookie_path) as database: + database.executescript( + """ + CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO meta (key, value) VALUES ('version', '24'); + CREATE TABLE cookies ( + host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, + path TEXT, expires_utc INTEGER, is_secure INTEGER, + is_httponly INTEGER, samesite INTEGER + ); + """ + ) + database.executemany( + """ + INSERT INTO cookies ( + host_key, name, value, encrypted_value, path, expires_utc, + is_secure, is_httponly, samesite + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + [ + ( + row["host"], + row["name"], + row.get("value", ""), + row.get("encrypted_value", b""), + row.get("path", "/"), + row.get("expires_utc", 0), + int(row.get("secure", False)), + int(row.get("http_only", False)), + row.get("same_site", -1), + ) + for row in rows + ], + ) + return profile_path + + +def _create_firefox_profile(home_dir: Path) -> Path: + root = home_dir / ".mozilla" / "firefox" + profile_path = root / "fixture.default-release" + profile_path.mkdir(parents=True) + (root / "profiles.ini").write_text( + "[Profile0]\nName=default-release\nIsRelative=1\n" + "Path=fixture.default-release\nDefault=1\n", + encoding="utf-8", + ) + with sqlite3.connect(profile_path / "cookies.sqlite") as database: + database.executescript( + """ + CREATE TABLE moz_cookies ( + name TEXT, value TEXT, host TEXT, path TEXT, expiry INTEGER, + isSecure INTEGER, isHttpOnly INTEGER, sameSite INTEGER + ); + INSERT INTO moz_cookies VALUES ( + 'firefox-session', 'plain-value', '.example.org', '/', + 2000000001, 1, 0, 1 + ); + """ + ) + return profile_path + + +def test_cookie_helpers_are_exported() -> None: + assert public_list_browser_profiles is list_browser_profiles + assert browser_list_browser_profiles is list_browser_profiles + assert public_read_browser_cookies is read_browser_cookies + + +def test_discovers_chromium_and_firefox_profiles(tmp_path: Path) -> None: + chromium_path = _create_chromium_profile(tmp_path, []) + firefox_path = _create_firefox_profile(tmp_path) + + assert list_browser_profiles( + platform="linux", home_dir=tmp_path, environment={} + ) == [ + BrowserProfile( + browser="chrome", + name="Default", + display_name="Primary profile", + path=chromium_path, + is_default=True, + ), + BrowserProfile( + browser="firefox", + name="default-release", + display_name="default-release", + path=firefox_path, + is_default=True, + ), + ] + + +def test_decrypts_chromium_and_returns_engine_cookie_shape(tmp_path: Path) -> None: + password = "fixture safe storage password" + host = ".example.com" + _create_chromium_profile( + tmp_path, + [ + { + "host": host, + "name": "SID", + "encrypted_value": _encrypt_cbc_cookie( + host, "decrypted-session", password + ), + "expires_utc": (2_000_000_000 + CHROME_EPOCH_OFFSET_SECONDS) + * 1_000_000, + "secure": True, + "http_only": True, + "same_site": 2, + }, + {"host": ".other.test", "name": "ignored", "value": "plain"}, + ], + ) + credential_reads = 0 + + def read_password(**_kwargs: object) -> str: + nonlocal credential_reads + credential_reads += 1 + return password + + cookies = read_browser_cookies_with_dependencies( + BrowserCookieReadOptions( + browser="chrome", + domain_filter="example.com", + cache=BrowserCookieCacheOptions(dir=tmp_path / "cache", ttl_minutes=60), + ), + platform="linux", + home_dir=tmp_path, + environment={}, + read_safe_storage_password=read_password, + ) + + assert cookies == [ + { + "name": "SID", + "value": "decrypted-session", + "domain": host, + "path": "/", + "expires": 2_000_000_000, + "httpOnly": True, + "secure": True, + "sameSite": "Strict", + } + ] + assert credential_reads == 1 + + +def test_reads_firefox_without_credentials(tmp_path: Path) -> None: + _create_firefox_profile(tmp_path) + + def fail_credential_read(**_kwargs: object) -> str: + raise AssertionError("Firefox must not read an OS credential") + + cookies = read_browser_cookies_with_dependencies( + BrowserCookieReadOptions(browser="firefox"), + platform="linux", + home_dir=tmp_path, + environment={}, + read_safe_storage_password=fail_credential_read, + ) + + assert cookies == [ + { + "name": "firefox-session", + "value": "plain-value", + "domain": ".example.org", + "path": "/", + "expires": 2_000_000_001, + "httpOnly": False, + "secure": True, + "sameSite": "Lax", + } + ] + + +def test_reuses_owner_only_credential_cache(tmp_path: Path) -> None: + password = "cached safe storage password" + hosts = [".example.com", ".example.org"] + _create_chromium_profile( + tmp_path, + [ + { + "host": host, + "name": f"session-{index}", + "encrypted_value": _encrypt_cbc_cookie( + host, f"value-{index}", password + ), + } + for index, host in enumerate(hosts) + ], + ) + cache_dir = tmp_path / "cache" + credential_reads = 0 + + def read_password(**_kwargs: object) -> str: + nonlocal credential_reads + credential_reads += 1 + return password + + for host in hosts: + read_browser_cookies_with_dependencies( + BrowserCookieReadOptions( + browser="chrome", + domain_filter=host, + cache=BrowserCookieCacheOptions(dir=cache_dir, ttl_minutes=60), + ), + platform="linux", + home_dir=tmp_path, + environment={}, + read_safe_storage_password=read_password, + ) + clear_browser_cookie_memory_cache() + + assert credential_reads == 1 + credential_file = next(cache_dir.glob("credential-*.json")) + assert stat.S_IMODE(credential_file.stat().st_mode) == 0o600 + cached = json.loads(credential_file.read_text(encoding="utf-8")) + assert cached["kind"] == "derived-key" + assert cached["key"] != password + assert password not in credential_file.read_text(encoding="utf-8") + + +def test_windows_gcm_and_app_bound_boundary() -> None: + key = bytes(range(32)) + host = ".example.net" + encrypted = _encrypt_gcm_cookie(host, "windows-session", key) + + assert ( + decrypt_chromium_cookie( + encrypted, + host=host, + database_version=24, + platform="win32", + key=key, + ) + == "windows-session" + ) + + try: + decrypt_chromium_cookie( + b"v20app-bound-cookie", + host=host, + database_version=24, + platform="win32", + key=key, + ) + except ValueError as error: + assert "app-bound" in str(error) + assert "outside the browser" in str(error) + else: + raise AssertionError("v20 data must fail with an explicit compatibility error") From 83d61165cb7969bd4b9d4d481b396f5e03f230b6 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:16:55 +0000 Subject: [PATCH 05/19] feat(python): import cookies from installed browsers --- python/README.md | 51 ++- python/changelog.d/69.added.md | 1 + python/src/browser_commander/__init__.py | 10 + .../src/browser_commander/browser/__init__.py | 12 + .../browser/browser_cookie_cache.py | 270 ++++++++++++++ .../browser/browser_cookie_credentials.py | 151 ++++++++ .../browser/browser_cookie_crypto.py | 96 +++++ .../browser/browser_cookies.py | 344 ++++++++++++++++++ .../browser/browser_profiles.py | 261 +++++++++++++ python/src/browser_commander/exports.py | 14 +- .../unit/browser/test_browser_cookies.py | 2 +- 11 files changed, 1209 insertions(+), 3 deletions(-) create mode 100644 python/changelog.d/69.added.md create mode 100644 python/src/browser_commander/browser/browser_cookie_cache.py create mode 100644 python/src/browser_commander/browser/browser_cookie_credentials.py create mode 100644 python/src/browser_commander/browser/browser_cookie_crypto.py create mode 100644 python/src/browser_commander/browser/browser_cookies.py create mode 100644 python/src/browser_commander/browser/browser_profiles.py diff --git a/python/README.md b/python/README.md index 3ffeb35..b877b88 100644 --- a/python/README.md +++ b/python/README.md @@ -174,7 +174,56 @@ The returned page is the raw engine page/driver and can be passed directly to `make_browser_commander()`. For Chrome 136 and newer, start the browser with a non-default `--user-data-dir`; remote debugging is intentionally disabled for the default Chrome profile. Cookie seeding uses only values supplied by the -caller and does not read the default profile. +caller. The installed-browser import below provides an explicit local way to +obtain those values. + +### Installed browser cookies + +Discover profiles and read cookies in the exact Playwright/Selenium cookie +shape: + +```python +from browser_commander import ( + BrowserCookieCacheOptions, + BrowserCookieReadOptions, + list_browser_profiles, + read_browser_cookies, +) + +print(list_browser_profiles("chrome")) + +cookies = read_browser_cookies( + BrowserCookieReadOptions( + browser="chrome", # chrome, edge, brave, chromium, or firefox + profile="Default", # optional; defaults to the selected browser profile + domain_filter="example.com", + cache=BrowserCookieCacheOptions(ttl_minutes=60), + ) +) +``` + +Each cookie contains `name`, `value`, `domain`, `path`, `expires`, `httpOnly`, +`secure`, and `sameSite`. The helper runs only when explicitly called and never +sends cookie data anywhere. Decrypted result and derived-key files default to +`~/.browser-commander/cookie-cache/` with owner-only permissions. A process lock +and the default 60-minute TTL keep Keychain, libsecret/KWallet, or DPAPI access +to at most one read across concurrent and repeated processes. Set `refresh=True` +to force a new credential read, customize `dir`/`ttl_minutes`, or set +`cache=False` to opt out. + +| Browser family | macOS | Linux | Windows | +| --- | --- | --- | --- | +| Chrome, Edge, Brave, Chromium | Keychain + AES-128-CBC (`v10`/`v11`) | libsecret/KWallet + AES-128-CBC (`v11`), or the Chromium `v10` fallback key | DPAPI-protected AES-256-GCM key (`v10`/`v11`) | +| Firefox | `cookies.sqlite` | `cookies.sqlite` | `cookies.sqlite` | + +Chromium database-version-24 domain hashes and its 1601-based timestamps are +handled automatically. Current Windows Chromium may use app-bound `v20` +encryption, which intentionally requires the browser's privileged service and +cannot be decrypted by an ordinary external process. The helper reports this +boundary; use a browser-supported export or saved Browser Commander storage +state for those cookies. `ignore_decryption_errors=True` returns any remaining +decryptable cookies. Treat imported cookies like passwords: keep cache paths +private, use a short TTL, never commit them, and seed only a dedicated profile. The `color_scheme` option emulates `prefers-color-scheme` at launch time: diff --git a/python/changelog.d/69.added.md b/python/changelog.d/69.added.md new file mode 100644 index 0000000..5a4c093 --- /dev/null +++ b/python/changelog.d/69.added.md @@ -0,0 +1 @@ +Add installed-browser profile discovery and local cookie import for Chromium-family browsers and Firefox, with owner-only cross-process credential caching. diff --git a/python/src/browser_commander/__init__.py b/python/src/browser_commander/__init__.py index 547c77f..547444a 100644 --- a/python/src/browser_commander/__init__.py +++ b/python/src/browser_commander/__init__.py @@ -18,6 +18,9 @@ CHROME_ARGS, TIMING, ActionStoppedError, + BrowserCookieCacheOptions, + BrowserCookieReadOptions, + BrowserProfile, ClickResult, ClickVerificationResult, ConnectOptions, @@ -84,6 +87,7 @@ # Element visibility is_visible, launch_browser, + list_browser_profiles, locator, log_element_info, make_url_condition, @@ -94,6 +98,7 @@ # Element selectors query_selector, query_selector_all, + read_browser_cookies, safe_evaluate, safe_operation, # Scroll interactions @@ -130,6 +135,9 @@ "ActionStoppedError", # Factory "BrowserCommander", + "BrowserCookieCacheOptions", + "BrowserCookieReadOptions", + "BrowserProfile", "ClickResult", "ClickVerificationResult", "ConnectOptions", @@ -196,6 +204,7 @@ # Element visibility "is_visible", "launch_browser", + "list_browser_profiles", "locator", "log_element_info", "make_browser_commander", @@ -207,6 +216,7 @@ # Element selectors "query_selector", "query_selector_all", + "read_browser_cookies", "safe_evaluate", "safe_operation", # Scroll interactions diff --git a/python/src/browser_commander/browser/__init__.py b/python/src/browser_commander/browser/__init__.py index eeb1284..b669077 100644 --- a/python/src/browser_commander/browser/__init__.py +++ b/python/src/browser_commander/browser/__init__.py @@ -2,6 +2,13 @@ from __future__ import annotations +from browser_commander.browser.browser_cookies import ( + BrowserCookieCacheOptions, + BrowserCookieReadOptions, + BrowserProfile, + list_browser_profiles, + read_browser_cookies, +) from browser_commander.browser.connector import ConnectOptions, connect_browser from browser_commander.browser.launcher import ( LaunchOptions, @@ -24,6 +31,9 @@ from browser_commander.browser.pdf import pdf __all__ = [ + "BrowserCookieCacheOptions", + "BrowserCookieReadOptions", + "BrowserProfile", "ConnectOptions", "GotoResult", "LaunchOptions", @@ -35,8 +45,10 @@ "emulate_media", "goto", "launch_browser", + "list_browser_profiles", # PDF generation "pdf", + "read_browser_cookies", "verify_navigation", "wait_after_action", "wait_for_navigation", diff --git a/python/src/browser_commander/browser/browser_cookie_cache.py b/python/src/browser_commander/browser/browser_cookie_cache.py new file mode 100644 index 0000000..5d5bdc7 --- /dev/null +++ b/python/src/browser_commander/browser/browser_cookie_cache.py @@ -0,0 +1,270 @@ +"""Owner-only cookie result and derived-key cache with process locking.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import threading +import time +import uuid +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +DEFAULT_TTL_MINUTES = 60.0 +LOCK_STALE_SECONDS = 30.0 +LOCK_WAIT_SECONDS = 30.0 +_credential_memory_cache: dict[str, bytes] = {} +_memory_lock = threading.Lock() + + +@dataclass(frozen=True) +class NormalizedCookieCache: + """Internal normalized cache configuration.""" + + enabled: bool + directory: Path | None = None + ttl_seconds: float = 0 + + +def clear_browser_cookie_memory_cache() -> None: + """Clear only the in-process key cache; intended for isolation and tests.""" + with _memory_lock: + _credential_memory_cache.clear() + + +def normalize_cookie_cache( + cache: object, home_dir: Path, ttl_minutes: float | None +) -> NormalizedCookieCache: + """Normalize the public cache object without importing its dataclass.""" + if cache is False: + return NormalizedCookieCache(enabled=False) + selected_ttl = ( + ttl_minutes + if ttl_minutes is not None + else getattr(cache, "ttl_minutes", DEFAULT_TTL_MINUTES) + ) + if not isinstance(selected_ttl, (int, float)) or selected_ttl < 0: + raise ValueError("cookie cache ttl_minutes must be a non-negative number") + configured_dir = getattr(cache, "dir", None) + directory = ( + Path(configured_dir).expanduser().resolve() + if configured_dir is not None + else home_dir / ".browser-commander" / "cookie-cache" + ) + return NormalizedCookieCache( + enabled=True, + directory=directory, + ttl_seconds=float(selected_ttl) * 60, + ) + + +def _hash(identity: str) -> str: + return hashlib.sha256(identity.encode()).hexdigest() + + +def _cache_path(cache: NormalizedCookieCache, kind: str, identity: str) -> Path: + assert cache.directory is not None + return cache.directory / f"{kind}-{_hash(identity)}.json" + + +def _ensure_cache_directory(directory: Path) -> None: + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + with suppress(OSError): + directory.chmod(0o700) + + +def _read_fresh_json( + path: Path, ttl_seconds: float, now: Callable[[], float] +) -> dict | None: + try: + value = json.loads(path.read_text(encoding="utf-8")) + age = now() - float(value["savedAt"]) + return value if 0 <= age <= ttl_seconds else None + except (OSError, ValueError, KeyError, TypeError): + return None + + +def _write_owner_only_json(path: Path, value: dict) -> None: + temporary_path = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp") + descriptor = os.open(temporary_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(value, handle, separators=(",", ":")) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + temporary_path.replace(path) + with suppress(OSError): + path.chmod(0o600) + finally: + with suppress(FileNotFoundError): + temporary_path.unlink() + + +def read_cookie_result_cache( + cache: NormalizedCookieCache, + identity: str, + *, + refresh: bool, + now: Callable[[], float] = time.time, +) -> list[dict] | None: + """Read a fresh decrypted-cookie result, unless refresh was requested.""" + if not cache.enabled or refresh: + return None + cached = _read_fresh_json( + _cache_path(cache, "cookies", identity), cache.ttl_seconds, now + ) + if ( + cached + and cached.get("kind") == "cookies" + and isinstance(cached.get("cookies"), list) + ): + return cached["cookies"] + return None + + +def write_cookie_result_cache( + cache: NormalizedCookieCache, + identity: str, + cookies: list[dict], + *, + now: Callable[[], float] = time.time, +) -> None: + """Store a decrypted-cookie result in an owner-only file.""" + if not cache.enabled: + return + assert cache.directory is not None + _ensure_cache_directory(cache.directory) + _write_owner_only_json( + _cache_path(cache, "cookies", identity), + {"version": 1, "kind": "cookies", "savedAt": now(), "cookies": cookies}, + ) + + +def _remove_stale_lock(lock_path: Path, now: Callable[[], float]) -> None: + try: + if now() - lock_path.stat().st_mtime > LOCK_STALE_SECONDS: + lock_path.unlink() + except OSError: + pass + + +def _acquire_lock_or_cached( + lock_path: Path, + cached_path: Path, + *, + initial_saved_at: object, + refresh: bool, + ttl_seconds: float, + now: Callable[[], float], +) -> tuple[int | None, dict | None]: + started_at = now() + while now() - started_at <= LOCK_WAIT_SECONDS: + try: + descriptor = os.open(lock_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + return descriptor, None + except FileExistsError: + cached = _read_fresh_json(cached_path, ttl_seconds, now) + if ( + cached + and cached.get("kind") == "derived-key" + and (not refresh or cached.get("savedAt") != initial_saved_at) + ): + return None, cached + _remove_stale_lock(lock_path, now) + time.sleep(0.05) + raise TimeoutError("timed out waiting for another cookie credential reader") + + +def _load_or_create_credential( + cache: NormalizedCookieCache, + identity: str, + create: Callable[[], bytes], + *, + refresh: bool, + metadata: dict, + now: Callable[[], float], +) -> bytes: + if not cache.enabled: + return bytes(create()) + assert cache.directory is not None + _ensure_cache_directory(cache.directory) + cached_path = _cache_path(cache, "credential", identity) + lock_path = cached_path.with_suffix(".json.lock") + initial = _read_fresh_json(cached_path, cache.ttl_seconds, now) + if not refresh and initial and initial.get("kind") == "derived-key": + return base64.b64decode(initial["key"]) + + descriptor, waited_cache = _acquire_lock_or_cached( + lock_path, + cached_path, + initial_saved_at=initial.get("savedAt") if initial else None, + refresh=refresh, + ttl_seconds=cache.ttl_seconds, + now=now, + ) + if waited_cache: + return base64.b64decode(waited_cache["key"]) + + assert descriptor is not None + os.close(descriptor) + try: + after_lock = _read_fresh_json(cached_path, cache.ttl_seconds, now) + if ( + after_lock + and after_lock.get("kind") == "derived-key" + and ( + not refresh + or after_lock.get("savedAt") + != (initial.get("savedAt") if initial else None) + ) + ): + return base64.b64decode(after_lock["key"]) + key = bytes(create()) + _write_owner_only_json( + cached_path, + { + "version": 1, + "kind": "derived-key", + "savedAt": now(), + "key": base64.b64encode(key).decode(), + **metadata, + }, + ) + return key + finally: + with suppress(FileNotFoundError): + lock_path.unlink() + + +def get_cached_credential( + cache: NormalizedCookieCache, + identity: str, + create: Callable[[], bytes], + *, + refresh: bool, + metadata: dict, + now: Callable[[], float] = time.time, +) -> bytes: + """Read or create a derived key with in-process and cross-process reuse.""" + memory_identity = f"{cache.directory}:{identity}" + if not refresh: + with _memory_lock: + cached = _credential_memory_cache.get(memory_identity) + if cached is not None: + return cached + key = _load_or_create_credential( + cache, + identity, + create, + refresh=refresh, + metadata=metadata, + now=now, + ) + with _memory_lock: + _credential_memory_cache[memory_identity] = key + return key diff --git a/python/src/browser_commander/browser/browser_cookie_credentials.py b/python/src/browser_commander/browser/browser_cookie_credentials.py new file mode 100644 index 0000000..94e65ab --- /dev/null +++ b/python/src/browser_commander/browser/browser_cookie_credentials.py @@ -0,0 +1,151 @@ +"""OS credential-store access used by installed Chromium cookie import.""" + +from __future__ import annotations + +import base64 +import ctypes +import json +import os +import subprocess +import sys +from collections.abc import Mapping +from ctypes import wintypes +from pathlib import Path + +SAFE_STORAGE = { + "brave": {"application": "brave", "service": "Brave Safe Storage"}, + "chrome": {"application": "chrome", "service": "Chrome Safe Storage"}, + "chromium": {"application": "chromium", "service": "Chromium Safe Storage"}, + "edge": { + "application": "microsoft-edge", + "service": "Microsoft Edge Safe Storage", + }, +} + + +def _run_credential_command(command: list[str], environment: Mapping[str, str]) -> str: + completed = subprocess.run( + command, + check=True, + capture_output=True, + text=True, + env=dict(environment), + ) + return completed.stdout.strip() + + +def _read_linux_safe_storage_password( + browser: str, environment: Mapping[str, str] +) -> str: + identity = SAFE_STORAGE[browser] + try: + password = _run_credential_command( + ["secret-tool", "lookup", "application", identity["application"]], + environment, + ) + if password: + return password + except (OSError, subprocess.CalledProcessError): + pass + try: + password = _run_credential_command( + [ + "kwallet-query", + "-r", + identity["service"], + "-f", + f"{identity['application']} Keys", + "kdewallet", + ], + environment, + ) + if password: + return password + except (OSError, subprocess.CalledProcessError): + pass + raise RuntimeError( + f"Could not read {identity['service']} from libsecret or KWallet; " + "install secret-tool or unlock the browser key store" + ) + + +def read_safe_storage_password( + *, + browser: str, + platform: str = sys.platform, + environment: Mapping[str, str] | None = None, +) -> str: + """Read a Chromium Safe Storage password from the OS credential store.""" + environment = os.environ if environment is None else environment + identity = SAFE_STORAGE.get(browser) + if identity is None: + raise ValueError(f"No Safe Storage identity is known for {browser}") + if platform == "darwin": + password = _run_credential_command( + [ + "security", + "find-generic-password", + "-w", + "-s", + identity["service"], + ], + environment, + ) + if not password: + raise RuntimeError(f"{identity['service']} returned an empty password") + return password + if platform == "linux": + return _read_linux_safe_storage_password(browser, environment) + raise ValueError(f"Safe Storage passwords are not used on {platform}") + + +class _DataBlob(ctypes.Structure): + _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))] + + +def decrypt_windows_dpapi(encrypted_value: bytes, **_kwargs: object) -> bytes: + """Decrypt bytes with Windows DPAPI in the current user's context.""" + if sys.platform != "win32": + raise RuntimeError("Windows DPAPI is only available on Windows") + buffer = ctypes.create_string_buffer(encrypted_value) + input_blob = _DataBlob( + len(encrypted_value), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte)) + ) + output_blob = _DataBlob() + crypt32 = ctypes.windll.crypt32 + kernel32 = ctypes.windll.kernel32 + if not crypt32.CryptUnprotectData( + ctypes.byref(input_blob), + None, + None, + None, + None, + 0, + ctypes.byref(output_blob), + ): + raise ctypes.WinError() + try: + return ctypes.string_at(output_blob.pbData, output_blob.cbData) + finally: + kernel32.LocalFree(output_blob.pbData) + + +def read_windows_encryption_key( + *, + local_state_path: Path, + environment: Mapping[str, str] | None = None, + decrypt_dpapi=decrypt_windows_dpapi, +) -> bytes: + """Recover the legacy AES-GCM key from Chromium Local State.""" + del environment + try: + state = json.loads(local_state_path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise RuntimeError(f"Could not read Chromium Local State: {error}") from error + encoded = state.get("os_crypt", {}).get("encrypted_key") + if not encoded: + raise RuntimeError("Chromium Local State has no os_crypt.encrypted_key") + encrypted_key = base64.b64decode(encoded) + if not encrypted_key.startswith(b"DPAPI"): + raise RuntimeError("Chromium Local State key has no DPAPI prefix") + return bytes(decrypt_dpapi(encrypted_key[5:])) diff --git a/python/src/browser_commander/browser/browser_cookie_crypto.py b/python/src/browser_commander/browser/browser_cookie_crypto.py new file mode 100644 index 0000000..6e54c32 --- /dev/null +++ b/python/src/browser_commander/browser/browser_cookie_crypto.py @@ -0,0 +1,96 @@ +"""Chromium cookie key derivation and decryption primitives.""" + +from __future__ import annotations + +import hashlib +import hmac +import sys + +from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +DOMAIN_HASH_BYTES = 32 + + +def derive_chromium_cookie_key(password: str, platform: str) -> bytes: + """Derive a Chromium AES-CBC key from its Safe Storage password.""" + if platform not in ("darwin", "linux"): + raise ValueError(f"CBC cookie keys are not used on {platform}") + return PBKDF2HMAC( + algorithm=hashes.SHA1(), + length=16, + salt=b"saltysalt", + iterations=1003 if platform == "darwin" else 1, + ).derive(password.encode()) + + +def _remove_domain_hash(plaintext: bytes, host: str, database_version: int) -> bytes: + if database_version < 24: + return plaintext + if len(plaintext) < DOMAIN_HASH_BYTES: + raise ValueError("decrypted cookie is missing its domain hash") + expected = hashlib.sha256(host.encode()).digest() + actual = plaintext[:DOMAIN_HASH_BYTES] + if not hmac.compare_digest(actual, expected): + raise ValueError("decrypted cookie domain hash does not match its host") + return plaintext[DOMAIN_HASH_BYTES:] + + +def _decrypt_cbc(encrypted_value: bytes, key: bytes) -> bytes: + decryptor = Cipher(algorithms.AES(key), modes.CBC(b" " * 16)).decryptor() + padded = decryptor.update(encrypted_value[3:]) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + return unpadder.update(padded) + unpadder.finalize() + + +def _decrypt_gcm(encrypted_value: bytes, key: bytes) -> bytes: + payload = encrypted_value[3:] + if len(payload) < 28: + raise ValueError("encrypted AES-GCM cookie is truncated") + nonce, ciphertext, tag = payload[:12], payload[12:-16], payload[-16:] + decryptor = Cipher(algorithms.AES(key), modes.GCM(nonce, tag)).decryptor() + return decryptor.update(ciphertext) + decryptor.finalize() + + +def decrypt_chromium_cookie( + encrypted_value: bytes, + *, + host: str, + database_version: int = 0, + platform: str = sys.platform, + key: bytes, +) -> str: + """Decrypt a Chromium cookie using an already recovered encryption key.""" + prefix = encrypted_value[:3] + if prefix == b"v20": + raise ValueError( + "Windows app-bound v20 cookies cannot be decrypted outside the browser; " + "use a browser-supported export or a previously saved storage state" + ) + if prefix not in (b"v10", b"v11"): + raise ValueError("cookie has an unsupported Chromium encryption prefix") + plaintext = ( + _decrypt_gcm(encrypted_value, key) + if platform == "win32" + else _decrypt_cbc(encrypted_value, key) + ) + return _remove_domain_hash(plaintext, host, database_version).decode("utf-8") + + +def chromium_same_site(value: int) -> str: + """Map Chromium's integer SameSite value to the automation API value.""" + if value == 2: + return "Strict" + if value == 1: + return "Lax" + return "None" + + +def firefox_same_site(value: int) -> str: + """Map Firefox's integer SameSite value to the automation API value.""" + if value == 2: + return "Strict" + if value == 1: + return "Lax" + return "None" diff --git a/python/src/browser_commander/browser/browser_cookies.py b/python/src/browser_commander/browser/browser_cookies.py new file mode 100644 index 0000000..e77597d --- /dev/null +++ b/python/src/browser_commander/browser/browser_cookies.py @@ -0,0 +1,344 @@ +"""Import cookies from installed browser profiles.""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import sys +import time +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Literal + +from browser_commander.browser.browser_cookie_cache import ( + NormalizedCookieCache, + clear_browser_cookie_memory_cache, + get_cached_credential, + normalize_cookie_cache, + read_cookie_result_cache, + write_cookie_result_cache, +) +from browser_commander.browser.browser_cookie_credentials import ( + decrypt_windows_dpapi, + read_safe_storage_password, + read_windows_encryption_key, +) +from browser_commander.browser.browser_cookie_crypto import ( + chromium_same_site, + decrypt_chromium_cookie, + derive_chromium_cookie_key, + firefox_same_site, +) +from browser_commander.browser.browser_profiles import ( + BrowserProfile, + find_cookie_database, + list_browser_profiles, + normalize_cookie_browser, + resolve_browser_profile, +) + +CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600 + + +@dataclass(frozen=True) +class BrowserCookieCacheOptions: + """Disk cache location and TTL for imported cookies and derived keys.""" + + dir: str | Path | None = None + ttl_minutes: float = 60 + + +@dataclass(frozen=True) +class BrowserCookieReadOptions: + """Options for reading an installed browser's cookies.""" + + browser: str + profile: str | None = None + domain_filter: str | None = None + cache: BrowserCookieCacheOptions | Literal[False] | None = None + ttl_minutes: float | None = None + refresh: bool = False + ignore_decryption_errors: bool = False + + +def _open_cookie_database(cookie_path: Path) -> sqlite3.Connection: + try: + connection = sqlite3.connect( + f"{cookie_path.resolve().as_uri()}?mode=ro", uri=True + ) + except sqlite3.Error as error: + raise RuntimeError( + f"Could not open browser cookie database: {error}" + ) from error + connection.row_factory = sqlite3.Row + return connection + + +def _read_database_version(database: sqlite3.Connection) -> int: + try: + row = database.execute( + "SELECT value FROM meta WHERE key = 'version'" + ).fetchone() + return int(row["value"]) if row else 0 + except (sqlite3.Error, TypeError, ValueError): + return 0 + + +def _domain_query(column: str, domain_filter: str | None) -> tuple[str, tuple]: + return ( + (f" WHERE {column} LIKE ?", (f"%{domain_filter}%",)) + if domain_filter + else ("", ()) + ) + + +def _read_firefox_rows( + database: sqlite3.Connection, domain_filter: str | None +) -> list[sqlite3.Row]: + where, parameters = _domain_query("host", domain_filter) + return database.execute( + "SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite " + f"FROM moz_cookies{where} ORDER BY host, name, path", + parameters, + ).fetchall() + + +def _read_chromium_rows( + database: sqlite3.Connection, domain_filter: str | None +) -> list[sqlite3.Row]: + where, parameters = _domain_query("host_key", domain_filter) + return database.execute( + "SELECT host_key, name, value, encrypted_value, path, expires_utc, " + f"is_secure, is_httponly, samesite FROM cookies{where} " + "ORDER BY host_key, name, path", + parameters, + ).fetchall() + + +def _map_firefox_rows(rows: list[sqlite3.Row]) -> list[dict]: + return [ + { + "name": row["name"], + "value": row["value"], + "domain": row["host"], + "path": row["path"] or "/", + "expires": int(row["expiry"]) if int(row["expiry"] or 0) > 0 else -1, + "httpOnly": bool(row["isHttpOnly"]), + "secure": bool(row["isSecure"]), + "sameSite": firefox_same_site(int(row["sameSite"] or 0)), + } + for row in rows + ] + + +def _chromium_expires(value: int | str | None) -> int: + microseconds = int(value or 0) + if microseconds == 0: + return -1 + return microseconds // 1_000_000 - CHROME_EPOCH_OFFSET_SECONDS + + +def _chromium_key_for_prefix(prefix: bytes, context: dict) -> bytes: + platform = context["platform"] + if platform == "linux" and prefix == b"v10": + return derive_chromium_cookie_key("peanuts", "linux") + if platform in ("linux", "darwin"): + + def create_key() -> bytes: + password = context["read_safe_storage_password"]( + browser=context["browser"], + platform=platform, + environment=context["environment"], + ) + return derive_chromium_cookie_key(password, platform) + + return get_cached_credential( + context["cache"], + f"{context['browser']}:{platform}:safe-storage", + create_key, + refresh=context["refresh"], + metadata={ + "browser": context["browser"], + "platform": platform, + "source": "safe-storage", + }, + now=context["now"], + ) + if platform == "win32": + + def create_windows_key() -> bytes: + return context["read_windows_encryption_key"]( + local_state_path=context["profile"].path.parent / "Local State", + environment=context["environment"], + decrypt_dpapi=context["decrypt_windows_dpapi"], + ) + + return get_cached_credential( + context["cache"], + f"{context['browser']}:win32:legacy-aes-key", + create_windows_key, + refresh=context["refresh"], + metadata={ + "browser": context["browser"], + "platform": platform, + "source": "dpapi", + }, + now=context["now"], + ) + raise RuntimeError(f"Chromium cookie decryption is unsupported on {platform}") + + +def _decrypt_chromium_row( + row: sqlite3.Row, database_version: int, context: dict +) -> str: + if row["value"]: + return str(row["value"]) + encrypted_value = bytes(row["encrypted_value"] or b"") + if not encrypted_value: + return "" + prefix = encrypted_value[:3] + if context["platform"] == "win32" and prefix not in (b"v10", b"v11"): + if prefix == b"v20": + return decrypt_chromium_cookie( + encrypted_value, + host=row["host_key"], + database_version=database_version, + platform="win32", + key=bytes(32), + ) + return context["decrypt_windows_dpapi"](encrypted_value).decode("utf-8") + key = _chromium_key_for_prefix(prefix, context) + return decrypt_chromium_cookie( + encrypted_value, + host=row["host_key"], + database_version=database_version, + platform=context["platform"], + key=key, + ) + + +def _map_chromium_rows( + rows: list[sqlite3.Row], database_version: int, context: dict +) -> list[dict]: + cookies = [] + for row in rows: + try: + cookies.append( + { + "name": row["name"], + "value": _decrypt_chromium_row(row, database_version, context), + "domain": row["host_key"], + "path": row["path"] or "/", + "expires": _chromium_expires(row["expires_utc"]), + "httpOnly": bool(row["is_httponly"]), + "secure": bool(row["is_secure"]), + "sameSite": chromium_same_site(int(row["samesite"])), + } + ) + except Exception as error: + if not context["ignore_decryption_errors"]: + raise RuntimeError( + f"Could not decrypt cookie {row['name']} for " + f"{row['host_key']}: {error}" + ) from error + return cookies + + +def _read_uncached_cookies( + browser: str, + cookie_path: Path, + domain_filter: str | None, + context: dict, +) -> list[dict]: + with _open_cookie_database(cookie_path) as database: + if browser == "firefox": + return _map_firefox_rows(_read_firefox_rows(database, domain_filter)) + database_version = _read_database_version(database) + return _map_chromium_rows( + _read_chromium_rows(database, domain_filter), database_version, context + ) + + +def read_browser_cookies_with_dependencies( + options: BrowserCookieReadOptions, + *, + platform: str = sys.platform, + home_dir: Path | None = None, + environment: Mapping[str, str] | None = None, + now: Callable[[], float] = time.time, + read_safe_storage_password=read_safe_storage_password, + read_windows_encryption_key=read_windows_encryption_key, + decrypt_windows_dpapi=decrypt_windows_dpapi, +) -> list[dict]: + """Read installed-browser cookies with injectable platform dependencies.""" + if not isinstance(options, BrowserCookieReadOptions): + raise TypeError("options must be a BrowserCookieReadOptions instance") + browser = normalize_cookie_browser(options.browser) + home_dir = home_dir or Path.home() + environment = os.environ if environment is None else environment + profile = resolve_browser_profile( + browser, + options.profile, + platform=platform, + home_dir=home_dir, + environment=environment, + ) + cookie_path = find_cookie_database(browser, profile.path) + if cookie_path is None: + raise FileNotFoundError(f"No cookie database exists in {profile.path}") + cache: NormalizedCookieCache = normalize_cookie_cache( + options.cache, home_dir, options.ttl_minutes + ) + identity = json.dumps( + { + "browser": browser, + "profile": str(profile.path), + "domain_filter": options.domain_filter, + }, + sort_keys=True, + ) + cached_cookies = read_cookie_result_cache( + cache, identity, refresh=options.refresh, now=now + ) + if cached_cookies is not None: + return cached_cookies + + cookies = _read_uncached_cookies( + browser, + cookie_path, + options.domain_filter, + { + "browser": browser, + "cache": cache, + "decrypt_windows_dpapi": decrypt_windows_dpapi, + "environment": environment, + "ignore_decryption_errors": options.ignore_decryption_errors, + "now": now, + "platform": platform, + "profile": profile, + "read_safe_storage_password": read_safe_storage_password, + "read_windows_encryption_key": read_windows_encryption_key, + "refresh": options.refresh, + }, + ) + write_cookie_result_cache(cache, identity, cookies, now=now) + return cookies + + +def read_browser_cookies(options: BrowserCookieReadOptions) -> list[dict]: + """Read cookies from an installed browser profile in automation shape.""" + return read_browser_cookies_with_dependencies(options) + + +__all__ = [ + "BrowserCookieCacheOptions", + "BrowserCookieReadOptions", + "BrowserProfile", + "clear_browser_cookie_memory_cache", + "decrypt_chromium_cookie", + "list_browser_profiles", + "read_browser_cookies", + "read_browser_cookies_with_dependencies", +] diff --git a/python/src/browser_commander/browser/browser_profiles.py b/python/src/browser_commander/browser/browser_profiles.py new file mode 100644 index 0000000..4249799 --- /dev/null +++ b/python/src/browser_commander/browser/browser_profiles.py @@ -0,0 +1,261 @@ +"""Discovery of installed browser profiles with cookie databases.""" + +from __future__ import annotations + +import json +import os +import sys +from collections.abc import Mapping +from configparser import ConfigParser +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path + +SUPPORTED_COOKIE_BROWSERS = ("chrome", "edge", "brave", "chromium", "firefox") + + +@dataclass(frozen=True) +class BrowserProfile: + """An installed browser profile containing a cookie database.""" + + browser: str + name: str + display_name: str + path: Path + is_default: bool + + +def normalize_cookie_browser(browser: str) -> str: + """Normalize a supported browser name or raise an actionable error.""" + normalized = "edge" if browser == "msedge" else browser + if normalized not in SUPPORTED_COOKIE_BROWSERS: + expected = ", ".join(SUPPORTED_COOKIE_BROWSERS) + raise ValueError(f"Unsupported browser: {browser}. Expected one of {expected}") + return normalized + + +def browser_profile_root( + browser: str, + *, + platform: str = sys.platform, + home_dir: Path | None = None, + environment: Mapping[str, str] | None = None, +) -> Path: + """Return the conventional user-data root for an installed browser.""" + browser = normalize_cookie_browser(browser) + home_dir = home_dir or Path.home() + environment = os.environ if environment is None else environment + if platform == "darwin": + support = home_dir / "Library" / "Application Support" + roots = { + "brave": support / "BraveSoftware" / "Brave-Browser", + "chrome": support / "Google" / "Chrome", + "chromium": support / "Chromium", + "edge": support / "Microsoft Edge", + "firefox": support / "Firefox", + } + return roots[browser] + if platform == "win32": + local = Path( + environment.get("LOCALAPPDATA", str(home_dir / "AppData" / "Local")) + ) + roaming = Path( + environment.get("APPDATA", str(home_dir / "AppData" / "Roaming")) + ) + roots = { + "brave": local / "BraveSoftware" / "Brave-Browser" / "User Data", + "chrome": local / "Google" / "Chrome" / "User Data", + "chromium": local / "Chromium" / "User Data", + "edge": local / "Microsoft" / "Edge" / "User Data", + "firefox": roaming / "Mozilla" / "Firefox", + } + return roots[browser] + roots = { + "brave": home_dir / ".config" / "BraveSoftware" / "Brave-Browser", + "chrome": home_dir / ".config" / "google-chrome", + "chromium": home_dir / ".config" / "chromium", + "edge": home_dir / ".config" / "microsoft-edge", + "firefox": home_dir / ".mozilla" / "firefox", + } + return roots[browser] + + +def find_cookie_database(browser: str, profile_path: Path) -> Path | None: + """Find the cookie database inside a specific browser profile.""" + if normalize_cookie_browser(browser) == "firefox": + candidate = profile_path / "cookies.sqlite" + return candidate if candidate.is_file() else None + for candidate in ( + profile_path / "Network" / "Cookies", + profile_path / "Cookies", + ): + if candidate.is_file(): + return candidate + return None + + +def _read_local_state(root: Path) -> dict: + try: + value = json.loads((root / "Local State").read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except (OSError, ValueError): + return {} + + +def _list_chromium_profiles(browser: str, root: Path) -> list[BrowserProfile]: + if not root.is_dir(): + return [] + local_state = _read_local_state(root) + profile_state = local_state.get("profile", {}) + info_cache = profile_state.get("info_cache", {}) + names = set(info_cache) + try: + names.update( + candidate.name + for candidate in root.iterdir() + if candidate.is_dir() + and (candidate.name == "Default" or candidate.name.startswith("Profile ")) + ) + except OSError: + return [] + + default_name = profile_state.get("last_used", "Default") + profiles = [] + for name in names: + profile_path = root / name + if find_cookie_database(browser, profile_path) is None: + continue + details = info_cache.get(name, {}) + profiles.append( + BrowserProfile( + browser=browser, + name=name, + display_name=details.get("name", name), + path=profile_path, + is_default=name == default_name + or (len(names) == 1 and name == "Default"), + ) + ) + return sorted(profiles, key=lambda item: (not item.is_default, item.name)) + + +def _read_firefox_ini(root: Path) -> ConfigParser: + parser = ConfigParser(interpolation=None) + with suppress(OSError): + parser.read(root / "profiles.ini", encoding="utf-8") + return parser + + +def _list_firefox_profiles(root: Path) -> list[BrowserProfile]: + if not root.is_dir(): + return [] + parser = _read_firefox_ini(root) + profiles = [] + for section_name in parser.sections(): + if not section_name.startswith("Profile"): + continue + section = parser[section_name] + configured_path = section.get("Path") + if not configured_path: + continue + profile_path = Path(configured_path) + if section.get("IsRelative", "1") != "0": + profile_path = (root / profile_path).resolve() + if find_cookie_database("firefox", profile_path) is None: + continue + display_name = section.get("Name", profile_path.name) + profiles.append( + BrowserProfile( + browser="firefox", + name=display_name, + display_name=display_name, + path=profile_path, + is_default=section.get("Default") == "1", + ) + ) + if profiles: + return sorted(profiles, key=lambda item: (not item.is_default, item.name)) + + profiles_root = root / "Profiles" + try: + candidates = list(profiles_root.iterdir()) + except OSError: + return [] + return [ + BrowserProfile( + browser="firefox", + name=candidate.name, + display_name=candidate.name, + path=candidate, + is_default=False, + ) + for candidate in sorted(candidates) + if candidate.is_dir() and find_cookie_database("firefox", candidate) + ] + + +def list_browser_profiles( + browser: str | None = None, + *, + platform: str = sys.platform, + home_dir: Path | None = None, + environment: Mapping[str, str] | None = None, +) -> list[BrowserProfile]: + """Discover cookie-bearing profiles from installed browsers.""" + browsers = ( + (normalize_cookie_browser(browser),) + if browser is not None + else SUPPORTED_COOKIE_BROWSERS + ) + profiles = [] + for candidate in browsers: + root = browser_profile_root( + candidate, + platform=platform, + home_dir=home_dir, + environment=environment, + ) + if candidate == "firefox": + profiles.extend(_list_firefox_profiles(root)) + else: + profiles.extend(_list_chromium_profiles(candidate, root)) + return profiles + + +def resolve_browser_profile( + browser: str, + profile: str | None, + *, + platform: str, + home_dir: Path, + environment: Mapping[str, str], +) -> BrowserProfile: + """Resolve a requested profile name or select the browser default.""" + browser = normalize_cookie_browser(browser) + profiles = list_browser_profiles( + browser, + platform=platform, + home_dir=home_dir, + environment=environment, + ) + if profile: + selected = next( + ( + candidate + for candidate in profiles + if profile + in (candidate.name, candidate.display_name, candidate.path.name) + ), + None, + ) + else: + selected = next( + (candidate for candidate in profiles if candidate.is_default), + profiles[0] if profiles else None, + ) + if selected is None: + detail = f' profile "{profile}"' if profile else " profile" + raise FileNotFoundError( + f"Could not find a cookie database for {browser}{detail}" + ) + return selected diff --git a/python/src/browser_commander/exports.py b/python/src/browser_commander/exports.py index d5ca2b6..329e0b8 100644 --- a/python/src/browser_commander/exports.py +++ b/python/src/browser_commander/exports.py @@ -7,6 +7,13 @@ # Re-export core utilities # Re-export browser management +from browser_commander.browser.browser_cookies import ( + BrowserCookieCacheOptions, + BrowserCookieReadOptions, + BrowserProfile, + list_browser_profiles, + read_browser_cookies, +) from browser_commander.browser.connector import ConnectOptions, connect_browser from browser_commander.browser.launcher import ( LaunchOptions, @@ -145,7 +152,10 @@ wait, ) -__all__ = [ +__all__ = [ # noqa: RUF022 - grouped by public API area + "BrowserCookieCacheOptions", + "BrowserCookieReadOptions", + "BrowserProfile", # Core utilities "CHROME_ARGS", "TIMING", @@ -220,6 +230,7 @@ "key_up", # Browser management "launch_browser", + "list_browser_profiles", "locator", "log_element_info", "make_url_condition", @@ -233,6 +244,7 @@ # Element selectors "query_selector", "query_selector_all", + "read_browser_cookies", "safe_evaluate", "safe_operation", # Scroll interactions diff --git a/python/tests/unit/browser/test_browser_cookies.py b/python/tests/unit/browser/test_browser_cookies.py index 21fd515..a94b193 100644 --- a/python/tests/unit/browser/test_browser_cookies.py +++ b/python/tests/unit/browser/test_browser_cookies.py @@ -58,7 +58,7 @@ def _encrypt_cbc_cookie(host: str, value: str, password: str) -> bytes: def _encrypt_gcm_cookie(host: str, value: str, key: bytes) -> bytes: - nonce = b"fixture-nonce" + nonce = b"fixture12345" encryptor = Cipher(algorithms.AES(key), modes.GCM(nonce)).encryptor() plaintext = hashlib.sha256(host.encode()).digest() + value.encode() ciphertext = encryptor.update(plaintext) + encryptor.finalize() From a3f70665e05a469becf1ddabbcb62a30ab03cc66 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:23:38 +0000 Subject: [PATCH 06/19] test(rust): define installed cookie import behavior --- rust/Cargo.lock | 232 ++++++++++++++++++++++++++++++++++ rust/Cargo.toml | 9 ++ rust/tests/browser_cookies.rs | 157 +++++++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 rust/tests/browser_cookies.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 27543df..0d81755 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2,6 +2,53 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -265,6 +312,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -282,17 +338,24 @@ dependencies = [ name = "browser-commander" version = "0.9.0" dependencies = [ + "aes", + "aes-gcm", "anyhow", "async-trait", "base64 0.22.1", + "cbc", "chromiumoxide", "dirs", "fantoccini", "futures", + "pbkdf2", "pretty_assertions", "regex", + "rusqlite", "serde", "serde_json", + "sha1", + "sha2", "thiserror", "tokio", "tokio-test", @@ -323,6 +386,15 @@ dependencies = [ "serde", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.51" @@ -407,6 +479,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -475,9 +557,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -507,6 +599,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -590,6 +683,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fantoccini" version = "0.21.5" @@ -797,6 +902,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -809,6 +924,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + [[package]] name = "heck" version = "0.4.1" @@ -821,6 +954,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -1043,6 +1185,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1106,6 +1258,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1212,6 +1375,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.75" @@ -1291,6 +1460,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1340,6 +1519,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1509,6 +1700,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1651,6 +1856,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1704,6 +1920,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2052,6 +2274,16 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.7" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index d35fb32..fa5a2c7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -22,6 +22,15 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" base64 = "0.22" +# Installed browser cookies +aes = "0.8" +aes-gcm = "0.10" +cbc = { version = "0.1", features = ["alloc", "block-padding"] } +pbkdf2 = "0.12" +rusqlite = { version = "0.32", features = ["bundled"] } +sha1 = "0.10" +sha2 = "0.10" + # Logging tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/rust/tests/browser_cookies.rs b/rust/tests/browser_cookies.rs new file mode 100644 index 0000000..c6bc429 --- /dev/null +++ b/rust/tests/browser_cookies.rs @@ -0,0 +1,157 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use aes::Aes128; +use browser_commander::{ + list_browser_profiles, read_browser_cookies, BrowserCookieReadOptions, + BrowserProfileOptions, +}; +use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit}; +use pbkdf2::pbkdf2_hmac; +use rusqlite::{params, Connection}; +use sha1::Sha1; +use sha2::{Digest, Sha256}; + +const CHROME_EPOCH_OFFSET_SECONDS: i64 = 11_644_473_600; + +type Aes128CbcEncryptor = cbc::Encryptor; + +#[test] +fn discovers_and_reads_installed_browser_cookie_profiles() -> anyhow::Result<()> { + let temporary_directory = TempDir::new("browser-cookies")?; + let host = ".example.com"; + let chrome_path = create_chromium_profile(temporary_directory.path(), host)?; + let firefox_path = create_firefox_profile(temporary_directory.path())?; + + let profiles = list_browser_profiles( + BrowserProfileOptions::default() + .home_dir(temporary_directory.path()) + .platform("linux"), + )?; + assert_eq!(profiles.len(), 2); + assert_eq!(profiles[0].browser, "chrome"); + assert_eq!(profiles[0].display_name, "Primary profile"); + assert_eq!(profiles[0].path, chrome_path); + assert!(profiles[0].is_default); + assert_eq!(profiles[1].browser, "firefox"); + assert_eq!(profiles[1].path, firefox_path); + + let cookies = read_browser_cookies( + BrowserCookieReadOptions::new("chrome") + .home_dir(temporary_directory.path()) + .platform("linux") + .domain_filter("example.com") + .cache(false), + )?; + assert_eq!(cookies.len(), 1); + assert_eq!(cookies[0].name, "SID"); + assert_eq!(cookies[0].value, "decrypted-session"); + assert_eq!(cookies[0].domain, host); + assert_eq!(cookies[0].path, "/"); + assert_eq!(cookies[0].expires, 2_000_000_000); + assert!(cookies[0].http_only); + assert!(cookies[0].secure); + assert_eq!(cookies[0].same_site, "Strict"); + + let firefox_cookies = read_browser_cookies( + BrowserCookieReadOptions::new("firefox") + .home_dir(temporary_directory.path()) + .platform("linux") + .cache(false), + )?; + assert_eq!(firefox_cookies.len(), 1); + assert_eq!(firefox_cookies[0].name, "firefox-session"); + assert_eq!(firefox_cookies[0].value, "plain-value"); + assert_eq!(firefox_cookies[0].same_site, "Lax"); + + Ok(()) +} + +fn create_chromium_profile(home: &Path, host: &str) -> anyhow::Result { + let root = home.join(".config/google-chrome"); + let profile = root.join("Default"); + let cookie_path = profile.join("Network/Cookies"); + fs::create_dir_all(cookie_path.parent().unwrap())?; + fs::write( + root.join("Local State"), + r#"{"profile":{"last_used":"Default","info_cache":{"Default":{"name":"Primary profile"}}}}"#, + )?; + + let database = Connection::open(cookie_path)?; + database.execute_batch( + "CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);\ + INSERT INTO meta (key, value) VALUES ('version', '24');\ + CREATE TABLE cookies (\ + host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT,\ + expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, samesite INTEGER\ + );", + )?; + database.execute( + "INSERT INTO cookies VALUES (?, 'SID', '', ?, '/', ?, 1, 1, 2)", + params![ + host, + encrypt_linux_cookie(host, "decrypted-session"), + (2_000_000_000_i64 + CHROME_EPOCH_OFFSET_SECONDS) * 1_000_000 + ], + )?; + Ok(profile) +} + +fn encrypt_linux_cookie(host: &str, value: &str) -> Vec { + let mut key = [0_u8; 16]; + pbkdf2_hmac::(b"peanuts", b"saltysalt", 1, &mut key); + let mut plaintext = Sha256::digest(host.as_bytes()).to_vec(); + plaintext.extend_from_slice(value.as_bytes()); + let encrypted = Aes128CbcEncryptor::new(&key.into(), &[0x20; 16].into()) + .encrypt_padded_vec_mut::(&plaintext); + [b"v10".as_slice(), encrypted.as_slice()].concat() +} + +fn create_firefox_profile(home: &Path) -> anyhow::Result { + let root = home.join(".mozilla/firefox"); + let profile = root.join("fixture.default-release"); + fs::create_dir_all(&profile)?; + fs::write( + root.join("profiles.ini"), + "[Profile0]\nName=default-release\nIsRelative=1\nPath=fixture.default-release\nDefault=1\n", + )?; + let database = Connection::open(profile.join("cookies.sqlite"))?; + database.execute_batch( + "CREATE TABLE moz_cookies (\ + name TEXT, value TEXT, host TEXT, path TEXT, expiry INTEGER,\ + isSecure INTEGER, isHttpOnly INTEGER, sameSite INTEGER\ + );\ + INSERT INTO moz_cookies VALUES (\ + 'firefox-session', 'plain-value', '.example.org', '/', 2000000001, 1, 0, 1\ + );", + )?; + Ok(profile) +} + +struct TempDir(PathBuf); + +impl TempDir { + fn new(label: &str) -> std::io::Result { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let path = std::env::temp_dir().join(format!( + "browser-commander-{label}-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&path)?; + Ok(Self(path)) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} From 86683dbe2f2b16df0307c707a972c61b88a96012 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:33:30 +0000 Subject: [PATCH 07/19] feat(rust): import cookies from installed browsers --- rust/README.md | 51 ++ .../69.installed-browser-cookies.md | 7 + rust/src/browser/browser_cookie_cache.rs | 396 ++++++++++++++++ .../src/browser/browser_cookie_credentials.rs | 139 ++++++ rust/src/browser/browser_cookie_crypto.rs | 128 +++++ rust/src/browser/browser_cookies.rs | 445 ++++++++++++++++++ rust/src/browser/browser_profiles.rs | 307 ++++++++++++ rust/src/browser/mod.rs | 10 + rust/src/lib.rs | 15 +- rust/tests/browser_cookies.rs | 3 +- 10 files changed, 1493 insertions(+), 8 deletions(-) create mode 100644 rust/changelog.d/69.installed-browser-cookies.md create mode 100644 rust/src/browser/browser_cookie_cache.rs create mode 100644 rust/src/browser/browser_cookie_credentials.rs create mode 100644 rust/src/browser/browser_cookie_crypto.rs create mode 100644 rust/src/browser/browser_cookies.rs create mode 100644 rust/src/browser/browser_profiles.rs diff --git a/rust/README.md b/rust/README.md index c8a8bd4..6d3fb56 100644 --- a/rust/README.md +++ b/rust/README.md @@ -161,6 +161,57 @@ pass a non-default `--user-data-dir` together with the remote-debugging flag; Chrome intentionally disables remote debugging for its default data directory. Cookies can be supplied explicitly with `ConnectOptions::seed_cookies()`. +### Installed browser cookies + +Discover profiles and read cookies in the same shape accepted by browser +contexts: + +```rust +use browser_commander::{ + list_browser_profiles, read_browser_cookies, BrowserCookieReadOptions, + BrowserProfileOptions, +}; + +let profiles = list_browser_profiles( + BrowserProfileOptions::default().browser("chrome"), +)?; +println!("{profiles:#?}"); + +let cookies = read_browser_cookies( + BrowserCookieReadOptions::new("chrome") + .profile("Default") + .domain_filter("example.com") + .ttl_minutes(60.0), +)?; +# Ok::<(), anyhow::Error>(()) +``` + +Each `BrowserCookie` contains `name`, `value`, `domain`, `path`, `expires`, +`http_only`, `secure`, and `same_site` (serialized as the Playwright-compatible +`httpOnly` and `sameSite` names). The explicit helper supports Chrome, Edge, +Brave, Chromium, and Firefox and never sends imported data anywhere. + +Decrypted results and derived keys default to +`~/.browser-commander/cookie-cache/` with owner-only permissions. A process +lock and the default 60-minute TTL keep Keychain, libsecret/KWallet, or DPAPI +access to at most one read across concurrent and repeated processes. Use +`.refresh(true)` to coordinate a new read, `.cache_dir(...)` and +`.ttl_minutes(...)` to customize storage, or `.cache(false)` to opt out. + +| Browser family | macOS | Linux | Windows | +| --- | --- | --- | --- | +| Chrome, Edge, Brave, Chromium | Keychain + AES-128-CBC (`v10`/`v11`) | libsecret/KWallet + AES-128-CBC (`v11`), or the Chromium `v10` fallback key | DPAPI-protected AES-256-GCM key (`v10`/`v11`) | +| Firefox | `cookies.sqlite` | `cookies.sqlite` | `cookies.sqlite` | + +Chromium database-version-24 domain hashes and 1601-based timestamps are +handled automatically. Current Windows Chromium may use app-bound `v20` +encryption, which requires the browser's privileged service and cannot be +decrypted by an ordinary external process. The helper reports this boundary; +use a browser-supported export or saved Browser Commander storage state for +those cookies. `.ignore_decryption_errors(true)` returns the remaining +decryptable cookies. Treat imported cookies like passwords: use a short TTL, +never commit cache files, and seed only a dedicated automation profile. + ### Navigation ```rust diff --git a/rust/changelog.d/69.installed-browser-cookies.md b/rust/changelog.d/69.installed-browser-cookies.md new file mode 100644 index 0000000..0d2e029 --- /dev/null +++ b/rust/changelog.d/69.installed-browser-cookies.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- Added installed Chrome, Edge, Brave, Chromium, and Firefox profile discovery and cookie import, including platform decryption and an owner-only cross-process cache. diff --git a/rust/src/browser/browser_cookie_cache.rs b/rust/src/browser/browser_cookie_cache.rs new file mode 100644 index 0000000..6f3352e --- /dev/null +++ b/rust/src/browser/browser_cookie_cache.rs @@ -0,0 +1,396 @@ +//! Owner-only cookie and derived-key cache with cross-process locking. + +use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; + +const DEFAULT_TTL_MINUTES: f64 = 60.0; +const LOCK_STALE_SECONDS: f64 = 30.0; +const LOCK_WAIT_SECONDS: f64 = 30.0; + +static CREDENTIAL_MEMORY_CACHE: OnceLock>>> = OnceLock::new(); + +#[derive(Debug, Clone)] +pub(crate) struct NormalizedCookieCache { + pub enabled: bool, + pub directory: PathBuf, + pub ttl_seconds: f64, +} + +pub(crate) fn normalize_cookie_cache( + enabled: bool, + directory: Option<&Path>, + home_dir: &Path, + ttl_minutes: Option, +) -> Result { + let ttl_minutes = ttl_minutes.unwrap_or(DEFAULT_TTL_MINUTES); + if !ttl_minutes.is_finite() || ttl_minutes < 0.0 { + return Err(anyhow!( + "cookie cache ttl_minutes must be a non-negative number" + )); + } + Ok(NormalizedCookieCache { + enabled, + directory: directory + .map(Path::to_path_buf) + .unwrap_or_else(|| home_dir.join(".browser-commander/cookie-cache")), + ttl_seconds: ttl_minutes * 60.0, + }) +} + +fn memory_cache() -> &'static Mutex>> { + CREDENTIAL_MEMORY_CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Clear only the current process's derived-key cache. +pub fn clear_browser_cookie_memory_cache() { + if let Ok(mut cache) = memory_cache().lock() { + cache.clear(); + } +} + +fn now_seconds() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs_f64()) + .unwrap_or_default() +} + +fn hash(identity: &str) -> String { + Sha256::digest(identity.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn cache_path(cache: &NormalizedCookieCache, kind: &str, identity: &str) -> PathBuf { + cache + .directory + .join(format!("{kind}-{}.json", hash(identity))) +} + +fn ensure_cache_directory(directory: &Path) -> Result<()> { + fs::create_dir_all(directory) + .with_context(|| format!("Could not create cookie cache {}", directory.display()))?; + restrict_owner_only(directory, true)?; + Ok(()) +} + +#[cfg(unix)] +fn restrict_owner_only(path: &Path, directory: bool) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let mode = if directory { 0o700 } else { 0o600 }; + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .with_context(|| format!("Could not protect cookie cache {}", path.display())) +} + +#[cfg(not(unix))] +fn restrict_owner_only(_path: &Path, _directory: bool) -> Result<()> { + Ok(()) +} + +fn read_fresh_json(path: &Path, ttl_seconds: f64) -> Option { + let value = serde_json::from_str::(&fs::read_to_string(path).ok()?).ok()?; + let saved_at = value.get("savedAt")?.as_f64()?; + let age = now_seconds() - saved_at; + (age >= 0.0 && age <= ttl_seconds).then_some(value) +} + +fn temporary_path(path: &Path) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + path.with_file_name(format!( + "{}.{}.{unique}.tmp", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("cache"), + std::process::id() + )) +} + +fn owner_only_file(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options + .open(path) + .with_context(|| format!("Could not create owner-only cache file {}", path.display())) +} + +fn write_owner_only_json(path: &Path, value: &Value) -> Result<()> { + let temporary = temporary_path(path); + let mut file = owner_only_file(&temporary)?; + let result = (|| -> Result<()> { + serde_json::to_writer(&mut file, value)?; + file.write_all(b"\n")?; + file.sync_all()?; + drop(file); + #[cfg(windows)] + if path.exists() { + fs::remove_file(path)?; + } + fs::rename(&temporary, path)?; + restrict_owner_only(path, false)?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result.with_context(|| format!("Could not write cookie cache {}", path.display())) +} + +pub(crate) fn read_cookie_result_cache( + cache: &NormalizedCookieCache, + identity: &str, + refresh: bool, +) -> Option> { + if !cache.enabled || refresh { + return None; + } + let value = read_fresh_json(&cache_path(cache, "cookies", identity), cache.ttl_seconds)?; + if value.get("kind").and_then(Value::as_str) != Some("cookies") { + return None; + } + value.get("cookies")?.as_array().cloned() +} + +pub(crate) fn write_cookie_result_cache( + cache: &NormalizedCookieCache, + identity: &str, + cookies: &[Value], +) -> Result<()> { + if !cache.enabled { + return Ok(()); + } + ensure_cache_directory(&cache.directory)?; + write_owner_only_json( + &cache_path(cache, "cookies", identity), + &json!({ + "version": 1, + "kind": "cookies", + "savedAt": now_seconds(), + "cookies": cookies, + }), + ) +} + +enum LockResult { + Acquired(File), + Cached(Value), +} + +fn remove_stale_lock(lock_path: &Path) { + let stale = fs::metadata(lock_path) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age.as_secs_f64() > LOCK_STALE_SECONDS); + if stale { + let _ = fs::remove_file(lock_path); + } +} + +fn acquire_lock_or_cached( + lock_path: &Path, + cached_path: &Path, + ttl_seconds: f64, + refresh: bool, + initial_saved_at: Option, +) -> Result { + let started = Instant::now(); + while started.elapsed().as_secs_f64() <= LOCK_WAIT_SECONDS { + match owner_only_file(lock_path) { + Ok(file) => return Ok(LockResult::Acquired(file)), + Err(error) + if error + .downcast_ref::() + .is_some_and(|error| error.kind() == ErrorKind::AlreadyExists) => + { + if let Some(value) = read_fresh_json(cached_path, ttl_seconds) { + let saved_at = value.get("savedAt").and_then(Value::as_f64); + if value.get("kind").and_then(Value::as_str) == Some("derived-key") + && (!refresh || saved_at != initial_saved_at) + { + return Ok(LockResult::Cached(value)); + } + } + remove_stale_lock(lock_path); + thread::sleep(Duration::from_millis(50)); + } + Err(error) => return Err(error), + } + } + Err(anyhow!( + "timed out waiting for another cookie credential reader" + )) +} + +fn decode_cached_key(value: &Value) -> Result> { + BASE64 + .decode( + value + .get("key") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("derived-key cache has no key"))?, + ) + .context("derived-key cache contains invalid base64") +} + +pub(crate) fn get_cached_credential( + cache: &NormalizedCookieCache, + identity: &str, + refresh: bool, + metadata: Map, + create: F, +) -> Result> +where + F: FnOnce() -> Result>, +{ + let memory_identity = format!("{}:{identity}", cache.directory.display()); + if !refresh { + if let Some(key) = memory_cache() + .lock() + .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))? + .get(&memory_identity) + .cloned() + { + return Ok(key); + } + } + let key = load_or_create_credential(cache, identity, refresh, metadata, create)?; + memory_cache() + .lock() + .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))? + .insert(memory_identity, key.clone()); + Ok(key) +} + +fn load_or_create_credential( + cache: &NormalizedCookieCache, + identity: &str, + refresh: bool, + metadata: Map, + create: F, +) -> Result> +where + F: FnOnce() -> Result>, +{ + if !cache.enabled { + return create(); + } + ensure_cache_directory(&cache.directory)?; + let cached_path = cache_path(cache, "credential", identity); + let lock_path = PathBuf::from(format!("{}.lock", cached_path.display())); + let initial = read_fresh_json(&cached_path, cache.ttl_seconds); + if !refresh { + if let Some(value) = initial + .as_ref() + .filter(|value| value.get("kind").and_then(Value::as_str) == Some("derived-key")) + { + return decode_cached_key(value); + } + } + let initial_saved_at = initial + .as_ref() + .and_then(|value| value.get("savedAt")) + .and_then(Value::as_f64); + match acquire_lock_or_cached( + &lock_path, + &cached_path, + cache.ttl_seconds, + refresh, + initial_saved_at, + )? { + LockResult::Cached(value) => decode_cached_key(&value), + LockResult::Acquired(lock) => { + drop(lock); + let result = (|| -> Result> { + if let Some(value) = read_fresh_json(&cached_path, cache.ttl_seconds) { + let saved_at = value.get("savedAt").and_then(Value::as_f64); + if value.get("kind").and_then(Value::as_str) == Some("derived-key") + && (!refresh || saved_at != initial_saved_at) + { + return decode_cached_key(&value); + } + } + let key = create()?; + let mut value = metadata; + value.insert("version".into(), json!(1)); + value.insert("kind".into(), json!("derived-key")); + value.insert("savedAt".into(), json!(now_seconds())); + value.insert("key".into(), json!(BASE64.encode(&key))); + write_owner_only_json(&cached_path, &Value::Object(value))?; + Ok(key) + })(); + let _ = fs::remove_file(lock_path); + result + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[test] + fn credential_cache_is_owner_only_and_reused_after_memory_reset() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let directory = std::env::temp_dir().join(format!( + "browser-commander-cache-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&directory); + let cache = NormalizedCookieCache { + enabled: true, + directory: directory.clone(), + ttl_seconds: 60.0, + }; + let calls = std::sync::atomic::AtomicUsize::new(0); + let create = || { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(vec![7_u8; 16]) + }; + let metadata = Map::new(); + get_cached_credential( + &cache, + "chrome:linux:safe-storage", + false, + metadata.clone(), + create, + )?; + clear_browser_cookie_memory_cache(); + get_cached_credential(&cache, "chrome:linux:safe-storage", false, metadata, create)?; + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + let cached = fs::read_dir(&directory)? + .flatten() + .find(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("credential-") + }) + .unwrap(); + assert_eq!(cached.metadata()?.permissions().mode() & 0o777, 0o600); + fs::remove_dir_all(directory)?; + Ok(()) + } +} diff --git a/rust/src/browser/browser_cookie_credentials.rs b/rust/src/browser/browser_cookie_credentials.rs new file mode 100644 index 0000000..5bab4df --- /dev/null +++ b/rust/src/browser/browser_cookie_credentials.rs @@ -0,0 +1,139 @@ +//! OS credential-store access for installed Chromium cookie import. + +use std::fs; +use std::path::Path; +use std::process::Command; + +use anyhow::{anyhow, Context, Result}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use serde_json::Value; + +struct SafeStorageIdentity { + application: &'static str, + service: &'static str, +} + +fn safe_storage_identity(browser: &str) -> Result { + let identity = match browser { + "brave" => SafeStorageIdentity { + application: "brave", + service: "Brave Safe Storage", + }, + "chrome" => SafeStorageIdentity { + application: "chrome", + service: "Chrome Safe Storage", + }, + "chromium" => SafeStorageIdentity { + application: "chromium", + service: "Chromium Safe Storage", + }, + "edge" => SafeStorageIdentity { + application: "microsoft-edge", + service: "Microsoft Edge Safe Storage", + }, + _ => return Err(anyhow!("No Safe Storage identity is known for {browser}")), + }; + Ok(identity) +} + +fn run_credential_command(command: &str, arguments: &[&str]) -> Result { + let output = Command::new(command) + .args(arguments) + .output() + .with_context(|| format!("Could not start {command}"))?; + if !output.status.success() { + return Err(anyhow!("{command} exited with {}", output.status)); + } + String::from_utf8(output.stdout) + .context("credential command returned invalid UTF-8") + .map(|value| value.trim().to_string()) +} + +pub(crate) fn read_safe_storage_password(browser: &str, platform: &str) -> Result { + let identity = safe_storage_identity(browser)?; + if platform == "darwin" { + let password = run_credential_command( + "security", + &["find-generic-password", "-w", "-s", identity.service], + )?; + return (!password.is_empty()) + .then_some(password) + .ok_or_else(|| anyhow!("{} returned an empty password", identity.service)); + } + if platform == "linux" { + if let Ok(password) = run_credential_command( + "secret-tool", + &["lookup", "application", identity.application], + ) { + if !password.is_empty() { + return Ok(password); + } + } + if let Ok(password) = run_credential_command( + "kwallet-query", + &[ + "-r", + identity.service, + "-f", + &format!("{} Keys", identity.application), + "kdewallet", + ], + ) { + if !password.is_empty() { + return Ok(password); + } + } + return Err(anyhow!( + "Could not read {} from libsecret or KWallet; install secret-tool or unlock the browser key store", + identity.service + )); + } + Err(anyhow!("Safe Storage passwords are not used on {platform}")) +} + +const DPAPI_SCRIPT: &str = concat!( + "$inputBytes=[Convert]::FromBase64String($args[0]);", + "$outputBytes=[Security.Cryptography.ProtectedData]::Unprotect(", + "$inputBytes,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);", + "[Convert]::ToBase64String($outputBytes)" +); + +pub(crate) fn decrypt_windows_dpapi(encrypted: &[u8]) -> Result> { + let encoded = BASE64.encode(encrypted); + let output = run_credential_command( + "powershell.exe", + &[ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + DPAPI_SCRIPT, + &encoded, + ], + )?; + BASE64 + .decode(output) + .context("DPAPI command returned invalid base64") +} + +pub(crate) fn read_windows_encryption_key(local_state_path: &Path) -> Result> { + let contents = fs::read_to_string(local_state_path).with_context(|| { + format!( + "Could not read Chromium Local State {}", + local_state_path.display() + ) + })?; + let state: Value = serde_json::from_str(&contents).context("Invalid Chromium Local State")?; + let encoded = state + .pointer("/os_crypt/encrypted_key") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("Chromium Local State has no os_crypt.encrypted_key"))?; + let encrypted_key = BASE64 + .decode(encoded) + .context("Chromium Local State encrypted_key is not base64")?; + let protected = encrypted_key + .strip_prefix(b"DPAPI") + .ok_or_else(|| anyhow!("Chromium Local State key does not have a DPAPI prefix"))?; + decrypt_windows_dpapi(protected) +} diff --git a/rust/src/browser/browser_cookie_crypto.rs b/rust/src/browser/browser_cookie_crypto.rs new file mode 100644 index 0000000..36ce7c2 --- /dev/null +++ b/rust/src/browser/browser_cookie_crypto.rs @@ -0,0 +1,128 @@ +//! Chromium cookie key derivation and decryption primitives. + +use aes::Aes128; +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use anyhow::{anyhow, Context, Result}; +use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; +use pbkdf2::pbkdf2_hmac; +use sha1::Sha1; +use sha2::{Digest, Sha256}; + +type Aes128CbcDecryptor = cbc::Decryptor; + +pub(crate) fn derive_chromium_cookie_key(password: &str, platform: &str) -> Result> { + let iterations = match platform { + "darwin" => 1003, + "linux" => 1, + _ => return Err(anyhow!("CBC cookie keys are not used on {platform}")), + }; + let mut key = vec![0_u8; 16]; + pbkdf2_hmac::(password.as_bytes(), b"saltysalt", iterations, &mut key); + Ok(key) +} + +fn remove_domain_hash<'a>(plaintext: &'a [u8], host: &str, version: i64) -> Result<&'a [u8]> { + if version < 24 { + return Ok(plaintext); + } + if plaintext.len() < 32 { + return Err(anyhow!("decrypted cookie is missing its domain hash")); + } + let expected = Sha256::digest(host.as_bytes()); + if plaintext[..32] != expected[..] { + return Err(anyhow!( + "decrypted cookie domain hash does not match its host" + )); + } + Ok(&plaintext[32..]) +} + +fn decrypt_cbc(encrypted: &[u8], key: &[u8]) -> Result> { + Aes128CbcDecryptor::new_from_slices(key, &[0x20; 16]) + .context("invalid Chromium AES-CBC key")? + .decrypt_padded_vec_mut::(&encrypted[3..]) + .map_err(|_| anyhow!("Chromium AES-CBC cookie padding is invalid")) +} + +fn decrypt_gcm(encrypted: &[u8], key: &[u8]) -> Result> { + let payload = &encrypted[3..]; + if payload.len() < 28 { + return Err(anyhow!("encrypted AES-GCM cookie is truncated")); + } + let cipher = Aes256Gcm::new_from_slice(key).context("invalid Chromium AES-GCM key")?; + cipher + .decrypt(Nonce::from_slice(&payload[..12]), &payload[12..]) + .map_err(|_| anyhow!("Chromium AES-GCM cookie authentication failed")) +} + +pub(crate) fn decrypt_chromium_cookie( + encrypted: &[u8], + host: &str, + database_version: i64, + platform: &str, + key: &[u8], +) -> Result { + if encrypted.len() < 3 { + return Err(anyhow!("encrypted Chromium cookie is truncated")); + } + match &encrypted[..3] { + b"v20" => { + return Err(anyhow!( + "Windows app-bound v20 cookies cannot be decrypted outside the browser; use a browser-supported export or a previously saved storage state" + )) + } + b"v10" | b"v11" => {} + _ => return Err(anyhow!("cookie has an unsupported Chromium encryption prefix")), + } + let plaintext = if platform == "win32" { + decrypt_gcm(encrypted, key)? + } else { + decrypt_cbc(encrypted, key)? + }; + String::from_utf8(remove_domain_hash(&plaintext, host, database_version)?.to_vec()) + .context("decrypted cookie is not valid UTF-8") +} + +pub(crate) fn chromium_same_site(value: i64) -> &'static str { + match value { + 2 => "Strict", + 1 => "Lax", + _ => "None", + } +} + +pub(crate) fn firefox_same_site(value: i64) -> &'static str { + chromium_same_site(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use aes_gcm::aead::Aead; + use sha2::Sha256; + + #[test] + fn decrypts_windows_gcm_and_rejects_app_bound_cookies() { + let key = [7_u8; 32]; + let host = ".example.test"; + let mut plaintext = Sha256::digest(host.as_bytes()).to_vec(); + plaintext.extend_from_slice(b"windows-session"); + let nonce = [3_u8; 12]; + let ciphertext = Aes256Gcm::new_from_slice(&key) + .unwrap() + .encrypt(Nonce::from_slice(&nonce), plaintext.as_slice()) + .unwrap(); + let encrypted = [b"v10".as_slice(), &nonce, ciphertext.as_slice()].concat(); + assert_eq!( + decrypt_chromium_cookie(&encrypted, host, 24, "win32", &key).unwrap(), + "windows-session" + ); + assert!( + decrypt_chromium_cookie(b"v20app-bound", host, 24, "win32", &key) + .unwrap_err() + .to_string() + .contains("app-bound") + ); + } +} diff --git a/rust/src/browser/browser_cookies.rs b/rust/src/browser/browser_cookies.rs new file mode 100644 index 0000000..07e095d --- /dev/null +++ b/rust/src/browser/browser_cookies.rs @@ -0,0 +1,445 @@ +//! Import cookies from installed Chrome-family and Firefox profiles. + +use std::path::PathBuf; + +use anyhow::{anyhow, Context, Result}; +use rusqlite::{params, Connection, OpenFlags}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; + +use super::browser_cookie_cache::{ + get_cached_credential, normalize_cookie_cache, read_cookie_result_cache, + write_cookie_result_cache, NormalizedCookieCache, +}; +use super::browser_cookie_credentials::{ + decrypt_windows_dpapi, read_safe_storage_password, read_windows_encryption_key, +}; +use super::browser_cookie_crypto::{ + chromium_same_site, decrypt_chromium_cookie, derive_chromium_cookie_key, firefox_same_site, +}; +use super::browser_profiles::{ + find_cookie_database, normalize_cookie_browser, resolve_browser_profile, BrowserProfile, + BrowserProfileOptions, +}; + +const CHROME_EPOCH_OFFSET_SECONDS: i64 = 11_644_473_600; + +/// A browser cookie in the shape accepted by Playwright/Puppeteer contexts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BrowserCookie { + /// Cookie name. + pub name: String, + /// Decrypted cookie value. + pub value: String, + /// Cookie domain, including any leading dot. + pub domain: String, + /// Cookie path. + pub path: String, + /// Unix expiry seconds, or `-1` for a session cookie. + pub expires: i64, + /// Whether JavaScript is prevented from reading the cookie. + pub http_only: bool, + /// Whether the cookie is restricted to secure transports. + pub secure: bool, + /// `Strict`, `Lax`, or `None`. + pub same_site: String, +} + +/// Options for [`read_browser_cookies`]. +#[derive(Debug, Clone)] +pub struct BrowserCookieReadOptions { + /// Installed browser name. + pub browser: String, + /// Optional on-disk or display profile name. + pub profile: Option, + /// Optional domain substring used by the SQLite query. + pub domain_filter: Option, + /// Enable the owner-only decrypted-result and derived-key cache. + pub cache: bool, + /// Override the cache directory. + pub cache_dir: Option, + /// Cache lifetime in minutes. + pub ttl_minutes: Option, + /// Bypass cached values and coordinate one refreshed credential read. + pub refresh: bool, + /// Skip individual cookies that cannot be decrypted. + pub ignore_decryption_errors: bool, + /// Home directory used for profile discovery and the default cache. + pub home_dir: PathBuf, + /// Platform convention (`linux`, `darwin`, or `win32`). + pub platform: String, +} + +impl BrowserCookieReadOptions { + /// Create options for one installed browser. + pub fn new(browser: impl Into) -> Self { + Self { + browser: browser.into(), + profile: None, + domain_filter: None, + cache: true, + cache_dir: None, + ttl_minutes: None, + refresh: false, + ignore_decryption_errors: false, + home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")), + platform: super::browser_profiles::current_platform().to_string(), + } + } + + /// Select a named installed-browser profile. + pub fn profile(mut self, profile: impl Into) -> Self { + self.profile = Some(profile.into()); + self + } + + /// Restrict the SQLite query to domains containing this value. + pub fn domain_filter(mut self, domain: impl Into) -> Self { + self.domain_filter = Some(domain.into()); + self + } + + /// Enable or disable disk caching. + pub fn cache(mut self, enabled: bool) -> Self { + self.cache = enabled; + self + } + + /// Override the owner-only cache directory. + pub fn cache_dir(mut self, directory: impl Into) -> Self { + self.cache_dir = Some(directory.into()); + self + } + + /// Set the decrypted-result and derived-key cache TTL. + pub fn ttl_minutes(mut self, minutes: f64) -> Self { + self.ttl_minutes = Some(minutes); + self + } + + /// Force a coordinated refresh of cached results and credentials. + pub fn refresh(mut self, refresh: bool) -> Self { + self.refresh = refresh; + self + } + + /// Skip cookies whose platform decryption fails. + pub fn ignore_decryption_errors(mut self, ignore: bool) -> Self { + self.ignore_decryption_errors = ignore; + self + } + + /// Override the home directory used for discovery. + pub fn home_dir(mut self, home_dir: impl Into) -> Self { + self.home_dir = home_dir.into(); + self + } + + /// Override the platform convention, primarily for portable tooling/tests. + pub fn platform(mut self, platform: impl AsRef) -> Self { + self.platform = super::browser_profiles::normalize_platform(platform.as_ref()).to_string(); + self + } +} + +#[derive(Debug)] +struct ChromiumRow { + host: String, + name: String, + value: String, + encrypted_value: Vec, + path: String, + expires: i64, + secure: bool, + http_only: bool, + same_site: i64, +} + +fn open_cookie_database(path: &std::path::Path) -> Result { + Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) + .with_context(|| format!("Could not open browser cookie database: {}", path.display())) +} + +fn read_database_version(database: &Connection) -> i64 { + database + .query_row("SELECT value FROM meta WHERE key = 'version'", [], |row| { + row.get::<_, String>(0) + }) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or_default() +} + +fn domain_pattern(domain_filter: Option<&str>) -> Option { + domain_filter.map(|domain| format!("%{domain}%")) +} + +fn read_chromium_rows( + database: &Connection, + domain_filter: Option<&str>, +) -> Result> { + let where_clause = domain_filter + .map(|_| " WHERE host_key LIKE ?1") + .unwrap_or(""); + let query = format!( + "SELECT host_key, name, value, encrypted_value, path, expires_utc, \ + is_secure, is_httponly, samesite FROM cookies{where_clause} \ + ORDER BY host_key, name, path" + ); + let mut statement = database.prepare(&query)?; + let pattern = domain_pattern(domain_filter); + let mapper = |row: &rusqlite::Row<'_>| { + Ok(ChromiumRow { + host: row.get(0)?, + name: row.get(1)?, + value: row.get(2)?, + encrypted_value: row.get(3)?, + path: row.get(4)?, + expires: row.get(5)?, + secure: row.get::<_, i64>(6)? != 0, + http_only: row.get::<_, i64>(7)? != 0, + same_site: row.get(8)?, + }) + }; + let rows = if let Some(pattern) = pattern.as_deref() { + statement.query_map(params![pattern], mapper)? + } else { + statement.query_map([], mapper)? + }; + rows.collect::>>() + .map_err(Into::into) +} + +fn read_firefox_cookies( + database: &Connection, + domain_filter: Option<&str>, +) -> Result> { + let where_clause = domain_filter.map(|_| " WHERE host LIKE ?1").unwrap_or(""); + let query = format!( + "SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite \ + FROM moz_cookies{where_clause} ORDER BY host, name, path" + ); + let mut statement = database.prepare(&query)?; + let pattern = domain_pattern(domain_filter); + let mapper = |row: &rusqlite::Row<'_>| { + let expires = row.get::<_, i64>(4)?; + Ok(BrowserCookie { + name: row.get(0)?, + value: row.get(1)?, + domain: row.get(2)?, + path: row.get::<_, String>(3).map(|path| { + if path.is_empty() { + "/".to_string() + } else { + path + } + })?, + expires: if expires > 0 { expires } else { -1 }, + secure: row.get::<_, i64>(5)? != 0, + http_only: row.get::<_, i64>(6)? != 0, + same_site: firefox_same_site(row.get(7)?).to_string(), + }) + }; + let rows = if let Some(pattern) = pattern.as_deref() { + statement.query_map(params![pattern], mapper)? + } else { + statement.query_map([], mapper)? + }; + rows.collect::>>() + .map_err(Into::into) +} + +fn credential_metadata(browser: &str, platform: &str, source: &str) -> Map { + let mut metadata = Map::new(); + metadata.insert("browser".into(), json!(browser)); + metadata.insert("platform".into(), json!(platform)); + metadata.insert("source".into(), json!(source)); + metadata +} + +fn chromium_key_for_prefix( + prefix: &[u8], + browser: &str, + platform: &str, + profile: &BrowserProfile, + cache: &NormalizedCookieCache, + refresh: bool, +) -> Result> { + if platform == "linux" && prefix == b"v10" { + return derive_chromium_cookie_key("peanuts", "linux"); + } + if platform == "linux" || platform == "darwin" { + return get_cached_credential( + cache, + &format!("{browser}:{platform}:safe-storage"), + refresh, + credential_metadata(browser, platform, "safe-storage"), + || { + derive_chromium_cookie_key( + &read_safe_storage_password(browser, platform)?, + platform, + ) + }, + ); + } + if platform == "win32" { + return get_cached_credential( + cache, + &format!("{browser}:win32:legacy-aes-key"), + refresh, + credential_metadata(browser, platform, "dpapi"), + || { + read_windows_encryption_key( + &profile + .path + .parent() + .unwrap_or(&profile.path) + .join("Local State"), + ) + }, + ); + } + Err(anyhow!( + "Chromium cookie decryption is unsupported on {platform}" + )) +} + +fn chromium_expires(value: i64) -> i64 { + if value == 0 { + -1 + } else { + value / 1_000_000 - CHROME_EPOCH_OFFSET_SECONDS + } +} + +fn decrypt_chromium_row( + row: ChromiumRow, + database_version: i64, + browser: &str, + platform: &str, + profile: &BrowserProfile, + cache: &NormalizedCookieCache, + refresh: bool, +) -> Result { + let value = if !row.value.is_empty() { + row.value + } else if row.encrypted_value.is_empty() { + String::new() + } else { + let prefix = row.encrypted_value.get(..3).unwrap_or_default(); + if platform == "win32" && prefix != b"v10" && prefix != b"v11" { + if prefix == b"v20" { + decrypt_chromium_cookie( + &row.encrypted_value, + &row.host, + database_version, + platform, + &[0_u8; 32], + )? + } else { + String::from_utf8(decrypt_windows_dpapi(&row.encrypted_value)?) + .context("DPAPI cookie is not valid UTF-8")? + } + } else { + let key = chromium_key_for_prefix(prefix, browser, platform, profile, cache, refresh)?; + decrypt_chromium_cookie( + &row.encrypted_value, + &row.host, + database_version, + platform, + &key, + )? + } + }; + Ok(BrowserCookie { + name: row.name, + value, + domain: row.host, + path: if row.path.is_empty() { + "/".into() + } else { + row.path + }, + expires: chromium_expires(row.expires), + http_only: row.http_only, + secure: row.secure, + same_site: chromium_same_site(row.same_site).to_string(), + }) +} + +fn read_chromium_cookies( + database: &Connection, + profile: &BrowserProfile, + options: &BrowserCookieReadOptions, + cache: &NormalizedCookieCache, +) -> Result> { + let version = read_database_version(database); + let mut cookies = Vec::new(); + for row in read_chromium_rows(database, options.domain_filter.as_deref())? { + let name = row.name.clone(); + let host = row.host.clone(); + match decrypt_chromium_row( + row, + version, + &options.browser, + &options.platform, + profile, + cache, + options.refresh, + ) { + Ok(cookie) => cookies.push(cookie), + Err(_) if options.ignore_decryption_errors => {} + Err(error) => { + return Err(anyhow!( + "Could not decrypt cookie {name} for {host}: {error}" + )) + } + } + } + Ok(cookies) +} + +/// Read cookies from an installed Chrome, Edge, Brave, Chromium, or Firefox profile. +pub fn read_browser_cookies(mut options: BrowserCookieReadOptions) -> Result> { + options.browser = normalize_cookie_browser(&options.browser)?.to_string(); + let discovery = BrowserProfileOptions::default() + .browser(&options.browser) + .home_dir(&options.home_dir) + .platform(&options.platform); + let profile = + resolve_browser_profile(&options.browser, options.profile.as_deref(), &discovery)?; + let cookie_path = find_cookie_database(&options.browser, &profile.path) + .ok_or_else(|| anyhow!("No cookie database exists in {}", profile.path.display()))?; + let cache = normalize_cookie_cache( + options.cache, + options.cache_dir.as_deref(), + &options.home_dir, + options.ttl_minutes, + )?; + let identity = serde_json::to_string(&json!({ + "browser": options.browser, + "profile": profile.path, + "domainFilter": options.domain_filter, + }))?; + if let Some(values) = read_cookie_result_cache(&cache, &identity, options.refresh) { + return values + .into_iter() + .map(serde_json::from_value) + .collect::>>() + .context("cached cookies have an invalid shape"); + } + + let database = open_cookie_database(&cookie_path)?; + let cookies = if options.browser == "firefox" { + read_firefox_cookies(&database, options.domain_filter.as_deref())? + } else { + read_chromium_cookies(&database, &profile, &options, &cache)? + }; + let serialized = cookies + .iter() + .map(serde_json::to_value) + .collect::>>()?; + write_cookie_result_cache(&cache, &identity, &serialized)?; + Ok(cookies) +} diff --git a/rust/src/browser/browser_profiles.rs b/rust/src/browser/browser_profiles.rs new file mode 100644 index 0000000..a6fcc7f --- /dev/null +++ b/rust/src/browser/browser_profiles.rs @@ -0,0 +1,307 @@ +//! Discovery of installed browser profiles that contain cookie databases. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; +use serde::Deserialize; + +/// Browsers whose on-disk cookie stores can be imported. +pub const SUPPORTED_COOKIE_BROWSERS: [&str; 5] = ["chrome", "edge", "brave", "chromium", "firefox"]; + +/// Metadata for a cookie-bearing installed browser profile. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserProfile { + /// Normalized browser name. + pub browser: String, + /// On-disk profile name. + pub name: String, + /// Human-readable profile name, when the browser supplies one. + pub display_name: String, + /// Absolute or home-relative profile directory. + pub path: PathBuf, + /// Whether the browser marks this profile as the default/last used one. + pub is_default: bool, +} + +/// Options for [`list_browser_profiles`]. +#[derive(Debug, Clone)] +pub struct BrowserProfileOptions { + /// Restrict discovery to one browser. `None` scans all supported browsers. + pub browser: Option, + /// Home directory used to resolve conventional profile locations. + pub home_dir: PathBuf, + /// Platform path convention (`linux`, `darwin`, or `win32`). + pub platform: String, +} + +impl Default for BrowserProfileOptions { + fn default() -> Self { + Self { + browser: None, + home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")), + platform: current_platform().to_string(), + } + } +} + +impl BrowserProfileOptions { + /// Restrict discovery to one installed browser. + pub fn browser(mut self, browser: impl Into) -> Self { + self.browser = Some(browser.into()); + self + } + + /// Override the home directory used for discovery. + pub fn home_dir(mut self, home_dir: impl Into) -> Self { + self.home_dir = home_dir.into(); + self + } + + /// Override the platform convention, primarily for portable tooling/tests. + pub fn platform(mut self, platform: impl AsRef) -> Self { + self.platform = normalize_platform(platform.as_ref()).to_string(); + self + } +} + +pub(crate) fn current_platform() -> &'static str { + normalize_platform(std::env::consts::OS) +} + +pub(crate) fn normalize_platform(platform: &str) -> &str { + match platform { + "macos" => "darwin", + "windows" => "win32", + other => other, + } +} + +pub(crate) fn normalize_cookie_browser(browser: &str) -> Result<&str> { + let normalized = if browser == "msedge" { "edge" } else { browser }; + if SUPPORTED_COOKIE_BROWSERS.contains(&normalized) { + Ok(normalized) + } else { + Err(anyhow!( + "Unsupported browser: {browser}. Expected one of {}", + SUPPORTED_COOKIE_BROWSERS.join(", ") + )) + } +} + +pub(crate) fn browser_profile_root( + browser: &str, + platform: &str, + home_dir: &Path, +) -> Result { + let browser = normalize_cookie_browser(browser)?; + let local = std::env::var_os("LOCALAPPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home_dir.join("AppData/Local")); + let roaming = std::env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|| home_dir.join("AppData/Roaming")); + let support = home_dir.join("Library/Application Support"); + let root = match (normalize_platform(platform), browser) { + ("darwin", "chrome") => support.join("Google/Chrome"), + ("darwin", "edge") => support.join("Microsoft Edge"), + ("darwin", "brave") => support.join("BraveSoftware/Brave-Browser"), + ("darwin", "chromium") => support.join("Chromium"), + ("darwin", "firefox") => support.join("Firefox"), + ("win32", "chrome") => local.join("Google/Chrome/User Data"), + ("win32", "edge") => local.join("Microsoft/Edge/User Data"), + ("win32", "brave") => local.join("BraveSoftware/Brave-Browser/User Data"), + ("win32", "chromium") => local.join("Chromium/User Data"), + ("win32", "firefox") => roaming.join("Mozilla/Firefox"), + (_, "chrome") => home_dir.join(".config/google-chrome"), + (_, "edge") => home_dir.join(".config/microsoft-edge"), + (_, "brave") => home_dir.join(".config/BraveSoftware/Brave-Browser"), + (_, "chromium") => home_dir.join(".config/chromium"), + (_, "firefox") => home_dir.join(".mozilla/firefox"), + _ => unreachable!(), + }; + Ok(root) +} + +pub(crate) fn find_cookie_database(browser: &str, profile_path: &Path) -> Option { + if browser == "firefox" { + let candidate = profile_path.join("cookies.sqlite"); + return candidate.is_file().then_some(candidate); + } + [ + profile_path.join("Network/Cookies"), + profile_path.join("Cookies"), + ] + .into_iter() + .find(|candidate| candidate.is_file()) +} + +#[derive(Debug, Default, Deserialize)] +struct LocalState { + #[serde(default)] + profile: LocalStateProfile, +} + +#[derive(Debug, Default, Deserialize)] +struct LocalStateProfile { + last_used: Option, + #[serde(default)] + info_cache: BTreeMap, +} + +#[derive(Debug, Default, Deserialize)] +struct LocalStateProfileInfo { + name: Option, +} + +fn list_chromium_profiles(browser: &str, root: &Path) -> Vec { + if !root.is_dir() { + return Vec::new(); + } + let state = fs::read_to_string(root.join("Local State")) + .ok() + .and_then(|contents| serde_json::from_str::(&contents).ok()) + .unwrap_or_default(); + let mut names = state + .profile + .info_cache + .keys() + .cloned() + .collect::>(); + if let Ok(entries) = fs::read_dir(root) { + for name in entries + .flatten() + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name == "Default" || name.starts_with("Profile ")) + { + names.insert(name); + } + } + let default_name = state.profile.last_used.as_deref().unwrap_or("Default"); + let only_default = names.len() == 1 && names.contains("Default"); + let mut profiles = names + .into_iter() + .filter_map(|name| { + let path = root.join(&name); + find_cookie_database(browser, &path)?; + let display_name = state + .profile + .info_cache + .get(&name) + .and_then(|info| info.name.clone()) + .unwrap_or_else(|| name.clone()); + Some(BrowserProfile { + browser: browser.to_string(), + is_default: name == default_name || (only_default && name == "Default"), + name, + display_name, + path, + }) + }) + .collect::>(); + profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone())); + profiles +} + +fn parse_ini(contents: &str) -> Vec> { + let mut sections = Vec::new(); + let mut current: Option> = None; + for raw_line in contents.lines() { + let line = raw_line.trim(); + if line.starts_with('[') && line.ends_with(']') { + if let Some(section) = current.take() { + sections.push(section); + } + let mut section = BTreeMap::new(); + section.insert("section".into(), line[1..line.len() - 1].into()); + current = Some(section); + } else if let (Some(section), Some((key, value))) = (current.as_mut(), line.split_once('=')) + { + section.insert(key.trim().into(), value.trim().into()); + } + } + if let Some(section) = current { + sections.push(section); + } + sections +} + +fn list_firefox_profiles(root: &Path) -> Vec { + let contents = match fs::read_to_string(root.join("profiles.ini")) { + Ok(contents) => contents, + Err(_) => return Vec::new(), + }; + let mut profiles = parse_ini(&contents) + .into_iter() + .filter(|section| { + section + .get("section") + .is_some_and(|name| name.starts_with("Profile")) + }) + .filter_map(|section| { + let configured = PathBuf::from(section.get("Path")?); + let path = if section.get("IsRelative").map(String::as_str) == Some("0") { + configured + } else { + root.join(configured) + }; + find_cookie_database("firefox", &path)?; + let name = section + .get("Name") + .cloned() + .or_else(|| path.file_name()?.to_str().map(String::from))?; + Some(BrowserProfile { + browser: "firefox".into(), + name: name.clone(), + display_name: name, + path, + is_default: section.get("Default").map(String::as_str) == Some("1"), + }) + }) + .collect::>(); + profiles.sort_by_key(|profile| (!profile.is_default, profile.name.clone())); + profiles +} + +/// Discover cookie-bearing profiles for Chrome, Edge, Brave, Chromium, and Firefox. +pub fn list_browser_profiles(options: BrowserProfileOptions) -> Result> { + let browsers = match options.browser.as_deref() { + Some(browser) => vec![normalize_cookie_browser(browser)?], + None => SUPPORTED_COOKIE_BROWSERS.to_vec(), + }; + let mut profiles = Vec::new(); + for browser in browsers { + let root = browser_profile_root(browser, &options.platform, &options.home_dir)?; + if browser == "firefox" { + profiles.extend(list_firefox_profiles(&root)); + } else { + profiles.extend(list_chromium_profiles(browser, &root)); + } + } + Ok(profiles) +} + +pub(crate) fn resolve_browser_profile( + browser: &str, + requested_profile: Option<&str>, + options: &BrowserProfileOptions, +) -> Result { + let profiles = list_browser_profiles(options.clone().browser(browser))?; + let selected = requested_profile + .and_then(|requested| { + profiles.iter().find(|profile| { + profile.name == requested + || profile.display_name == requested + || profile.path.file_name().and_then(|name| name.to_str()) == Some(requested) + }) + }) + .or_else(|| profiles.iter().find(|profile| profile.is_default)) + .or_else(|| profiles.first()); + selected.cloned().ok_or_else(|| { + let detail = requested_profile + .map(|profile| format!(" profile \"{profile}\"")) + .unwrap_or_else(|| " profile".into()); + anyhow!("Could not find a cookie database for {browser}{detail}") + }) +} diff --git a/rust/src/browser/mod.rs b/rust/src/browser/mod.rs index e7bfd85..0e78aff 100644 --- a/rust/src/browser/mod.rs +++ b/rust/src/browser/mod.rs @@ -4,6 +4,11 @@ //! - Launching browser instances //! - Navigation operations +mod browser_cookie_cache; +mod browser_cookie_credentials; +mod browser_cookie_crypto; +mod browser_cookies; +mod browser_profiles; pub mod chromiumoxide_adapter; pub mod connector; pub mod launcher; @@ -11,6 +16,11 @@ pub mod media; pub mod navigation_ops; pub mod node_bridge; +pub use browser_cookie_cache::clear_browser_cookie_memory_cache; +pub use browser_cookies::{read_browser_cookies, BrowserCookie, BrowserCookieReadOptions}; +pub use browser_profiles::{ + list_browser_profiles, BrowserProfile, BrowserProfileOptions, SUPPORTED_COOKIE_BROWSERS, +}; pub use chromiumoxide_adapter::ChromiumoxidePage; pub use connector::{connect_browser, ConnectOptions}; pub use launcher::{launch_browser, Browser, LaunchOptions, LaunchResult}; diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 3126a85..24cab1a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -50,8 +50,10 @@ pub mod utilities; // Re-export commonly used items at crate root pub use browser::{ - connect_browser, emulate_media, launch_browser, Browser, ChromiumoxidePage, ColorScheme, - ConnectOptions, EmulateMediaOptions, LaunchOptions, LaunchResult, NodeBridgePage, + clear_browser_cookie_memory_cache, connect_browser, emulate_media, launch_browser, + list_browser_profiles, read_browser_cookies, Browser, BrowserCookie, BrowserCookieReadOptions, + BrowserProfile, BrowserProfileOptions, ChromiumoxidePage, ColorScheme, ConnectOptions, + EmulateMediaOptions, LaunchOptions, LaunchResult, NodeBridgePage, SUPPORTED_COOKIE_BROWSERS, }; pub use core::{ DialogEvent, DialogManager, DialogType, EngineAdapter, EngineError, EngineType, Logger, @@ -66,10 +68,11 @@ pub use core::{ /// ``` pub mod prelude { pub use crate::browser::{ - connect_browser, emulate_media, goto, launch_browser, verify_navigation, - wait_for_navigation, wait_for_url_stabilization, Browser, ColorScheme, ConnectOptions, - EmulateMediaOptions, LaunchOptions, LaunchResult, NavigationOptions, NavigationResult, - WaitUntil, + clear_browser_cookie_memory_cache, connect_browser, emulate_media, goto, launch_browser, + list_browser_profiles, read_browser_cookies, verify_navigation, wait_for_navigation, + wait_for_url_stabilization, Browser, BrowserCookie, BrowserCookieReadOptions, + BrowserProfile, BrowserProfileOptions, ColorScheme, ConnectOptions, EmulateMediaOptions, + LaunchOptions, LaunchResult, NavigationOptions, NavigationResult, WaitUntil, }; pub use crate::core::{ is_navigation_error, is_timeout_error, DialogEvent, DialogManager, DialogType, diff --git a/rust/tests/browser_cookies.rs b/rust/tests/browser_cookies.rs index c6bc429..91607fc 100644 --- a/rust/tests/browser_cookies.rs +++ b/rust/tests/browser_cookies.rs @@ -4,8 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use aes::Aes128; use browser_commander::{ - list_browser_profiles, read_browser_cookies, BrowserCookieReadOptions, - BrowserProfileOptions, + list_browser_profiles, read_browser_cookies, BrowserCookieReadOptions, BrowserProfileOptions, }; use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit}; use pbkdf2::pbkdf2_hmac; From 923688dbb5a5c2a82549f8d4a2443725486cd8aa Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:36:29 +0000 Subject: [PATCH 08/19] fix(cache): coordinate credential reads across runtimes --- experiments/cookie-cache-parity.mjs | 74 +++++++++++++++++++ js/src/browser/browser-cookie-cache.js | 26 +++---- .../fixtures/browser-cookie-cache-worker.mjs | 21 ++++++ js/tests/unit/browser/browser-cookies.test.js | 38 ++++++++++ 4 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 experiments/cookie-cache-parity.mjs create mode 100644 js/tests/fixtures/browser-cookie-cache-worker.mjs diff --git a/experiments/cookie-cache-parity.mjs b/experiments/cookie-cache-parity.mjs new file mode 100644 index 0000000..d2fa961 --- /dev/null +++ b/experiments/cookie-cache-parity.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { getCachedCredential } from "../js/src/browser/browser-cookie-cache.js"; + +const execFile = promisify(execFileCallback); +const repositoryRoot = path.dirname( + path.dirname(fileURLToPath(import.meta.url)), +); +const temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), "browser-commander-cookie-cache-parity-"), +); +const expectedKey = Buffer.alloc(16, 7); + +try { + await getCachedCredential({ + cache: { + enabled: true, + dir: temporaryDirectory, + ttlSeconds: 60, + }, + identity: "chrome:linux:safe-storage", + refresh: false, + metadata: { + browser: "chrome", + platform: "linux", + source: "safe-storage", + }, + create: async () => expectedKey, + }); + + const pythonSource = ` +from pathlib import Path +import sys + +from browser_commander.browser.browser_cookie_cache import ( + NormalizedCookieCache, + get_cached_credential, +) + +cache = NormalizedCookieCache(True, Path(sys.argv[1]), 60.0) +key = get_cached_credential( + cache, + "chrome:linux:safe-storage", + lambda: (_ for _ in ()).throw(RuntimeError("credential provider was called")), + refresh=False, + metadata={}, +) +assert key == bytes([7]) * 16 +`; + const pythonPath = path.join(repositoryRoot, "python", "src"); + await execFile( + process.env.PYTHON ?? "python", + ["-c", pythonSource, temporaryDirectory], + { + env: { + ...process.env, + PYTHONPATH: [pythonPath, process.env.PYTHONPATH] + .filter(Boolean) + .join(path.delimiter), + }, + }, + ); + + assert.ok(true, "Python reused the JavaScript-derived credential cache"); + console.log("JavaScript/Python cookie credential cache parity passed"); +} finally { + await rm(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/js/src/browser/browser-cookie-cache.js b/js/src/browser/browser-cookie-cache.js index 592ae90..17029c6 100644 --- a/js/src/browser/browser-cookie-cache.js +++ b/js/src/browser/browser-cookie-cache.js @@ -38,7 +38,7 @@ export function normalizeCookieCache(cache, homeDir, ttlMinutes) { dir: path.resolve( cache?.dir ?? path.join(homeDir, '.browser-commander', 'cookie-cache') ), - ttlMilliseconds: selectedTtl * 60_000, + ttlSeconds: selectedTtl * 60, }; } @@ -47,11 +47,11 @@ async function ensureCacheDirectory(cacheDir) { await chmod(cacheDir, 0o700).catch(() => {}); } -async function readFreshJson(filePath, ttlMilliseconds, now) { +async function readFreshJson(filePath, ttlSeconds, now) { try { const value = JSON.parse(await readFile(filePath, 'utf8')); - const age = now() - value.savedAt; - return age >= 0 && age <= ttlMilliseconds ? value : null; + const age = now() / 1000 - value.savedAt; + return age >= 0 && age <= ttlSeconds ? value : null; } catch { return null; } @@ -90,7 +90,7 @@ export async function readCookieResultCache({ } const cached = await readFreshJson( cachePath(cache.dir, 'cookies', identity), - cache.ttlMilliseconds, + cache.ttlSeconds, now ); return cached?.kind === 'cookies' && Array.isArray(cached.cookies) @@ -111,7 +111,7 @@ export async function writeCookieResultCache({ await writeOwnerOnlyJson(cachePath(cache.dir, 'cookies', identity), { version: 1, kind: 'cookies', - savedAt: now(), + savedAt: now() / 1000, cookies, }); } @@ -142,7 +142,7 @@ async function acquireCredentialLock(lockPath, cachedPath, options) { } const cached = await readFreshJson( cachedPath, - options.ttlMilliseconds, + options.ttlSeconds, options.now ); if ( @@ -172,7 +172,7 @@ async function loadOrCreateCredential({ await ensureCacheDirectory(cache.dir); const cachedPath = cachePath(cache.dir, 'credential', identity); const lockPath = `${cachedPath}.lock`; - const initial = await readFreshJson(cachedPath, cache.ttlMilliseconds, now); + const initial = await readFreshJson(cachedPath, cache.ttlSeconds, now); if (!refresh && initial?.kind === 'derived-key') { return Buffer.from(initial.key, 'base64'); } @@ -181,18 +181,14 @@ async function loadOrCreateCredential({ initialSavedAt: initial?.savedAt, now, refresh, - ttlMilliseconds: cache.ttlMilliseconds, + ttlSeconds: cache.ttlSeconds, }); if (acquired.cached) { return Buffer.from(acquired.cached.key, 'base64'); } try { - const afterLock = await readFreshJson( - cachedPath, - cache.ttlMilliseconds, - now - ); + const afterLock = await readFreshJson(cachedPath, cache.ttlSeconds, now); if ( afterLock?.kind === 'derived-key' && (!refresh || afterLock.savedAt !== initial?.savedAt) @@ -203,7 +199,7 @@ async function loadOrCreateCredential({ await writeOwnerOnlyJson(cachedPath, { version: 1, kind: 'derived-key', - savedAt: now(), + savedAt: now() / 1000, key: key.toString('base64'), ...metadata, }); diff --git a/js/tests/fixtures/browser-cookie-cache-worker.mjs b/js/tests/fixtures/browser-cookie-cache-worker.mjs new file mode 100644 index 0000000..7c40be2 --- /dev/null +++ b/js/tests/fixtures/browser-cookie-cache-worker.mjs @@ -0,0 +1,21 @@ +import { appendFile } from 'node:fs/promises'; + +import { getCachedCredential } from '../../src/browser/browser-cookie-cache.js'; + +const [, , cacheDir, credentialReads] = process.argv; + +await getCachedCredential({ + cache: { enabled: true, dir: cacheDir, ttlSeconds: 60 }, + identity: 'chrome:linux:safe-storage', + refresh: false, + metadata: { + browser: 'chrome', + platform: 'linux', + source: 'safe-storage', + }, + create: async () => { + await appendFile(credentialReads, `${process.pid}\n`, 'utf8'); + await new Promise((resolve) => setTimeout(resolve, 250)); + return Buffer.alloc(16, 7); + }, +}); diff --git a/js/tests/unit/browser/browser-cookies.test.js b/js/tests/unit/browser/browser-cookies.test.js index b707e29..12a197e 100644 --- a/js/tests/unit/browser/browser-cookies.test.js +++ b/js/tests/unit/browser/browser-cookies.test.js @@ -1,4 +1,5 @@ import assert from 'node:assert'; +import { execFile as execFileCallback } from 'node:child_process'; import { createCipheriv, createHash, @@ -17,6 +18,8 @@ import { } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; import { afterEach, describe, it } from 'node:test'; import Database from 'better-sqlite3'; @@ -34,6 +37,10 @@ import { } from '../../../src/index.js'; const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600; +const execFile = promisify(execFileCallback); +const credentialWorker = fileURLToPath( + new URL('../../fixtures/browser-cookie-cache-worker.mjs', import.meta.url) +); function chromeExpires(unixSeconds) { return (unixSeconds + CHROME_EPOCH_OFFSET_SECONDS) * 1_000_000; @@ -393,6 +400,37 @@ describe('installed browser cookie import', () => { assert.equal(JSON.stringify(cached).includes(password), false); }); + it('coordinates one credential read across separate processes', async () => { + temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'browser-commander-cookie-process-cache-') + ); + const cacheDir = path.join(temporaryDirectory, 'cache'); + const credentialReads = path.join(temporaryDirectory, 'credential-reads'); + + await Promise.all( + Array.from({ length: 3 }, () => + execFile(process.execPath, [ + credentialWorker, + cacheDir, + credentialReads, + ]) + ) + ); + + const readers = (await readFile(credentialReads, 'utf8')) + .trim() + .split(/\r?\n/u); + assert.equal(readers.length, 1); + const credentialFile = (await readdir(cacheDir)).find((name) => + name.startsWith('credential-') + ); + const cached = JSON.parse( + await readFile(path.join(cacheDir, credentialFile), 'utf8') + ); + assert.ok(cached.savedAt < Date.now() / 1000 + 1); + assert.ok(cached.savedAt > Date.now() / 1000 - 60); + }); + it('decrypts Windows AES-GCM data and rejects app-bound v20 data', () => { const key = randomBytes(32); const host = '.example.net'; From b0f14631d4543e9042ff829678cadc1a488f3c3a Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:36:55 +0000 Subject: [PATCH 09/19] Revert "Initial commit with task details" This reverts commit 1e1239e8ad5e8f7e58edeacb132b3951ae12aff5. --- .gitkeep | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitkeep b/.gitkeep index 9b4a961..3811e7a 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,4 +1,3 @@ # .gitkeep file auto-generated at 2026-05-10T11:15:41.449Z for PR creation at branch issue-51-f2a446afa3b9 for issue https://github.com/link-foundation/browser-commander/issues/51 # Updated: 2026-06-28T20:40:17.765Z -# Updated: 2026-08-01T16:08:23.376Z -# Updated: 2026-08-02T05:46:19.964Z \ No newline at end of file +# Updated: 2026-08-01T16:08:23.376Z \ No newline at end of file From e57e0e99d43768d442c1256fd9ad6a3b0ba5fb95 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:45:13 +0000 Subject: [PATCH 10/19] test(cache): reproduce in-process credential expiry --- js/tests/unit/browser/browser-cookies.test.js | 22 ++++++++++ .../unit/browser/test_browser_cookies.py | 41 +++++++++++++++++++ rust/src/browser/browser_cookie_cache.rs | 31 ++++++++++++++ 3 files changed, 94 insertions(+) diff --git a/js/tests/unit/browser/browser-cookies.test.js b/js/tests/unit/browser/browser-cookies.test.js index 12a197e..5ef48cb 100644 --- a/js/tests/unit/browser/browser-cookies.test.js +++ b/js/tests/unit/browser/browser-cookies.test.js @@ -31,6 +31,7 @@ import { readBrowserCookies, readBrowserCookiesWithDependencies, } from '../../../src/browser/browser-cookies.js'; +import { getCachedCredential } from '../../../src/browser/browser-cookie-cache.js'; import { listBrowserProfiles as publicListBrowserProfiles, readBrowserCookies as publicReadBrowserCookies, @@ -431,6 +432,27 @@ describe('installed browser cookie import', () => { assert.ok(cached.savedAt > Date.now() / 1000 - 60); }); + it('expires the in-process credential cache after its TTL', async () => { + let currentTime = 1_700_000_000_000; + let credentialReads = 0; + const options = { + cache: { enabled: false, ttlSeconds: 60 }, + identity: 'chrome:linux:ttl-test', + refresh: false, + metadata: {}, + now: () => currentTime, + create: async () => { + credentialReads += 1; + return Buffer.alloc(16, credentialReads); + }, + }; + + assert.equal((await getCachedCredential(options))[0], 1); + currentTime += 61_000; + assert.equal((await getCachedCredential(options))[0], 2); + assert.equal(credentialReads, 2); + }); + it('decrypts Windows AES-GCM data and rejects app-bound v20 data', () => { const key = randomBytes(32); const host = '.example.net'; diff --git a/python/tests/unit/browser/test_browser_cookies.py b/python/tests/unit/browser/test_browser_cookies.py index a94b193..6bbd90a 100644 --- a/python/tests/unit/browser/test_browser_cookies.py +++ b/python/tests/unit/browser/test_browser_cookies.py @@ -34,6 +34,10 @@ read_browser_cookies, read_browser_cookies_with_dependencies, ) +from browser_commander.browser.browser_cookie_cache import ( + NormalizedCookieCache, + get_cached_credential, +) CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600 @@ -334,3 +338,40 @@ def test_windows_gcm_and_app_bound_boundary() -> None: assert "outside the browser" in str(error) else: raise AssertionError("v20 data must fail with an explicit compatibility error") + + +def test_in_process_credential_cache_expires_after_ttl(tmp_path: Path) -> None: + current_time = 1_700_000_000.0 + credential_reads = 0 + + def now() -> float: + return current_time + + def create() -> bytes: + nonlocal credential_reads + credential_reads += 1 + return bytes([credential_reads]) * 16 + + cache = NormalizedCookieCache( + enabled=False, + directory=tmp_path, + ttl_seconds=60, + ) + assert get_cached_credential( + cache, + "chrome:linux:ttl-test", + create, + refresh=False, + metadata={}, + now=now, + )[0] == 1 + current_time += 61 + assert get_cached_credential( + cache, + "chrome:linux:ttl-test", + create, + refresh=False, + metadata={}, + now=now, + )[0] == 2 + assert credential_reads == 2 diff --git a/rust/src/browser/browser_cookie_cache.rs b/rust/src/browser/browser_cookie_cache.rs index 6f3352e..ab2de75 100644 --- a/rust/src/browser/browser_cookie_cache.rs +++ b/rust/src/browser/browser_cookie_cache.rs @@ -393,4 +393,35 @@ mod tests { fs::remove_dir_all(directory)?; Ok(()) } + + #[test] + fn in_process_credential_cache_expires_after_ttl() -> Result<()> { + let directory = std::env::temp_dir().join(format!( + "browser-commander-memory-ttl-test-{}", + std::process::id() + )); + let cache = NormalizedCookieCache { + enabled: false, + directory, + ttl_seconds: 0.0, + }; + let calls = std::sync::atomic::AtomicUsize::new(0); + let create = || { + let call = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + Ok(vec![call as u8; 16]) + }; + + clear_browser_cookie_memory_cache(); + assert_eq!( + get_cached_credential(&cache, "chrome:linux:ttl-test", false, Map::new(), create)?[0], + 1 + ); + thread::sleep(Duration::from_millis(5)); + assert_eq!( + get_cached_credential(&cache, "chrome:linux:ttl-test", false, Map::new(), create)?[0], + 2 + ); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + Ok(()) + } } From b66184d4f92e05ed03ba0e535f298d7ad5274019 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:47:14 +0000 Subject: [PATCH 11/19] fix(cache): expire in-process credentials by ttl --- js/src/browser/browser-cookie-cache.js | 40 ++++++++---- .../browser/browser_cookie_cache.py | 41 ++++++++---- .../unit/browser/test_browser_cookies.py | 38 +++++++----- rust/src/browser/browser_cookie_cache.rs | 62 ++++++++++++------- 4 files changed, 118 insertions(+), 63 deletions(-) diff --git a/js/src/browser/browser-cookie-cache.js b/js/src/browser/browser-cookie-cache.js index 17029c6..e01547a 100644 --- a/js/src/browser/browser-cookie-cache.js +++ b/js/src/browser/browser-cookie-cache.js @@ -24,15 +24,15 @@ export function clearBrowserCookieMemoryCache() { } export function normalizeCookieCache(cache, homeDir, ttlMinutes) { - if (cache === false) { - return { enabled: false }; - } const selectedTtl = ttlMinutes ?? cache?.ttlMinutes ?? DEFAULT_TTL_MINUTES; if (!Number.isFinite(selectedTtl) || selectedTtl < 0) { throw new RangeError( 'cookie cache ttlMinutes must be a non-negative number' ); } + if (cache === false) { + return { enabled: false, ttlSeconds: selectedTtl * 60 }; + } return { enabled: true, dir: path.resolve( @@ -167,14 +167,17 @@ async function loadOrCreateCredential({ metadata, }) { if (!cache.enabled) { - return create(); + return { key: Buffer.from(await create()), savedAt: now() / 1000 }; } await ensureCacheDirectory(cache.dir); const cachedPath = cachePath(cache.dir, 'credential', identity); const lockPath = `${cachedPath}.lock`; const initial = await readFreshJson(cachedPath, cache.ttlSeconds, now); if (!refresh && initial?.kind === 'derived-key') { - return Buffer.from(initial.key, 'base64'); + return { + key: Buffer.from(initial.key, 'base64'), + savedAt: initial.savedAt, + }; } const acquired = await acquireCredentialLock(lockPath, cachedPath, { @@ -184,7 +187,10 @@ async function loadOrCreateCredential({ ttlSeconds: cache.ttlSeconds, }); if (acquired.cached) { - return Buffer.from(acquired.cached.key, 'base64'); + return { + key: Buffer.from(acquired.cached.key, 'base64'), + savedAt: acquired.cached.savedAt, + }; } try { @@ -193,17 +199,21 @@ async function loadOrCreateCredential({ afterLock?.kind === 'derived-key' && (!refresh || afterLock.savedAt !== initial?.savedAt) ) { - return Buffer.from(afterLock.key, 'base64'); + return { + key: Buffer.from(afterLock.key, 'base64'), + savedAt: afterLock.savedAt, + }; } const key = Buffer.from(await create()); + const savedAt = now() / 1000; await writeOwnerOnlyJson(cachedPath, { version: 1, kind: 'derived-key', - savedAt: now() / 1000, + savedAt, key: key.toString('base64'), ...metadata, }); - return key; + return { key, savedAt }; } finally { await acquired.close(); await rm(lockPath, { force: true }); @@ -212,13 +222,19 @@ async function loadOrCreateCredential({ export async function getCachedCredential(options) { const memoryIdentity = `${options.cache.dir ?? 'disabled'}:${options.identity}`; + const now = options.now ?? Date.now; if (!options.refresh && credentialPromises.has(memoryIdentity)) { - return credentialPromises.get(memoryIdentity); + const cached = await credentialPromises.get(memoryIdentity); + const age = now() / 1000 - cached.savedAt; + if (age >= 0 && age <= options.cache.ttlSeconds) { + return cached.key; + } + credentialPromises.delete(memoryIdentity); } - const promise = loadOrCreateCredential({ now: Date.now, ...options }); + const promise = loadOrCreateCredential({ ...options, now }); credentialPromises.set(memoryIdentity, promise); try { - return await promise; + return (await promise).key; } catch (error) { if (credentialPromises.get(memoryIdentity) === promise) { credentialPromises.delete(memoryIdentity); diff --git a/python/src/browser_commander/browser/browser_cookie_cache.py b/python/src/browser_commander/browser/browser_cookie_cache.py index 5d5bdc7..ce0863a 100644 --- a/python/src/browser_commander/browser/browser_cookie_cache.py +++ b/python/src/browser_commander/browser/browser_cookie_cache.py @@ -17,7 +17,7 @@ DEFAULT_TTL_MINUTES = 60.0 LOCK_STALE_SECONDS = 30.0 LOCK_WAIT_SECONDS = 30.0 -_credential_memory_cache: dict[str, bytes] = {} +_credential_memory_cache: dict[str, tuple[bytes, float]] = {} _memory_lock = threading.Lock() @@ -40,8 +40,6 @@ def normalize_cookie_cache( cache: object, home_dir: Path, ttl_minutes: float | None ) -> NormalizedCookieCache: """Normalize the public cache object without importing its dataclass.""" - if cache is False: - return NormalizedCookieCache(enabled=False) selected_ttl = ( ttl_minutes if ttl_minutes is not None @@ -49,6 +47,11 @@ def normalize_cookie_cache( ) if not isinstance(selected_ttl, (int, float)) or selected_ttl < 0: raise ValueError("cookie cache ttl_minutes must be a non-negative number") + if cache is False: + return NormalizedCookieCache( + enabled=False, + ttl_seconds=float(selected_ttl) * 60, + ) configured_dir = getattr(cache, "dir", None) directory = ( Path(configured_dir).expanduser().resolve() @@ -188,16 +191,16 @@ def _load_or_create_credential( refresh: bool, metadata: dict, now: Callable[[], float], -) -> bytes: +) -> tuple[bytes, float]: if not cache.enabled: - return bytes(create()) + return bytes(create()), now() assert cache.directory is not None _ensure_cache_directory(cache.directory) cached_path = _cache_path(cache, "credential", identity) lock_path = cached_path.with_suffix(".json.lock") initial = _read_fresh_json(cached_path, cache.ttl_seconds, now) if not refresh and initial and initial.get("kind") == "derived-key": - return base64.b64decode(initial["key"]) + return base64.b64decode(initial["key"]), float(initial["savedAt"]) descriptor, waited_cache = _acquire_lock_or_cached( lock_path, @@ -208,7 +211,10 @@ def _load_or_create_credential( now=now, ) if waited_cache: - return base64.b64decode(waited_cache["key"]) + return ( + base64.b64decode(waited_cache["key"]), + float(waited_cache["savedAt"]), + ) assert descriptor is not None os.close(descriptor) @@ -223,19 +229,23 @@ def _load_or_create_credential( != (initial.get("savedAt") if initial else None) ) ): - return base64.b64decode(after_lock["key"]) + return ( + base64.b64decode(after_lock["key"]), + float(after_lock["savedAt"]), + ) key = bytes(create()) + saved_at = now() _write_owner_only_json( cached_path, { "version": 1, "kind": "derived-key", - "savedAt": now(), + "savedAt": saved_at, "key": base64.b64encode(key).decode(), **metadata, }, ) - return key + return key, saved_at finally: with suppress(FileNotFoundError): lock_path.unlink() @@ -256,8 +266,13 @@ def get_cached_credential( with _memory_lock: cached = _credential_memory_cache.get(memory_identity) if cached is not None: - return cached - key = _load_or_create_credential( + key, saved_at = cached + age = now() - saved_at + if 0 <= age <= cache.ttl_seconds: + return key + with _memory_lock: + _credential_memory_cache.pop(memory_identity, None) + key, saved_at = _load_or_create_credential( cache, identity, create, @@ -266,5 +281,5 @@ def get_cached_credential( now=now, ) with _memory_lock: - _credential_memory_cache[memory_identity] = key + _credential_memory_cache[memory_identity] = (key, saved_at) return key diff --git a/python/tests/unit/browser/test_browser_cookies.py b/python/tests/unit/browser/test_browser_cookies.py index 6bbd90a..ff49724 100644 --- a/python/tests/unit/browser/test_browser_cookies.py +++ b/python/tests/unit/browser/test_browser_cookies.py @@ -357,21 +357,27 @@ def create() -> bytes: directory=tmp_path, ttl_seconds=60, ) - assert get_cached_credential( - cache, - "chrome:linux:ttl-test", - create, - refresh=False, - metadata={}, - now=now, - )[0] == 1 + assert ( + get_cached_credential( + cache, + "chrome:linux:ttl-test", + create, + refresh=False, + metadata={}, + now=now, + )[0] + == 1 + ) current_time += 61 - assert get_cached_credential( - cache, - "chrome:linux:ttl-test", - create, - refresh=False, - metadata={}, - now=now, - )[0] == 2 + assert ( + get_cached_credential( + cache, + "chrome:linux:ttl-test", + create, + refresh=False, + metadata={}, + now=now, + )[0] + == 2 + ) assert credential_reads == 2 diff --git a/rust/src/browser/browser_cookie_cache.rs b/rust/src/browser/browser_cookie_cache.rs index ab2de75..da46c44 100644 --- a/rust/src/browser/browser_cookie_cache.rs +++ b/rust/src/browser/browser_cookie_cache.rs @@ -18,7 +18,14 @@ const DEFAULT_TTL_MINUTES: f64 = 60.0; const LOCK_STALE_SECONDS: f64 = 30.0; const LOCK_WAIT_SECONDS: f64 = 30.0; -static CREDENTIAL_MEMORY_CACHE: OnceLock>>> = OnceLock::new(); +static CREDENTIAL_MEMORY_CACHE: OnceLock>> = + OnceLock::new(); + +#[derive(Debug, Clone)] +struct CachedCredential { + key: Vec, + saved_at: f64, +} #[derive(Debug, Clone)] pub(crate) struct NormalizedCookieCache { @@ -48,7 +55,7 @@ pub(crate) fn normalize_cookie_cache( }) } -fn memory_cache() -> &'static Mutex>> { +fn memory_cache() -> &'static Mutex> { CREDENTIAL_MEMORY_CACHE.get_or_init(|| Mutex::new(HashMap::new())) } @@ -242,15 +249,20 @@ fn acquire_lock_or_cached( )) } -fn decode_cached_key(value: &Value) -> Result> { - BASE64 +fn decode_cached_credential(value: &Value) -> Result { + let key = BASE64 .decode( value .get("key") .and_then(Value::as_str) .ok_or_else(|| anyhow!("derived-key cache has no key"))?, ) - .context("derived-key cache contains invalid base64") + .context("derived-key cache contains invalid base64")?; + let saved_at = value + .get("savedAt") + .and_then(Value::as_f64) + .ok_or_else(|| anyhow!("derived-key cache has no savedAt timestamp"))?; + Ok(CachedCredential { key, saved_at }) } pub(crate) fn get_cached_credential( @@ -265,21 +277,23 @@ where { let memory_identity = format!("{}:{identity}", cache.directory.display()); if !refresh { - if let Some(key) = memory_cache() + let mut memory = memory_cache() .lock() - .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))? - .get(&memory_identity) - .cloned() - { - return Ok(key); + .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))?; + if let Some(cached) = memory.get(&memory_identity) { + let age = now_seconds() - cached.saved_at; + if age >= 0.0 && age <= cache.ttl_seconds { + return Ok(cached.key.clone()); + } } + memory.remove(&memory_identity); } - let key = load_or_create_credential(cache, identity, refresh, metadata, create)?; + let credential = load_or_create_credential(cache, identity, refresh, metadata, create)?; memory_cache() .lock() .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))? - .insert(memory_identity, key.clone()); - Ok(key) + .insert(memory_identity, credential.clone()); + Ok(credential.key) } fn load_or_create_credential( @@ -288,12 +302,15 @@ fn load_or_create_credential( refresh: bool, metadata: Map, create: F, -) -> Result> +) -> Result where F: FnOnce() -> Result>, { if !cache.enabled { - return create(); + return Ok(CachedCredential { + key: create()?, + saved_at: now_seconds(), + }); } ensure_cache_directory(&cache.directory)?; let cached_path = cache_path(cache, "credential", identity); @@ -304,7 +321,7 @@ where .as_ref() .filter(|value| value.get("kind").and_then(Value::as_str) == Some("derived-key")) { - return decode_cached_key(value); + return decode_cached_credential(value); } } let initial_saved_at = initial @@ -318,26 +335,27 @@ where refresh, initial_saved_at, )? { - LockResult::Cached(value) => decode_cached_key(&value), + LockResult::Cached(value) => decode_cached_credential(&value), LockResult::Acquired(lock) => { drop(lock); - let result = (|| -> Result> { + let result = (|| -> Result { if let Some(value) = read_fresh_json(&cached_path, cache.ttl_seconds) { let saved_at = value.get("savedAt").and_then(Value::as_f64); if value.get("kind").and_then(Value::as_str) == Some("derived-key") && (!refresh || saved_at != initial_saved_at) { - return decode_cached_key(&value); + return decode_cached_credential(&value); } } let key = create()?; + let saved_at = now_seconds(); let mut value = metadata; value.insert("version".into(), json!(1)); value.insert("kind".into(), json!("derived-key")); - value.insert("savedAt".into(), json!(now_seconds())); + value.insert("savedAt".into(), json!(saved_at)); value.insert("key".into(), json!(BASE64.encode(&key))); write_owner_only_json(&cached_path, &Value::Object(value))?; - Ok(key) + Ok(CachedCredential { key, saved_at }) })(); let _ = fs::remove_file(lock_path); result From 6382ecf354245d885a84d2ad41cbe1e96abf4e4a Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:50:39 +0000 Subject: [PATCH 12/19] style(python): sort cookie cache test imports --- python/tests/unit/browser/test_browser_cookies.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/python/tests/unit/browser/test_browser_cookies.py b/python/tests/unit/browser/test_browser_cookies.py index ff49724..4bc98e3 100644 --- a/python/tests/unit/browser/test_browser_cookies.py +++ b/python/tests/unit/browser/test_browser_cookies.py @@ -26,6 +26,10 @@ from browser_commander.browser import ( list_browser_profiles as browser_list_browser_profiles, ) +from browser_commander.browser.browser_cookie_cache import ( + NormalizedCookieCache, + get_cached_credential, +) from browser_commander.browser.browser_cookies import ( BrowserProfile, clear_browser_cookie_memory_cache, @@ -34,10 +38,6 @@ read_browser_cookies, read_browser_cookies_with_dependencies, ) -from browser_commander.browser.browser_cookie_cache import ( - NormalizedCookieCache, - get_cached_credential, -) CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600 From 54ee7fc4b01d422571441cbce51b57b334ffd06b Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:57:16 +0000 Subject: [PATCH 13/19] test(cookies): cover credential refresh edge cases --- js/tests/unit/browser/browser-cookies.test.js | 95 +++++++++++++++++++ .../unit/browser/test_browser_cookies.py | 92 ++++++++++++++++++ .../src/browser/browser_cookie_credentials.rs | 12 +++ rust/src/browser/browser_cookie_crypto.rs | 15 +++ rust/src/browser/browser_cookies.rs | 27 ++++++ 5 files changed, 241 insertions(+) diff --git a/js/tests/unit/browser/browser-cookies.test.js b/js/tests/unit/browser/browser-cookies.test.js index 5ef48cb..3b8e0f3 100644 --- a/js/tests/unit/browser/browser-cookies.test.js +++ b/js/tests/unit/browser/browser-cookies.test.js @@ -32,6 +32,8 @@ import { readBrowserCookiesWithDependencies, } from '../../../src/browser/browser-cookies.js'; import { getCachedCredential } from '../../../src/browser/browser-cookie-cache.js'; +import { readSafeStoragePassword } from '../../../src/browser/browser-cookie-credentials.js'; +import { decodeChromiumCookiePlaintext } from '../../../src/browser/browser-cookie-crypto.js'; import { listBrowserProfiles as publicListBrowserProfiles, readBrowserCookies as publicReadBrowserCookies, @@ -484,4 +486,97 @@ describe('installed browser cookie import', () => { /app-bound.*cannot be decrypted outside the browser/i ); }); + + it('removes and validates the version-24 host hash after legacy DPAPI', () => { + const host = '.legacy.example'; + const plaintext = Buffer.concat([ + createHash('sha256').update(host).digest(), + Buffer.from('legacy-session'), + ]); + + assert.equal( + decodeChromiumCookiePlaintext({ + plaintext, + host, + databaseVersion: 24, + }), + 'legacy-session' + ); + assert.throws( + () => + decodeChromiumCookiePlaintext({ + plaintext, + host: '.wrong.example', + databaseVersion: 24, + }), + /domain hash does not match/i + ); + }); + + it('uses Chromium product casing for the KWallet folder', async () => { + const calls = []; + const password = await readSafeStoragePassword({ + browser: 'chrome', + platform: 'linux', + environment: {}, + runCredentialCommand: async (command, args) => { + calls.push([command, args]); + if (command === 'secret-tool') { + throw new Error('libsecret unavailable'); + } + return 'kwallet-password'; + }, + }); + + assert.equal(password, 'kwallet-password'); + assert.deepEqual(calls[1], [ + 'kwallet-query', + [ + '-r', + 'Chrome Safe Storage', + '-f', + 'Chrome Keys', + 'kdewallet', + ], + ]); + }); + + it('refreshes the OS credential only once for one multi-cookie import', async () => { + temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'browser-commander-cookie-refresh-') + ); + const password = 'one refresh password'; + const host = '.refresh.example'; + await createChromiumProfile({ + homeDir: temporaryDirectory, + rows: ['first', 'second'].map((name) => ({ + host, + name, + encryptedValue: encryptCbcCookie({ host, value: name, password }), + })), + }); + let credentialReads = 0; + + const cookies = await readBrowserCookiesWithDependencies( + { browser: 'chrome', cache: false, refresh: true }, + { + platform: 'linux', + homeDir: temporaryDirectory, + environment: {}, + readSafeStoragePassword: async () => { + credentialReads += 1; + return password; + }, + } + ); + + assert.deepEqual( + cookies.map(({ name, value }) => ({ name, value })), + [ + { name: 'first', value: 'first' }, + { name: 'second', value: 'second' }, + ] + ); + assert.equal(credentialReads, 1); + }); }); diff --git a/python/tests/unit/browser/test_browser_cookies.py b/python/tests/unit/browser/test_browser_cookies.py index 4bc98e3..c5d286c 100644 --- a/python/tests/unit/browser/test_browser_cookies.py +++ b/python/tests/unit/browser/test_browser_cookies.py @@ -26,6 +26,7 @@ from browser_commander.browser import ( list_browser_profiles as browser_list_browser_profiles, ) +from browser_commander.browser import browser_cookie_credentials as cookie_credentials from browser_commander.browser.browser_cookie_cache import ( NormalizedCookieCache, get_cached_credential, @@ -38,6 +39,9 @@ read_browser_cookies, read_browser_cookies_with_dependencies, ) +from browser_commander.browser.browser_cookie_crypto import ( + decode_chromium_cookie_plaintext, +) CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600 @@ -381,3 +385,91 @@ def create() -> bytes: == 2 ) assert credential_reads == 2 + + +def test_legacy_dpapi_plaintext_validates_version_24_host_hash() -> None: + host = ".legacy.example" + plaintext = hashlib.sha256(host.encode()).digest() + b"legacy-session" + + assert ( + decode_chromium_cookie_plaintext( + plaintext, + host=host, + database_version=24, + ) + == "legacy-session" + ) + try: + decode_chromium_cookie_plaintext( + plaintext, + host=".wrong.example", + database_version=24, + ) + except ValueError as error: + assert "domain hash does not match" in str(error) + else: + raise AssertionError("a mismatched legacy DPAPI host hash must fail") + + +def test_linux_kwallet_uses_chromium_product_casing(monkeypatch) -> None: + calls: list[list[str]] = [] + + def run(command: list[str], _environment: dict[str, str]) -> str: + calls.append(command) + if command[0] == "secret-tool": + raise FileNotFoundError + return "kwallet-password" + + monkeypatch.setattr(cookie_credentials, "_run_credential_command", run) + assert ( + cookie_credentials.read_safe_storage_password( + browser="chrome", + platform="linux", + environment={}, + ) + == "kwallet-password" + ) + assert calls[1] == [ + "kwallet-query", + "-r", + "Chrome Safe Storage", + "-f", + "Chrome Keys", + "kdewallet", + ] + + +def test_refresh_reads_credential_once_for_multi_cookie_import(tmp_path: Path) -> None: + password = "one refresh password" + host = ".refresh.example" + _create_chromium_profile( + tmp_path, + [ + { + "host": host, + "name": name, + "encrypted_value": _encrypt_cbc_cookie(host, name, password), + } + for name in ("first", "second") + ], + ) + credential_reads = 0 + + def read_password(**_kwargs: object) -> str: + nonlocal credential_reads + credential_reads += 1 + return password + + cookies = read_browser_cookies_with_dependencies( + BrowserCookieReadOptions(browser="chrome", cache=False, refresh=True), + platform="linux", + home_dir=tmp_path, + environment={}, + read_safe_storage_password=read_password, + ) + + assert [(cookie["name"], cookie["value"]) for cookie in cookies] == [ + ("first", "first"), + ("second", "second"), + ] + assert credential_reads == 1 diff --git a/rust/src/browser/browser_cookie_credentials.rs b/rust/src/browser/browser_cookie_credentials.rs index 5bab4df..1f3d1da 100644 --- a/rust/src/browser/browser_cookie_credentials.rs +++ b/rust/src/browser/browser_cookie_credentials.rs @@ -137,3 +137,15 @@ pub(crate) fn read_windows_encryption_key(local_state_path: &Path) -> Result Result Result<()> { + let mut keys = OperationKeyCache::default(); + let mut calls = 0; + assert_eq!( + keys.get_or_try_create("safe-storage", || { + calls += 1; + Ok(vec![7_u8; 16]) + })?, + vec![7_u8; 16] + ); + assert_eq!( + keys.get_or_try_create("safe-storage", || { + calls += 1; + Ok(vec![8_u8; 16]) + })?, + vec![7_u8; 16] + ); + assert_eq!(calls, 1); + Ok(()) + } +} From 6959e3c244fb154e0093975c6f9726966582d3fc Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 06:59:47 +0000 Subject: [PATCH 14/19] fix(cookies): reuse refreshed platform credentials --- js/src/browser/browser-cookie-credentials.js | 38 +++--- js/src/browser/browser-cookie-crypto.js | 17 ++- js/src/browser/browser-cookies.js | 99 +++++++++------- js/tests/unit/browser/browser-cookies.test.js | 8 +- .../browser/browser_cookie_credentials.py | 21 +++- .../browser/browser_cookie_crypto.py | 16 ++- .../browser/browser_cookies.py | 78 +++++++++---- .../unit/browser/test_browser_cookies.py | 8 +- .../src/browser/browser_cookie_credentials.rs | 13 +-- rust/src/browser/browser_cookie_crypto.rs | 12 +- rust/src/browser/browser_cookies.rs | 110 ++++++++++++------ 11 files changed, 282 insertions(+), 138 deletions(-) diff --git a/js/src/browser/browser-cookie-credentials.js b/js/src/browser/browser-cookie-credentials.js index 323053a..e999f62 100644 --- a/js/src/browser/browser-cookie-credentials.js +++ b/js/src/browser/browser-cookie-credentials.js @@ -5,11 +5,24 @@ import { promisify } from 'node:util'; const execFile = promisify(execFileCallback); const SAFE_STORAGE = { - brave: { application: 'brave', service: 'Brave Safe Storage' }, - chrome: { application: 'chrome', service: 'Chrome Safe Storage' }, - chromium: { application: 'chromium', service: 'Chromium Safe Storage' }, + brave: { + application: 'brave', + folder: 'Brave Keys', + service: 'Brave Safe Storage', + }, + chrome: { + application: 'chrome', + folder: 'Chrome Keys', + service: 'Chrome Safe Storage', + }, + chromium: { + application: 'chromium', + folder: 'Chromium Keys', + service: 'Chromium Safe Storage', + }, edge: { application: 'microsoft-edge', + folder: 'Microsoft Edge Keys', service: 'Microsoft Edge Safe Storage', }, }; @@ -24,10 +37,10 @@ async function runCredentialCommand(command, args, environment) { return stdout.trim(); } -async function readLinuxSafeStoragePassword(browser, environment) { +async function readLinuxSafeStoragePassword(browser, environment, runCommand) { const identity = SAFE_STORAGE[browser]; try { - const password = await runCredentialCommand( + const password = await runCommand( 'secret-tool', ['lookup', 'application', identity.application], environment @@ -39,15 +52,9 @@ async function readLinuxSafeStoragePassword(browser, environment) { // Try KWallet before reporting that v11 key storage is unavailable. } try { - const password = await runCredentialCommand( + const password = await runCommand( 'kwallet-query', - [ - '-r', - identity.service, - '-f', - `${identity.application} Keys`, - 'kdewallet', - ], + ['-r', identity.service, '-f', identity.folder, 'kdewallet'], environment ); if (password) { @@ -66,13 +73,14 @@ export async function readSafeStoragePassword({ browser, platform = process.platform, environment = process.env, + runCredentialCommand: runCommand = runCredentialCommand, }) { const identity = SAFE_STORAGE[browser]; if (!identity) { throw new Error(`No Safe Storage identity is known for ${browser}`); } if (platform === 'darwin') { - const password = await runCredentialCommand( + const password = await runCommand( 'security', ['find-generic-password', '-w', '-s', identity.service], environment @@ -83,7 +91,7 @@ export async function readSafeStoragePassword({ return password; } if (platform === 'linux') { - return readLinuxSafeStoragePassword(browser, environment); + return readLinuxSafeStoragePassword(browser, environment, runCommand); } throw new Error(`Safe Storage passwords are not used on ${platform}`); } diff --git a/js/src/browser/browser-cookie-crypto.js b/js/src/browser/browser-cookie-crypto.js index 9f4748c..301b2bf 100644 --- a/js/src/browser/browser-cookie-crypto.js +++ b/js/src/browser/browser-cookie-crypto.js @@ -41,6 +41,17 @@ function decodeCookieValue(plaintext) { return new TextDecoder('utf-8', { fatal: true }).decode(plaintext); } +/** Validate and decode plaintext returned by a Chromium platform decryptor. */ +export function decodeChromiumCookiePlaintext({ + plaintext, + host, + databaseVersion = 0, +}) { + return decodeCookieValue( + removeDomainHash(Buffer.from(plaintext), host, databaseVersion) + ); +} + function decryptCbcCookie(encryptedValue, key) { const decipher = createDecipheriv('aes-128-cbc', key, CHROMIUM_CBC_IV); return Buffer.concat([ @@ -89,7 +100,11 @@ export function decryptChromiumCookie({ platform === 'win32' ? decryptGcmCookie(encrypted, key) : decryptCbcCookie(encrypted, key); - return decodeCookieValue(removeDomainHash(plaintext, host, databaseVersion)); + return decodeChromiumCookiePlaintext({ + plaintext, + host, + databaseVersion, + }); } export function chromiumSameSite(value) { diff --git a/js/src/browser/browser-cookies.js b/js/src/browser/browser-cookies.js index dc61ba9..46ff9eb 100644 --- a/js/src/browser/browser-cookies.js +++ b/js/src/browser/browser-cookies.js @@ -1,6 +1,5 @@ import path from 'node:path'; import os from 'node:os'; -import { TextDecoder } from 'node:util'; import Database from 'better-sqlite3'; @@ -13,6 +12,7 @@ import { } from './browser-cookie-cache.js'; import { chromiumSameSite, + decodeChromiumCookiePlaintext, decryptChromiumCookie, deriveChromiumCookieKey, firefoxSameSite, @@ -112,48 +112,62 @@ function chromiumKeyForPrefix(context, prefix) { return deriveChromiumCookieKey('peanuts', 'linux'); } if (context.platform === 'linux' || context.platform === 'darwin') { - return getCachedCredential({ - cache: context.cache, - identity: `${context.browser}:${context.platform}:safe-storage`, - refresh: context.refresh, - now: context.now, - metadata: { - browser: context.browser, - platform: context.platform, - source: 'safe-storage', - }, - create: async () => - deriveChromiumCookieKey( - await context.readSafeStoragePassword({ + const identity = `${context.browser}:${context.platform}:safe-storage`; + if (!context.credentialKeys.has(identity)) { + context.credentialKeys.set( + identity, + getCachedCredential({ + cache: context.cache, + identity, + refresh: context.refresh, + now: context.now, + metadata: { browser: context.browser, platform: context.platform, - environment: context.environment, - }), - context.platform - ), - }); + source: 'safe-storage', + }, + create: async () => + deriveChromiumCookieKey( + await context.readSafeStoragePassword({ + browser: context.browser, + platform: context.platform, + environment: context.environment, + }), + context.platform + ), + }) + ); + } + return context.credentialKeys.get(identity); } if (context.platform === 'win32') { - return getCachedCredential({ - cache: context.cache, - identity: `${context.browser}:win32:legacy-aes-key`, - refresh: context.refresh, - now: context.now, - metadata: { - browser: context.browser, - platform: context.platform, - source: 'dpapi', - }, - create: () => - context.readWindowsEncryptionKey({ - localStatePath: path.join( - path.dirname(context.profile.path), - 'Local State' - ), - environment: context.environment, - decryptDpapi: context.decryptWindowsDpapi, - }), - }); + const identity = `${context.browser}:win32:legacy-aes-key`; + if (!context.credentialKeys.has(identity)) { + context.credentialKeys.set( + identity, + getCachedCredential({ + cache: context.cache, + identity, + refresh: context.refresh, + now: context.now, + metadata: { + browser: context.browser, + platform: context.platform, + source: 'dpapi', + }, + create: () => + context.readWindowsEncryptionKey({ + localStatePath: path.join( + path.dirname(context.profile.path), + 'Local State' + ), + environment: context.environment, + decryptDpapi: context.decryptWindowsDpapi, + }), + }) + ); + } + return context.credentialKeys.get(identity); } throw new Error( `Chromium cookie decryption is unsupported on ${context.platform}` @@ -182,7 +196,11 @@ async function decryptChromiumRow(row, databaseVersion, context) { const plaintext = await context.decryptWindowsDpapi(encryptedValue, { environment: context.environment, }); - return new TextDecoder('utf8', { fatal: true }).decode(plaintext); + return decodeChromiumCookiePlaintext({ + plaintext, + host: row.host_key, + databaseVersion, + }); } const key = await chromiumKeyForPrefix(context, prefix); return decryptChromiumCookie({ @@ -285,6 +303,7 @@ export async function readBrowserCookiesWithDependencies( cookies = await mapChromiumRows(rows, databaseVersion, { browser, cache, + credentialKeys: new Map(), decryptWindowsDpapi: dependencies.decryptWindowsDpapi ?? decryptWindowsDpapi, environment, diff --git a/js/tests/unit/browser/browser-cookies.test.js b/js/tests/unit/browser/browser-cookies.test.js index 3b8e0f3..ef76497 100644 --- a/js/tests/unit/browser/browser-cookies.test.js +++ b/js/tests/unit/browser/browser-cookies.test.js @@ -531,13 +531,7 @@ describe('installed browser cookie import', () => { assert.equal(password, 'kwallet-password'); assert.deepEqual(calls[1], [ 'kwallet-query', - [ - '-r', - 'Chrome Safe Storage', - '-f', - 'Chrome Keys', - 'kdewallet', - ], + ['-r', 'Chrome Safe Storage', '-f', 'Chrome Keys', 'kdewallet'], ]); }); diff --git a/python/src/browser_commander/browser/browser_cookie_credentials.py b/python/src/browser_commander/browser/browser_cookie_credentials.py index 94e65ab..c13dc5f 100644 --- a/python/src/browser_commander/browser/browser_cookie_credentials.py +++ b/python/src/browser_commander/browser/browser_cookie_credentials.py @@ -13,11 +13,24 @@ from pathlib import Path SAFE_STORAGE = { - "brave": {"application": "brave", "service": "Brave Safe Storage"}, - "chrome": {"application": "chrome", "service": "Chrome Safe Storage"}, - "chromium": {"application": "chromium", "service": "Chromium Safe Storage"}, + "brave": { + "application": "brave", + "folder": "Brave Keys", + "service": "Brave Safe Storage", + }, + "chrome": { + "application": "chrome", + "folder": "Chrome Keys", + "service": "Chrome Safe Storage", + }, + "chromium": { + "application": "chromium", + "folder": "Chromium Keys", + "service": "Chromium Safe Storage", + }, "edge": { "application": "microsoft-edge", + "folder": "Microsoft Edge Keys", "service": "Microsoft Edge Safe Storage", }, } @@ -54,7 +67,7 @@ def _read_linux_safe_storage_password( "-r", identity["service"], "-f", - f"{identity['application']} Keys", + identity["folder"], "kdewallet", ], environment, diff --git a/python/src/browser_commander/browser/browser_cookie_crypto.py b/python/src/browser_commander/browser/browser_cookie_crypto.py index 6e54c32..e2f56da 100644 --- a/python/src/browser_commander/browser/browser_cookie_crypto.py +++ b/python/src/browser_commander/browser/browser_cookie_crypto.py @@ -53,6 +53,16 @@ def _decrypt_gcm(encrypted_value: bytes, key: bytes) -> bytes: return decryptor.update(ciphertext) + decryptor.finalize() +def decode_chromium_cookie_plaintext( + plaintext: bytes, + *, + host: str, + database_version: int = 0, +) -> str: + """Validate and decode plaintext returned by a platform decryptor.""" + return _remove_domain_hash(plaintext, host, database_version).decode("utf-8") + + def decrypt_chromium_cookie( encrypted_value: bytes, *, @@ -75,7 +85,11 @@ def decrypt_chromium_cookie( if platform == "win32" else _decrypt_cbc(encrypted_value, key) ) - return _remove_domain_hash(plaintext, host, database_version).decode("utf-8") + return decode_chromium_cookie_plaintext( + plaintext, + host=host, + database_version=database_version, + ) def chromium_same_site(value: int) -> str: diff --git a/python/src/browser_commander/browser/browser_cookies.py b/python/src/browser_commander/browser/browser_cookies.py index e77597d..0979861 100644 --- a/python/src/browser_commander/browser/browser_cookies.py +++ b/python/src/browser_commander/browser/browser_cookies.py @@ -27,6 +27,7 @@ ) from browser_commander.browser.browser_cookie_crypto import ( chromium_same_site, + decode_chromium_cookie_plaintext, decrypt_chromium_cookie, derive_chromium_cookie_key, firefox_same_site, @@ -154,17 +155,22 @@ def create_key() -> bytes: ) return derive_chromium_cookie_key(password, platform) - return get_cached_credential( - context["cache"], - f"{context['browser']}:{platform}:safe-storage", - create_key, - refresh=context["refresh"], - metadata={ - "browser": context["browser"], - "platform": platform, - "source": "safe-storage", - }, - now=context["now"], + identity = f"{context['browser']}:{platform}:safe-storage" + return _operation_credential( + context, + identity, + lambda: get_cached_credential( + context["cache"], + identity, + create_key, + refresh=context["refresh"], + metadata={ + "browser": context["browser"], + "platform": platform, + "source": "safe-storage", + }, + now=context["now"], + ), ) if platform == "win32": @@ -175,21 +181,41 @@ def create_windows_key() -> bytes: decrypt_dpapi=context["decrypt_windows_dpapi"], ) - return get_cached_credential( - context["cache"], - f"{context['browser']}:win32:legacy-aes-key", - create_windows_key, - refresh=context["refresh"], - metadata={ - "browser": context["browser"], - "platform": platform, - "source": "dpapi", - }, - now=context["now"], + identity = f"{context['browser']}:win32:legacy-aes-key" + return _operation_credential( + context, + identity, + lambda: get_cached_credential( + context["cache"], + identity, + create_windows_key, + refresh=context["refresh"], + metadata={ + "browser": context["browser"], + "platform": platform, + "source": "dpapi", + }, + now=context["now"], + ), ) raise RuntimeError(f"Chromium cookie decryption is unsupported on {platform}") +def _operation_credential( + context: dict, identity: str, create: Callable[[], bytes] +) -> bytes: + attempts = context["credential_attempts"] + if identity not in attempts: + try: + attempts[identity] = bytes(create()) + except Exception as error: + attempts[identity] = error + result = attempts[identity] + if isinstance(result, Exception): + raise result + return result + + def _decrypt_chromium_row( row: sqlite3.Row, database_version: int, context: dict ) -> str: @@ -208,7 +234,12 @@ def _decrypt_chromium_row( platform="win32", key=bytes(32), ) - return context["decrypt_windows_dpapi"](encrypted_value).decode("utf-8") + plaintext = context["decrypt_windows_dpapi"](encrypted_value) + return decode_chromium_cookie_plaintext( + plaintext, + host=row["host_key"], + database_version=database_version, + ) key = _chromium_key_for_prefix(prefix, context) return decrypt_chromium_cookie( encrypted_value, @@ -312,6 +343,7 @@ def read_browser_cookies_with_dependencies( { "browser": browser, "cache": cache, + "credential_attempts": {}, "decrypt_windows_dpapi": decrypt_windows_dpapi, "environment": environment, "ignore_decryption_errors": options.ignore_decryption_errors, diff --git a/python/tests/unit/browser/test_browser_cookies.py b/python/tests/unit/browser/test_browser_cookies.py index c5d286c..0b43a99 100644 --- a/python/tests/unit/browser/test_browser_cookies.py +++ b/python/tests/unit/browser/test_browser_cookies.py @@ -23,14 +23,17 @@ from browser_commander import ( read_browser_cookies as public_read_browser_cookies, ) +from browser_commander.browser import browser_cookie_credentials as cookie_credentials from browser_commander.browser import ( list_browser_profiles as browser_list_browser_profiles, ) -from browser_commander.browser import browser_cookie_credentials as cookie_credentials from browser_commander.browser.browser_cookie_cache import ( NormalizedCookieCache, get_cached_credential, ) +from browser_commander.browser.browser_cookie_crypto import ( + decode_chromium_cookie_plaintext, +) from browser_commander.browser.browser_cookies import ( BrowserProfile, clear_browser_cookie_memory_cache, @@ -39,9 +42,6 @@ read_browser_cookies, read_browser_cookies_with_dependencies, ) -from browser_commander.browser.browser_cookie_crypto import ( - decode_chromium_cookie_plaintext, -) CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600 diff --git a/rust/src/browser/browser_cookie_credentials.rs b/rust/src/browser/browser_cookie_credentials.rs index 1f3d1da..c65ad9e 100644 --- a/rust/src/browser/browser_cookie_credentials.rs +++ b/rust/src/browser/browser_cookie_credentials.rs @@ -11,6 +11,7 @@ use serde_json::Value; struct SafeStorageIdentity { application: &'static str, + folder: &'static str, service: &'static str, } @@ -18,18 +19,22 @@ fn safe_storage_identity(browser: &str) -> Result { let identity = match browser { "brave" => SafeStorageIdentity { application: "brave", + folder: "Brave Keys", service: "Brave Safe Storage", }, "chrome" => SafeStorageIdentity { application: "chrome", + folder: "Chrome Keys", service: "Chrome Safe Storage", }, "chromium" => SafeStorageIdentity { application: "chromium", + folder: "Chromium Keys", service: "Chromium Safe Storage", }, "edge" => SafeStorageIdentity { application: "microsoft-edge", + folder: "Microsoft Edge Keys", service: "Microsoft Edge Safe Storage", }, _ => return Err(anyhow!("No Safe Storage identity is known for {browser}")), @@ -72,13 +77,7 @@ pub(crate) fn read_safe_storage_password(browser: &str, platform: &str) -> Resul } if let Ok(password) = run_credential_command( "kwallet-query", - &[ - "-r", - identity.service, - "-f", - &format!("{} Keys", identity.application), - "kdewallet", - ], + &["-r", identity.service, "-f", identity.folder, "kdewallet"], ) { if !password.is_empty() { return Ok(password); diff --git a/rust/src/browser/browser_cookie_crypto.rs b/rust/src/browser/browser_cookie_crypto.rs index b0aecdb..4feee3f 100644 --- a/rust/src/browser/browser_cookie_crypto.rs +++ b/rust/src/browser/browser_cookie_crypto.rs @@ -56,6 +56,15 @@ fn decrypt_gcm(encrypted: &[u8], key: &[u8]) -> Result> { .map_err(|_| anyhow!("Chromium AES-GCM cookie authentication failed")) } +pub(crate) fn decode_chromium_plaintext( + plaintext: &[u8], + host: &str, + database_version: i64, +) -> Result { + String::from_utf8(remove_domain_hash(plaintext, host, database_version)?.to_vec()) + .context("decrypted cookie is not valid UTF-8") +} + pub(crate) fn decrypt_chromium_cookie( encrypted: &[u8], host: &str, @@ -80,8 +89,7 @@ pub(crate) fn decrypt_chromium_cookie( } else { decrypt_cbc(encrypted, key)? }; - String::from_utf8(remove_domain_hash(&plaintext, host, database_version)?.to_vec()) - .context("decrypted cookie is not valid UTF-8") + decode_chromium_plaintext(&plaintext, host, database_version) } pub(crate) fn chromium_same_site(value: i64) -> &'static str { diff --git a/rust/src/browser/browser_cookies.rs b/rust/src/browser/browser_cookies.rs index f335e72..34be722 100644 --- a/rust/src/browser/browser_cookies.rs +++ b/rust/src/browser/browser_cookies.rs @@ -1,5 +1,6 @@ //! Import cookies from installed Chrome-family and Firefox profiles. +use std::collections::HashMap; use std::path::PathBuf; use anyhow::{anyhow, Context, Result}; @@ -15,7 +16,8 @@ use super::browser_cookie_credentials::{ decrypt_windows_dpapi, read_safe_storage_password, read_windows_encryption_key, }; use super::browser_cookie_crypto::{ - chromium_same_site, decrypt_chromium_cookie, derive_chromium_cookie_key, firefox_same_site, + chromium_same_site, decode_chromium_plaintext, decrypt_chromium_cookie, + derive_chromium_cookie_key, firefox_same_site, }; use super::browser_profiles::{ find_cookie_database, normalize_cookie_browser, resolve_browser_profile, BrowserProfile, @@ -156,6 +158,33 @@ struct ChromiumRow { same_site: i64, } +#[derive(Default)] +struct OperationKeyCache { + attempts: HashMap, String>>, +} + +struct CookieDecryptionState<'a> { + cache: &'a NormalizedCookieCache, + operation_keys: OperationKeyCache, +} + +impl OperationKeyCache { + fn get_or_try_create(&mut self, identity: &str, create: F) -> Result> + where + F: FnOnce() -> Result>, + { + if !self.attempts.contains_key(identity) { + self.attempts.insert( + identity.to_string(), + create().map_err(|error| error.to_string()), + ); + } + self.attempts[identity] + .clone() + .map_err(|error| anyhow!(error)) + } +} + fn open_cookie_database(path: &std::path::Path) -> Result { Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) .with_context(|| format!("Could not open browser cookie database: {}", path.display())) @@ -263,42 +292,48 @@ fn chromium_key_for_prefix( browser: &str, platform: &str, profile: &BrowserProfile, - cache: &NormalizedCookieCache, refresh: bool, + state: &mut CookieDecryptionState<'_>, ) -> Result> { if platform == "linux" && prefix == b"v10" { return derive_chromium_cookie_key("peanuts", "linux"); } if platform == "linux" || platform == "darwin" { - return get_cached_credential( - cache, - &format!("{browser}:{platform}:safe-storage"), - refresh, - credential_metadata(browser, platform, "safe-storage"), - || { - derive_chromium_cookie_key( - &read_safe_storage_password(browser, platform)?, - platform, - ) - }, - ); + let identity = format!("{browser}:{platform}:safe-storage"); + return state.operation_keys.get_or_try_create(&identity, || { + get_cached_credential( + state.cache, + &identity, + refresh, + credential_metadata(browser, platform, "safe-storage"), + || { + derive_chromium_cookie_key( + &read_safe_storage_password(browser, platform)?, + platform, + ) + }, + ) + }); } if platform == "win32" { - return get_cached_credential( - cache, - &format!("{browser}:win32:legacy-aes-key"), - refresh, - credential_metadata(browser, platform, "dpapi"), - || { - read_windows_encryption_key( - &profile - .path - .parent() - .unwrap_or(&profile.path) - .join("Local State"), - ) - }, - ); + let identity = format!("{browser}:win32:legacy-aes-key"); + return state.operation_keys.get_or_try_create(&identity, || { + get_cached_credential( + state.cache, + &identity, + refresh, + credential_metadata(browser, platform, "dpapi"), + || { + read_windows_encryption_key( + &profile + .path + .parent() + .unwrap_or(&profile.path) + .join("Local State"), + ) + }, + ) + }); } Err(anyhow!( "Chromium cookie decryption is unsupported on {platform}" @@ -319,8 +354,8 @@ fn decrypt_chromium_row( browser: &str, platform: &str, profile: &BrowserProfile, - cache: &NormalizedCookieCache, refresh: bool, + state: &mut CookieDecryptionState<'_>, ) -> Result { let value = if !row.value.is_empty() { row.value @@ -338,11 +373,14 @@ fn decrypt_chromium_row( &[0_u8; 32], )? } else { - String::from_utf8(decrypt_windows_dpapi(&row.encrypted_value)?) - .context("DPAPI cookie is not valid UTF-8")? + decode_chromium_plaintext( + &decrypt_windows_dpapi(&row.encrypted_value)?, + &row.host, + database_version, + )? } } else { - let key = chromium_key_for_prefix(prefix, browser, platform, profile, cache, refresh)?; + let key = chromium_key_for_prefix(prefix, browser, platform, profile, refresh, state)?; decrypt_chromium_cookie( &row.encrypted_value, &row.host, @@ -376,6 +414,10 @@ fn read_chromium_cookies( ) -> Result> { let version = read_database_version(database); let mut cookies = Vec::new(); + let mut state = CookieDecryptionState { + cache, + operation_keys: OperationKeyCache::default(), + }; for row in read_chromium_rows(database, options.domain_filter.as_deref())? { let name = row.name.clone(); let host = row.host.clone(); @@ -385,8 +427,8 @@ fn read_chromium_cookies( &options.browser, &options.platform, profile, - cache, options.refresh, + &mut state, ) { Ok(cookie) => cookies.push(cookie), Err(_) if options.ignore_decryption_errors => {} From e3cbb13ccf693a702f57cbe35b4b086997d1278f Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 07:08:12 +0000 Subject: [PATCH 15/19] docs(python): clarify imported cookie shape --- python/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/README.md b/python/README.md index 94a84f2..85ff742 100644 --- a/python/README.md +++ b/python/README.md @@ -179,7 +179,7 @@ obtain those values. ### Installed browser cookies -Discover profiles and read cookies in the exact Playwright/Selenium cookie +Discover profiles and read cookies in the exact Playwright/Puppeteer cookie shape: ```python From 93fa3d24fab2bd79d30b5e5cf6828814529055a9 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 07:11:27 +0000 Subject: [PATCH 16/19] test(cookies): isolate partial result caches --- js/tests/unit/browser/browser-cookies.test.js | 44 +++++++++++++++++++ .../unit/browser/test_browser_cookies.py | 37 ++++++++++++++++ rust/tests/browser_cookies.rs | 34 ++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/js/tests/unit/browser/browser-cookies.test.js b/js/tests/unit/browser/browser-cookies.test.js index ef76497..ed005dd 100644 --- a/js/tests/unit/browser/browser-cookies.test.js +++ b/js/tests/unit/browser/browser-cookies.test.js @@ -573,4 +573,48 @@ describe('installed browser cookie import', () => { ); assert.equal(credentialReads, 1); }); + + it('does not reuse a partial result cache for a strict import', async () => { + temporaryDirectory = await mkdtemp( + path.join(os.tmpdir(), 'browser-commander-cookie-strict-cache-') + ); + await createChromiumProfile({ + homeDir: temporaryDirectory, + rows: [ + { + host: '.strict.example', + name: 'broken', + encryptedValue: Buffer.from('v10invalid-cbc'), + }, + ], + }); + const cache = { + dir: path.join(temporaryDirectory, 'cache'), + ttlMinutes: 60, + }; + const dependencies = { + platform: 'linux', + homeDir: temporaryDirectory, + environment: {}, + }; + + assert.deepEqual( + await readBrowserCookiesWithDependencies( + { + browser: 'chrome', + cache, + ignoreDecryptionErrors: true, + }, + dependencies + ), + [] + ); + await assert.rejects( + readBrowserCookiesWithDependencies( + { browser: 'chrome', cache }, + dependencies + ), + /Could not decrypt cookie broken/ + ); + }); }); diff --git a/python/tests/unit/browser/test_browser_cookies.py b/python/tests/unit/browser/test_browser_cookies.py index 0b43a99..79c046e 100644 --- a/python/tests/unit/browser/test_browser_cookies.py +++ b/python/tests/unit/browser/test_browser_cookies.py @@ -12,6 +12,7 @@ from cryptography.hazmat.primitives import hashes, padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +import pytest from browser_commander import ( BrowserCookieCacheOptions, @@ -473,3 +474,39 @@ def read_password(**_kwargs: object) -> str: ("second", "second"), ] assert credential_reads == 1 + + +def test_partial_result_cache_is_not_reused_for_strict_import(tmp_path: Path) -> None: + _create_chromium_profile( + tmp_path, + [ + { + "host": ".strict.example", + "name": "broken", + "encrypted_value": b"v10invalid-cbc", + } + ], + ) + cache = BrowserCookieCacheOptions(dir=tmp_path / "cache", ttl_minutes=60) + dependencies = { + "platform": "linux", + "home_dir": tmp_path, + "environment": {}, + } + + assert ( + read_browser_cookies_with_dependencies( + BrowserCookieReadOptions( + browser="chrome", + cache=cache, + ignore_decryption_errors=True, + ), + **dependencies, + ) + == [] + ) + with pytest.raises(RuntimeError, match="Could not decrypt cookie broken"): + read_browser_cookies_with_dependencies( + BrowserCookieReadOptions(browser="chrome", cache=cache), + **dependencies, + ) diff --git a/rust/tests/browser_cookies.rs b/rust/tests/browser_cookies.rs index 91607fc..39ffdd7 100644 --- a/rust/tests/browser_cookies.rs +++ b/rust/tests/browser_cookies.rs @@ -67,6 +67,40 @@ fn discovers_and_reads_installed_browser_cookie_profiles() -> anyhow::Result<()> Ok(()) } +#[test] +fn partial_result_cache_is_not_reused_for_strict_import() -> anyhow::Result<()> { + let temporary_directory = TempDir::new("browser-cookie-strict-cache")?; + let profile = create_chromium_profile(temporary_directory.path(), ".strict.example")?; + let database = Connection::open(profile.join("Network/Cookies"))?; + database.execute( + "UPDATE cookies SET encrypted_value = ?1", + params![b"v10invalid-cbc".as_slice()], + )?; + drop(database); + let cache_dir = temporary_directory.path().join("cache"); + + let partial = read_browser_cookies( + BrowserCookieReadOptions::new("chrome") + .home_dir(temporary_directory.path()) + .platform("linux") + .cache_dir(&cache_dir) + .ignore_decryption_errors(true), + )?; + assert!(partial.is_empty()); + + let strict = read_browser_cookies( + BrowserCookieReadOptions::new("chrome") + .home_dir(temporary_directory.path()) + .platform("linux") + .cache_dir(cache_dir), + ); + assert!(strict + .unwrap_err() + .to_string() + .contains("Could not decrypt cookie SID")); + Ok(()) +} + fn create_chromium_profile(home: &Path, host: &str) -> anyhow::Result { let root = home.join(".config/google-chrome"); let profile = root.join("Default"); From d26e14d67447cb7673fe07b936ad1501978531c4 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 07:11:58 +0000 Subject: [PATCH 17/19] fix(cookies): separate strict and partial result caches --- js/src/browser/browser-cookies.js | 1 + python/src/browser_commander/browser/browser_cookies.py | 1 + python/tests/unit/browser/test_browser_cookies.py | 2 +- rust/src/browser/browser_cookies.rs | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/js/src/browser/browser-cookies.js b/js/src/browser/browser-cookies.js index 46ff9eb..8c5771d 100644 --- a/js/src/browser/browser-cookies.js +++ b/js/src/browser/browser-cookies.js @@ -280,6 +280,7 @@ export async function readBrowserCookiesWithDependencies( browser, profile: profile.path, domainFilter: options.domainFilter ?? null, + ignoreDecryptionErrors: options.ignoreDecryptionErrors === true, }); const now = dependencies.now ?? Date.now; const cachedCookies = await readCookieResultCache({ diff --git a/python/src/browser_commander/browser/browser_cookies.py b/python/src/browser_commander/browser/browser_cookies.py index 0979861..297adb2 100644 --- a/python/src/browser_commander/browser/browser_cookies.py +++ b/python/src/browser_commander/browser/browser_cookies.py @@ -327,6 +327,7 @@ def read_browser_cookies_with_dependencies( "browser": browser, "profile": str(profile.path), "domain_filter": options.domain_filter, + "ignore_decryption_errors": options.ignore_decryption_errors, }, sort_keys=True, ) diff --git a/python/tests/unit/browser/test_browser_cookies.py b/python/tests/unit/browser/test_browser_cookies.py index 79c046e..7704a7a 100644 --- a/python/tests/unit/browser/test_browser_cookies.py +++ b/python/tests/unit/browser/test_browser_cookies.py @@ -9,10 +9,10 @@ from pathlib import Path from typing import Any +import pytest from cryptography.hazmat.primitives import hashes, padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -import pytest from browser_commander import ( BrowserCookieCacheOptions, diff --git a/rust/src/browser/browser_cookies.rs b/rust/src/browser/browser_cookies.rs index 34be722..19544a1 100644 --- a/rust/src/browser/browser_cookies.rs +++ b/rust/src/browser/browser_cookies.rs @@ -463,6 +463,7 @@ pub fn read_browser_cookies(mut options: BrowserCookieReadOptions) -> Result Date: Sun, 2 Aug 2026 07:28:46 +0000 Subject: [PATCH 18/19] fix(js): support sqlite with install scripts disabled --- js/src/browser/browser-cookie-database.js | 35 +++++++++++++++++++ js/src/browser/browser-cookies.js | 17 +++++---- js/tests/unit/browser/browser-cookies.test.js | 7 ++-- 3 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 js/src/browser/browser-cookie-database.js diff --git a/js/src/browser/browser-cookie-database.js b/js/src/browser/browser-cookie-database.js new file mode 100644 index 0000000..bfe6d0e --- /dev/null +++ b/js/src/browser/browser-cookie-database.js @@ -0,0 +1,35 @@ +import BetterSqlite3 from 'better-sqlite3'; + +let builtInSqlitePromise; + +function loadBuiltInSqlite() { + builtInSqlitePromise ??= import('node:sqlite') + .then(({ DatabaseSync }) => DatabaseSync) + .catch(() => null); + return builtInSqlitePromise; +} + +/** Open SQLite without requiring a native addon on Node versions that provide it. */ +export async function openSqliteDatabase( + filename, + { fileMustExist = false, readOnly = false } = {} +) { + const DatabaseSync = await loadBuiltInSqlite(); + if (DatabaseSync) { + return new DatabaseSync(filename, { readOnly }); + } + return new BetterSqlite3(filename, { + fileMustExist, + readonly: readOnly, + }); +} + +/** Configure a statement to preserve Chromium's 64-bit timestamp values. */ +export function preserveIntegerPrecision(statement) { + if (typeof statement.setReadBigInts === 'function') { + statement.setReadBigInts(true); + } else { + statement.safeIntegers(); + } + return statement; +} diff --git a/js/src/browser/browser-cookies.js b/js/src/browser/browser-cookies.js index 8c5771d..26b9395 100644 --- a/js/src/browser/browser-cookies.js +++ b/js/src/browser/browser-cookies.js @@ -1,8 +1,6 @@ import path from 'node:path'; import os from 'node:os'; -import Database from 'better-sqlite3'; - import { clearBrowserCookieMemoryCache, getCachedCredential, @@ -22,6 +20,10 @@ import { readSafeStoragePassword, readWindowsEncryptionKey, } from './browser-cookie-credentials.js'; +import { + openSqliteDatabase, + preserveIntegerPrecision, +} from './browser-cookie-database.js'; import { findCookieDatabase, listBrowserProfiles, @@ -52,7 +54,7 @@ function firefoxExpires(value) { } function queryRows(database, query, domainFilter) { - const statement = database.prepare(query).safeIntegers(); + const statement = preserveIntegerPrecision(database.prepare(query)); return domainFilter ? statement.all(`%${domainFilter}%`) : statement.all(); } @@ -237,9 +239,12 @@ async function mapChromiumRows(rows, databaseVersion, context) { return cookies; } -function openCookieDatabase(cookiePath) { +async function openCookieDatabase(cookiePath) { try { - return new Database(cookiePath, { fileMustExist: true, readonly: true }); + return await openSqliteDatabase(cookiePath, { + fileMustExist: true, + readOnly: true, + }); } catch (error) { throw new Error(`Could not open browser cookie database: ${error.message}`); } @@ -293,7 +298,7 @@ export async function readBrowserCookiesWithDependencies( return cachedCookies; } - const database = openCookieDatabase(cookiePath); + const database = await openCookieDatabase(cookiePath); let cookies; try { if (browser === 'firefox') { diff --git a/js/tests/unit/browser/browser-cookies.test.js b/js/tests/unit/browser/browser-cookies.test.js index ed005dd..ff254d4 100644 --- a/js/tests/unit/browser/browser-cookies.test.js +++ b/js/tests/unit/browser/browser-cookies.test.js @@ -22,8 +22,6 @@ import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { afterEach, describe, it } from 'node:test'; -import Database from 'better-sqlite3'; - import { clearBrowserCookieMemoryCache, decryptChromiumCookie, @@ -31,6 +29,7 @@ import { readBrowserCookies, readBrowserCookiesWithDependencies, } from '../../../src/browser/browser-cookies.js'; +import { openSqliteDatabase } from '../../../src/browser/browser-cookie-database.js'; import { getCachedCredential } from '../../../src/browser/browser-cookie-cache.js'; import { readSafeStoragePassword } from '../../../src/browser/browser-cookie-credentials.js'; import { decodeChromiumCookiePlaintext } from '../../../src/browser/browser-cookie-crypto.js'; @@ -94,7 +93,7 @@ async function createChromiumProfile({ homeDir, rows, profile = 'Default' }) { }) ); - const database = new Database(cookiePath); + const database = await openSqliteDatabase(cookiePath); database.exec(` CREATE TABLE meta (key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR); INSERT INTO meta (key, value) VALUES ('version', '24'); @@ -143,7 +142,7 @@ async function createFirefoxProfile({ homeDir, rows }) { path.join(root, 'profiles.ini'), `[Profile0]\nName=default-release\nIsRelative=1\nPath=${profileName}\nDefault=1\n` ); - const database = new Database(cookiePath); + const database = await openSqliteDatabase(cookiePath); database.exec(` CREATE TABLE moz_cookies ( name TEXT, From abb6505f59ef766eae29a881a22bbf669b8e1879 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 2 Aug 2026 07:45:10 +0000 Subject: [PATCH 19/19] fix(cache): enforce owner-only Windows ACLs --- docs/feature-parity.md | 4 +- js/src/browser/browser-cookie-cache.js | 38 ++++++++++++- js/tests/unit/browser/browser-cookies.test.js | 11 +++- .../browser/browser_cookie_cache.py | 38 +++++++++++-- .../unit/browser/test_browser_cookies.py | 17 +++++- rust/src/browser/browser_cookie_cache.rs | 54 +++++++++++++++++-- 6 files changed, 150 insertions(+), 12 deletions(-) diff --git a/docs/feature-parity.md b/docs/feature-parity.md index 7a73af3..f536e0c 100644 --- a/docs/feature-parity.md +++ b/docs/feature-parity.md @@ -75,7 +75,9 @@ service and are intentionally reported as unsupported for ordinary external processes. All three APIs can skip those individual values when partial import is acceptable. The shared derived-key cache uses one schema and lock identity, so JavaScript, Rust, and Python processes do not independently prompt within a -TTL window. +TTL window. Cache directories/files use `0700`/`0600` modes on POSIX; on +Windows they remove inherited ACL entries and grant access only to the current +user. ## Automation-Friendly Launch Defaults diff --git a/js/src/browser/browser-cookie-cache.js b/js/src/browser/browser-cookie-cache.js index e01547a..7b8809b 100644 --- a/js/src/browser/browser-cookie-cache.js +++ b/js/src/browser/browser-cookie-cache.js @@ -1,4 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; +import { execFile as execFileCallback } from 'node:child_process'; import { chmod, mkdir, @@ -9,11 +10,14 @@ import { stat, } from 'node:fs/promises'; import path from 'node:path'; +import { promisify } from 'node:util'; const DEFAULT_TTL_MINUTES = 60; const LOCK_STALE_MILLISECONDS = 30_000; const LOCK_WAIT_MILLISECONDS = 30_000; const credentialPromises = new Map(); +const execFile = promisify(execFileCallback); +let windowsPrincipalPromise; function hash(value) { return createHash('sha256').update(value).digest('hex'); @@ -42,9 +46,39 @@ export function normalizeCookieCache(cache, homeDir, ttlMinutes) { }; } +function windowsPrincipal() { + windowsPrincipalPromise ??= execFile('whoami', [], { + windowsHide: true, + }).then(({ stdout }) => stdout.trim()); + return windowsPrincipalPromise; +} + +async function restrictOwnerOnly(targetPath, directory) { + if (process.platform === 'win32') { + const principal = await windowsPrincipal(); + if (!principal) { + throw new Error('Could not identify the current Windows user'); + } + const permission = directory ? '(OI)(CI)F' : 'F'; + await execFile( + 'icacls', + [ + targetPath, + '/inheritance:r', + '/grant:r', + `${principal}:${permission}`, + '/q', + ], + { windowsHide: true } + ); + return; + } + await chmod(targetPath, directory ? 0o700 : 0o600); +} + async function ensureCacheDirectory(cacheDir) { await mkdir(cacheDir, { recursive: true, mode: 0o700 }); - await chmod(cacheDir, 0o700).catch(() => {}); + await restrictOwnerOnly(cacheDir, true); } async function readFreshJson(filePath, ttlSeconds, now) { @@ -72,7 +106,7 @@ async function writeOwnerOnlyJson(filePath, value) { await rm(temporaryPath, { force: true }); throw error; } - await chmod(filePath, 0o600).catch(() => {}); + await restrictOwnerOnly(filePath, false); } function cachePath(cacheDir, kind, identity) { diff --git a/js/tests/unit/browser/browser-cookies.test.js b/js/tests/unit/browser/browser-cookies.test.js index ff254d4..b5a0cc4 100644 --- a/js/tests/unit/browser/browser-cookies.test.js +++ b/js/tests/unit/browser/browser-cookies.test.js @@ -392,7 +392,16 @@ describe('installed browser cookie import', () => { ); assert.ok(credentialFile); const credentialPath = path.join(cacheDir, credentialFile); - assert.equal((await stat(credentialPath)).mode & 0o777, 0o600); + if (process.platform === 'win32') { + const [{ stdout: acl }, { stdout: principal }] = await Promise.all([ + execFile('icacls', [credentialPath]), + execFile('whoami'), + ]); + assert.ok(acl.toLowerCase().includes(principal.trim().toLowerCase())); + assert.equal(acl.includes('(I)'), false); + } else { + assert.equal((await stat(credentialPath)).mode & 0o777, 0o600); + } const cached = JSON.parse(await readFile(credentialPath, 'utf8')); assert.equal(cached.kind, 'derived-key'); assert.equal( diff --git a/python/src/browser_commander/browser/browser_cookie_cache.py b/python/src/browser_commander/browser/browser_cookie_cache.py index ce0863a..7d0eeb6 100644 --- a/python/src/browser_commander/browser/browser_cookie_cache.py +++ b/python/src/browser_commander/browser/browser_cookie_cache.py @@ -6,6 +6,7 @@ import hashlib import json import os +import subprocess import threading import time import uuid @@ -19,6 +20,7 @@ LOCK_WAIT_SECONDS = 30.0 _credential_memory_cache: dict[str, tuple[bytes, float]] = {} _memory_lock = threading.Lock() +_windows_principal: str | None = None @dataclass(frozen=True) @@ -74,10 +76,39 @@ def _cache_path(cache: NormalizedCookieCache, kind: str, identity: str) -> Path: return cache.directory / f"{kind}-{_hash(identity)}.json" +def _current_windows_principal() -> str: + global _windows_principal + if _windows_principal is None: + result = subprocess.run(["whoami"], check=True, capture_output=True, text=True) + _windows_principal = result.stdout.strip() + if not _windows_principal: + raise OSError("Could not identify the current Windows user") + return _windows_principal + + +def _restrict_owner_only(path: Path, *, directory: bool) -> None: + if os.name == "nt": + permission = "(OI)(CI)F" if directory else "F" + subprocess.run( + [ + "icacls", + str(path), + "/inheritance:r", + "/grant:r", + f"{_current_windows_principal()}:{permission}", + "/q", + ], + check=True, + capture_output=True, + text=True, + ) + return + path.chmod(0o700 if directory else 0o600) + + def _ensure_cache_directory(directory: Path) -> None: directory.mkdir(parents=True, exist_ok=True, mode=0o700) - with suppress(OSError): - directory.chmod(0o700) + _restrict_owner_only(directory, directory=True) def _read_fresh_json( @@ -101,8 +132,7 @@ def _write_owner_only_json(path: Path, value: dict) -> None: handle.flush() os.fsync(handle.fileno()) temporary_path.replace(path) - with suppress(OSError): - path.chmod(0o600) + _restrict_owner_only(path, directory=False) finally: with suppress(FileNotFoundError): temporary_path.unlink() diff --git a/python/tests/unit/browser/test_browser_cookies.py b/python/tests/unit/browser/test_browser_cookies.py index 7704a7a..2bf98b8 100644 --- a/python/tests/unit/browser/test_browser_cookies.py +++ b/python/tests/unit/browser/test_browser_cookies.py @@ -4,8 +4,10 @@ import hashlib import json +import os import sqlite3 import stat +import subprocess from pathlib import Path from typing import Any @@ -307,7 +309,20 @@ def read_password(**_kwargs: object) -> str: assert credential_reads == 1 credential_file = next(cache_dir.glob("credential-*.json")) - assert stat.S_IMODE(credential_file.stat().st_mode) == 0o600 + if os.name == "nt": + acl = subprocess.run( + ["icacls", str(credential_file)], + check=True, + capture_output=True, + text=True, + ).stdout + principal = subprocess.run( + ["whoami"], check=True, capture_output=True, text=True + ).stdout.strip() + assert principal.casefold() in acl.casefold() + assert "(I)" not in acl + else: + assert stat.S_IMODE(credential_file.stat().st_mode) == 0o600 cached = json.loads(credential_file.read_text(encoding="utf-8")) assert cached["kind"] == "derived-key" assert cached["key"] != password diff --git a/rust/src/browser/browser_cookie_cache.rs b/rust/src/browser/browser_cookie_cache.rs index da46c44..fa3e0d6 100644 --- a/rust/src/browser/browser_cookie_cache.rs +++ b/rust/src/browser/browser_cookie_cache.rs @@ -102,11 +102,47 @@ fn restrict_owner_only(path: &Path, directory: bool) -> Result<()> { .with_context(|| format!("Could not protect cookie cache {}", path.display())) } -#[cfg(not(unix))] -fn restrict_owner_only(_path: &Path, _directory: bool) -> Result<()> { +#[cfg(windows)] +fn restrict_owner_only(path: &Path, directory: bool) -> Result<()> { + use std::process::Command; + + let whoami = Command::new("whoami") + .output() + .context("Could not identify the current Windows user")?; + if !whoami.status.success() { + return Err(anyhow!("Could not identify the current Windows user")); + } + let principal = String::from_utf8(whoami.stdout) + .context("Windows user identity was not valid UTF-8")? + .trim() + .to_owned(); + if principal.is_empty() { + return Err(anyhow!("Could not identify the current Windows user")); + } + let permission = if directory { "(OI)(CI)F" } else { "F" }; + let status = Command::new("icacls") + .arg(path) + .args(["/inheritance:r", "/grant:r"]) + .arg(format!("{principal}:{permission}")) + .arg("/q") + .status() + .with_context(|| format!("Could not protect cookie cache {}", path.display()))?; + if !status.success() { + return Err(anyhow!( + "Could not protect cookie cache {} with a Windows ACL", + path.display() + )); + } Ok(()) } +#[cfg(all(not(unix), not(windows)))] +fn restrict_owner_only(_path: &Path, _directory: bool) -> Result<()> { + Err(anyhow!( + "owner-only cookie caching is unsupported on this platform" + )) +} + fn read_fresh_json(path: &Path, ttl_seconds: f64) -> Option { let value = serde_json::from_str::(&fs::read_to_string(path).ok()?).ok()?; let saved_at = value.get("savedAt")?.as_f64()?; @@ -367,9 +403,9 @@ where mod tests { use super::*; - #[cfg(unix)] #[test] fn credential_cache_is_owner_only_and_reused_after_memory_reset() -> Result<()> { + #[cfg(unix)] use std::os::unix::fs::PermissionsExt; let directory = std::env::temp_dir().join(format!( @@ -407,7 +443,19 @@ mod tests { .starts_with("credential-") }) .unwrap(); + #[cfg(unix)] assert_eq!(cached.metadata()?.permissions().mode() & 0o777, 0o600); + #[cfg(windows)] + { + use std::process::Command; + + let acl = Command::new("icacls").arg(cached.path()).output()?; + let principal = Command::new("whoami").output()?; + let acl = String::from_utf8(acl.stdout)?.to_lowercase(); + let principal = String::from_utf8(principal.stdout)?.trim().to_lowercase(); + assert!(acl.contains(&principal)); + assert!(!acl.contains("(i)")); + } fs::remove_dir_all(directory)?; Ok(()) }