@sandprivacy/sandgate 0.2.2 → 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/README.md CHANGED
@@ -56,6 +56,24 @@ That's it. Your agent now has four new tools.
56
56
 
57
57
  **CAPTCHAs.** sandgate will never auto-solve a CAPTCHA — that's the point of a CAPTCHA. The pattern that works today, by composition: tell your agent that on hitting one it should call `request_approval("CAPTCHA on <site> — solve it at the computer, then approve to continue")`. Your phone buzzes, you solve it where the browser is, you tap approve, the agent resumes. Same behavior as OpenAI's Operator, plus the notification.
58
58
 
59
+ ## Face ID / Touch ID on sensitive approvals (optional)
60
+
61
+ A tap proves someone holds your unlocked phone. A biometric assertion
62
+ proves it was *you*, on the *enrolled* device — and the gateway verifies
63
+ it cryptographically instead of trusting the page:
64
+
65
+ ```bash
66
+ sandgate enroll-biometric # your phone asks; Face ID confirms
67
+ sandgate biometric on # now every approval must be signed
68
+ ```
69
+
70
+ Each approval carries a WebAuthn assertion over a challenge derived from
71
+ the request id, signed inside your phone's secure enclave. sandgate
72
+ stores only the public key and checks the signature, the relying party,
73
+ the challenge and the user-verification flag. Anything off — a replayed
74
+ assertion, another device, a missing biometric — is a denial, never an
75
+ approval. Off by default; `sandgate status` tells you where you stand.
76
+
59
77
  ## Policies
60
78
 
