@sandprivacy/sandgate 0.3.0 → 0.3.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/dist/index.js +6 -3
- package/dist/pwa-approver.js +14 -0
- package/dist/server.js +2 -6
- package/dist/test/approver-wiring.test.js +111 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -423,9 +423,12 @@ async function cmdTestApproval() {
|
|
|
423
423
|
const data = loadVault(pass);
|
|
424
424
|
let approver;
|
|
425
425
|
if (data.pwa) {
|
|
426
|
-
const {
|
|
427
|
-
|
|
428
|
-
|
|
426
|
+
const { pwaApproverFrom } = await import("./pwa-approver.js");
|
|
427
|
+
const config = loadConfig();
|
|
428
|
+
approver = pwaApproverFrom(data, config);
|
|
429
|
+
console.log(config.requireBiometric
|
|
430
|
+
? "Sending test approval to the paired PWA — Face ID required (60s timeout)…"
|
|
431
|
+
: "Sending test approval to the paired PWA (60s timeout)…");
|
|
429
432
|
}
|
|
430
433
|
else if (data.telegram) {
|
|
431
434
|
approver = new TelegramApprover(data.telegram.botToken, data.telegram.chatId);
|
package/dist/pwa-approver.js
CHANGED
|
@@ -101,3 +101,17 @@ export class PwaApprover {
|
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Build the PWA approver from vault + config. Everything that talks to the
|
|
106
|
+
* phone goes through here: when `serve` and the CLI each assembled their
|
|
107
|
+
* own config, the CLI silently lost biometric enforcement.
|
|
108
|
+
*/
|
|
109
|
+
export function pwaApproverFrom(vault, config) {
|
|
110
|
+
if (!vault.pwa)
|
|
111
|
+
return null;
|
|
112
|
+
return new PwaApprover({
|
|
113
|
+
...vault.pwa,
|
|
114
|
+
biometric: vault.biometric,
|
|
115
|
+
requireBiometric: config.requireBiometric,
|
|
116
|
+
});
|
|
117
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { loadVault } from "./vault.js";
|
|
|
5
5
|
import { loadConfig, totpPolicy } from "./config.js";
|
|
6
6
|
import { generateCode } from "./totp.js";
|
|
7
7
|
import { TelegramApprover } from "./telegram.js";
|
|
8
|
-
import {
|
|
8
|
+
import { pwaApproverFrom } from "./pwa-approver.js";
|
|
9
9
|
import { backendFromVault } from "./inbox.js";
|
|
10
10
|
import { audit } from "./audit.js";
|
|
11
11
|
/**
|
|
@@ -29,11 +29,7 @@ export async function serve(passphrase) {
|
|
|
29
29
|
}
|
|
30
30
|
// PWA (E2EE push) wins over Telegram when both are configured.
|
|
31
31
|
const approver = vault.pwa
|
|
32
|
-
?
|
|
33
|
-
...vault.pwa,
|
|
34
|
-
biometric: vault.biometric,
|
|
35
|
-
requireBiometric: config.requireBiometric,
|
|
36
|
-
})
|
|
32
|
+
? pwaApproverFrom(vault, config)
|
|
37
33
|
: vault.telegram
|
|
38
34
|
? new TelegramApprover(vault.telegram.botToken, vault.telegram.chatId)
|
|
39
35
|
: null;
|
|
@@ -0,0 +1,111 @@
|
|
|
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 { pwaApproverFrom } from "../pwa-approver.js";
|
|
9
|
+
import { DEFAULT_CONFIG } from "../config.js";
|
|
10
|
+
/**
|
|
11
|
+
* Guards the wiring, not the crypto: enforcement must reach the phone
|
|
12
|
+
* from every entry point. `sandgate test-approval` once built its own
|
|
13
|
+
* approver config and quietly dropped requireBiometric, so an enforced
|
|
14
|
+
* vault still accepted a bare tap. One builder now serves serve() and the
|
|
15
|
+
* CLI, and this test fails if it ever stops enforcing.
|
|
16
|
+
*/
|
|
17
|
+
const CREDENTIAL = {
|
|
18
|
+
credentialId: "unused-for-this-test",
|
|
19
|
+
publicKeySpki: "unused-for-this-test",
|
|
20
|
+
rpId: "localhost",
|
|
21
|
+
origin: "http://localhost",
|
|
22
|
+
enrolledAt: new Date().toISOString(),
|
|
23
|
+
};
|
|
24
|
+
/** A phone that taps Approve without producing any assertion. */
|
|
25
|
+
async function tapWithoutAssertion(relayUrl, pairId, secret) {
|
|
26
|
+
const key = deriveKey(secret);
|
|
27
|
+
for (let i = 0; i < 100; i++) {
|
|
28
|
+
const res = await fetch(`${relayUrl}/api/pending?pairId=${pairId}`);
|
|
29
|
+
const items = (await res.json());
|
|
30
|
+
if (items.length) {
|
|
31
|
+
const { requestId, payload } = items[0];
|
|
32
|
+
const request = open(key, payload, aadForRequest(requestId));
|
|
33
|
+
await fetch(`${relayUrl}/api/decision`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "Content-Type": "application/json" },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
pairId,
|
|
38
|
+
requestId,
|
|
39
|
+
payload: seal(key, { requestId, approved: true, ts: Date.now() }, aadForDecision(requestId)),
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
return request;
|
|
43
|
+
}
|
|
44
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
45
|
+
}
|
|
46
|
+
throw new Error("phone never saw a request");
|
|
47
|
+
}
|
|
48
|
+
/** Settle a promise without ever leaving a rejection unhandled. */
|
|
49
|
+
function settled(p) {
|
|
50
|
+
return p.then((value) => ({ value }), (error) => ({ error }));
|
|
51
|
+
}
|
|
52
|
+
async function withRelay(fn) {
|
|
53
|
+
const relay = await startRelay({
|
|
54
|
+
port: 0,
|
|
55
|
+
stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
|
|
56
|
+
});
|
|
57
|
+
try {
|
|
58
|
+
return await fn(`http://localhost:${relay.port}`);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
relay.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
test("an enforced vault rejects an approval that carries no assertion", async () => {
|
|
65
|
+
await withRelay(async (relayUrl) => {
|
|
66
|
+
const pairing = newPairing();
|
|
67
|
+
const vault = {
|
|
68
|
+
totp: {},
|
|
69
|
+
pwa: { relayUrl, pairId: pairing.pairId, secret: pairing.secret },
|
|
70
|
+
biometric: CREDENTIAL,
|
|
71
|
+
};
|
|
72
|
+
const approver = pwaApproverFrom(vault, { ...DEFAULT_CONFIG, requireBiometric: true });
|
|
73
|
+
const deciding = settled(approver.request({ title: "Pay 300 EUR", timeoutSec: 15 }));
|
|
74
|
+
const request = await tapWithoutAssertion(relayUrl, pairing.pairId, pairing.secret);
|
|
75
|
+
// The phone must be TOLD to ask for biometrics...
|
|
76
|
+
assert.equal(request.requireBiometric, true, "the request did not demand a biometric");
|
|
77
|
+
// ...and a bare tap must not pass for an approval.
|
|
78
|
+
const outcome = await deciding;
|
|
79
|
+
assert.equal(outcome.value, undefined, "a bare tap was accepted as an approval");
|
|
80
|
+
assert.match(String(outcome.error), /without the required biometric assertion/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
test("without enforcement, the same tap is a normal approval", async () => {
|
|
84
|
+
await withRelay(async (relayUrl) => {
|
|
85
|
+
const pairing = newPairing();
|
|
86
|
+
const vault = {
|
|
87
|
+
totp: {},
|
|
88
|
+
pwa: { relayUrl, pairId: pairing.pairId, secret: pairing.secret },
|
|
89
|
+
};
|
|
90
|
+
const approver = pwaApproverFrom(vault, { ...DEFAULT_CONFIG, requireBiometric: false });
|
|
91
|
+
const deciding = settled(approver.request({ title: "Harmless", timeoutSec: 15 }));
|
|
92
|
+
const request = await tapWithoutAssertion(relayUrl, pairing.pairId, pairing.secret);
|
|
93
|
+
assert.equal(request.requireBiometric, false);
|
|
94
|
+
assert.deepEqual((await deciding).value, { approved: true, decision: "approved" });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
test("enforcement without an enrolled credential refuses instead of downgrading", async () => {
|
|
98
|
+
await withRelay(async (relayUrl) => {
|
|
99
|
+
const pairing = newPairing();
|
|
100
|
+
const vault = {
|
|
101
|
+
totp: {},
|
|
102
|
+
pwa: { relayUrl, pairId: pairing.pairId, secret: pairing.secret },
|
|
103
|
+
};
|
|
104
|
+
const approver = pwaApproverFrom(vault, { ...DEFAULT_CONFIG, requireBiometric: true });
|
|
105
|
+
const deciding = settled(approver.request({ title: "Pay 300 EUR", timeoutSec: 15 }));
|
|
106
|
+
await tapWithoutAssertion(relayUrl, pairing.pairId, pairing.secret);
|
|
107
|
+
const outcome = await deciding;
|
|
108
|
+
assert.equal(outcome.value, undefined, "an unenrolled vault approved anyway");
|
|
109
|
+
assert.match(String(outcome.error), /no credential is enrolled/);
|
|
110
|
+
});
|
|
111
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sandprivacy/sandgate",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|