@sandprivacy/sandgate 0.3.1 → 0.3.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/README.md CHANGED
@@ -74,6 +74,11 @@ the challenge and the user-verification flag. Anything off — a replayed
74
74
  assertion, another device, a missing biometric — is a denial, never an
75
75
  approval. Off by default; `sandgate status` tells you where you stand.
76
76
 
77
+ Turning it back off (`sandgate biometric off`) asks for the vault
78
+ passphrase: the switch lives inside the encrypted vault, so editing a
79
+ config file — or running the command without the passphrase — cannot
80
+ weaken your setup.
81
+
77
82
  ## Policies
78
83
 
79
84
  ```bash
package/dist/config.js CHANGED
@@ -26,3 +26,10 @@ export function saveConfig(config) {
26
26
  export function totpPolicy(config, domain) {
27
27
  return config.policies.totp[domain] ?? config.policies.totpDefault;
28
28
  }
29
+ /**
30
+ * Is a verified biometric required? The vault wins; the plaintext config
31
+ * flag is only a fallback for vaults written before 0.3.2.
32
+ */
33
+ export function biometricRequired(vault, config) {
34
+ return vault.requireBiometric ?? config.requireBiometric ?? false;
35
+ }
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { testImapConnection } from "./inbox.js";
8
8
  import { resolvePassphrase, protectPassphraseDpapi, dpapiDecryptCommand } from "./passphrase.js";
9
9
  import { auditPath } from "./paths.js";
10
10
  import { vaultExists, loadVault, saveVault, rekeyVault, } from "./vault.js";
11
- import { loadConfig, saveConfig } from "./config.js";
11
+ import { loadConfig, saveConfig, biometricRequired } from "./config.js";
12
12
  import { normalizeSecret, generateCode } from "./totp.js";
13
13
  import { TelegramApprover, discoverChatId } from "./telegram.js";
14
14
  import { serve } from "./server.js";
@@ -284,9 +284,24 @@ async function cmdBiometric(mode) {
284
284
  console.error("Usage: sandgate biometric <on|off>");
285
285
  process.exit(1);
286
286
  }
287
+ // Turning a protection off must cost the passphrase, so the flag lives
288
+ // in the vault — not in a plaintext file anyone could edit.
289
+ const prompter = new Prompter();
290
+ const pass = await getPassphrase(prompter);
291
+ prompter.close();
292
+ const data = loadVault(pass);
293
+ if (mode === "on" && !data.biometric) {
294
+ console.error("Nothing enrolled yet. Run `sandgate enroll-biometric` first.");
295
+ process.exit(1);
296
+ }
297
+ data.requireBiometric = mode === "on";
298
+ saveVault(pass, data);
299
+ // Retire the legacy plaintext flag so the two can never disagree.
287
300
  const config = loadConfig();
288
- config.requireBiometric = mode === "on";
289
- saveConfig(config);
301
+ if (config.requireBiometric) {
302
+ config.requireBiometric = false;
303
+ saveConfig(config);
304
+ }
290
305
  console.log(mode === "on"
291
306
  ? "Biometric approvals required. Every approval must now be signed by the enrolled device."
292
307
  : "Biometric requirement off. A tap is enough again.");
@@ -305,6 +320,7 @@ async function cmdStatus() {
305
320
  const auditCount = existsSync(auditPath())
306
321
  ? readFileSync(auditPath(), "utf8").trim().split("\n").filter(Boolean).length
307
322
  : 0;
323
+ const requiresBio = biometricRequired(data, config);
308
324
  const approval = data.pwa
309
325
  ? `PWA via ${data.pwa.relayUrl} (Telegram ${data.telegram ? "fallback" : "not set"})`
310
326
  : data.telegram
@@ -326,7 +342,7 @@ async function cmdStatus() {
326
342
  .map(([d, p]) => `${d}=${p}`)
327
343
  .join(", ")
328
344
  : ""));
329
- console.log(` biometric ${config.requireBiometric
345
+ console.log(` biometric ${requiresBio
330
346
  ? data.biometric
331
347
  ? "required (enrolled)"
332
348
  : "REQUIRED BUT NOT ENROLLED — run `sandgate enroll-biometric`"
@@ -426,7 +442,7 @@ async function cmdTestApproval() {
426
442
  const { pwaApproverFrom } = await import("./pwa-approver.js");
427
443
  const config = loadConfig();
428
444
  approver = pwaApproverFrom(data, config);
429
- console.log(config.requireBiometric
445
+ console.log(biometricRequired(data, config)
430
446
  ? "Sending test approval to the paired PWA — Face ID required (60s timeout)…"
431
447
  : "Sending test approval to the paired PWA (60s timeout)…");
432
448
  }
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { biometricRequired } from "./config.js";
2
3
  import { deriveKey, seal, open, aadForRequest, aadForDecision } from "./pwacrypto.js";
3
4
  import { verifyAssertion, verifyEnrollment, } from "./webauthn.js";
4
5
  export class PwaApprover {
@@ -112,6 +113,6 @@ export function pwaApproverFrom(vault, config) {
112
113
  return new PwaApprover({
113
114
  ...vault.pwa,
114
115
  biometric: vault.biometric,
115
- requireBiometric: config.requireBiometric,
116
+ requireBiometric: biometricRequired(vault, config),
116
117
  });
117
118
  }
@@ -631,7 +631,26 @@ export const PWA_HTML = `<!doctype html>
631
631
  }
632
632
  }
