prism-mcp-server 20.14.0 → 20.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Prism Coder
2
2
 
3
- **Give your AI agent memory that lasts.** Persistent sessions, knowledge graphs, and offline tool-routing fully local and free.
3
+ **Give your AI agent memory that lasts — and see the cloud tokens it never had to spend.** Persistent sessions, knowledge graphs, offline tool-routing, and an auditable savings meter. Fully local and free.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/prism-mcp-server?color=cb0000&label=npm)](https://www.npmjs.com/package/prism-mcp-server)
6
6
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-00ADD8)](https://github.com/modelcontextprotocol/servers)
@@ -11,7 +11,7 @@
11
11
  <img src="docs/mind-palace-dashboard-v20.8.png" alt="Prism Mind Palace dashboard v20.8.0 — project state with handoff summary, pending TODOs, intent health, neural graph, and time-travel history" width="700" />
12
12
  </p>
13
13
 
14
- Prism Coder is an [MCP server](https://modelcontextprotocol.io) that gives Claude, Cursor, and other AI tools long-term memory that survives across sessions. It ships with the open-weight `prism-coder` model fleet (2B–27B) for fast, offline tool-routing — no cloud required.
14
+ Prism Coder is an [MCP server](https://modelcontextprotocol.io) that gives Claude, Cursor, and other AI tools long-term memory that survives across sessions. It ships with the open-weight `prism-coder` model fleet (2B–27B) for fast, offline tool-routing — no cloud required. And it keeps score: every call served locally is metered, so `prism savings` shows the token volume that never reached your cloud model — [measured honestly, in tokens](#local_savings--what-local-serving-actually-displaced).
15
15
 
16
16
  **No account needed. No API keys. Runs on your machine.**
17
17
  A paid subscription adds cloud sync, higher model tiers, and team features through the [Synalux portal](https://synalux.ai).
@@ -25,6 +25,11 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
25
25
  - **Local-first inference** — bounded work is routed through local Ollama models
26
26
  first, with automatic 2B/4B/9B/27B selection based on installed models,
27
27
  available RAM, context fit, and subscription entitlements.
28
+ - **A savings meter you can audit** — `prism savings` (or the `local_savings`
29
+ tool from any host) reports the token volume local serving kept off your
30
+ cloud model: headline, local share, per-model breakdown. It reports tokens,
31
+ never an invented dollar figure, and prints its assumptions and known
32
+ undercounts inline — a number you can check, not marketing.
28
33
  - **Route-output enforcement** — route mode returns only well-formed calls to
29
34
  tools the host actually advertised. Standard and higher plans can add
30
35
  authenticated deterministic correction; `route_guard: "local"` keeps the
@@ -56,6 +61,23 @@ Prism works locally without an account, API key, or cloud subscription. Add a
56
61
  Synalux subscription when you want cloud memory, paid-tier skills, or team
57
62
  features.
58
63
 
64
+ After a few sessions, ask what it's been worth:
65
+
66
+ ```bash
67
+ prism savings --period month
68
+ ```
69
+
70
+ ```
71
+ 💾 Local serving — LAST 30 DAYS
72
+ ~510K tokens kept off your cloud model
73
+ 53 call(s) served locally of 58 routed (91%)
74
+ ```
75
+
76
+ Your numbers will differ — that's the point: it reports what *your* machine
77
+ actually served, not a projection. Full report anatomy and the honesty rules
78
+ behind it are in the
79
+ [`local_savings` section](#local_savings--what-local-serving-actually-displaced).
80
+
59
81
  ### Install as a plugin
60
82
 
61
83
  Prism also ships as a plugin, which registers the MCP server and the startup
@@ -116,6 +138,25 @@ or by re-enabling after each run.
116
138
  <details>
117
139
  <summary>Release history (optional)</summary>
118
140
 
141
+ ## What's New in v20.16.0
142
+
143
+ ### See What Local Serving Saves You — Meterable, Auditable, Team-Wide
144
+
145
+ - **`local_savings` tool + `prism savings` CLI** — the token volume local
146
+ serving kept off your cloud model: all time, trailing 30/7 days, or any
147
+ `--days N` window. Tokens, never an invented dollar figure, with the
148
+ assumptions and known undercounts printed inline.
149
+ - **Team roll-up (paid)** — `prism savings --sync-enable` uploads per-day
150
+ counters only (never content; the payload is a closed field set the server
151
+ also enforces); `prism savings --team` shows the workspace-wide total with
152
+ per-member share. Off by default.
153
+ - **E2E sync foundation** — sealed multi-recipient envelopes (X25519 +
154
+ HKDF-SHA256 + AES-256-GCM on node:crypto, no new dependency) and per-device
155
+ identities, adversarially reviewed: cross-machine session sync will ship on
156
+ a relay that only ever stores ciphertext.
157
+ - **Push-time public-leak guard** — outgoing diffs AND commit messages are
158
+ scanned before anything leaves the machine.
159
+
119
160
  ## What's New in v20.12.1
120
161
 
121
162
  - **`prism connect --refresh` now converges every registration it owns**, not
@@ -724,6 +765,12 @@ Every conversation feeds a persistent store. The next session loads the right co
724
765
 
725
766
  The dashboard shows your current project state, pending TODOs, intent health, and a neural knowledge graph — all built automatically from your agent sessions.
726
767
 
768
+ It runs on loopback and is gated by a per-startup token by default — open the
769
+ tokenized URL printed in the startup log (`http://localhost:3000/?token=…`).
770
+ Requests with an untrusted `Host`/`Origin` are refused, closing the DNS-rebinding
771
+ exposure fixed in GHSA-9cvx-7x8q-3g6m. See [docs/IDE_SETUP.md](docs/IDE_SETUP.md#securing-the-dashboard)
772
+ to pin the token, disable it, or configure Basic Auth / JWKS.
773
+
727
774
  ### Export — read the record outside the agent
728
775
 
729
776
  `session_export_memory` writes your memory out as plain files you can read,
@@ -1055,6 +1102,7 @@ Prism exposes 40+ MCP tools. The core memory loop:
1055
1102
  | `knowledge_ingest` | Teach Prism a codebase or document |
1056
1103
  | `prism_infer` | Local-first inference (route/chat/code modes, thinking, cloud escalation) |
1057
1104
  | `inference_metrics` | Session delegation or persisted MCP + VS Code panel local/cloud stats |
1105
+ | `local_savings` | Token volume local serving kept off your cloud model, all time / 30 days / session |
1058
1106
 
1059
1107
  ### `query_memory_natural` — memory first, current sources when needed
1060
1108
 
@@ -1112,6 +1160,68 @@ The same block also appears automatically in `session_save_ledger` and `session_
1112
1160
 
1113
1161
  **Note:** The default session view tracks this MCP process's `prism_infer` delegation. The all-time view combines persisted MCP calls with Synalux VS Code panel inference. Neither view includes the host agent's own token spend; use that host's native usage reporting when available.
1114
1162
 
1163
+ ### `local_savings` — what local serving actually displaced
1164
+
1165
+ `inference_metrics` reports raw counters. `local_savings` answers the question
1166
+ behind them: how much work never reached your cloud model. Call the tool in any
1167
+ host, or run `prism savings` from a terminal — `--period all|month|week|session`,
1168
+ `--days N` for any custom trailing window (e.g. `--days 90` for a quarter), and
1169
+ `--json` for machine-readable output:
1170
+
1171
+ ```
1172
+ 💾 Local serving — LAST 30 DAYS (2026-08-02 → 2026-08-26)
1173
+
1174
+ ~510K tokens kept off your cloud model
1175
+ 53 call(s) served locally of 58 routed (91%)
1176
+ Breakdown: 461,400 prompt + 48,400 completion
1177
+
1178
+ By model:
1179
+ prism-coder:9b: 41 call(s), ~505K tokens
1180
+ prism-coder:4b: 12 call(s), ~4.8K tokens
1181
+
1182
+ Counts tokens a local model handled instead of your cloud model. On the token
1183
+ axis, the token count is measured — a floor, with known undercounts listed
1184
+ when present. On the displacement axis, prism cannot observe the call your
1185
+ host would have made, so whether all of it would have hit the cloud is an
1186
+ assumption. Read it as: at most this much displacement, of at least this
1187
+ token volume.
1188
+
1189
+ Caveats:
1190
+ · 12 local call(s) hit the KV cache, so Ollama reported 0 prompt tokens for
1191
+ context that was really submitted — prompt tokens are undercounted.
1192
+ · 3 refused call(s) excluded — nothing was served, so nothing was displaced.
1193
+ ```
1194
+
1195
+ **Why tokens and not money.** Prism reports token volume and never a dollar
1196
+ figure, because it cannot honestly produce one: published rates change and a
1197
+ bundled price table would be wrong on a timer; prism never observes which model
1198
+ your host would have used, and that choice alone is a multiple-fold spread on
1199
+ the same tokens; and most users are on flat plans where a currency figure means
1200
+ nothing at all. Tokens are the one unit prism measured itself. If you know your
1201
+ own effective rate, multiply — the split is printed for exactly that reason.
1202
+
1203
+ Refused calls are excluded, the VS Code panel-playground share is disclosed
1204
+ separately, and the known sources of undercount are listed inline rather than
1205
+ left implicit — so the durable (`week`/`month`/`all`/`--days`) headline is a
1206
+ measured floor rather than a number that merely looks precise. Figures are
1207
+ per-machine: each machine reports its own local ledger.
1208
+
1209
+ **Team roll-up (paid).** On paid plans, `prism savings --sync-enable` opts this
1210
+ machine into savings sync: once a session, prism uploads per-day **counters** —
1211
+ call counts and token totals, never prompts, completions, project names, or
1212
+ model output (the upload payload is a closed field set; the server rejects
1213
+ anything else). `prism savings --team` (or the `local_savings` tool with
1214
+ `scope: "team"`) then shows the roll-up across your workspace: total tokens
1215
+ kept off cloud models, per-member share, device counts. Members who haven't
1216
+ opted in simply aren't counted, so the team figure is a floor. Sync is off by
1217
+ default, `--sync-disable` stops it, and the deeper session-content sync
1218
+ (handoffs) is a separate channel with end-to-end encryption — the two are
1219
+ deliberately not unified, because an E2E blob cannot be aggregated and a
1220
+ counter channel must never carry content. The `session` view is the one
1221
+ exception: on KV-cache hits it estimates submitted prompt tokens from text
1222
+ (the ledger counts the measured 0 instead), so it is marked `(est.)` and says
1223
+ so whenever that happens.
1224
+
1115
1225
  ### Local-model delegation (default)
1116
1226
 
1117
1227
  Prism routes qualifying bounded work—bulk classification, field extraction,
package/dist/cli.js CHANGED
@@ -1040,6 +1040,100 @@ scmCmd
1040
1040
  process.exit(1);
1041
1041
  }
1042
1042
  });
1043
+ // ─── prism savings ────────────────────────────────────────────
1044
+ // Surfaces the local-serving meter outside an MCP host. Reports TOKENS, not
1045
+ // money — see the header of tools/savingsHandler.ts for why a dollar figure
1046
+ // would be fabricated.
1047
+ program
1048
+ .command('savings')
1049
+ .description('Show token volume displaced by local serving (all time by default)')
1050
+ .option('-p, --period <period>', 'Window: all | month | week | session', 'all')
1051
+ .option('-d, --days <n>', 'Custom trailing window in days (overrides --period)')
1052
+ .option('--team', 'Show the team roll-up from the portal (paid; members who opted in)')
1053
+ .option('--workspace <id>', 'Workspace id for --team')
1054
+ .option('--sync-enable', 'Opt in to savings sync (paid): daily COUNTERS only, never content')
1055
+ .option('--sync-disable', 'Opt out of savings sync')
1056
+ .option('--sync-now', 'Push the trailing window of daily counters now (requires sync enabled first)')
1057
+ .option('--json', 'Emit machine-readable JSON output')
1058
+ .action(async (options) => {
1059
+ try {
1060
+ if (options.syncEnable || options.syncDisable) {
1061
+ const { setSetting } = await import('./storage/configStorage.js');
1062
+ await setSetting('PRISM_SAVINGS_SYNC', options.syncEnable ? '1' : '0');
1063
+ console.log(options.syncEnable
1064
+ ? 'Savings sync enabled. What leaves this machine: per-day call/token COUNTERS only — '
1065
+ + 'never prompts, completions, or project names. Disable anytime with --sync-disable.'
1066
+ : 'Savings sync disabled. Nothing further leaves this machine on this channel.');
1067
+ if (!options.syncNow)
1068
+ return;
1069
+ }
1070
+ if (options.syncNow) {
1071
+ const { pushSavings } = await import('./sync/savingsSync.js');
1072
+ const r = await pushSavings();
1073
+ if (r.pushed) {
1074
+ console.log(`Pushed ${r.days} day row(s) of counters.`);
1075
+ return;
1076
+ }
1077
+ console.error(`Not pushed: ${r.reason}`);
1078
+ process.exit(r.reason === 'nothing_to_push' || r.reason === 'disabled' ? 0 : 1);
1079
+ }
1080
+ if (options.team) {
1081
+ const { fetchTeamSavings, renderTeamSavings } = await import('./sync/savingsSync.js');
1082
+ const teamDays = options.days !== undefined ? Math.floor(Number(options.days)) : 30;
1083
+ if (!Number.isFinite(teamDays) || teamDays <= 0) {
1084
+ console.error(`--days expects a positive number, got "${options.days}".`);
1085
+ process.exit(1);
1086
+ }
1087
+ const result = await fetchTeamSavings(options.workspace, teamDays);
1088
+ if (!result.ok) {
1089
+ console.error(result.reason === 'not_entitled'
1090
+ ? 'Team savings needs a paid plan with team features and workspace membership.'
1091
+ : `Team savings unavailable: ${result.reason}`);
1092
+ process.exit(1);
1093
+ }
1094
+ console.log(options.json ? JSON.stringify(result.team, null, 2) : renderTeamSavings(result.team));
1095
+ return;
1096
+ }
1097
+ const { queryLocalSavings } = await import('./storage/inferMetricsLedger.js');
1098
+ const { renderSavings, sessionSavings, windowStart } = await import('./tools/savingsHandler.js');
1099
+ const raw = (options.days !== undefined && options.period === undefined ? 'all' : (options.period ?? 'all')).toLowerCase();
1100
+ if (!['all', 'month', 'week', 'session'].includes(raw)) {
1101
+ console.error(`Unknown period "${raw}" — expected all, month, week, or session.`);
1102
+ process.exit(1);
1103
+ }
1104
+ const period = raw;
1105
+ let days;
1106
+ if (options.days !== undefined) {
1107
+ days = Number(options.days);
1108
+ if (!Number.isFinite(days) || days <= 0) {
1109
+ console.error(`--days expects a positive number, got "${options.days}".`);
1110
+ process.exit(1);
1111
+ }
1112
+ days = Math.floor(days);
1113
+ }
1114
+ // A CLI invocation is a fresh process, so the in-memory session
1115
+ // accumulators are always empty here. Say so rather than printing a
1116
+ // truthful-but-useless zero that reads as "local serving does nothing".
1117
+ if (period === 'session') {
1118
+ console.error('Note: `prism savings --period session` runs in a new process with no ' +
1119
+ 'session history. Use the local_savings MCP tool inside a host session, ' +
1120
+ 'or --period month/all for the durable ledger.');
1121
+ }
1122
+ const data = period === 'session'
1123
+ ? sessionSavings()
1124
+ : await queryLocalSavings(windowStart(period, Date.now(), days));
1125
+ if (!data) {
1126
+ console.error('Inference ledger unavailable — no durable figure can be reported.');
1127
+ process.exit(1);
1128
+ }
1129
+ const rendered = renderSavings(data, period, days);
1130
+ console.log(options.json ? JSON.stringify(rendered.data, null, 2) : rendered.text);
1131
+ }
1132
+ catch (err) {
1133
+ console.error(`savings failed: ${err instanceof Error ? err.message : String(err)}`);
1134
+ process.exit(1);
1135
+ }
1136
+ });
1043
1137
  // ─── prism update-models ──────────────────────────────────────
1044
1138
  // Standalone model convergence: pull each installed prism-coder tier from
1045
1139
  // the registry and repair its local alias. connect runs this automatically;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Device identity for E2E sync — one X25519 keypair per machine.
3
+ *
4
+ * The private key is generated here, written with 0600 permissions, and NEVER
5
+ * leaves this machine — not to the portal, not into a blob, not into logs.
6
+ * Only the PUBLIC key is published (at device registration) so other devices
7
+ * can seal envelopes to it. Losing the file means this device can no longer
8
+ * open blobs sealed to it; that is the designed failure mode, strictly better
9
+ * than any recovery path that would require the key to exist somewhere else.
10
+ *
11
+ * Storage: <dataDir>/sync-device-key.pem (PKCS8 PEM), alongside the config DB.
12
+ * Resolution mirrors the ledger's: PRISM_DATA_DIR (test sandbox / relocation)
13
+ * else ~/.prism-mcp. Tests must never touch a real device key.
14
+ */
15
+ import { createPrivateKey, createPublicKey, generateKeyPairSync, } from "node:crypto";
16
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { resolve } from "node:path";
19
+ import { keyIdOf, rawFromPublicKey, EnvelopeError } from "./syncEnvelope.js";
20
+ const KEY_FILENAME = "sync-device-key.pem";
21
+ function keyPath() {
22
+ const dir = process.env.PRISM_DATA_DIR
23
+ ? resolve(process.env.PRISM_DATA_DIR)
24
+ : resolve(homedir(), ".prism-mcp");
25
+ return resolve(dir, KEY_FILENAME);
26
+ }
27
+ /**
28
+ * Load this machine's device key, creating it on first use.
29
+ *
30
+ * Never overwrites: an existing file is authoritative even if unreadable as a
31
+ * key — failing loudly beats silently rotating an identity that other devices
32
+ * have already sealed blobs to.
33
+ */
34
+ export function loadOrCreateDeviceIdentity() {
35
+ const path = keyPath();
36
+ if (existsSync(path)) {
37
+ let privateKey;
38
+ try {
39
+ privateKey = createPrivateKey(readFileSync(path, "utf8"));
40
+ }
41
+ catch (e) {
42
+ throw new EnvelopeError(`device key at ${path} exists but cannot be parsed — refusing to rotate an established ` +
43
+ `identity automatically (${e instanceof Error ? e.message : e})`);
44
+ }
45
+ if (privateKey.asymmetricKeyType !== "x25519") {
46
+ throw new EnvelopeError(`device key at ${path} is ${privateKey.asymmetricKeyType}, expected x25519`);
47
+ }
48
+ const publicKey = createPublicKey(privateKey);
49
+ const rawPublicKey = rawFromPublicKey(publicKey);
50
+ return { privateKey, publicKey, rawPublicKey, keyId: keyIdOf(rawPublicKey), created: false };
51
+ }
52
+ const { privateKey, publicKey } = generateKeyPairSync("x25519");
53
+ const pem = privateKey.export({ format: "pem", type: "pkcs8" });
54
+ const dir = resolve(path, "..");
55
+ if (!existsSync(dir))
56
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
57
+ // 'wx' = exclusive create: two concurrent first-runs cannot silently
58
+ // overwrite each other's identity, and a dangling symlink pre-planted at
59
+ // the path fails with EEXIST instead of being followed. Then chmod,
60
+ // because writeFileSync's mode is masked by umask and a device key
61
+ // readable by group/other for even a moment is a defect. (On Windows,
62
+ // POSIX modes are advisory — the profile directory ACL is the boundary.)
63
+ try {
64
+ writeFileSync(path, pem, { mode: 0o600, flag: "wx" });
65
+ }
66
+ catch (e) {
67
+ if (e.code === "EEXIST") {
68
+ // Lost the creation race — the other writer's identity wins.
69
+ return loadOrCreateDeviceIdentity();
70
+ }
71
+ throw e;
72
+ }
73
+ chmodSync(path, 0o600);
74
+ const rawPublicKey = rawFromPublicKey(publicKey);
75
+ return { privateKey, publicKey, rawPublicKey, keyId: keyIdOf(rawPublicKey), created: true };
76
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Sealed envelope for cross-machine sync — the E2E foundation of Phase 2.
3
+ *
4
+ * The local-first contract this enforces: the sync relay stores CIPHERTEXT
5
+ * ONLY. A blob is encrypted to the set of the user's registered DEVICE public
6
+ * keys before it leaves the machine; the server never holds a key that opens
7
+ * anything. That property is what makes "your agent's memory never exists
8
+ * unencrypted off your machines" a statement of architecture rather than of
9
+ * policy, so nothing in this module may weaken it for convenience:
10
+ *
11
+ * - No shared master secret exists, so nothing secret is ever transported.
12
+ * Adding a device means publishing its PUBLIC key; each blob carries the
13
+ * content key wrapped once per recipient device (the age/libsodium
14
+ * sealed-box model).
15
+ * - Everything is node:crypto (X25519 + HKDF-SHA256 + AES-256-GCM). No new
16
+ * dependency: a wasm/native crypto package would widen the supply-chain
17
+ * surface of an npm-distributed MCP server for zero capability gain.
18
+ * - Envelopes are versioned and self-describing. v1 is the only version;
19
+ * opening anything else fails loudly rather than guessing.
20
+ * - The AAD binds a context string (e.g. "prism-sync:v1:<project>") into the
21
+ * payload AEAD, so a valid blob replayed under a different project fails
22
+ * authentication instead of decrypting somewhere it does not belong.
23
+ *
24
+ * Wire shapes are JSON-safe (base64 strings) because blobs travel through the
25
+ * portal API and land in a database column.
26
+ */
27
+ import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, } from "node:crypto";
28
+ export const ENVELOPE_VERSION = 1;
29
+ export const ENVELOPE_ALG = "x25519-hkdf-sha256+aes-256-gcm";
30
+ /** Raw X25519 public keys are exactly 32 bytes. */
31
+ const X25519_RAW_LEN = 32;
32
+ const GCM_NONCE_LEN = 12;
33
+ const GCM_TAG_LEN = 16;
34
+ const CEK_LEN = 32;
35
+ /** DER prefix for an X25519 SubjectPublicKeyInfo — lets us accept raw 32-byte
36
+ * keys at the API boundary while node:crypto works with SPKI objects. */
37
+ const X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
38
+ export class EnvelopeError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = "EnvelopeError";
42
+ }
43
+ }
44
+ /** Key id every party derives identically from the raw public key. */
45
+ export function keyIdOf(rawPublicKey) {
46
+ if (rawPublicKey.length !== X25519_RAW_LEN) {
47
+ throw new EnvelopeError(`public key must be ${X25519_RAW_LEN} raw bytes, got ${rawPublicKey.length}`);
48
+ }
49
+ return createHash("sha256").update(rawPublicKey).digest("hex").slice(0, 16);
50
+ }
51
+ function publicKeyFromRaw(raw) {
52
+ if (raw.length !== X25519_RAW_LEN) {
53
+ throw new EnvelopeError(`public key must be ${X25519_RAW_LEN} raw bytes, got ${raw.length}`);
54
+ }
55
+ return createPublicKey({
56
+ key: Buffer.concat([X25519_SPKI_PREFIX, raw]),
57
+ format: "der",
58
+ type: "spki",
59
+ });
60
+ }
61
+ export function rawFromPublicKey(key) {
62
+ const der = key.export({ format: "der", type: "spki" });
63
+ const raw = der.subarray(der.length - X25519_RAW_LEN);
64
+ if (der.length < X25519_RAW_LEN || raw.length !== X25519_RAW_LEN) {
65
+ throw new EnvelopeError("could not extract raw X25519 key from SPKI");
66
+ }
67
+ return Buffer.from(raw);
68
+ }
69
+ /**
70
+ * KEK derivation. The salt commits to BOTH public keys so a key confusion
71
+ * between recipients (or a swapped ephemeral) derives a different KEK and the
72
+ * GCM unwrap fails authentication instead of silently decrypting.
73
+ */
74
+ function deriveKek(shared, ephemeralRaw, recipientRaw) {
75
+ const salt = createHash("sha256")
76
+ .update("prism-sync-v1")
77
+ .update(ephemeralRaw)
78
+ .update(recipientRaw)
79
+ .digest();
80
+ return Buffer.from(hkdfSync("sha256", shared, salt, "prism-sync-kek-v1", CEK_LEN));
81
+ }
82
+ function gcmSeal(key, nonce, plaintext, aad) {
83
+ const cipher = createCipheriv("aes-256-gcm", key, nonce);
84
+ cipher.setAAD(aad);
85
+ const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
86
+ return Buffer.concat([ct, cipher.getAuthTag()]);
87
+ }
88
+ function gcmOpen(key, nonce, sealed, aad) {
89
+ if (sealed.length < GCM_TAG_LEN)
90
+ throw new EnvelopeError("ciphertext shorter than a GCM tag");
91
+ const ct = sealed.subarray(0, sealed.length - GCM_TAG_LEN);
92
+ const tag = sealed.subarray(sealed.length - GCM_TAG_LEN);
93
+ // Every crypto call sits INSIDE the try. An adversarial review measured
94
+ // that createDecipheriv on a 0-byte IV (base64 is lenient — a relay can
95
+ // set n:"" and Buffer.from decodes it to 0 bytes) threw a raw TypeError
96
+ // from outside the old try, breaking the uniform-EnvelopeError contract.
97
+ try {
98
+ const decipher = createDecipheriv("aes-256-gcm", key, nonce);
99
+ decipher.setAAD(aad);
100
+ decipher.setAuthTag(tag);
101
+ return Buffer.concat([decipher.update(ct), decipher.final()]);
102
+ }
103
+ catch {
104
+ // Deliberately indistinguishable: wrong key, tampered ciphertext,
105
+ // malformed nonce, and swapped-context AAD all present identically.
106
+ throw new EnvelopeError("authentication failed");
107
+ }
108
+ }
109
+ /**
110
+ * Seal `plaintext` for every recipient device.
111
+ *
112
+ * @param recipientRawPublicKeys raw 32-byte X25519 public keys (the shapes
113
+ * devices publish at registration)
114
+ * @param aadContext cleartext routing context bound into the AEAD, e.g.
115
+ * "prism-sync:v1:<project>". Opening with a different context fails.
116
+ */
117
+ export function sealFor(recipientRawPublicKeys, plaintext, aadContext) {
118
+ if (recipientRawPublicKeys.length === 0) {
119
+ throw new EnvelopeError("refusing to seal for zero recipients — the blob would be unopenable");
120
+ }
121
+ if (!aadContext) {
122
+ throw new EnvelopeError("aadContext is required — unbound blobs can be replayed across contexts");
123
+ }
124
+ const cek = randomBytes(CEK_LEN);
125
+ const payloadNonce = randomBytes(GCM_NONCE_LEN);
126
+ const aad = Buffer.from(aadContext, "utf8");
127
+ const ct = gcmSeal(cek, payloadNonce, plaintext, aad);
128
+ const recipients = recipientRawPublicKeys.map((recipientRaw) => {
129
+ const recipientKey = publicKeyFromRaw(recipientRaw);
130
+ const eph = generateKeyPairSync("x25519");
131
+ const ephRaw = rawFromPublicKey(eph.publicKey);
132
+ const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey });
133
+ const kek = deriveKek(shared, ephRaw, recipientRaw);
134
+ const kid = keyIdOf(recipientRaw);
135
+ const wrapNonce = randomBytes(GCM_NONCE_LEN);
136
+ const ck = gcmSeal(kek, wrapNonce, cek, Buffer.from(kid, "utf8"));
137
+ return {
138
+ kid,
139
+ epk: ephRaw.toString("base64"),
140
+ n: wrapNonce.toString("base64"),
141
+ ck: ck.toString("base64"),
142
+ };
143
+ });
144
+ return {
145
+ v: ENVELOPE_VERSION,
146
+ alg: ENVELOPE_ALG,
147
+ recipients,
148
+ n: payloadNonce.toString("base64"),
149
+ ct: ct.toString("base64"),
150
+ aad: aadContext,
151
+ };
152
+ }
153
+ /**
154
+ * Open an envelope with this device's private key.
155
+ *
156
+ * @param expectedAadContext the context the CALLER expects this blob to belong
157
+ * to. Passed explicitly rather than read from the envelope: trusting the
158
+ * attacker-controlled `aad` field would turn the binding into decoration.
159
+ */
160
+ export function openSealed(envelope, devicePrivateKey, deviceRawPublicKey, expectedAadContext) {
161
+ if (envelope.v !== ENVELOPE_VERSION) {
162
+ throw new EnvelopeError(`unsupported envelope version ${envelope.v}`);
163
+ }
164
+ if (envelope.alg !== ENVELOPE_ALG) {
165
+ throw new EnvelopeError(`unsupported algorithm ${envelope.alg}`);
166
+ }
167
+ if (envelope.aad !== expectedAadContext) {
168
+ throw new EnvelopeError("envelope context does not match the expected context");
169
+ }
170
+ const ourKid = keyIdOf(deviceRawPublicKey);
171
+ const entry = envelope.recipients.find((r) => r.kid === ourKid);
172
+ if (!entry) {
173
+ throw new EnvelopeError("this device is not a recipient of this envelope");
174
+ }
175
+ const ephRaw = Buffer.from(entry.epk, "base64");
176
+ let shared;
177
+ try {
178
+ // Node rejects low-order X25519 points by THROWING a raw OpenSSL
179
+ // error (it never returns an all-zero secret). The epk is
180
+ // relay-controlled, so that throw must collapse to the uniform
181
+ // failure like every other adversarial-input path.
182
+ const ephKey = publicKeyFromRaw(ephRaw);
183
+ shared = diffieHellman({ privateKey: devicePrivateKey, publicKey: ephKey });
184
+ }
185
+ catch {
186
+ throw new EnvelopeError("authentication failed");
187
+ }
188
+ const kek = deriveKek(shared, ephRaw, deviceRawPublicKey);
189
+ const cek = gcmOpen(kek, Buffer.from(entry.n, "base64"), Buffer.from(entry.ck, "base64"), Buffer.from(ourKid, "utf8"));
190
+ return gcmOpen(cek, Buffer.from(envelope.n, "base64"), Buffer.from(envelope.ct, "base64"), Buffer.from(expectedAadContext, "utf8"));
191
+ }
192
+ /** More devices than any user has; far below any allocation hazard. */
193
+ export const MAX_ENVELOPE_RECIPIENTS = 64;
194
+ /** Handoff/ledger blobs are text; 16 MiB of base64 is well past any real one. */
195
+ export const MAX_ENVELOPE_CT_CHARS = 16 * 1024 * 1024;
196
+ /** Structural validation for envelopes arriving off the wire. Bounds sizes so
197
+ * a hostile relay cannot make the opener allocate unbounded memory before
198
+ * any authentication happens. */
199
+ export function isSealedEnvelope(value) {
200
+ if (typeof value !== "object" || value === null || Array.isArray(value))
201
+ return false;
202
+ const o = value;
203
+ if (o.v !== ENVELOPE_VERSION || o.alg !== ENVELOPE_ALG)
204
+ return false;
205
+ if (typeof o.n !== "string" || typeof o.ct !== "string" || typeof o.aad !== "string")
206
+ return false;
207
+ if (o.n.length > 64 || o.aad.length > 512)
208
+ return false;
209
+ if (o.ct.length > MAX_ENVELOPE_CT_CHARS)
210
+ return false;
211
+ if (!Array.isArray(o.recipients) || o.recipients.length === 0)
212
+ return false;
213
+ if (o.recipients.length > MAX_ENVELOPE_RECIPIENTS)
214
+ return false;
215
+ return o.recipients.every((r) => {
216
+ if (typeof r !== "object" || r === null)
217
+ return false;
218
+ const e = r;
219
+ return typeof e.kid === "string" && e.kid.length <= 32 &&
220
+ typeof e.epk === "string" && e.epk.length <= 64 &&
221
+ typeof e.n === "string" && e.n.length <= 64 &&
222
+ typeof e.ck === "string" && e.ck.length <= 256;
223
+ });
224
+ }
225
+ /** Re-exported for deviceKeys; not part of the public sealing API. */
226
+ export const _internal = { publicKeyFromRaw, createPrivateKey };
@@ -0,0 +1,43 @@
1
+ import { randomBytes } from "crypto";
2
+ import { safeCompare } from "./authUtils.js";
3
+ function isTruthy(v) {
4
+ const s = (v || "").trim().toLowerCase();
5
+ return s === "1" || s === "true" || s === "yes" || s === "on";
6
+ }
7
+ /**
8
+ * Resolve the active dashboard token, or null when token mode is off. A pinned
9
+ * token wins over a random one so operators can share a stable URL; an empty or
10
+ * whitespace-only pin is ignored (falls back to a random token).
11
+ */
12
+ export function resolveDashboardToken(cfg) {
13
+ if (cfg.authEnabled)
14
+ return null; // real auth is the gate
15
+ if (isTruthy(cfg.optOut))
16
+ return null; // explicit opt-out (Host guard still applies)
17
+ const pinned = (cfg.pinnedToken || "").trim();
18
+ if (pinned)
19
+ return pinned;
20
+ return randomBytes(32).toString("hex");
21
+ }
22
+ /** Extract the prism_dashboard_token cookie value, if present. */
23
+ export function tokenFromCookie(cookieHeader) {
24
+ // Capture the whole value (any run of non-";", non-space) so pinned tokens
25
+ // with hyphens/underscores match; the name is anchored to start-or-"; " so a
26
+ // look-alike cookie (evil_prism_dashboard_token=…) cannot match.
27
+ const m = (cookieHeader || "").match(/(?:^|;\s*)prism_dashboard_token=([^;\s]+)/);
28
+ return m ? m[1] : null;
29
+ }
30
+ /**
31
+ * True when the request presents the active token via the cookie, the
32
+ * X-Prism-Dashboard-Token header, or a ?token= query param. Every comparison is
33
+ * timing-safe and only runs against the single active token.
34
+ */
35
+ export function requestHasToken(headers, queryToken, activeToken) {
36
+ const candidates = [tokenFromCookie(headers.cookie), headers.headerToken ?? null, queryToken];
37
+ return candidates.some((c) => c !== null && safeCompare(c, activeToken));
38
+ }
39
+ /** Build the Set-Cookie value that stores the token for a browser session. */
40
+ export function buildTokenCookie(token, maxAgeMs, secure) {
41
+ return (`prism_dashboard_token=${token}; Path=/; HttpOnly; SameSite=Strict; ` +
42
+ `Max-Age=${Math.floor(maxAgeMs / 1000)}${secure ? "; Secure" : ""}`);
43
+ }