rechrome 1.46.0 → 1.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/e2e.js +2 -2
- package/e2e.ts +2 -2
- package/extension-token.js +37 -1
- package/extension-token.ts +37 -1
- package/fill-secret.js +542 -40
- package/fill-secret.ts +293 -33
- package/listeners.js +9 -5
- package/listeners.ts +10 -4
- package/package.json +9 -2
- package/rechrome.js +152 -34
- package/rechrome.ts +152 -34
- package/record.js +281 -0
- package/record.ts +251 -0
- package/rtc-channel.js +303 -0
- package/rtc-channel.ts +130 -0
- package/secret.js +15 -10
- package/secret.ts +2 -70
- package/serve.js +137 -13
- package/serve.ts +137 -13
- package/shares.js +49 -12
- package/shares.ts +39 -6
- package/token.js +88 -0
- package/token.ts +74 -0
- package/webrtc.js +92 -85
- package/webrtc.ts +6 -128
package/README.md
CHANGED
|
@@ -56,6 +56,9 @@ rech tab-list
|
|
|
56
56
|
rech eval "() => document.title"
|
|
57
57
|
rech --profile work@example.com open https://example.com # another registered profile
|
|
58
58
|
rech --isolate open https://accounts.example.com # throwaway session, e.g. for a login flow
|
|
59
|
+
rech --isolate=login open https://accounts.example.com # named isolated session: reused by later
|
|
60
|
+
rech --isolate=login fill-secret e12 --from-env PASSWORD # --isolate=login commands (same tabs)
|
|
61
|
+
rech session close login # until you close it (rech session ls lists it)
|
|
59
62
|
```
|
|
60
63
|
|
|
61
64
|
Any [playwright-cli](https://github.com/snomiao/playwright-cli) command works after `rech`. When a
|
|
@@ -174,6 +177,10 @@ Over **WebRTC** the two machines need no open port, VPN or domain. A small signa
|
|
|
174
177
|
encrypted with keys the signal server never sees. The first `rech` command after connecting starts
|
|
175
178
|
a small local bridge that keeps the connection open between commands.
|
|
176
179
|
|
|
180
|
+
**Opening the link in a browser shows what it gives**, without redeeming it: whether the host is
|
|
181
|
+
online, the share and when it ends, whether the invite is still open, its profiles, and its open
|
|
182
|
+
sessions. Anyone holding the link can see that. `rech connect` lists the open sessions too.
|
|
183
|
+
|
|
177
184
|
**The link is an invite, not a key.** `rech connect` redeems it once: this machine gets its own
|
|
178
185
|
device credential (saved to `.rechrome/.env.local`) and the invite stops working, so a link that
|
|
179
186
|
leaks after it was used is harmless, and if someone else used it first you'll know. Unused invites
|
|
@@ -545,7 +552,11 @@ Each client gets its own browser session, keyed by the **git worktree root path*
|
|
|
545
552
|
roll up to their parent), or the current directory outside git. So two worktrees of one repo
|
|
546
553
|
get separate sessions, and `git checkout` keeps you in the same one. `RECH_IDENTITY=branch`
|
|
547
554
|
restores the older `<remote>/tree/<branch>` keying and `RECH_IDENTITY=cwd` keys on the exact
|
|
548
|
-
directory. Pass `-s=<name>` for a named sub-session,
|
|
555
|
+
directory. Pass `-s=<name>` for a named sub-session, `--isolate` for a throwaway one (a new session per
|
|
556
|
+
command), or `--isolate=<name>` for a named isolated session (`-s=iso_<name>`) that later
|
|
557
|
+
`--isolate=<name>` commands from the same worktree reuse until `rech session close <name>`.
|
|
558
|
+
An isolated session is its own tab group in the same Chrome profile: it shares the profile's
|
|
559
|
+
cookies, so profile-scoped listeners still refuse `eval`, `run-code` and other arbitrary code in it.
|
|
549
560
|
A share device's sessions are additionally keyed by its share and device, so they never meet
|
|
550
561
|
another device's or a listener link's; `rech session ls` lists the ones this link drove.
|
|
551
562
|
|
package/e2e.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
//
|
|
2
|
+
// token.ts
|
|
3
3
|
var ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
4
4
|
var B58 = "[1-9A-HJ-NP-Za-km-z]";
|
|
5
5
|
var BODY_LEN = 22;
|
|
@@ -145,7 +145,7 @@ async function open(key, frame, th, state) {
|
|
|
145
145
|
var pack = (obj) => enc.encode(JSON.stringify(obj));
|
|
146
146
|
var unpack = (bytes) => JSON.parse(dec.decode(bytes));
|
|
147
147
|
function randomHex(n) {
|
|
148
|
-
return
|
|
148
|
+
return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(n)), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
149
149
|
}
|
|
150
150
|
export {
|
|
151
151
|
CONFIRM_TIMEOUT_MS,
|
package/e2e.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// The AES keys are HKDF(secret, transcriptHash) where the transcript binds both DTLS
|
|
8
8
|
// fingerprints: a relay that swaps in its own fingerprints derives different keys, and the
|
|
9
9
|
// mandatory key-confirmation round fails closed.
|
|
10
|
-
import { parseToken } from "./
|
|
10
|
+
import { parseToken } from "./token.ts";
|
|
11
11
|
|
|
12
12
|
export const PROTO = "rech-e2e-1";
|
|
13
13
|
export const MAX_CHUNK = 12_000; // plaintext bytes per sealed frame, well under SCTP limits
|
|
@@ -102,5 +102,5 @@ export async function open(key: CryptoKey, frame: ArrayBuffer | Uint8Array, th:
|
|
|
102
102
|
export const pack = (obj: unknown) => enc.encode(JSON.stringify(obj));
|
|
103
103
|
export const unpack = (bytes: Uint8Array) => JSON.parse(dec.decode(bytes));
|
|
104
104
|
export function randomHex(n: number): string {
|
|
105
|
-
return
|
|
105
|
+
return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(n)), b => b.toString(16).padStart(2, "0")).join("");
|
|
106
106
|
}
|
package/extension-token.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// extension-token.ts
|
|
3
|
-
import {
|
|
3
|
+
import { Database } from "bun:sqlite";
|
|
4
|
+
import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
|
|
5
|
+
import { tmpdir } from "os";
|
|
4
6
|
import { join } from "path";
|
|
5
7
|
function varint(b, cursor) {
|
|
6
8
|
let value = 0;
|
|
@@ -111,7 +113,41 @@ function* logRecords(b) {
|
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
}
|
|
116
|
+
function readExtensionTokenFromSqlite(userDataDir, profileDir, extensionId) {
|
|
117
|
+
const path = join(userDataDir, profileDir, "LocalStorage");
|
|
118
|
+
if (!existsSync(path))
|
|
119
|
+
return null;
|
|
120
|
+
const query = (file, readonly) => {
|
|
121
|
+
const db = new Database(file, readonly ? { readonly: true } : undefined);
|
|
122
|
+
try {
|
|
123
|
+
const row = db.query("SELECT e.value AS value, e.value_compression_type AS compression FROM map_entries e JOIN maps m ON m.row_id = e.map_id WHERE m.storage_key = ? AND e.key = ?").get(Buffer.from(`chrome-extension://${extensionId}/`, "latin1"), Buffer.from("\x01auth-token", "latin1"));
|
|
124
|
+
if (!row || row.compression !== 0)
|
|
125
|
+
return null;
|
|
126
|
+
const value = Buffer.from(row.value).toString("latin1");
|
|
127
|
+
return /^\x01[A-Za-z0-9_-]{43}$/.test(value) ? value.slice(1) : null;
|
|
128
|
+
} finally {
|
|
129
|
+
db.close();
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
return query(path, true);
|
|
134
|
+
} catch {}
|
|
135
|
+
const tmp = mkdtempSync(join(tmpdir(), "rech-ls-"));
|
|
136
|
+
try {
|
|
137
|
+
copyFileSync(path, join(tmp, "LocalStorage"));
|
|
138
|
+
if (existsSync(`${path}-wal`))
|
|
139
|
+
copyFileSync(`${path}-wal`, join(tmp, "LocalStorage-wal"));
|
|
140
|
+
return query(join(tmp, "LocalStorage"), false);
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
114
147
|
function readExtensionTokenFromProfile(userDataDir, profileDir, extensionId) {
|
|
148
|
+
return readExtensionTokenFromSqlite(userDataDir, profileDir, extensionId) ?? readExtensionTokenFromLeveldb(userDataDir, profileDir, extensionId);
|
|
149
|
+
}
|
|
150
|
+
function readExtensionTokenFromLeveldb(userDataDir, profileDir, extensionId) {
|
|
115
151
|
const dir = join(userDataDir, profileDir, "Local Storage", "leveldb");
|
|
116
152
|
const wanted = Buffer.from(`_chrome-extension://${extensionId}\x00\x01auth-token`, "latin1");
|
|
117
153
|
let newest = -1n, token = null;
|
package/extension-token.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
2
4
|
import { join } from "path";
|
|
3
5
|
|
|
4
6
|
function varint(b: Buffer, cursor: { p: number }): number {
|
|
@@ -87,7 +89,41 @@ function* logRecords(b: Buffer): Generator<Buffer> {
|
|
|
87
89
|
// Read files without opening the database or taking Chrome's LevelDB lock. Internal
|
|
88
90
|
// sequence numbers, not filenames/byte order, determine the latest value. Decode
|
|
89
91
|
// SST prefix compression and Snappy, and scope the key to the exact extension origin.
|
|
92
|
+
// Chromium 156+ keeps localStorage in a SQLite file `<profile>/LocalStorage` (maps + map_entries)
|
|
93
|
+
// instead of `Local Storage/leveldb`; keys and values keep the same 0x01-prefixed Latin-1 encoding.
|
|
94
|
+
function readExtensionTokenFromSqlite(userDataDir: string, profileDir: string, extensionId: string): string | null {
|
|
95
|
+
const path = join(userDataDir, profileDir, "LocalStorage");
|
|
96
|
+
if (!existsSync(path)) return null;
|
|
97
|
+
const query = (file: string, readonly: boolean): string | null => {
|
|
98
|
+
const db = new Database(file, readonly ? { readonly: true } : undefined);
|
|
99
|
+
try {
|
|
100
|
+
const row = db.query(
|
|
101
|
+
"SELECT e.value AS value, e.value_compression_type AS compression FROM map_entries e JOIN maps m ON m.row_id = e.map_id WHERE m.storage_key = ? AND e.key = ?",
|
|
102
|
+
).get(Buffer.from(`chrome-extension://${extensionId}/`, "latin1"), Buffer.from("\x01auth-token", "latin1")) as { value: Uint8Array; compression: number } | null;
|
|
103
|
+
if (!row || row.compression !== 0) return null;
|
|
104
|
+
const value = Buffer.from(row.value).toString("latin1");
|
|
105
|
+
return /^\x01[A-Za-z0-9_-]{43}$/.test(value) ? value.slice(1) : null;
|
|
106
|
+
} finally { db.close(); }
|
|
107
|
+
};
|
|
108
|
+
// Chrome keeps the file in WAL mode. While Chrome runs, a recent write (a freshly minted token)
|
|
109
|
+
// can sit in the -wal file only, and a read-only open sees it. Once Chrome has exited and
|
|
110
|
+
// removed the -shm file, a read-only open fails (SQLITE_CANTOPEN): then read a private copy of
|
|
111
|
+
// the file and its -wal, which never touches Chrome's own files. (No `file:` URIs: Bun's SQLite
|
|
112
|
+
// on Linux does not take them.)
|
|
113
|
+
try { return query(path, true); } catch {}
|
|
114
|
+
const tmp = mkdtempSync(join(tmpdir(), "rech-ls-"));
|
|
115
|
+
try {
|
|
116
|
+
copyFileSync(path, join(tmp, "LocalStorage"));
|
|
117
|
+
if (existsSync(`${path}-wal`)) copyFileSync(`${path}-wal`, join(tmp, "LocalStorage-wal"));
|
|
118
|
+
return query(join(tmp, "LocalStorage"), false);
|
|
119
|
+
} catch { return null; } finally { rmSync(tmp, { recursive: true, force: true }); }
|
|
120
|
+
}
|
|
121
|
+
|
|
90
122
|
export function readExtensionTokenFromProfile(userDataDir: string, profileDir: string, extensionId: string): string | null {
|
|
123
|
+
return readExtensionTokenFromSqlite(userDataDir, profileDir, extensionId) ?? readExtensionTokenFromLeveldb(userDataDir, profileDir, extensionId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readExtensionTokenFromLeveldb(userDataDir: string, profileDir: string, extensionId: string): string | null {
|
|
91
127
|
const dir = join(userDataDir, profileDir, "Local Storage", "leveldb");
|
|
92
128
|
const wanted = Buffer.from(`_chrome-extension://${extensionId}\0\x01auth-token`, "latin1");
|
|
93
129
|
let newest = -1n, token: string | null = null;
|