633
633
 
634
+ // Push, SSE and the safety poll all trigger refreshes, and they arrive
635
+ // together. Two of them used to race in the gap between "do I have this
636
+ // card?" and adding it — decrypting is async — so one request could
637
+ // render several times, and only the last copy stayed wired. One refresh
638
+ // runs at a time now; overlapping triggers coalesce into one re-run.
639
+ var refreshing = false;
640
+ var refreshQueued = false;
641
+
634
642
  async function fetchPending() {
643
+ if (refreshing) { refreshQueued = true; return; }
644
+ refreshing = true;
645
+ try {
646
+ await refreshOnce();
647
+ } finally {
648
+ refreshing = false;
649
+ if (refreshQueued) { refreshQueued = false; fetchPending(); }
650
+ }
651
+ }
652
+
653
+ async function refreshOnce() {
635
654
  var seen = {};
636
655
  await Promise.all(pairs.map(async function (p) {
637
656
  var raw;
@@ -642,16 +661,27 @@ export const PWA_HTML = `<!doctype html>
642
661
  var key = p.pairId + ":" + raw[i].requestId;
643
662
  seen[key] = true;
644
663
  if (cards[key]) continue;
664
+ // Claim the key before awaiting: two vaults resolving at once must
665
+ // not both decide the card is missing.
666
+ cards[key] = null;
645
667
  try {
646
668
  var req = await openSealed(p, raw[i].payload, "req:" + raw[i].requestId);
647
669
  addCard(key, p, raw[i].requestId, req);
648
- } catch (e) { /* not ours / tampered */ }
670
+ } catch (e) {
671
+ delete cards[key]; // not ours / tampered — release the claim
672
+ }
649
673
  }
650
674
  }));
651
675
  for (var cid in cards) {
652
- if (!seen[cid]) { cards[cid].el.remove(); delete cards[cid]; }
676
+ if (!seen[cid] && cards[cid]) { cards[cid].el.remove(); delete cards[cid]; }
653
677
  }
654
- ensureEmpty(Object.keys(cards).length === 0);
678
+ ensureEmpty(activeCount() === 0);
679
+ }
680
+
681
+ function activeCount() {
682
+ var n = 0;
683
+ for (var k in cards) if (cards[k]) n++;
684
+ return n;
655
685
  }
656
686
 
657
687
  function addCard(id, p, requestId, req) {
@@ -765,7 +795,7 @@ export const PWA_HTML = `<!doctype html>
765
795
  c.barEl.className = "bar" + (remaining < total * 0.25 ? " low" : "");
766
796
  }
767
797
  setInterval(function () {
768
- for (var id in cards) tickOne(cards[id]);
798
+ for (var id in cards) if (cards[id]) tickOne(cards[id]);
769
799
  }, 1000);
770
800
 
771
801
  function makeActionBtn(label, cls, icon, onTap) {
@@ -794,7 +824,7 @@ export const PWA_HTML = `<!doctype html>
794
824
  cards[id].el.remove();
795
825
  delete cards[id];
796
826
  }
797
- ensureEmpty(Object.keys(cards).length === 0);
827
+ ensureEmpty(activeCount() === 0);
798
828
  } catch (err) {
799
829
  btn.disabled = false;
800
830
  alert("Could not send your decision: " + (err && err.message ? err.message : err));
package/dist/server.js CHANGED
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
4
  import { loadVault } from "./vault.js";
5
- import { loadConfig, totpPolicy } from "./config.js";
5
+ import { loadConfig, totpPolicy, biometricRequired } from "./config.js";
6
6
  import { generateCode } from "./totp.js";
7
7
  import { TelegramApprover } from "./telegram.js";
8
8
  import { pwaApproverFrom } from "./pwa-approver.js";
@@ -23,7 +23,7 @@ function refusal(message) {
23
23
  export async function serve(passphrase) {
24
24
  const vault = loadVault(passphrase);
25
25
  const config = loadConfig();
26
- if (config.requireBiometric && !vault.biometric) {
26
+ if (biometricRequired(vault, config) && !vault.biometric) {
27
27
  // Fail closed rather than silently downgrade to a plain tap.
28
28
  throw new Error("requireBiometric is on but no credential is enrolled. Run `sandgate enroll-biometric`, or turn it off with `sandgate biometric off`.");
29
29
  }
@@ -109,3 +109,14 @@ test("enforcement without an enrolled credential refuses instead of downgrading"
109
109
  assert.match(String(outcome.error), /no credential is enrolled/);
110
110
  });
111
111
  });
112
+ test("the vault flag wins over the legacy plaintext config flag", async () => {
113
+ const { biometricRequired } = await import("../config.js");
114
+ const base = { totp: {} };
115
+ // Vaults written before 0.3.2 kept the switch in config.json.
116
+ assert.equal(biometricRequired(base, { ...DEFAULT_CONFIG, requireBiometric: true }), true);
117
+ // Once the vault carries it, the plaintext file cannot re-enable...
118
+ assert.equal(biometricRequired({ ...base, requireBiometric: false }, { ...DEFAULT_CONFIG, requireBiometric: true }), false);
119
+ // ...nor disable it: only the passphrase-protected vault decides.
120
+ assert.equal(biometricRequired({ ...base, requireBiometric: true }, { ...DEFAULT_CONFIG, requireBiometric: false }), true);
121
+ assert.equal(biometricRequired(base, DEFAULT_CONFIG), false);
122
+ });
@@ -0,0 +1,64 @@
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 } from "../pwacrypto.js";
7
+ import { startRelay } from "../relay/server.js";
8
+ import { PwaApprover } from "../pwa-approver.js";
9
+ import { loadPage, waitFor } from "./helpers/page.js";
10
+ /**
11
+ * One request must never render twice. Concurrent refreshes (an SSE event,
12
+ * the safety poll and a visibility change can all land together) used to
13
+ * race in the async gap between "do I already have this card?" and adding
14
+ * it, leaving a duplicate whose buttons pointed at nothing.
15
+ */
16
+ test("concurrent refreshes render a single card per request", async () => {
17
+ const relay = await startRelay({
18
+ port: 0,
19
+ stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
20
+ });
21
+ const relayUrl = `http://localhost:${relay.port}`;
22
+ try {
23
+ const pairing = newPairing();
24
+ const { window, close } = await loadPage(relayUrl, {
25
+ hash: `#p=${pairing.pairId}&s=${pairing.secret}`,
26
+ });
27
+ try {
28
+ const approver = new PwaApprover({
29
+ relayUrl,
30
+ pairId: pairing.pairId,
31
+ secret: pairing.secret,
32
+ });
33
+ const deciding = approver.request({ title: "Only once", timeoutSec: 25 }).catch(() => { });
34
+ // Wait until the relay actually holds the request, otherwise the
35
+ // burst below races an empty queue and proves nothing.
36
+ for (let i = 0; i < 60; i++) {
37
+ const items = (await (await fetch(`${relayUrl}/api/pending?pairId=${pairing.pairId}`)).json());
38
+ if (items.length)
39
+ break;
40
+ await new Promise((r) => setTimeout(r, 50));
41
+ }
42
+ // Every visibilitychange kicks a refresh; firing a burst reproduces
43
+ // the overlap that a real phone hits when push, SSE and poll agree.
44
+ for (let i = 0; i < 8; i++) {
45
+ window.document.dispatchEvent(new window.Event("visibilitychange"));
46
+ }
47
+ await waitFor(() => window.document.querySelector(".card"));
48
+ await new Promise((r) => setTimeout(r, 1500)); // let every racer land
49
+ const cards = window.document.querySelectorAll(".card");
50
+ assert.equal(cards.length, 1, `rendered ${cards.length} cards for one request`);
51
+ assert.equal(window.document.querySelectorAll(".card button.ok").length, 1, "a duplicate card left dead buttons behind");
52
+ // And the surviving card must still work.
53
+ window.document.querySelector(".card button.ok").click();
54
+ await waitFor(() => (window.document.querySelectorAll(".card").length === 0 ? true : null));
55
+ await deciding;
56
+ }
57
+ finally {
58
+ close();
59
+ }
60
+ }
61
+ finally {
62
+ relay.close();
63
+ }
64
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandprivacy/sandgate",
3
- "version": "0.3.1",
3
+ "version": "0.3.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",