@symbols-cli/cli 0.0.1
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/LICENSE +8 -0
- package/README.md +103 -0
- package/dist/auth/client.js +531 -0
- package/dist/auth/credentials.js +293 -0
- package/dist/auth/hosts.js +85 -0
- package/dist/auth/loopback.js +108 -0
- package/dist/auth/pkce.js +33 -0
- package/dist/auth/wire.js +40 -0
- package/dist/commands/arm.js +154 -0
- package/dist/commands/curl.js +101 -0
- package/dist/commands/doctor.js +217 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logout.js +78 -0
- package/dist/commands/mcp.js +33 -0
- package/dist/commands/project.js +145 -0
- package/dist/commands/status.js +78 -0
- package/dist/commands/sync.js +94 -0
- package/dist/commands/uninstall.js +149 -0
- package/dist/commands/up.js +176 -0
- package/dist/commands/update.js +120 -0
- package/dist/commands/watch.js +155 -0
- package/dist/commands/whoami.js +103 -0
- package/dist/index.js +147 -0
- package/dist/mcp/scopes.js +215 -0
- package/dist/mcp/server.js +366 -0
- package/dist/mcp/tools.js +646 -0
- package/dist/skills/bundle.js +441 -0
- package/dist/skills/claude-md.js +135 -0
- package/dist/skills/install.js +188 -0
- package/dist/skills/settings-merge.js +107 -0
- package/dist/sync/api.js +380 -0
- package/dist/sync/diff.js +172 -0
- package/dist/sync/ledger.js +319 -0
- package/dist/sync/paths.js +447 -0
- package/dist/sync/protect.js +108 -0
- package/dist/sync/reconcile.js +870 -0
- package/dist/sync/watcher.js +206 -0
- package/dist/util/log.js +58 -0
- package/dist/util/platform.js +79 -0
- package/dist/util/version.js +24 -0
- package/package.json +44 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
// Where the device credential lives.
|
|
6
|
+
//
|
|
7
|
+
// TWO STORES, and which one is in use is reported by `symbols whoami` rather than
|
|
8
|
+
// assumed:
|
|
9
|
+
//
|
|
10
|
+
// * macOS: the login keychain, via `security(1)`. Not because it stops a
|
|
11
|
+
// same-uid process — it does not; `security find-generic-password` succeeds
|
|
12
|
+
// for anything running as this user without a prompt — but because it keeps
|
|
13
|
+
// the refresh token out of the filesystem, off Time Machine, out of `tar`
|
|
14
|
+
// backups, out of an accidental `git add ~`, and away from every tool that
|
|
15
|
+
// walks $HOME looking for dotfiles. That is the realistic exposure.
|
|
16
|
+
// * Everywhere else: `~/.symbols/credentials` at 0600, written 0600 from
|
|
17
|
+
// creation via `mode` + an explicit `fchmod`, never 0644-then-tightened.
|
|
18
|
+
//
|
|
19
|
+
// ⚠ Do not describe either as containment against the local agent. It runs as
|
|
20
|
+
// the same uid and can read both. The properties that actually bound a stolen
|
|
21
|
+
// credential are elsewhere: a 15-minute access TTL, server-side durable device
|
|
22
|
+
// revocation (`cli_device.revoked_at`, which survives an API restart), refresh
|
|
23
|
+
// reuse-detection that revokes the device on a PROVEN replay (a token the server
|
|
24
|
+
// can show it issued — see `cli_token_history`), and the fact that the CLI token
|
|
25
|
+
// carries no money scope at all.
|
|
26
|
+
//
|
|
27
|
+
// The ANTI-ROLLBACK COUNTER (S1) belongs here too, in the keychain half, for
|
|
28
|
+
// exactly the reason the plan gives: a counter the agent can rewind is theatre.
|
|
29
|
+
// It is a separate item so a bundle rollback cannot be achieved by deleting the
|
|
30
|
+
// credential and logging in again.
|
|
31
|
+
import { promises as fs } from "node:fs";
|
|
32
|
+
import { hostname, platform } from "node:os";
|
|
33
|
+
import { join, dirname } from "node:path";
|
|
34
|
+
import { execFile } from "node:child_process";
|
|
35
|
+
import { promisify } from "node:util";
|
|
36
|
+
import { symbolsHome } from "../util/platform.js";
|
|
37
|
+
import { eprint } from "../util/log.js";
|
|
38
|
+
const exec = promisify(execFile);
|
|
39
|
+
const SERVICE = "com.symbols.cli";
|
|
40
|
+
const ACCOUNT = "device-credential";
|
|
41
|
+
/**
|
|
42
|
+
* S1's anti-rollback counter — a SEPARATE keychain item, deliberately.
|
|
43
|
+
*
|
|
44
|
+
* Sharing the credential's item would make "downgrade the skills bundle" as easy
|
|
45
|
+
* as `symbols logout && symbols login`: the counter would be destroyed with the
|
|
46
|
+
* credential and the highest-sequence memory with it.
|
|
47
|
+
*/
|
|
48
|
+
const SEQUENCE_ACCOUNT = "skills-bundle-sequence";
|
|
49
|
+
export function credentialsPath() {
|
|
50
|
+
return join(symbolsHome(), "credentials");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Which store this process will use.
|
|
54
|
+
*
|
|
55
|
+
* `SYMBOLS_CREDENTIAL_STORE=file` forces the file store — needed for CI and for
|
|
56
|
+
* the conformance harness, where no keychain is unlocked.
|
|
57
|
+
*/
|
|
58
|
+
export function storeKind() {
|
|
59
|
+
const forced = process.env["SYMBOLS_CREDENTIAL_STORE"];
|
|
60
|
+
if (forced === "file")
|
|
61
|
+
return "file";
|
|
62
|
+
if (forced === "keychain")
|
|
63
|
+
return "keychain";
|
|
64
|
+
return platform() === "darwin" ? "keychain" : "file";
|
|
65
|
+
}
|
|
66
|
+
export async function save(cred) {
|
|
67
|
+
const kind = storeKind();
|
|
68
|
+
const blob = JSON.stringify(cred);
|
|
69
|
+
if (kind === "keychain") {
|
|
70
|
+
try {
|
|
71
|
+
await keychainWrite(blob);
|
|
72
|
+
// Belt and braces: if a file store was in use before (or the user switched
|
|
73
|
+
// platforms with a synced home), do not leave a stale credential behind
|
|
74
|
+
// where a later downgrade would silently pick it up.
|
|
75
|
+
await fs.rm(credentialsPath(), { force: true });
|
|
76
|
+
return "keychain";
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
// A locked or absent keychain must not be fatal — fall back and SAY SO, so
|
|
80
|
+
// the user is never told their token is in the keychain when it is on disk.
|
|
81
|
+
eprint(`symbols: keychain unavailable (${err instanceof Error ? err.message : String(err)}); ` +
|
|
82
|
+
`falling back to ${credentialsPath()} at 0600\n`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
await fileWrite(blob);
|
|
86
|
+
return "file";
|
|
87
|
+
}
|
|
88
|
+
export async function load() {
|
|
89
|
+
if (storeKind() === "keychain") {
|
|
90
|
+
const fromKeychain = await keychainRead();
|
|
91
|
+
if (fromKeychain)
|
|
92
|
+
return parse(fromKeychain);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return parse(await fs.readFile(credentialsPath(), "utf8"));
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err.code === "ENOENT")
|
|
99
|
+
return null;
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function clear() {
|
|
104
|
+
// Both stores, unconditionally. `logout` that leaves a copy behind in the
|
|
105
|
+
// other store is the worst possible outcome of this function.
|
|
106
|
+
await keychainDelete();
|
|
107
|
+
await fs.rm(credentialsPath(), { force: true });
|
|
108
|
+
}
|
|
109
|
+
function parse(blob) {
|
|
110
|
+
try {
|
|
111
|
+
const v = JSON.parse(blob);
|
|
112
|
+
if (!v.refreshToken || !v.deviceId || !v.origin || !v.userId)
|
|
113
|
+
return null;
|
|
114
|
+
return v;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** A stable, human-recognisable name for this machine in the device list. */
|
|
121
|
+
export function deviceLabel() {
|
|
122
|
+
return `${hostname()} (${platform()})`;
|
|
123
|
+
}
|
|
124
|
+
export function bundleSequenceStore() {
|
|
125
|
+
return storeKind() === "keychain" ? "keychain" : "file";
|
|
126
|
+
}
|
|
127
|
+
function sequencePath() {
|
|
128
|
+
return join(symbolsHome(), "bundle-sequence");
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The highest bundle sequence this machine has ever accepted. 0 when unset.
|
|
132
|
+
*
|
|
133
|
+
* ⚠ THE PAYLOAD IS JSON, NOT A BARE INTEGER.
|
|
134
|
+
*
|
|
135
|
+
* It was originally JSON to dodge a bug: `keychainReadAccount` hex-decoded any
|
|
136
|
+
* all-hex, even-length value, so a bare `"12"` came back as `"\x12"` and the
|
|
137
|
+
* counter silently reset to 0 on exactly the values that look like hex — 12, 20,
|
|
138
|
+
* 22, 30, … **That branch has since been DELETED at the source**, so the
|
|
139
|
+
* workaround is no longer load-bearing; JSON is kept because it is
|
|
140
|
+
* self-describing and lets the payload gain a field without a format change.
|
|
141
|
+
*
|
|
142
|
+
* (Recording the correction rather than deleting it: a comment that describes a
|
|
143
|
+
* mechanism which no longer exists is what made the SEC-8 gamble look safe in
|
|
144
|
+
* `client.ts`. Stale reasoning outlives stale code.)
|
|
145
|
+
*/
|
|
146
|
+
export async function readBundleSequence() {
|
|
147
|
+
const raw = bundleSequenceStore() === "keychain"
|
|
148
|
+
? await keychainReadAccount(SEQUENCE_ACCOUNT)
|
|
149
|
+
: await fs.readFile(sequencePath(), "utf8").catch(() => null);
|
|
150
|
+
if (!raw)
|
|
151
|
+
return 0;
|
|
152
|
+
let n = 0;
|
|
153
|
+
try {
|
|
154
|
+
const v = JSON.parse(raw);
|
|
155
|
+
n = typeof v.sequence === "number" ? v.sequence : 0;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
n = 0;
|
|
159
|
+
}
|
|
160
|
+
// A corrupt or missing counter reads as 0 — permissive, and deliberately so:
|
|
161
|
+
// failing closed here would brick the CLI on a keychain hiccup, while failing
|
|
162
|
+
// open costs only the rollback floor, which is re-established on the next
|
|
163
|
+
// accepted bundle. The bundle still has to VERIFY, which is the control that
|
|
164
|
+
// may never degrade.
|
|
165
|
+
return Number.isInteger(n) && n > 0 ? n : 0;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Raise the floor. Never lowers it, even if asked.
|
|
169
|
+
*
|
|
170
|
+
* Returns the value now in force, so a caller can log what actually happened
|
|
171
|
+
* rather than what it requested.
|
|
172
|
+
*/
|
|
173
|
+
export async function recordBundleSequence(seq) {
|
|
174
|
+
const current = await readBundleSequence();
|
|
175
|
+
if (!Number.isInteger(seq) || seq <= current)
|
|
176
|
+
return current;
|
|
177
|
+
const blob = JSON.stringify({ sequence: seq });
|
|
178
|
+
if (bundleSequenceStore() === "keychain") {
|
|
179
|
+
try {
|
|
180
|
+
await keychainWriteAccount(SEQUENCE_ACCOUNT, blob);
|
|
181
|
+
return seq;
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
eprint(`symbols: keychain unavailable for the bundle counter ` +
|
|
185
|
+
`(${err instanceof Error ? err.message : String(err)}); using ${sequencePath()}\n`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
await fs.mkdir(dirname(sequencePath()), { recursive: true, mode: 0o700 });
|
|
189
|
+
await fs.writeFile(sequencePath(), `${blob}\n`, { mode: 0o600 });
|
|
190
|
+
return seq;
|
|
191
|
+
}
|
|
192
|
+
// ── file store ───────────────────────────────────────────────────────────────
|
|
193
|
+
async function fileWrite(blob) {
|
|
194
|
+
const path = credentialsPath();
|
|
195
|
+
await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
196
|
+
// Write-then-rename so a crash never leaves a truncated credential, and open
|
|
197
|
+
// the temp file 0600 from creation — a 0644 window, however brief, is enough
|
|
198
|
+
// for another local user to win a read on a shared machine.
|
|
199
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
200
|
+
const handle = await fs.open(tmp, "wx", 0o600);
|
|
201
|
+
try {
|
|
202
|
+
await handle.writeFile(blob, "utf8");
|
|
203
|
+
// `mode` on open is masked by umask; fchmod is not.
|
|
204
|
+
await handle.chmod(0o600);
|
|
205
|
+
await handle.sync();
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
await handle.close();
|
|
209
|
+
}
|
|
210
|
+
await fs.rename(tmp, path);
|
|
211
|
+
await fs.chmod(path, 0o600);
|
|
212
|
+
}
|
|
213
|
+
// ── keychain store (macOS) ───────────────────────────────────────────────────
|
|
214
|
+
// Two items live in this keychain service — the device credential and S1's
|
|
215
|
+
// anti-rollback counter — so every helper takes the ACCOUNT. `keychainWrite`
|
|
216
|
+
// and friends keep their old signatures for the credential's call sites.
|
|
217
|
+
async function keychainWrite(blob) {
|
|
218
|
+
return keychainWriteAccount(ACCOUNT, blob);
|
|
219
|
+
}
|
|
220
|
+
async function keychainRead() {
|
|
221
|
+
return keychainReadAccount(ACCOUNT);
|
|
222
|
+
}
|
|
223
|
+
async function keychainWriteAccount(account, blob) {
|
|
224
|
+
// ⚠ VERIFIED on macOS 25.2, because the obvious "safer" form is a trap:
|
|
225
|
+
// `security add-generic-password … -w -` does NOT read stdin — it stores the
|
|
226
|
+
// literal one-character string "-", so a credential written that way is
|
|
227
|
+
// silently garbage. Both supported forms (`-w <secret>` and `-X <hex>`) pass
|
|
228
|
+
// the secret as an ARGV VALUE, briefly visible in `ps` to this uid and to
|
|
229
|
+
// root. Both are already inside the trust boundary, so this is noted rather
|
|
230
|
+
// than defended; eliminating it needs the native Keychain API (a native
|
|
231
|
+
// module), which is not worth a build dependency in v1.
|
|
232
|
+
//
|
|
233
|
+
// `-X` is chosen over `-w` anyway: hex keeps the token out of anything that
|
|
234
|
+
// scrapes process lists for token-shaped strings, and it is byte-exact for
|
|
235
|
+
// payloads `-w` would mangle.
|
|
236
|
+
await exec("security", [
|
|
237
|
+
"add-generic-password",
|
|
238
|
+
"-a",
|
|
239
|
+
account,
|
|
240
|
+
"-s",
|
|
241
|
+
SERVICE,
|
|
242
|
+
"-U", // update in place; without it a second login errors with a duplicate
|
|
243
|
+
"-D",
|
|
244
|
+
account === SEQUENCE_ACCOUNT
|
|
245
|
+
? "Symbols CLI skills-bundle anti-rollback counter"
|
|
246
|
+
: "Symbols CLI device credential",
|
|
247
|
+
"-X",
|
|
248
|
+
Buffer.from(blob, "utf8").toString("hex"),
|
|
249
|
+
]);
|
|
250
|
+
}
|
|
251
|
+
async function keychainReadAccount(account) {
|
|
252
|
+
try {
|
|
253
|
+
const { stdout } = await exec("security", [
|
|
254
|
+
"find-generic-password",
|
|
255
|
+
"-a",
|
|
256
|
+
account,
|
|
257
|
+
"-s",
|
|
258
|
+
SERVICE,
|
|
259
|
+
"-w",
|
|
260
|
+
]);
|
|
261
|
+
const raw = stdout.trim();
|
|
262
|
+
if (!raw)
|
|
263
|
+
return null;
|
|
264
|
+
// ⚠ RETURNED VERBATIM. There is deliberately NO hex-decode branch.
|
|
265
|
+
//
|
|
266
|
+
// An earlier version added one "so a future non-JSON payload cannot fail
|
|
267
|
+
// silently", guarded by `all-hex && even length`. That guard matches far more
|
|
268
|
+
// than intended: the string "12" is all-hex and even-length, so a stored
|
|
269
|
+
// value of `12` came back as `"\x12"`. The P2 agent hit it storing an
|
|
270
|
+
// anti-rollback counter — the floor silently vanished on 12, 20, 22, 30…
|
|
271
|
+
// and a rollback protection that evaporates on some values is worse than one
|
|
272
|
+
// that is absent, because it is believed.
|
|
273
|
+
//
|
|
274
|
+
// `-w` prints the secret as text whenever it is valid UTF-8, including one
|
|
275
|
+
// written with `-X` (verified on macOS 25.2). Every payload this CLI stores
|
|
276
|
+
// is UTF-8 JSON, so the text path is the only path. A future caller needing
|
|
277
|
+
// binary must base64 it BEFORE storing rather than reintroducing a guess
|
|
278
|
+
// here — a heuristic that inspects the value cannot distinguish encodings it
|
|
279
|
+
// was never told about.
|
|
280
|
+
return raw;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function keychainDelete() {
|
|
287
|
+
try {
|
|
288
|
+
await exec("security", ["delete-generic-password", "-a", ACCOUNT, "-s", SERVICE]);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Not present is the desired end state.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
// S3 — host pinning for every credential-bearing request.
|
|
6
|
+
//
|
|
7
|
+
// Any process running as this user can set SYMBOLS_API_URL and redirect the next
|
|
8
|
+
// refresh — and the rotating refresh token with it — to a host it controls. So
|
|
9
|
+
// the API origin is an allowlist, not configuration.
|
|
10
|
+
//
|
|
11
|
+
// ⚠ SCOPE, stated honestly: this closes the REMOTE redirect (a stray env var, a
|
|
12
|
+
// network attacker). It does NOT bind an agent running as the same uid, which
|
|
13
|
+
// can read the credential file outright or replace this binary. Never describe
|
|
14
|
+
// host-pinning as containment against the agent — the credential's real defences
|
|
15
|
+
// are its 15-minute TTL, durable device revocation, and the fact that it carries
|
|
16
|
+
// no money scope at all.
|
|
17
|
+
//
|
|
18
|
+
// Mirrors the discipline `google_signin` already applies to `web_base`
|
|
19
|
+
// (apps/desktop/src-tauri/src/main.rs:408-419).
|
|
20
|
+
const ALLOWED_ORIGINS = ["https://symbols.finance", "https://www.symbols.finance"];
|
|
21
|
+
/** Localhost is permitted only when explicitly opted into, for development. */
|
|
22
|
+
function devOriginAllowed(origin) {
|
|
23
|
+
if (process.env["SYMBOLS_ALLOW_INSECURE_ORIGIN"] !== "1")
|
|
24
|
+
return false;
|
|
25
|
+
try {
|
|
26
|
+
const u = new URL(origin);
|
|
27
|
+
return ((u.protocol === "http:" || u.protocol === "https:") &&
|
|
28
|
+
(u.hostname === "127.0.0.1" || u.hostname === "localhost"));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class DisallowedOriginError extends Error {
|
|
35
|
+
constructor(origin) {
|
|
36
|
+
super(`refusing to use API origin '${origin}'. Allowed: ${ALLOWED_ORIGINS.join(", ")}. ` +
|
|
37
|
+
`Set SYMBOLS_ALLOW_INSECURE_ORIGIN=1 to permit http://127.0.0.1 for development.`);
|
|
38
|
+
this.name = "DisallowedOriginError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The API origin for this process, validated.
|
|
43
|
+
*
|
|
44
|
+
* Structural comparison on a parsed URL — never a prefix or substring test, which
|
|
45
|
+
* is what lets `https://symbols.finance.evil.com` through.
|
|
46
|
+
*/
|
|
47
|
+
export function apiOrigin() {
|
|
48
|
+
const raw = (process.env["SYMBOLS_API_URL"] ?? ALLOWED_ORIGINS[0]).trim().replace(/\/+$/, "");
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = new URL(raw);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new DisallowedOriginError(raw);
|
|
55
|
+
}
|
|
56
|
+
// Compare the normalised origin, so userinfo, a path, or a query cannot smuggle
|
|
57
|
+
// an allowed hostname past the check.
|
|
58
|
+
const origin = parsed.origin;
|
|
59
|
+
if (parsed.username || parsed.password)
|
|
60
|
+
throw new DisallowedOriginError(raw);
|
|
61
|
+
if (ALLOWED_ORIGINS.includes(origin))
|
|
62
|
+
return origin;
|
|
63
|
+
if (devOriginAllowed(origin))
|
|
64
|
+
return origin;
|
|
65
|
+
throw new DisallowedOriginError(raw);
|
|
66
|
+
}
|
|
67
|
+
export function isAllowedOrigin(candidate) {
|
|
68
|
+
try {
|
|
69
|
+
const saved = process.env["SYMBOLS_API_URL"];
|
|
70
|
+
process.env["SYMBOLS_API_URL"] = candidate;
|
|
71
|
+
try {
|
|
72
|
+
apiOrigin();
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
if (saved === undefined)
|
|
77
|
+
delete process.env["SYMBOLS_API_URL"];
|
|
78
|
+
else
|
|
79
|
+
process.env["SYMBOLS_API_URL"] = saved;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
// The loopback listener that receives the exchange code from the browser.
|
|
6
|
+
//
|
|
7
|
+
// Ported from the Tauri desktop flow (apps/desktop/src-tauri/src/main.rs:403-494),
|
|
8
|
+
// keeping its properties rather than its code. Each one is here for a reason:
|
|
9
|
+
//
|
|
10
|
+
// * BIND THE PORT BEFORE OPENING THE BROWSER. That ownership is the security
|
|
11
|
+
// boundary — the desktop source says so in as many words. Open the browser
|
|
12
|
+
// first and there is a window where another local process can bind the port
|
|
13
|
+
// the redirect is about to target.
|
|
14
|
+
// * A 16-byte CSPRNG nonce, echoed by the browser and checked here. Combined
|
|
15
|
+
// with PKCE this means a racing process needs both the port AND the nonce AND
|
|
16
|
+
// the verifier.
|
|
17
|
+
// * ONLY `/cb`. Everything else is a 404, so the listener is not a general
|
|
18
|
+
// local HTTP surface for the duration.
|
|
19
|
+
// * `Referrer-Policy: no-referrer` on the response, so the code cannot leak via
|
|
20
|
+
// the Referer header when the success page links onward.
|
|
21
|
+
// * `Cache-Control: no-store`, so it does not sit in a disk cache.
|
|
22
|
+
// * One shot, five-minute deadline. The listener closes after the first valid
|
|
23
|
+
// callback or the timeout, whichever comes first.
|
|
24
|
+
import { createServer } from "node:http";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
const DEADLINE_MS = 5 * 60 * 1000;
|
|
27
|
+
const SUCCESS_HTML = `<!doctype html><meta charset="utf-8">
|
|
28
|
+
<title>Signed in</title>
|
|
29
|
+
<body style="font:16px system-ui;padding:3rem;text-align:center">
|
|
30
|
+
<h1>Signed in</h1><p>You can close this tab and return to your terminal.</p>`;
|
|
31
|
+
/**
|
|
32
|
+
* Start the listener. **Await this before opening the browser** — the returned
|
|
33
|
+
* `redirectUri` is only valid because the port is already bound.
|
|
34
|
+
*/
|
|
35
|
+
export async function startLoopback() {
|
|
36
|
+
const nonce = randomBytes(16).toString("hex");
|
|
37
|
+
let settle;
|
|
38
|
+
let fail;
|
|
39
|
+
const received = new Promise((res, rej) => {
|
|
40
|
+
settle = res;
|
|
41
|
+
fail = rej;
|
|
42
|
+
});
|
|
43
|
+
const server = createServer((req, res) => {
|
|
44
|
+
// The listener exists for exactly one path. Anything else is not ours.
|
|
45
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
46
|
+
if (url.pathname !== "/cb") {
|
|
47
|
+
res.writeHead(404, { "cache-control": "no-store" }).end();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const code = url.searchParams.get("code");
|
|
51
|
+
const gotNonce = url.searchParams.get("nonce");
|
|
52
|
+
if (!gotNonce || gotNonce !== nonce) {
|
|
53
|
+
// Someone else's callback, or a probe. Do not reveal which.
|
|
54
|
+
res.writeHead(400, { "cache-control": "no-store" }).end("bad request");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (!code) {
|
|
58
|
+
res.writeHead(400, { "cache-control": "no-store" }).end("missing code");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
res
|
|
62
|
+
.writeHead(200, {
|
|
63
|
+
"content-type": "text/html; charset=utf-8",
|
|
64
|
+
// The code is in this request's URL; no-referrer stops it riding along
|
|
65
|
+
// to any host the success page might reference.
|
|
66
|
+
"referrer-policy": "no-referrer",
|
|
67
|
+
"cache-control": "no-store",
|
|
68
|
+
})
|
|
69
|
+
.end(SUCCESS_HTML);
|
|
70
|
+
settle({ code });
|
|
71
|
+
close();
|
|
72
|
+
});
|
|
73
|
+
let closed = false;
|
|
74
|
+
const close = () => {
|
|
75
|
+
if (closed)
|
|
76
|
+
return;
|
|
77
|
+
closed = true;
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
// ⚠ `close()` alone only stops NEW connections — keep-alive sockets stay
|
|
80
|
+
// open and keep being served, so the listener would still answer after the
|
|
81
|
+
// code was delivered. That makes "one shot" nominal rather than real, and it
|
|
82
|
+
// leaves a local HTTP surface open for the rest of the process's life.
|
|
83
|
+
// `closeAllConnections` is what actually ends it. Caught by the
|
|
84
|
+
// "one shot" test, which used a keep-alive fetch and got a 404 instead of a
|
|
85
|
+
// connection refusal.
|
|
86
|
+
server.closeAllConnections?.();
|
|
87
|
+
server.close();
|
|
88
|
+
};
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
fail(new Error("timed out waiting for the browser (5 minutes)"));
|
|
91
|
+
close();
|
|
92
|
+
}, DEADLINE_MS);
|
|
93
|
+
// Do not hold the process open on this timer alone.
|
|
94
|
+
timer.unref?.();
|
|
95
|
+
// 127.0.0.1 explicitly — NOT `::` and not 0.0.0.0, which would accept the
|
|
96
|
+
// callback from off-machine.
|
|
97
|
+
await new Promise((resolve, reject) => {
|
|
98
|
+
server.once("error", reject);
|
|
99
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
100
|
+
});
|
|
101
|
+
const addr = server.address();
|
|
102
|
+
return {
|
|
103
|
+
redirectUri: `http://127.0.0.1:${addr.port}/cb`,
|
|
104
|
+
nonce,
|
|
105
|
+
received,
|
|
106
|
+
close,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
// PKCE (RFC 7636), S256 only.
|
|
6
|
+
//
|
|
7
|
+
// The CLI is a PUBLIC client: it ships to users' machines and holds no secret, so
|
|
8
|
+
// "prove you are the app" is impossible. PKCE proves something weaker and
|
|
9
|
+
// sufficient instead — "you are the same process that STARTED this login."
|
|
10
|
+
//
|
|
11
|
+
// That is exactly the property needed here. The exchange code travels back over
|
|
12
|
+
// a loopback port that any local process could race for. Without PKCE, winning
|
|
13
|
+
// that race is enough to redeem the code; with it, the winner also needs the
|
|
14
|
+
// verifier, which never leaves this process's memory.
|
|
15
|
+
//
|
|
16
|
+
// `plain` is in the RFC and is deliberately not implemented: it makes the
|
|
17
|
+
// challenge equal the verifier, so anyone who observes one has the other, which
|
|
18
|
+
// defeats the entire mechanism. The server refuses anything but S256 too.
|
|
19
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
20
|
+
/** RFC 7636 §4.1: 43–128 chars from the unreserved set. 32 bytes → 43 chars. */
|
|
21
|
+
export function createPkce() {
|
|
22
|
+
const verifier = base64url(randomBytes(32));
|
|
23
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
24
|
+
return { verifier, challenge, method: "S256" };
|
|
25
|
+
}
|
|
26
|
+
/** base64url without padding — `+/=` are not URL-safe and the RFC forbids `=`. */
|
|
27
|
+
function base64url(buf) {
|
|
28
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
29
|
+
}
|
|
30
|
+
/** Exported for the test: recompute a challenge the way the server will. */
|
|
31
|
+
export function challengeFor(verifier) {
|
|
32
|
+
return base64url(createHash("sha256").update(verifier).digest());
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
/**
|
|
6
|
+
* Redeem the exchange code.
|
|
7
|
+
*
|
|
8
|
+
* ⚠ NO `device_id`. The server assigns it and returns it. An earlier client
|
|
9
|
+
* generated one here and sent it, where it was silently ignored — so the client
|
|
10
|
+
* stored an id that named no row, and `symbols logout` revoked nothing.
|
|
11
|
+
*/
|
|
12
|
+
export function tokenBody(args) {
|
|
13
|
+
return {
|
|
14
|
+
code: args.code,
|
|
15
|
+
code_verifier: args.verifier,
|
|
16
|
+
next_token_hash: args.nextTokenHash,
|
|
17
|
+
device_name: args.deviceName,
|
|
18
|
+
device_platform: args.devicePlatform,
|
|
19
|
+
client_version: args.clientVersion,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Rotate, write-ahead.
|
|
24
|
+
*
|
|
25
|
+
* `next_token_hash` names a token the client has ALREADY written to disk, so the
|
|
26
|
+
* server never has to hand a credential back — which is the capability that made
|
|
27
|
+
* an earlier `refresh` a session takeover.
|
|
28
|
+
*/
|
|
29
|
+
export function refreshBody(cred, nextTokenHash) {
|
|
30
|
+
return { refresh_token: cred.refreshToken, next_token_hash: nextTokenHash };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Revoke this device.
|
|
34
|
+
*
|
|
35
|
+
* The refresh token, NOT the device id — the id is an identifier, not a secret,
|
|
36
|
+
* and accepting it would let anyone who learned one sign that laptop out.
|
|
37
|
+
*/
|
|
38
|
+
export function logoutBody(cred) {
|
|
39
|
+
return { refresh_token: cred.refreshToken };
|
|
40
|
+
}
|