@sandprivacy/sandgate 0.2.1 → 0.2.2

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/dist/index.js CHANGED
@@ -5,9 +5,9 @@ import { join } from "node:path";
5
5
  import { read } from "read";
6
6
  import { getQuota } from "./sandmail.js";
7
7
  import { testImapConnection } from "./inbox.js";
8
- import { resolvePassphrase } from "./passphrase.js";
8
+ import { resolvePassphrase, protectPassphraseDpapi, dpapiDecryptCommand } from "./passphrase.js";
9
9
  import { auditPath } from "./paths.js";
10
- import { vaultExists, loadVault, saveVault, } from "./vault.js";
10
+ import { vaultExists, loadVault, saveVault, rekeyVault, } from "./vault.js";
11
11
  import { loadConfig, saveConfig } from "./config.js";
12
12
  import { normalizeSecret, generateCode } from "./totp.js";
13
13
  import { TelegramApprover, discoverChatId } from "./telegram.js";
@@ -25,6 +25,8 @@ Usage:
25
25
  sandgate connect-sandmail <api-key> Connect the sandmail inbox backend
26
26
  sandgate connect-imap Connect your own IMAP mailbox instead (self-hosted)
27
27
  sandgate test-approval Send a test approval to your phone
28
+ sandgate rekey Change the vault passphrase
29
+ sandgate protect Store the passphrase in the OS store (Windows DPAPI)
28
30
  sandgate status Show what is configured and active
29
31
  sandgate audit [n] Show the last n audit entries (default 20)
30
32
  sandgate serve Run the MCP server (stdio)
@@ -221,6 +223,37 @@ async function cmdConnectImap() {
221
223
  : "";
222
224
  console.log(`IMAP connected (${config.user}@${config.host}).${note}`);
223
225
  }