61
79
  ```bash
@@ -101,7 +119,8 @@ How the trust works: the pairing secret travels once, inside the URL **fragment*
101
119
  - [x] `sandgate audit` — pretty-print the audit trail
102
120
  - [x] Mobile PWA with end-to-end-encrypted push (`sandgate relay` + `sandgate pair`), multi-vault, on-device history
103
121
  - [x] `ask_human` — free-text answers (SMS codes on your real number, security questions)
104
- - [ ] OS keychain for the vault passphrase
122
+ - [x] OS keychain for the vault passphrase (`SANDGATE_PASSPHRASE_CMD`, `sandgate protect`)
123
+ - [x] Face ID / Touch ID on approvals, verified server-side (`sandgate enroll-biometric`)
105
124
  - [ ] Slack approval channel with multiple approvers (teams)
106
125
  - [ ] Team policies (shared vault, centralized audit)
107
126
  - [x] Framework guides: [Claude Code](docs/integrations/claude-code.md), [browser-use](docs/integrations/browser-use.md), [Playwright MCP](docs/integrations/playwright-mcp.md), [LangGraph](docs/integrations/langgraph.md)
package/dist/config.js CHANGED
@@ -8,6 +8,7 @@ export const DEFAULT_CONFIG = {
8
8
  identityDefault: "auto",
9
9
  },
10
10
  approvalTimeoutSec: 120,
11
+ requireBiometric: false,
11
12
  };
12
13
  export function loadConfig() {
13
14
  if (!existsSync(configPath()))
package/dist/index.js CHANGED
@@ -27,6 +27,8 @@ Usage:
27
27
  sandgate test-approval Send a test approval to your phone
28
28
  sandgate rekey Change the vault passphrase
29
29
  sandgate protect Store the passphrase in the OS store (Windows DPAPI)
30
+ sandgate enroll-biometric Enroll Face ID / Touch ID on the paired phone
31
+ sandgate biometric <on|off> Require a verified biometric for every approval
30
32
  sandgate status Show what is configured and active
31
33
  sandgate audit [n] Show the last n audit entries (default 20)
32
34
  sandgate serve Run the MCP server (stdio)
@@ -254,6 +256,41 @@ async function cmdProtect() {
254
256
  console.log(`Passphrase verified against the vault and stored, DPAPI-encrypted, at:\n ${target}\n\n` +
255
257
  `Point your MCP config at it with:\n SANDGATE_PASSPHRASE_CMD = ${dpapiDecryptCommand(target)}`);
256
258
  }
259
+ async function cmdEnrollBiometric() {
260
+ const prompter = new Prompter();
261
+ const pass = await getPassphrase(prompter);
262
+ prompter.close();
263
+ const data = loadVault(pass);
264
+ if (!data.pwa) {
265
+ console.error("Biometrics need the PWA channel. Run `sandgate pair <relay-url>` first.");
266
+ process.exit(1);
267
+ }
268
+ const { PwaApprover } = await import("./pwa-approver.js");
269
+ const approver = new PwaApprover(data.pwa);
270
+ console.log("Sent to your phone — tap Enable and confirm with Face ID / Touch ID (2 min)…");
271
+ const credential = await approver.enroll(120);
272
+ if (!credential) {
273
+ console.error("Not enrolled (declined or timed out).");
274
+ process.exit(1);
275
+ }
276
+ data.biometric = credential;
277
+ saveVault(pass, data);
278
+ console.log(`Enrolled. sandgate stored only the public key (${credential.rpId}).
279
+ ` +
280
+ "Turn enforcement on with: sandgate biometric on");
281
+ }
282
+ async function cmdBiometric(mode) {
283
+ if (mode !== "on" && mode !== "off") {
284
+ console.error("Usage: sandgate biometric <on|off>");
285
+ process.exit(1);
286
+ }
287
+ const config = loadConfig();
288
+ config.requireBiometric = mode === "on";
289
+ saveConfig(config);
290
+ console.log(mode === "on"
291
+ ? "Biometric approvals required. Every approval must now be signed by the enrolled device."
292
+ : "Biometric requirement off. A tap is enough again.");
293
+ }
257
294
  async function cmdStatus() {
258
295
  if (!vaultExists()) {
259
296
  console.log("No vault. Run `sandgate init` to get started.");
@@ -289,6 +326,13 @@ async function cmdStatus() {
289
326
  .map(([d, p]) => `${d}=${p}`)
290
327
  .join(", ")
291
328
  : ""));
329
+ console.log(` biometric ${config.requireBiometric
330
+ ? data.biometric
331
+ ? "required (enrolled)"
332
+ : "REQUIRED BUT NOT ENROLLED — run `sandgate enroll-biometric`"
333
+ : data.biometric
334
+ ? "enrolled, not enforced"
335
+ : "off"}`);
292
336
  console.log(` audit entries ${auditCount}`);
293
337
  }
294
338
  async function cmdAudit(countArg) {
@@ -379,9 +423,12 @@ async function cmdTestApproval() {
379
423
  const data = loadVault(pass);
380
424
  let approver;
381
425
  if (data.pwa) {
382
- const { PwaApprover } = await import("./pwa-approver.js");
383
- approver = new PwaApprover(data.pwa);
384
- console.log("Sending test approval to the paired PWA (60s timeout)…");
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)…");
385
432
  }
386
433
  else if (data.telegram) {
387
434
  approver = new TelegramApprover(data.telegram.botToken, data.telegram.chatId);
@@ -423,6 +470,10 @@ async function main() {
423
470
  return cmdRekey();
424
471
  case "protect":
425
472
  return cmdProtect();
473
+ case "enroll-biometric":
474
+ return cmdEnrollBiometric();
475
+ case "biometric":
476
+ return cmdBiometric(args[0]);
426
477
  case "status":
427
478
  return cmdStatus();
428
479
  case "audit":
@@ -1,5 +1,6 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { deriveKey, seal, open, aadForRequest, aadForDecision } from "./pwacrypto.js";
3
+ import { verifyAssertion, verifyEnrollment, } from "./webauthn.js";
3
4
  export class PwaApprover {
4
5
  config;
5
6
  key;
@@ -13,7 +14,18 @@ export class PwaApprover {
13
14
  /** Post a sealed request and long-poll its sealed decision (or null on timeout). */
14
15
  async roundTrip(kind, req) {
15
16
  const requestId = randomBytes(16).toString("base64url");
16
- const sealed = seal(this.key, { kind, title: req.title, body: req.body, timeoutSec: req.timeoutSec, ts: Date.now() }, aadForRequest(requestId));
17
+ // Biometric enforcement travels inside the sealed request: the relay
18
+ // cannot see it, and the phone cannot be told to skip it by anyone else.
19
+ const needsBiometric = kind !== "enroll" && !!this.config.requireBiometric;
20
+ const sealed = seal(this.key, {
21
+ kind,
22
+ title: req.title,
23
+ body: req.body,
24
+ timeoutSec: req.timeoutSec,
25
+ ts: Date.now(),
26
+ requireBiometric: needsBiometric,
27
+ credentialId: needsBiometric ? this.config.biometric?.credentialId : undefined,
28
+ }, aadForRequest(requestId));
17
29
  const post = await fetch(this.url("/api/request"), {
18
30
  method: "POST",
19
31
  headers: { "Content-Type": "application/json" },
@@ -34,26 +46,72 @@ export class PwaApprover {
34
46
  const decision = open(this.key, payload, aadForDecision(requestId));
35
47
  if (decision.requestId !== requestId)
36
48
  continue; // belt and suspenders; AAD already binds it
37
- return decision;
49
+ if (needsBiometric && decision.approved) {
50
+ // Fail closed: an approval without a verifiable assertion is not
51
+ // an approval. verifyAssertion throws on anything suspicious.
52
+ if (!this.config.biometric) {
53
+ throw new Error("Biometric approval is required but no credential is enrolled. Run `sandgate enroll-biometric`.");
54
+ }
55
+ if (!decision.assertion) {
56
+ throw new Error("Approval arrived without the required biometric assertion.");
57
+ }
58
+ verifyAssertion(decision.assertion, this.config.biometric, requestId);
59
+ }
60
+ return { requestId, decision };
38
61
  }
39
62
  return null;
40
63
  }
41
64
  async request(req) {
42
- const decision = await this.roundTrip("approval", req);
43
- if (!decision)
65
+ const result = await this.roundTrip("approval", req);
66
+ if (!result)
44
67
  return { approved: false, decision: "timeout" };
45
68
  return {
46
- approved: decision.approved,
47
- decision: decision.approved ? "approved" : "denied",
69
+ approved: result.decision.approved,
70
+ decision: result.decision.approved ? "approved" : "denied",
48
71
  };
49
72
  }
50
73
  async ask(req) {
51
- const decision = await this.roundTrip("input", req);
52
- if (!decision)
74
+ const result = await this.roundTrip("input", req);
75
+ if (!result)
53
76
  return { answer: null, decision: "timeout" };
77
+ const { decision } = result;
54
78
  if (!decision.approved || typeof decision.answer !== "string") {
55
79
  return { answer: null, decision: "denied" };
56
80
  }
57
81
  return { answer: decision.answer, decision: "answered" };
58
82
  }
83
+ /**
84
+ * Enroll the phone's platform authenticator. Returns the credential to
85
+ * store in the vault, or null if the human declined or let it expire.
86
+ */
87
+ async enroll(timeoutSec) {
88
+ const result = await this.roundTrip("enroll", {
89
+ title: "Enable Face ID / Touch ID approvals",
90
+ body: "Your device will sign approvals from now on. sandgate stores only the public key.",
91
+ timeoutSec,
92
+ });
93
+ if (!result)
94
+ return null;
95
+ const { requestId, decision } = result;
96
+ if (!decision.approved || !decision.enrollment)
97
+ return null;
98
+ return verifyEnrollment(decision.enrollment, {
99
+ requestId,
100
+ origin: new URL(this.config.relayUrl).origin,
101
+ });
102
+ }
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
+ });
59
117
  }
@@ -410,6 +410,69 @@ export const PWA_HTML = `<!doctype html>
410
410
  return { iv: bytesToB64u(iv), ct: bytesToB64u(ct) };
