@sandprivacy/sandgate 0.1.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.
@@ -0,0 +1,113 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { newPairing, deriveKey, seal, open, aadForRequest, aadForDecision, } from "../pwacrypto.js";
7
+ import { startRelay } from "../relay/server.js";
8
+ import { PwaApprover } from "../pwa-approver.js";
9
+ test("seal/open round-trips; tampering and wrong AAD are rejected", () => {
10
+ const { secret } = newPairing();
11
+ const key = deriveKey(secret);
12
+ const sealed = seal(key, { hello: "world" }, aadForRequest("r1"));
13
+ assert.deepEqual(open(key, sealed, aadForRequest("r1")), { hello: "world" });
14
+ assert.throws(() => open(key, sealed, aadForRequest("r2")), /authentication/);
15
+ const tampered = { ...sealed, ct: sealed.ct.slice(0, -4) + "AAAA" };
16
+ assert.throws(() => open(key, tampered, aadForRequest("r1")), /authentication/);
17
+ const otherKey = deriveKey(newPairing().secret);
18
+ assert.throws(() => open(otherKey, sealed, aadForRequest("r1")), /authentication/);
19
+ });
20
+ /** Plays the phone: polls pending, decrypts, answers. Same crypto as the PWA. */
21
+ async function phoneAnswers(relayUrl, pairId, secret, approved) {
22
+ const key = deriveKey(secret);
23
+ for (let i = 0; i < 50; i++) {
24
+ const res = await fetch(`${relayUrl}/api/pending?pairId=${pairId}`);
25
+ const items = (await res.json());
26
+ if (items.length) {
27
+ const item = items[0];
28
+ const req = open(key, item.payload, aadForRequest(item.requestId));
29
+ const decision = seal(key, { requestId: item.requestId, approved, ts: Date.now() }, aadForDecision(item.requestId));
30
+ await fetch(`${relayUrl}/api/decision`, {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify({ pairId, requestId: item.requestId, payload: decision }),
34
+ });
35
+ return req;
36
+ }
37
+ await new Promise((r) => setTimeout(r, 50));
38
+ }
39
+ throw new Error("phone never saw a pending request");
40
+ }
41
+ test("full approval round-trip through the relay (approve, deny, timeout)", async () => {
42
+ const relay = await startRelay({
43
+ port: 0,
44
+ stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
45
+ });
46
+ const relayUrl = `http://localhost:${relay.port}`;
47
+ try {
48
+ const pairing = newPairing();
49
+ const approver = new PwaApprover({
50
+ relayUrl,
51
+ pairId: pairing.pairId,
52
+ secret: pairing.secret,
53
+ });
54
+ // Approve
55
+ const phone1 = phoneAnswers(relayUrl, pairing.pairId, pairing.secret, true);
56
+ const r1 = await approver.request({ title: "Pay 300 EUR", timeoutSec: 10 });
57
+ assert.deepEqual(r1, { approved: true, decision: "approved" });
58
+ assert.equal((await phone1).title, "Pay 300 EUR"); // phone could read the sealed request
59
+ // Deny
60
+ const phone2 = phoneAnswers(relayUrl, pairing.pairId, pairing.secret, false);
61
+ const r2 = await approver.request({ title: "Delete account", timeoutSec: 10 });
62
+ assert.deepEqual(r2, { approved: false, decision: "denied" });
63
+ await phone2;
64
+ // Timeout (nobody answers)
65
+ const r3 = await approver.request({ title: "Silence", timeoutSec: 1 });
66
+ assert.deepEqual(r3, { approved: false, decision: "timeout" });
67
+ }
68
+ finally {
69
+ relay.close();
70
+ }
71
+ });
72
+ test("a relay cannot forge an approval (bad blob is rejected, request times out)", async () => {
73
+ const relay = await startRelay({
74
+ port: 0,
75
+ stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
76
+ });
77
+ const relayUrl = `http://localhost:${relay.port}`;
78
+ try {
79
+ const pairing = newPairing();
80
+ const approver = new PwaApprover({
81
+ relayUrl,
82
+ pairId: pairing.pairId,
83
+ secret: pairing.secret,
84
+ });
85
+ // "Evil relay": answers with a forged decision sealed under the WRONG key.
86
+ const evil = (async () => {
87
+ const wrongKey = deriveKey(newPairing().secret);
88
+ for (let i = 0; i < 50; i++) {
89
+ const res = await fetch(`${relayUrl}/api/pending?pairId=${pairing.pairId}`);
90
+ const items = (await res.json());
91
+ if (items.length) {
92
+ const forged = seal(wrongKey, { requestId: items[0].requestId, approved: true, ts: Date.now() }, aadForDecision(items[0].requestId));
93
+ await fetch(`${relayUrl}/api/decision`, {
94
+ method: "POST",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: JSON.stringify({
97
+ pairId: pairing.pairId,
98
+ requestId: items[0].requestId,
99
+ payload: forged,
100
+ }),
101
+ });
102
+ return;
103
+ }
104
+ await new Promise((r) => setTimeout(r, 50));
105
+ }
106
+ })();
107
+ await assert.rejects(approver.request({ title: "Forgery target", timeoutSec: 3 }), /authentication/);
108
+ await evil;
109
+ }
110
+ finally {
111
+ relay.close();
112
+ }
113
+ });
@@ -0,0 +1,81 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { TelegramApprover } from "../telegram.js";
4
+ /**
5
+ * Fake transport: captures sent messages, then feeds callback taps to the
6
+ * dispatcher. Lets us prove several concurrent approvals resolve correctly
7
+ * through the single getUpdates loop.
8
+ */
9
+ class FakeTelegram extends TelegramApprover {
10
+ sent = [];
11
+ taps = [];
12
+ nextMessageId = 1;
13
+ nextUpdateId = 1;
14
+ constructor() {
15
+ super("fake-token", "12345");
16
+ }
17
+ async api(method, params) {
18
+ if (method === "sendMessage") {
19
+ const nonce = params.reply_markup.inline_keyboard[0][0].callback_data.split(":")[1];
20
+ const messageId = this.nextMessageId++;
21
+ this.sent.push({ messageId, nonce });
22
+ return { message_id: messageId };
23
+ }
24
+ if (method === "getUpdates") {
25
+ await new Promise((r) => setTimeout(r, 10));
26
+ const updates = this.taps.map((tap) => ({
27
+ update_id: this.nextUpdateId++,
28
+ callback_query: {
29
+ id: "cb" + this.nextUpdateId,
30
+ data: `${tap.verdict}:${tap.nonce}`,
31
+ message: { chat: { id: 12345 } },
32
+ },
33
+ }));
34
+ this.taps = [];
35
+ return updates;
36
+ }
37
+ return {}; // answerCallbackQuery, editMessageText
38
+ }
39
+ }
40
+ test("two concurrent approvals resolve independently through one dispatcher", async () => {
41
+ const fake = new FakeTelegram();
42
+ const a = fake.request({ title: "Payment A", timeoutSec: 5 });
43
+ const b = fake.request({ title: "Payment B", timeoutSec: 5 });
44
+ // Wait until both messages are sent, then tap: deny A, approve B.
45
+ while (fake.sent.length < 2)
46
+ await new Promise((r) => setTimeout(r, 5));
47
+ fake.taps.push({ verdict: "no", nonce: fake.sent[0].nonce });
48
+ fake.taps.push({ verdict: "ok", nonce: fake.sent[1].nonce });
49
+ const [ra, rb] = await Promise.all([a, b]);
50
+ assert.deepEqual(ra, { approved: false, decision: "denied" });
51
+ assert.deepEqual(rb, { approved: true, decision: "approved" });
52
+ });
53
+ test("a tap from a foreign chat is ignored, and the request times out", async () => {
54
+ class ForeignChat extends FakeTelegram {
55
+ async api(method, params) {
56
+ if (method === "getUpdates" && this.sent.length) {
57
+ await new Promise((r) => setTimeout(r, 10));
58
+ return [
59
+ {
60
+ update_id: 999,
61
+ callback_query: {
62
+ id: "cb999",
63
+ data: `ok:${this.sent[0].nonce}`,
64
+ message: { chat: { id: 666 } }, // wrong chat
65
+ },
66
+ },
67
+ ];
68
+ }
69
+ return super.api(method, params);
70
+ }
71
+ }
72
+ const fake = new ForeignChat();
73
+ const result = await fake.request({ title: "Sensitive", timeoutSec: 1 });
74
+ assert.equal(result.decision, "timeout");
75
+ assert.equal(result.approved, false);
76
+ });
77
+ test("unanswered requests time out as denied", async () => {
78
+ const fake = new FakeTelegram();
79
+ const result = await fake.request({ title: "Silence", timeoutSec: 1 });
80
+ assert.deepEqual(result, { approved: false, decision: "timeout" });
81
+ });
package/dist/totp.js ADDED
@@ -0,0 +1,28 @@
1
+ import * as OTPAuth from "otpauth";
2
+ /** Generate the current code for a stored seed. The seed never leaves this module's callers' memory. */
3
+ export function generateCode(secret, opts) {
4
+ const totp = new OTPAuth.TOTP({
5
+ secret: OTPAuth.Secret.fromBase32(normalizeSecret(secret)),
6
+ digits: opts?.digits ?? 6,
7
+ period: opts?.period ?? 30,
8
+ algorithm: "SHA1",
9
+ });
10
+ const period = opts?.period ?? 30;
11
+ const now = Math.floor(Date.now() / 1000);
12
+ return {
13
+ code: totp.generate(),
14
+ secondsRemaining: period - (now % period),
15
+ };
16
+ }
17
+ /** Accept secrets pasted with spaces/dashes/lowercase, and full otpauth:// URIs. */
18
+ export function normalizeSecret(input) {
19
+ const trimmed = input.trim();
20
+ if (trimmed.startsWith("otpauth://")) {
21
+ const url = new URL(trimmed);
22
+ const secret = url.searchParams.get("secret");
23
+ if (!secret)
24
+ throw new Error("otpauth:// URI has no secret parameter.");
25
+ return secret.toUpperCase();
26
+ }
27
+ return trimmed.replace(/[\s-]/g, "").toUpperCase();
28
+ }
package/dist/vault.js ADDED
@@ -0,0 +1,46 @@
1
+ import { scryptSync, randomBytes, createCipheriv, createDecipheriv, } from "node:crypto";
2
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
3
+ import { vaultPath } from "./paths.js";
4
+ const SCRYPT_OPTS = { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
5
+ function deriveKey(passphrase, salt) {
6
+ return scryptSync(passphrase, salt, 32, SCRYPT_OPTS);
7
+ }
8
+ export function vaultExists() {
9
+ return existsSync(vaultPath());
10
+ }
11
+ export function saveVault(passphrase, data) {
12
+ const salt = randomBytes(16);
13
+ const iv = randomBytes(12);
14
+ const key = deriveKey(passphrase, salt);
15
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
16
+ const plaintext = Buffer.from(JSON.stringify(data), "utf8");
17
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
18
+ const file = {
19
+ version: 1,
20
+ kdf: "scrypt",
21
+ salt: salt.toString("base64"),
22
+ iv: iv.toString("base64"),
23
+ tag: cipher.getAuthTag().toString("base64"),
24
+ data: encrypted.toString("base64"),
25
+ };
26
+ writeFileSync(vaultPath(), JSON.stringify(file), { mode: 0o600 });
27
+ }
28
+ export function loadVault(passphrase) {
29
+ if (!vaultExists()) {
30
+ throw new Error(`No vault found at ${vaultPath()}. Run \`sandgate init\` first.`);
31
+ }
32
+ const file = JSON.parse(readFileSync(vaultPath(), "utf8"));
33
+ const key = deriveKey(passphrase, Buffer.from(file.salt, "base64"));
34
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(file.iv, "base64"));
35
+ decipher.setAuthTag(Buffer.from(file.tag, "base64"));
36
+ try {
37
+ const plaintext = Buffer.concat([
38
+ decipher.update(Buffer.from(file.data, "base64")),
39
+ decipher.final(),
40
+ ]);
41
+ return JSON.parse(plaintext.toString("utf8"));
42
+ }
43
+ catch {
44
+ throw new Error("Could not unlock the vault: wrong passphrase.");
45
+ }
46
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@sandprivacy/sandgate",
3
+ "version": "0.1.0",
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
+ "license": "AGPL-3.0-only",
6
+ "type": "module",
7
+ "bin": {
8
+ "sandgate": "dist/index.js"
9
+ },
10
+ "main": "dist/index.js",
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "dev": "tsx src/index.ts",
19
+ "test": "npm run build && node scripts/test.mjs"
20
+ },
21
+ "homepage": "https://sandgate.dev",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/sandprivacy/sandgate.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/sandprivacy/sandgate/issues"
28
+ },
29
+ "keywords": [
30
+ "mcp",
31
+ "ai-agents",
32
+ "human-in-the-loop",
33
+ "2fa",
34
+ "totp",
35
+ "approval",
36
+ "email-verification",
37
+ "browser-use",
38
+ "claude"
39
+ ],
40
+ "engines": {
41
+ "node": ">=18"
42
+ },
43
+ "dependencies": {
44
+ "@modelcontextprotocol/sdk": "^1.30.0",
45
+ "imapflow": "^1.7.8",
46
+ "mailparser": "^3.9.20",
47
+ "otpauth": "^9.5.1",
48
+ "qrcode-terminal": "^0.12.0",
49
+ "read": "^5.0.1",
50
+ "web-push": "^3.6.7",
51
+ "zod": "^4.5.4"
52
+ },
53
+ "devDependencies": {
54
+ "@types/mailparser": "^3.4.6",
55
+ "@types/node": "^26.4.1",
56
+ "@types/qrcode-terminal": "^0.12.2",
57
+ "@types/web-push": "^3.6.4",
58
+ "tsx": "^4.23.13",
59
+ "typescript": "^7.0.2"
60
+ }
61
+ }