226
+ async function cmdRekey() {
227
+ const prompter = new Prompter();
228
+ const current = await prompter.ask("Current passphrase: ", { hidden: true });
229
+ const next = await prompter.ask("New passphrase: ", { hidden: true });
230
+ const confirm = await prompter.ask("Confirm new passphrase: ", { hidden: true });
231
+ prompter.close();
232
+ if (!next) {
233
+ console.error("A passphrase is required.");
234
+ process.exit(1);
235
+ }
236
+ if (next !== confirm) {
237
+ console.error("Passphrases do not match.");
238
+ process.exit(1);
239
+ }
240
+ rekeyVault(current, next); // throws "wrong passphrase" if current is bad
241
+ console.log("Vault re-encrypted. Update SANDGATE_PASSPHRASE (or your passphrase command's store) everywhere sandgate serve is launched.");
242
+ }
243
+ async function cmdProtect() {
244
+ const prompter = new Prompter();
245
+ const pass = await prompter.ask("Vault passphrase: ", { hidden: true });
246
+ prompter.close();
247
+ if (!pass) {
248
+ console.error("A passphrase is required.");
249
+ process.exit(1);
250
+ }
251
+ loadVault(pass); // validate before storing — a typo here would be silent later
252
+ const target = join(sandgateDir(), "pass.dpapi");
253
+ protectPassphraseDpapi(pass, target);
254
+ console.log(`Passphrase verified against the vault and stored, DPAPI-encrypted, at:\n ${target}\n\n` +
255
+ `Point your MCP config at it with:\n SANDGATE_PASSPHRASE_CMD = ${dpapiDecryptCommand(target)}`);
256
+ }
224
257
  async function cmdStatus() {
225
258
  if (!vaultExists()) {
226
259
  console.log("No vault. Run `sandgate init` to get started.");
@@ -386,6 +419,10 @@ async function main() {
386
419
  return cmdRelay(args[0]);
387
420
  case "pair":
388
421
  return cmdPair(args[0]);
422
+ case "rekey":
423
+ return cmdRekey();
424
+ case "protect":
425
+ return cmdProtect();
389
426
  case "status":
390
427
  return cmdStatus();
391
428
  case "audit":
@@ -1,4 +1,4 @@
1
- import { execSync } from "node:child_process";
1
+ import { execSync, spawnSync } from "node:child_process";
2
2
  /**
3
3
  * Resolve the vault passphrase for non-interactive runs (MCP clients
4
4
  * launching `sandgate serve`). Two sources, in order:
@@ -13,6 +13,32 @@ import { execSync } from "node:child_process";
13
13
  * Either way the model never sees it: it lives in the launcher's
14
14
  * environment, not in the agent's context.
15
15
  */
16
+ /**
17
+ * Windows only: encrypt the passphrase with DPAPI (bound to the current
18
+ * Windows session) and write the blob to filePath. The plaintext travels
19
+ * to PowerShell via stdin — never argv, never env.
20
+ */
21
+ export function protectPassphraseDpapi(passphrase, filePath) {
22
+ if (process.platform !== "win32") {
23
+ throw new Error("DPAPI is Windows-only. On macOS use the keychain (`security add-generic-password`), on Linux `secret-tool store` — see docs/integrations/claude-code.md.");
24
+ }
25
+ const script = "$plain = [Console]::In.ReadToEnd().TrimEnd([char]13, [char]10); " +
26
+ "if ($plain.Length -eq 0) { exit 2 }; " +
27
+ "$ss = ConvertTo-SecureString $plain -AsPlainText -Force; " +
28
+ `$ss | ConvertFrom-SecureString | Out-File -Encoding ascii '${filePath.replace(/'/g, "''")}'`;
29
+ const result = spawnSync("powershell", ["-NoProfile", "-Command", script], {
30
+ input: passphrase,
31
+ windowsHide: true,
32
+ timeout: 30_000,
33
+ });
34
+ if (result.status !== 0) {
35
+ throw new Error(`DPAPI protection failed (exit ${result.status}): ${result.stderr?.toString().slice(0, 300)}`);
36
+ }
37
+ }
38
+ /** The command that decrypts what protectPassphraseDpapi wrote. */
39
+ export function dpapiDecryptCommand(filePath) {
40
+ return `powershell -NoProfile -Command "[Net.NetworkCredential]::new('', (Get-Content '${filePath}' | ConvertTo-SecureString)).Password"`;
41
+ }
16
42
  export function resolvePassphrase(env) {
17
43
  if (env.SANDGATE_PASSPHRASE)
18
44
  return env.SANDGATE_PASSPHRASE;
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  // Point sandgate at a throwaway home before importing modules that use it.
7
7
  process.env.SANDGATE_HOME = mkdtempSync(join(tmpdir(), "sandgate-test-"));
8
- const { saveVault, loadVault } = await import("../vault.js");
8
+ const { saveVault, loadVault, rekeyVault } = await import("../vault.js");
9
9
  const { generateCode, normalizeSecret } = await import("../totp.js");
10
10
  const { loadConfig, saveConfig, totpPolicy } = await import("../config.js");
11
11
  test("vault round-trips and rejects a wrong passphrase", () => {
@@ -17,6 +17,14 @@ test("vault round-trips and rejects a wrong passphrase", () => {
17
17
  assert.deepEqual(loadVault("correct horse"), data);
18
18
  assert.throws(() => loadVault("wrong"), /wrong passphrase/);
19
19
  });
20
+ test("rekey re-encrypts under the new passphrase and retires the old one", () => {
21
+ const data = { totp: { "site.com": { secret: "JBSWY3DPEHPK3PXP" } } };
22
+ saveVault("old-pass", data);
23
+ rekeyVault("old-pass", "new-pass");
24
+ assert.deepEqual(loadVault("new-pass"), data);
25
+ assert.throws(() => loadVault("old-pass"), /wrong passphrase/);
26
+ assert.throws(() => rekeyVault("old-pass", "whatever"), /wrong passphrase/);
27
+ });
20
28
  test("totp generates stable 6-digit codes", () => {
21
29
  const a = generateCode("JBSWY3DPEHPK3PXP");
22
30
  const b = generateCode("JBSWY3DPEHPK3PXP");
@@ -0,0 +1,18 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { execSync } from "node:child_process";
4
+ import { mkdtempSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { protectPassphraseDpapi, dpapiDecryptCommand } from "../passphrase.js";
8
+ // DPAPI is Windows-only; the CI matrix covers both, so skip cleanly elsewhere.
9
+ const windowsOnly = { skip: process.platform !== "win32" };
10
+ test("DPAPI protect/decrypt round-trips the passphrase", windowsOnly, () => {
11
+ const file = join(mkdtempSync(join(tmpdir(), "sandgate-dpapi-")), "pass.dpapi");
12
+ protectPassphraseDpapi("correct horse battery staple", file);
13
+ const decrypted = execSync(dpapiDecryptCommand(file), { encoding: "utf8" }).trim();
14
+ assert.equal(decrypted, "correct horse battery staple");
15
+ });
16
+ test("protect refuses on non-Windows platforms", { skip: process.platform === "win32" }, () => {
17
+ assert.throws(() => protectPassphraseDpapi("x", "/tmp/x"), /Windows-only/);
18
+ });
package/dist/vault.js CHANGED
@@ -25,6 +25,11 @@ export function saveVault(passphrase, data) {
25
25
  };
26
26
  writeFileSync(vaultPath(), JSON.stringify(file), { mode: 0o600 });
27
27
  }
28
+ /** Change the vault passphrase in place: decrypt with the old, re-encrypt with the new. */
29
+ export function rekeyVault(oldPassphrase, newPassphrase) {
30
+ const data = loadVault(oldPassphrase);
31
+ saveVault(newPassphrase, data);
32
+ }
28
33
  export function loadVault(passphrase) {
29
34
  if (!vaultExists()) {
30
35
  throw new Error(`No vault found at ${vaultPath()}. Run \`sandgate init\` first.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandprivacy/sandgate",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "The human gateway for AI agents — approvals, 2FA codes and email verification, self-hosted. Your agent asks; you decide; secrets never touch the LLM.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",