411
411
  }
412
412
 
413
+ // --- WebAuthn ceremonies (Face ID / Touch ID) ---------------------------
414
+ // The challenge is derived from the request id on both sides, so an
415
+ // assertion is worthless on any other request. sandgate only ever sees
416
+ // the public key: the private key never leaves the secure enclave.
417
+ async function webauthnChallenge(requestId) {
418
+ var digest = await crypto.subtle.digest(
419
+ "SHA-256",
420
+ enc.encode("sandgate-webauthn-v1:" + requestId)
421
+ );
422
+ return new Uint8Array(digest);
423
+ }
424
+
425
+ async function doEnroll(requestId) {
426
+ if (!window.PublicKeyCredential) throw new Error("this device has no passkey support");
427
+ var cred = await navigator.credentials.create({
428
+ publicKey: {
429
+ challenge: await webauthnChallenge(requestId),
430
+ rp: { id: location.hostname, name: "sandgate" },
431
+ user: {
432
+ id: crypto.getRandomValues(new Uint8Array(16)),
433
+ name: "sandgate",
434
+ displayName: "sandgate",
435
+ },
436
+ pubKeyCredParams: [{ type: "public-key", alg: -7 }],
437
+ authenticatorSelection: {
438
+ authenticatorAttachment: "platform",
439
+ userVerification: "required",
440
+ residentKey: "discouraged",
441
+ },
442
+ attestation: "none",
443
+ timeout: 60000,
444
+ },
445
+ });
446
+ var spki = cred.response.getPublicKey && cred.response.getPublicKey();
447
+ if (!spki) throw new Error("this device did not expose the public key");
448
+ return {
449
+ credentialId: bytesToB64u(cred.rawId),
450
+ publicKeySpki: bytesToB64u(spki),
451
+ clientDataJSON: bytesToB64u(cred.response.clientDataJSON),
452
+ };
453
+ }
454
+
455
+ async function doAssert(requestId, credentialId) {
456
+ if (!window.PublicKeyCredential) throw new Error("this device has no passkey support");
457
+ var assertion = await navigator.credentials.get({
458
+ publicKey: {
459
+ challenge: await webauthnChallenge(requestId),
460
+ rpId: location.hostname,
461
+ allowCredentials: credentialId
462
+ ? [{ type: "public-key", id: b64uToBytes(credentialId) }]
463
+ : undefined,
464
+ userVerification: "required",
465
+ timeout: 60000,
466
+ },
467
+ });
468
+ return {
469
+ credentialId: bytesToB64u(assertion.rawId),
470
+ authenticatorData: bytesToB64u(assertion.response.authenticatorData),
471
+ clientDataJSON: bytesToB64u(assertion.response.clientDataJSON),
472
+ signature: bytesToB64u(assertion.response.signature),
473
+ };
474
+ }
475
+
413
476
  // --- presence + push subscription ---------------------------------------
414
477
  // Announce this page to the relay immediately (push or not), so the
415
478
  // "sandgate pair" command can report "phone connected" without waiting
@@ -593,13 +656,20 @@ export const PWA_HTML = `<!doctype html>
593
656
 
594
657
  function addCard(id, p, requestId, req) {
595
658
  var isInput = req.kind === "input";
659
+ var isEnroll = req.kind === "enroll";
596
660
  var card = document.createElement("div");
597
661
  card.className = "card";
598
662
 
599
663
  var who = document.createElement("div"); who.className = "who";
600
664
  who.textContent =
601
665
  (pairs.length > 1 ? p.name + " · " : "") +
602
- (isInput ? "agent · question" : "agent · approval request");
666
+ (isEnroll
667
+ ? "sandgate · setup"
668
+ : isInput
669
+ ? "agent · question"
670
+ : req.requireBiometric
671
+ ? "agent · approval · Face ID"
672
+ : "agent · approval request");
603
673
  card.appendChild(who);
604
674
  var h = document.createElement("h2"); h.textContent = req.title; card.appendChild(h);
605
675
  // NOTE: never name this variable p — var is function-scoped and would
@@ -607,7 +677,7 @@ export const PWA_HTML = `<!doctype html>
607
677
  if (req.body) { var bodyP = document.createElement("p"); bodyP.textContent = req.body; card.appendChild(bodyP); }
608
678
 
