@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 +39 -2
- package/dist/passphrase.js +27 -1
- package/dist/test/core.test.js +9 -1
- package/dist/test/protect.test.js +18 -0
- package/dist/vault.js +5 -0
- package/package.json +1 -1
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":
|
package/dist/passphrase.js
CHANGED
|
@@ -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;
|
package/dist/test/core.test.js
CHANGED
|
@@ -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.
|
|
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",
|