chrome-tools 1.1.9 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/src/core/db.js +110 -11
- package/dist/src/modules/passwords.js +16 -9
- package/package.json +2 -4
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
1
|
# chrome-tools
|
|
2
2
|
|
|
3
3
|
CLI & Node.js library for Chrome browser data, including passwords, bookmarks, history, cookies, and localStorage.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
**Node.js >= 22.16.0.** Chrome's stores are read through the built-in
|
|
8
|
+
[`node:sqlite`](https://nodejs.org/api/sqlite.html) module rather than a native
|
|
9
|
+
dependency, so there is nothing to compile at install time. 22.16.0 is the floor
|
|
10
|
+
because that is the release where the connection `timeout` option began to be
|
|
11
|
+
honoured — on 22.15 and earlier it is accepted and silently ignored, which is
|
|
12
|
+
worse than rejected.
|
|
13
|
+
|
|
14
|
+
### The experimental warning
|
|
15
|
+
|
|
16
|
+
Node prints this to **stderr** on the first `node:sqlite` load, once per process:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
(node:12345) ExperimentalWarning: SQLite is an experimental feature and might change at any time
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Measured across releases, it is emitted on **22.16 through 25.6**, and is silent
|
|
23
|
+
from **25.7.0**, where `node:sqlite` moved to Node's "release candidate"
|
|
24
|
+
stability index. So on current LTS you should expect to see it.
|
|
25
|
+
|
|
26
|
+
It is a warning, not an error: stdout stays clean, so piping JSON out of the CLI
|
|
27
|
+
is unaffected. If you drive this from a harness that treats *any* stderr output
|
|
28
|
+
as failure, set `NODE_NO_WARNINGS=1` (or run node with `--no-warnings`) in that
|
|
29
|
+
harness. This package deliberately does not suppress the warning for you — a
|
|
30
|
+
library silencing process-wide warnings would hide unrelated ones too.
|
|
31
|
+
|
|
32
|
+
Because the module is still pre-stable, its API may change between major Node
|
|
33
|
+
releases. The surface used here — `prepare`, `all`, `run`, `close`,
|
|
34
|
+
`setReadBigInts`, and `VACUUM INTO` — has been present since 22.5.0.
|
package/dist/src/core/db.js
CHANGED
|
@@ -1,13 +1,95 @@
|
|
|
1
1
|
import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join, basename } from "node:path";
|
|
4
|
-
import
|
|
5
|
-
/**
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
/** SQLite result codes meaning "someone else owns this database right now". */
|
|
6
|
+
const SQLITE_BUSY = 5;
|
|
7
|
+
const SQLITE_LOCKED = 6;
|
|
8
|
+
/**
|
|
9
|
+
* Whether a failure is "another process owns this database", not a broken one.
|
|
10
|
+
*
|
|
11
|
+
* `node:sqlite` reports *every* failure as `code: "ERR_SQLITE_ERROR"` and puts
|
|
12
|
+
* the SQLite result code in `errcode`, so the `code` property is useless as a
|
|
13
|
+
* discriminator here — reading it is what an obvious port of this function from
|
|
14
|
+
* `better-sqlite3` (which set `code: "SQLITE_BUSY"`) would do, and it would call
|
|
15
|
+
* every locked store a corrupt one.
|
|
16
|
+
*
|
|
17
|
+
* Masked to the low byte because SQLite may report an *extended* code — 517
|
|
18
|
+
* (`SQLITE_BUSY_SNAPSHOT`) and 262 (`SQLITE_LOCKED_SHAREDCACHE`) are the same
|
|
19
|
+
* two conditions with detail attached, and both must take the copy path.
|
|
20
|
+
*
|
|
21
|
+
* The message check stays as the fallback: it is what caught this before
|
|
22
|
+
* `errcode` existed, and it costs nothing to keep for a build that leaves the
|
|
23
|
+
* field unset.
|
|
24
|
+
*/
|
|
6
25
|
function isLocked(err) {
|
|
7
|
-
const
|
|
26
|
+
const errcode = err?.errcode;
|
|
27
|
+
if (typeof errcode === "number") {
|
|
28
|
+
const primary = errcode & 0xff;
|
|
29
|
+
if (primary === SQLITE_BUSY || primary === SQLITE_LOCKED)
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
8
32
|
const message = err instanceof Error ? err.message : String(err);
|
|
9
|
-
return
|
|
10
|
-
|
|
33
|
+
return /database is locked|database is busy/i.test(message);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Puts a row back into the shape callers have always been handed: an ordinary
|
|
37
|
+
* object, holding plain numbers, with `Buffer` for a BLOB.
|
|
38
|
+
*
|
|
39
|
+
* `queryDatabase` is exported from the package root, so all three of these are
|
|
40
|
+
* public API rather than internal detail — a caller running its own SQL against
|
|
41
|
+
* `Login Data` gets whatever this returns.
|
|
42
|
+
*
|
|
43
|
+
* **The object itself.** `node:sqlite` builds each row with a null prototype,
|
|
44
|
+
* where better-sqlite3 returned an ordinary object. `Object.keys`, `in`, and
|
|
45
|
+
* `JSON.stringify` all still work, which is why this hides so well — but
|
|
46
|
+
* `row.hasOwnProperty(…)` is a `TypeError`, and so is anything that coerces the
|
|
47
|
+
* row to a string, including `String(row)` and dropping it in a template
|
|
48
|
+
* literal. Rebuilt here rather than re-prototyped: the loop that has to visit
|
|
49
|
+
* every cell anyway is the cheapest place to do it.
|
|
50
|
+
*
|
|
51
|
+
* **Integers.** This is the whole reason the statement is put in BigInt mode.
|
|
52
|
+
* `node:sqlite` does not quietly lose precision past `Number.MAX_SAFE_INTEGER`
|
|
53
|
+
* the way better-sqlite3 did — it **throws** `ERR_OUT_OF_RANGE` partway through
|
|
54
|
+
* reading the row. Chrome writes every timestamp as microseconds since 1601,
|
|
55
|
+
* which lands around 1.3e16 and is therefore *always* past that line, so in the
|
|
56
|
+
* default number mode a `History`, `Login Data`, or `Cookies` row cannot be read
|
|
57
|
+
* at all. Not an edge case: it is every row with a time on it. BigInt mode is
|
|
58
|
+
* the only mode that can read those columns, and this undoes the widening so
|
|
59
|
+
* nothing downstream has to learn a second numeric type. The precision that
|
|
60
|
+
* costs is precision better-sqlite3 was already dropping — sub-2-microsecond
|
|
61
|
+
* detail on a 400-year-old epoch.
|
|
62
|
+
*
|
|
63
|
+
* **Blobs.** `node:sqlite` hands back a bare `Uint8Array` where better-sqlite3
|
|
64
|
+
* gave a `Buffer`, and the difference is not a type-checking nicety: it fails
|
|
65
|
+
* *silently*. `Buffer` is a `Uint8Array`, so an `instanceof` guard still passes
|
|
66
|
+
* and nothing throws — but `blob.toString("utf-8")` on the bare array ignores
|
|
67
|
+
* the encoding and returns the decimal bytes joined by commas, so a caller
|
|
68
|
+
* decoding `password_value` or `encrypted_value` gets `"118,49,48,…"` and no
|
|
69
|
+
* error to notice. `Buffer.isBuffer` also flips to false, and the read helpers
|
|
70
|
+
* (`readUInt32BE`, `equals`, …) are simply absent.
|
|
71
|
+
*
|
|
72
|
+
* Wrapped rather than copied: each row owns its own `ArrayBuffer` here, so
|
|
73
|
+
* re-using the memory is safe and a large blob is not duplicated to change its
|
|
74
|
+
* prototype. An actual `Buffer` is left alone.
|
|
75
|
+
*/
|
|
76
|
+
function toCallerTypes(rows) {
|
|
77
|
+
return rows.map((row) => {
|
|
78
|
+
const plain = {};
|
|
79
|
+
for (const key of Object.keys(row)) {
|
|
80
|
+
const value = row[key];
|
|
81
|
+
if (typeof value === "bigint") {
|
|
82
|
+
plain[key] = Number(value);
|
|
83
|
+
}
|
|
84
|
+
else if (value instanceof Uint8Array && !Buffer.isBuffer(value)) {
|
|
85
|
+
plain[key] = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
plain[key] = value;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return plain;
|
|
92
|
+
});
|
|
11
93
|
}
|
|
12
94
|
/**
|
|
13
95
|
* Copies a database SQLite will not open, alongside the sidecars that make it
|
|
@@ -44,10 +126,24 @@ export function queryDatabase(dbFilePath, sql, params = []) {
|
|
|
44
126
|
const tmpDb = join(tmpDir, basename(dbFilePath));
|
|
45
127
|
let sourceDb;
|
|
46
128
|
try {
|
|
47
|
-
sourceDb = new
|
|
48
|
-
readonly
|
|
49
|
-
|
|
50
|
-
|
|
129
|
+
sourceDb = new DatabaseSync(dbFilePath, {
|
|
130
|
+
// `readOnly`, not `readonly`. An unrecognized option is *ignored* rather
|
|
131
|
+
// than rejected, so the lowercase spelling — which is what better-sqlite3
|
|
132
|
+
// called it, and what this line used to say — silently hands back a
|
|
133
|
+
// writable handle onto the user's live browser profile.
|
|
134
|
+
readOnly: true,
|
|
135
|
+
// No `fileMustExist` equivalent, and none needed: a read-only open will
|
|
136
|
+
// not create the file, and the `existsSync` guard above is the explicit
|
|
137
|
+
// check that turns a missing store into a message worth reading.
|
|
138
|
+
//
|
|
139
|
+
// No wait. A running Chrome holds these stores in `locking_mode =
|
|
140
|
+
// EXCLUSIVE` for as long as it is open, so a busy timeout here buys
|
|
141
|
+
// nothing that waiting longer would win — the lock is not transient, and
|
|
142
|
+
// the copy below is the answer to it either way. Five seconds *per store*
|
|
143
|
+
// was the whole cost of reading a saved login while the browser was up:
|
|
144
|
+
// two stores, ten seconds, spent to arrive at the fallback that then
|
|
145
|
+
// succeeded instantly.
|
|
146
|
+
timeout: 0,
|
|
51
147
|
});
|
|
52
148
|
sourceDb.prepare("VACUUM INTO ?").run(tmpDb);
|
|
53
149
|
}
|
|
@@ -83,9 +179,12 @@ export function queryDatabase(dbFilePath, sql, params = []) {
|
|
|
83
179
|
}
|
|
84
180
|
let db;
|
|
85
181
|
try {
|
|
86
|
-
db = new
|
|
182
|
+
db = new DatabaseSync(tmpDb, { readOnly: true, timeout: 5000 });
|
|
87
183
|
const stmt = db.prepare(sql);
|
|
88
|
-
|
|
184
|
+
// Read wide, hand back narrow. See `toCallerTypes` — without this, every
|
|
185
|
+
// query that touches a Chrome timestamp throws instead of returning rows.
|
|
186
|
+
stmt.setReadBigInts(true);
|
|
187
|
+
return toCallerTypes(params.length > 0 ? stmt.all(...params) : stmt.all());
|
|
89
188
|
}
|
|
90
189
|
catch (err) {
|
|
91
190
|
throw new Error(`Database query failed on '${basename(dbFilePath)}': ${err.message}`);
|
|
@@ -160,7 +160,21 @@ export function getPasswords({ chromeDir, profilePath, profile, search, browser,
|
|
|
160
160
|
const rows = queryDatabase(dbPath, `SELECT origin_url, username_value, password_value, date_last_used
|
|
161
161
|
FROM logins ORDER BY origin_url`);
|
|
162
162
|
for (const row of rows) {
|
|
163
|
-
|
|
163
|
+
// Matched before anything is decrypted. Both columns the search
|
|
164
|
+
// looks at — the origin and the username — are stored in the clear,
|
|
165
|
+
// so a row that cannot match never needs its password read at all.
|
|
166
|
+
//
|
|
167
|
+
// Decrypting first cost one platform decryption per stored login on
|
|
168
|
+
// the way to finding one: a store with a few hundred entries spent
|
|
169
|
+
// upwards of ten seconds answering "what is the password for this
|
|
170
|
+
// one site", and every one of those decryptions was thrown away by
|
|
171
|
+
// the filter immediately below.
|
|
172
|
+
if (term &&
|
|
173
|
+
!(row.origin_url && row.origin_url.toLowerCase().includes(term)) &&
|
|
174
|
+
!(row.username_value && row.username_value.toLowerCase().includes(term))) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
results.push({
|
|
164
178
|
profile: p.name,
|
|
165
179
|
source: store.source,
|
|
166
180
|
url: row.origin_url,
|
|
@@ -169,14 +183,7 @@ export function getPasswords({ chromeDir, profilePath, profile, search, browser,
|
|
|
169
183
|
? decryptValue(row.password_value, derivedKey ??= deriveEncryptionKey(root, browser))
|
|
170
184
|
: "",
|
|
171
185
|
lastUsed: chromeTimeToDate(row.date_last_used || 0),
|
|
172
|
-
};
|
|
173
|
-
if (term) {
|
|
174
|
-
if (!(entry.url && entry.url.toLowerCase().includes(term)) &&
|
|
175
|
-
!(entry.username && entry.username.toLowerCase().includes(term))) {
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
results.push(entry);
|
|
186
|
+
});
|
|
180
187
|
}
|
|
181
188
|
}
|
|
182
189
|
catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-tools",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.11",
|
|
4
4
|
"description": "CLI & Node.js library for chrome",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -52,16 +52,14 @@
|
|
|
52
52
|
"license": "MIT",
|
|
53
53
|
"packageManager": "pnpm@11.9.0",
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"better-sqlite3": "^13.0.3",
|
|
56
55
|
"chalk": "^6.0.0",
|
|
57
56
|
"cli-table3": "^0.6.5",
|
|
58
57
|
"commander": "^15.0.0"
|
|
59
58
|
},
|
|
60
59
|
"engines": {
|
|
61
|
-
"node": ">=22.
|
|
60
|
+
"node": ">=22.16.0"
|
|
62
61
|
},
|
|
63
62
|
"devDependencies": {
|
|
64
|
-
"@types/better-sqlite3": "^9.6.0",
|
|
65
63
|
"@types/node": "^26.1.2",
|
|
66
64
|
"oxlint": "1.77.0",
|
|
67
65
|
"typescript": "^7.0.2"
|