609
679
  var input = null;
610
- if (isInput) {
680
+ if (isInput && !isEnroll) {
611
681
  input = document.createElement("input");
612
682
  input.className = "answer-input";
613
683
  input.placeholder = "Your answer";
@@ -623,7 +693,20 @@ export const PWA_HTML = `<!doctype html>
623
693
  card.appendChild(timer);
624
694
 
625
695
  var row = document.createElement("div"); row.className = "row";
626
- if (isInput) {
696
+ if (isEnroll) {
697
+ row.appendChild(makeActionBtn("Enable", "ok", CHECK, function (btn) {
698
+ btn.disabled = true;
699
+ doEnroll(requestId).then(function (enrollment) {
700
+ submitDecision(id, { requestId: requestId, approved: true, ts: Date.now(), enrollment: enrollment }, "approved", btn);
701
+ }).catch(function (e) {
702
+ btn.disabled = false;
703
+ alert("Could not enable Face ID: " + (e && e.message ? e.message : e));
704
+ });
705
+ }));
706
+ row.appendChild(makeActionBtn("Not now", "no", CROSS, function (btn) {
707
+ submitDecision(id, { requestId: requestId, approved: false, ts: Date.now() }, "denied", btn);
708
+ }));
709
+ } else if (isInput) {
627
710
  var sendBtn = makeActionBtn("Send", "ok", CHECK, function (btn) {
628
711
  var value = input.value.trim();
629
712
  if (!value) { input.focus(); return; }
@@ -637,8 +720,18 @@ export const PWA_HTML = `<!doctype html>
637
720
  submitDecision(id, { requestId: requestId, approved: false, ts: Date.now() }, "denied", btn);
638
721
  }));
639
722
  } else {
640
- row.appendChild(makeActionBtn("Approve", "ok", CHECK, function (btn) {
641
- submitDecision(id, { requestId: requestId, approved: true, ts: Date.now() }, "approved", btn);
723
+ row.appendChild(makeActionBtn(req.requireBiometric ? "Approve with Face ID" : "Approve", "ok", CHECK, function (btn) {
724
+ if (!req.requireBiometric) {
725
+ submitDecision(id, { requestId: requestId, approved: true, ts: Date.now() }, "approved", btn);
726
+ return;
727
+ }
728
+ btn.disabled = true;
729
+ doAssert(requestId, req.credentialId).then(function (assertion) {
730
+ submitDecision(id, { requestId: requestId, approved: true, ts: Date.now(), assertion: assertion }, "approved", btn);
731
+ }).catch(function (e) {
732
+ btn.disabled = false;
733
+ alert("Face ID check failed, approval not sent: " + (e && e.message ? e.message : e));
734
+ });
642
735
  }));
643
736
  row.appendChild(makeActionBtn("Deny", "no", CROSS, function (btn) {
644
737
  submitDecision(id, { requestId: requestId, approved: false, ts: Date.now() }, "denied", btn);
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 { PwaApprover } from "./pwa-approver.js";
8
+ import { pwaApproverFrom } from "./pwa-approver.js";
9
9
  import { backendFromVault } from "./inbox.js";
10
10
  import { audit } from "./audit.js";
11
11
  /**
@@ -23,9 +23,13 @@ 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) {
27
+ // Fail closed rather than silently downgrade to a plain tap.
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
+ }
26
30
  // PWA (E2EE push) wins over Telegram when both are configured.
27
31
  const approver = vault.pwa
28
- ? new PwaApprover(vault.pwa)
32
+ ? pwaApproverFrom(vault, config)
29
33
  : vault.telegram
30
34
  ? new TelegramApprover(vault.telegram.botToken, vault.telegram.chatId)
31
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
+ });
@@ -0,0 +1,55 @@
1
+ import { webcrypto } from "node:crypto";
2
+ import { JSDOM } from "jsdom";
3
+ import { PWA_HTML } from "../../relay/pwa-page.js";
4
+ /**
5
+ * Loads the REAL page script into a DOM wired to a REAL relay: this is the
6
+ * layer that caught nothing while it didn't exist, and now gates releases.
7
+ */
8
+ export async function loadPage(relayUrl, opts) {
9
+ const alerts = [];
10
+ const dom = new JSDOM(PWA_HTML, {
11
+ url: relayUrl + "/" + (opts.hash ?? ""),
12
+ runScripts: "outside-only",
13
+ pretendToBeVisual: true,
14
+ });
15
+ const w = dom.window;
16
+ // The pieces jsdom doesn't ship, wired to the real relay / real crypto.
17
+ w.fetch = (input, init) => fetch(new URL(input, relayUrl), init);
18
+ Object.defineProperty(w, "crypto", { value: webcrypto });
19
+ w.alert = (message) => alerts.push(String(message));
20
+ w.confirm = () => true;
21
+ if (opts.localStorage) {
22
+ for (const [key, value] of Object.entries(opts.localStorage)) {
23
+ w.localStorage.setItem(key, value);
24
+ }
25
+ }
26
+ // Execute the page's inline script exactly as a browser would. The
27
+ // decision alert is instrumented to carry the full stack: a one-line
28
+ // message told us nothing the day this layer was missing.
29
+ let script = dom.window.document.querySelector("script").textContent;
30
+ script = script.replace('alert("Could not send your decision: " + (err && err.message ? err.message : err));', 'alert("Could not send your decision: " + (err && err.stack ? err.stack : err));');
31
+ dom.window.eval(script);
32
+ // window.close() tears down the page's setIntervals so the test process
33
+ // can exit; without it the suite hangs forever.
34
+ return { window: w, alerts, close: () => dom.window.close() };
35
+ }
36
+ /**
37
+ * Without EventSource in jsdom the page falls back to its 8s poll, so give
38
+ * waits comfortable room past that boundary.
39
+ */
40
+ export function waitFor(fn, ms = 15000) {
41
+ return new Promise((resolve, reject) => {
42
+ const started = Date.now();
43
+ const timer = setInterval(() => {
44
+ const value = fn();
45
+ if (value) {
46
+ clearInterval(timer);
47
+ resolve(value);
48
+ }
49
+ else if (Date.now() - started > ms) {
50
+ clearInterval(timer);
51
+ reject(new Error("waitFor timed out"));
52
+ }
53
+ }, 50);
54
+ });
55
+ }
@@ -0,0 +1,170 @@
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 { createHash, generateKeyPairSync, sign as cryptoSign } from "node:crypto";
7
+ import { newPairing } from "../pwacrypto.js";
8
+ import { startRelay } from "../relay/server.js";
9
+ import { PwaApprover } from "../pwa-approver.js";
10
+ import { loadPage, waitFor } from "./helpers/page.js";
11
+ /**
12
+ * Browser-level biometric tests: the real page script runs the real
13
+ * WebAuthn ceremonies against a simulated platform authenticator (same
14
+ * key type, same signed payload as a Face ID enclave), and the real
15
+ * gateway verifies the result. Covers enrollment, a genuine approval,
16
+ * and — the one that matters — an authenticator that is not the enrolled
17
+ * one being rejected.
18
+ */
19
+ function b64u(buf) {
20
+ return Buffer.from(buf).toString("base64url");
21
+ }
22
+ /** Install a fake platform authenticator into a jsdom window. */
23
+ function installAuthenticator(window, opts) {
24
+ const origin = window.location.origin;
25
+ const rpIdHash = createHash("sha256").update(window.location.hostname).digest();
26
+ const clientData = (type, challenge) => Buffer.from(JSON.stringify({ type, challenge: b64u(challenge), origin }), "utf8");
27
+ window.PublicKeyCredential = function () { };
28
+ Object.defineProperty(window.navigator, "credentials", {
29
+ configurable: true,
30
+ value: {
31
+ async create(request) {
32
+ const cd = clientData("webauthn.create", request.publicKey.challenge);
33
+ return {
34
+ rawId: opts.credentialId,
35
+ response: {
36
+ clientDataJSON: cd,
37
+ getPublicKey: () => opts.publicKeySpki,
38
+ },
39
+ };
40
+ },
41
+ async get(request) {
42
+ const cd = clientData("webauthn.get", request.publicKey.challenge);
43
+ const authData = Buffer.concat([rpIdHash, Buffer.from([0x05]), Buffer.alloc(4)]);
44
+ const signed = Buffer.concat([authData, createHash("sha256").update(cd).digest()]);
45
+ return {
46
+ rawId: opts.credentialId,
47
+ response: {
48
+ authenticatorData: authData,
49
+ clientDataJSON: cd,
50
+ signature: cryptoSign("sha256", signed, opts.signingKey),
51
+ },
52
+ };
53
+ },
54
+ },
55
+ });
56
+ }
57
+ function keypair() {
58
+ const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
59
+ return {
60
+ signingKey: privateKey,
61
+ publicKeySpki: publicKey.export({ format: "der", type: "spki" }),
62
+ };
63
+ }
64
+ async function withRelay(fn) {
65
+ const relay = await startRelay({
66
+ port: 0,
67
+ stateDir: mkdtempSync(join(tmpdir(), "sandgate-relay-")),
68
+ });
69
+ try {
70
+ return await fn(`http://localhost:${relay.port}`);
71
+ }
72
+ finally {
73
+ relay.close();
74
+ }
75
+ }
76
+ test("enrollment through the page yields a credential the gateway trusts", async () => {
77
+ await withRelay(async (relayUrl) => {
78
+ const pairing = newPairing();
79
+ const { window, alerts, close } = await loadPage(relayUrl, {
80
+ hash: `#p=${pairing.pairId}&s=${pairing.secret}`,
81
+ });
82
+ try {
83
+ const device = keypair();
84
+ installAuthenticator(window, { ...device, credentialId: Buffer.from("cred-1") });
85
+ const approver = new PwaApprover({ relayUrl, pairId: pairing.pairId, secret: pairing.secret });
86
+ const enrolling = approver.enroll(20);
87
+ const enableBtn = await waitFor(() => window.document.querySelector(".card button.ok"));
88
+ assert.match(window.document.querySelector(".card .who").textContent, /setup/);
89
+ enableBtn.click();
90
+ const credential = await enrolling;
91
+ assert.ok(credential, "enrollment returned nothing");
92
+ assert.equal(credential.rpId, "localhost");
93
+ assert.equal(credential.publicKeySpki, b64u(device.publicKeySpki));
94
+ assert.deepEqual(alerts, [], `page alerted: ${alerts.join(" | ")}`);
95
+ }
96
+ finally {
97
+ close();
98
+ }
99
+ });
100
+ });
101
+ test("with biometrics required, an approval signed by the enrolled device is accepted", async () => {
102
+ await withRelay(async (relayUrl) => {
103
+ const pairing = newPairing();
104
+ const device = keypair();
105
+ const credentialId = Buffer.from("cred-1");
106
+ // 1. Enroll.
107
+ const enrollPage = await loadPage(relayUrl, { hash: `#p=${pairing.pairId}&s=${pairing.secret}` });
108
+ installAuthenticator(enrollPage.window, { ...device, credentialId });
109
+ const enroller = new PwaApprover({ relayUrl, pairId: pairing.pairId, secret: pairing.secret });
110
+ const enrolling = enroller.enroll(20);
111
+ (await waitFor(() => enrollPage.window.document.querySelector(".card button.ok"))).click();
112
+ const credential = await enrolling;
113
+ enrollPage.close();
114
+ assert.ok(credential);
115
+ // 2. Approve, biometrics enforced.
116
+ const page = await loadPage(relayUrl, { hash: `#p=${pairing.pairId}&s=${pairing.secret}` });
117
+ installAuthenticator(page.window, { ...device, credentialId });
118
+ try {
119
+ const approver = new PwaApprover({
120
+ relayUrl,
121
+ pairId: pairing.pairId,
122
+ secret: pairing.secret,
123
+ biometric: credential,
124
+ requireBiometric: true,
125
+ });
126
+ const deciding = approver.request({ title: "Pay 300 EUR", timeoutSec: 20 });
127
+ const approveBtn = await waitFor(() => page.window.document.querySelector(".card button.ok"));
128
+ assert.match(approveBtn.textContent, /Face ID/);
129
+ approveBtn.click();
130
+ assert.deepEqual(await deciding, { approved: true, decision: "approved" });
131
+ assert.deepEqual(page.alerts, [], `page alerted: ${page.alerts.join(" | ")}`);
132
+ }
133
+ finally {
134
+ page.close();
135
+ }
136
+ });
137
+ });
138
+ test("an approval signed by a different authenticator is refused, fail closed", async () => {
139
+ await withRelay(async (relayUrl) => {
140
+ const pairing = newPairing();
141
+ const enrolledDevice = keypair();
142
+ const credentialId = Buffer.from("cred-1");
143
+ const enrollPage = await loadPage(relayUrl, { hash: `#p=${pairing.pairId}&s=${pairing.secret}` });
144
+ installAuthenticator(enrollPage.window, { ...enrolledDevice, credentialId });
145
+ const enroller = new PwaApprover({ relayUrl, pairId: pairing.pairId, secret: pairing.secret });
146
+ const enrolling = enroller.enroll(20);
147
+ (await waitFor(() => enrollPage.window.document.querySelector(".card button.ok"))).click();
148
+ const credential = await enrolling;
149
+ enrollPage.close();
150
+ // The phone now signs with someone else's key — a stolen page, a
151
+ // tampered client. The gateway must not accept it as an approval.
152
+ const page = await loadPage(relayUrl, { hash: `#p=${pairing.pairId}&s=${pairing.secret}` });
153
+ installAuthenticator(page.window, { ...keypair(), credentialId });
154
+ try {
155
+ const approver = new PwaApprover({
156
+ relayUrl,
157
+ pairId: pairing.pairId,
158
+ secret: pairing.secret,
159
+ biometric: credential,
160
+ requireBiometric: true,
161
+ });
162
+ const deciding = approver.request({ title: "Pay 300 EUR", timeoutSec: 20 });
163
+ (await waitFor(() => page.window.document.querySelector(".card button.ok"))).click();
164
+ await assert.rejects(deciding, /does not verify/);
165
+ }
166
+ finally {
167
+ page.close();
168
+ }
169
+ });
170
+ });
@@ -3,64 +3,16 @@ import assert from "node:assert/strict";
3
3
  import { mkdtempSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { webcrypto } from "node:crypto";
7
- import { JSDOM } from "jsdom";
8
6
  import { newPairing } from "../pwacrypto.js";
9
7
  import { startRelay } from "../relay/server.js";
10
- import { PWA_HTML } from "../relay/pwa-page.js";
11
8
  import { PwaApprover } from "../pwa-approver.js";
9
+ import { loadPage, waitFor } from "./helpers/page.js";
12
10
  /**
13
11
  * Browser-level tests: the REAL page script, executed in a DOM, against a
14
12
  * REAL relay — pairing via URL fragment, card rendering, a click on
15
13
  * Approve/Deny, and the legacy-storage migration. This is the layer that
16
14
  * caught nothing while it didn't exist; it exists now.
17
15
  */
18
- async function loadPage(relayUrl, opts) {
19
- const alerts = [];
20
- const dom = new JSDOM(PWA_HTML, {
21
- url: relayUrl + "/" + (opts.hash ?? ""),
22
- runScripts: "outside-only",
23
- pretendToBeVisual: true,
24
- });
25
- const w = dom.window;
26
- // The pieces jsdom doesn't ship, wired to the real relay / real crypto.
27
- w.fetch = (input, init) => fetch(new URL(input, relayUrl), init);
28
- Object.defineProperty(w, "crypto", { value: webcrypto });
29
- w.alert = (message) => alerts.push(String(message));
30
- w.confirm = () => true;
31
- if (opts.localStorage) {
32
- for (const [key, value] of Object.entries(opts.localStorage)) {
33
- w.localStorage.setItem(key, value);
34
- }
35
- }
36
- // Execute the page's inline script exactly as a browser would. The
37
- // decision alert is instrumented to carry the full stack: a one-line
38
- // message told us nothing the day this layer was missing.
39
- let script = dom.window.document.querySelector("script").textContent;
40
- script = script.replace('alert("Could not send your decision: " + (err && err.message ? err.message : err));', 'alert("Could not send your decision: " + (err && err.stack ? err.stack : err));');
41
- dom.window.eval(script);
42
- // window.close() tears down the page's setIntervals so the test process
43
- // can exit; without it the suite hangs forever.
44
- return { window: w, alerts, close: () => dom.window.close() };
45
- }
46
- // Without EventSource in jsdom the page falls back to its 8s poll, so give
47
- // waits comfortable room past that boundary.
48
- function waitFor(fn, ms = 15000) {
49
- return new Promise((resolve, reject) => {
50
- const started = Date.now();
51
- const timer = setInterval(() => {
52
- const value = fn();
53
- if (value) {
54
- clearInterval(timer);
55
- resolve(value);
56
- }
57
- else if (Date.now() - started > ms) {
58
- clearInterval(timer);
59
- reject(new Error("waitFor timed out"));
60
- }
61
- }, 50);
62
- });
63
- }
64
16
  for (const scenario of ["fragment", "legacy-storage"]) {
65
17
  test(`page pairs (${scenario}), renders the card, and Approve round-trips`, async () => {
66
18
  const relay = await startRelay({
@@ -0,0 +1,100 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createHash, generateKeyPairSync, sign as cryptoSign } from "node:crypto";
4
+ import { challengeFor, verifyEnrollment, verifyAssertion, } from "../webauthn.js";
5
+ /**
6
+ * A synthetic platform authenticator: same key type (ES256), same signed
7
+ * payload layout (authenticatorData || SHA256(clientDataJSON)), same DER
8
+ * signatures as a real Face ID enclave. It lets us test the verification
9
+ * path — including every rejection — without a physical device.
10
+ */
11
+ const ORIGIN = "https://relay.sandgate.dev";
12
+ const RP_ID = "relay.sandgate.dev";
13
+ function b64u(buf) {
14
+ return Buffer.from(buf).toString("base64url");
15
+ }
16
+ function makeAuthenticator() {
17
+ const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
18
+ const spki = publicKey.export({ format: "der", type: "spki" });
19
+ const credentialId = b64u(Buffer.from("credential-id-bytes"));
20
+ const clientData = (type, challenge, origin = ORIGIN) => b64u(Buffer.from(JSON.stringify({ type, challenge, origin }), "utf8"));
21
+ const authData = (opts) => {
22
+ const data = Buffer.alloc(37);
23
+ createHash("sha256").update(opts?.rpId ?? RP_ID).digest().copy(data, 0);
24
+ data[32] = opts?.flags ?? 0x05; // UP | UV
25
+ return data;
26
+ };
27
+ return {
28
+ credentialId,
29
+ publicKeySpki: b64u(spki),
30
+ enroll: (requestId) => ({
31
+ credentialId,
32
+ publicKeySpki: b64u(spki),
33
+ clientDataJSON: clientData("webauthn.create", challengeFor(requestId)),
34
+ }),
35
+ assert: (requestId, opts) => {
36
+ const cd = clientData(opts?.type ?? "webauthn.get", challengeFor(requestId), opts?.origin);
37
+ const ad = authData(opts);
38
+ const signed = Buffer.concat([
39
+ ad,
40
+ createHash("sha256").update(Buffer.from(cd, "base64url")).digest(),
41
+ ]);
42
+ const signature = cryptoSign("sha256", signed, privateKey);
43
+ if (opts?.tamper)
44
+ signature[signature.length - 1] ^= 0xff;
45
+ return {
46
+ credentialId,
47
+ authenticatorData: b64u(ad),
48
+ clientDataJSON: cd,
49
+ signature: b64u(signature),
50
+ };
51
+ },
52
+ };
53
+ }
54
+ function enrolled(auth) {
55
+ return verifyEnrollment(auth.enroll("enroll-req"), {
56
+ requestId: "enroll-req",
57
+ origin: ORIGIN,
58
+ });
59
+ }
60
+ test("enrollment yields a usable credential bound to the relay", () => {
61
+ const cred = enrolled(makeAuthenticator());
62
+ assert.equal(cred.rpId, RP_ID);
63
+ assert.equal(cred.origin, ORIGIN);
64
+ assert.ok(cred.publicKeySpki.length > 0);
65
+ });
66
+ test("enrollment for another request or origin is rejected", () => {
67
+ const auth = makeAuthenticator();
68
+ assert.throws(() => verifyEnrollment(auth.enroll("req-a"), { requestId: "req-b", origin: ORIGIN }), /Challenge mismatch/);
69
+ assert.throws(() => verifyEnrollment(auth.enroll("req-a"), { requestId: "req-a", origin: "https://evil.example" }), /Unexpected origin/);
70
+ });
71
+ test("a genuine assertion for the right request verifies", () => {
72
+ const auth = makeAuthenticator();
73
+ const cred = enrolled(auth);
74
+ verifyAssertion(auth.assert("req-1"), cred, "req-1"); // must not throw
75
+ });
76
+ test("an assertion cannot be replayed on another request", () => {
77
+ const auth = makeAuthenticator();
78
+ const cred = enrolled(auth);
79
+ assert.throws(() => verifyAssertion(auth.assert("req-1"), cred, "req-2"), /Challenge mismatch/);
80
+ });
81
+ test("a tampered signature is rejected", () => {
82
+ const auth = makeAuthenticator();
83
+ const cred = enrolled(auth);
84
+ assert.throws(() => verifyAssertion(auth.assert("req-1", { tamper: true }), cred, "req-1"), /does not verify/);
85
+ });
86
+ test("an assertion without the user-verification flag is rejected", () => {
87
+ const auth = makeAuthenticator();
88
+ const cred = enrolled(auth);
89
+ assert.throws(() => verifyAssertion(auth.assert("req-1", { flags: 0x01 }), cred, "req-1"), /User verification flag missing/);
90
+ });
91
+ test("an assertion signed for another relying party is rejected", () => {
92
+ const auth = makeAuthenticator();
93
+ const cred = enrolled(auth);
94
+ assert.throws(() => verifyAssertion(auth.assert("req-1", { rpId: "phish.example" }), cred, "req-1"), /different relying party/);
95
+ });
96
+ test("another authenticator's assertion is rejected", () => {
97
+ const cred = enrolled(makeAuthenticator());
98
+ const attacker = makeAuthenticator();
99
+ assert.throws(() => verifyAssertion(attacker.assert("req-1"), cred, "req-1"), /does not verify/);
100
+ });
@@ -0,0 +1,83 @@
1
+ import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
2
+ /** Deterministic per-request challenge: both sides derive it from the request id. */
3
+ export function challengeFor(requestId) {
4
+ return createHash("sha256")
5
+ .update("sandgate-webauthn-v1:" + requestId)
6
+ .digest("base64url");
7
+ }
8
+ function parseClientData(b64url) {
9
+ const json = Buffer.from(b64url, "base64url").toString("utf8");
10
+ const data = JSON.parse(json);
11
+ if (typeof data.type !== "string" || typeof data.challenge !== "string") {
12
+ throw new Error("Malformed clientDataJSON.");
13
+ }
14
+ return data;
15
+ }
16
+ function checkClientData(evidence, expected) {
17
+ const data = parseClientData(evidence.clientDataJSON);
18
+ if (data.type !== expected.type) {
19
+ throw new Error(`Wrong ceremony type: ${data.type}.`);
20
+ }
21
+ if (data.challenge !== expected.challenge) {
22
+ throw new Error("Challenge mismatch (assertion is not for this request).");
23
+ }
24
+ if (data.origin !== expected.origin) {
25
+ throw new Error(`Unexpected origin: ${data.origin}.`);
26
+ }
27
+ }
28
+ /** Validate an enrollment and produce the record to store in the vault. */
29
+ export function verifyEnrollment(evidence, expected) {
30
+ checkClientData(evidence, {
31
+ type: "webauthn.create",
32
+ challenge: challengeFor(expected.requestId),
33
+ origin: expected.origin,
34
+ });
35
+ const spki = Buffer.from(evidence.publicKeySpki, "base64url");
36
+ createPublicKey({ key: spki, format: "der", type: "spki" }); // throws if unusable
37
+ return {
38
+ credentialId: evidence.credentialId,
39
+ publicKeySpki: evidence.publicKeySpki,
40
+ rpId: new URL(expected.origin).hostname,
41
+ origin: expected.origin,
42
+ enrolledAt: new Date().toISOString(),
43
+ };
44
+ }
45
+ /**
46
+ * Verify an assertion for a given request. Throws on any failure — callers
47
+ * treat a throw as a denial, never as an approval.
48
+ */
49
+ export function verifyAssertion(evidence, credential, requestId) {
50
+ if (evidence.credentialId !== credential.credentialId) {
51
+ throw new Error("Assertion from an unknown credential.");
52
+ }
53
+ checkClientData(evidence, {
54
+ type: "webauthn.get",
55
+ challenge: challengeFor(requestId),
56
+ origin: credential.origin,
57
+ });
58
+ const authData = Buffer.from(evidence.authenticatorData, "base64url");
59
+ if (authData.length < 37)
60
+ throw new Error("Malformed authenticatorData.");
61
+ const rpIdHash = createHash("sha256").update(credential.rpId).digest();
62
+ if (!authData.subarray(0, 32).equals(rpIdHash)) {
63
+ throw new Error("Assertion signed for a different relying party.");
64
+ }
65
+ const flags = authData[32];
66
+ if ((flags & 0x01) === 0)
67
+ throw new Error("User presence flag missing.");
68
+ if ((flags & 0x04) === 0) {
69
+ throw new Error("User verification flag missing (biometric/PIN not performed).");
70
+ }
71
+ const clientDataHash = createHash("sha256")
72
+ .update(Buffer.from(evidence.clientDataJSON, "base64url"))
73
+ .digest();
74
+ const signedData = Buffer.concat([authData, clientDataHash]);
75
+ const publicKey = createPublicKey({
76
+ key: Buffer.from(credential.publicKeySpki, "base64url"),
77
+ format: "der",
78
+ type: "spki",
79
+ });
80
+ const ok = cryptoVerify("sha256", signedData, publicKey, Buffer.from(evidence.signature, "base64url"));
81
+ if (!ok)
82
+ throw new Error("Assertion signature does not verify.");
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sandprivacy/sandgate",
3
- "version": "0.2.2",
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",