@sandprivacy/sandgate 0.2.2 → 0.3.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.
- package/README.md +20 -1
- package/dist/config.js +1 -0
- package/dist/index.js +48 -0
- package/dist/pwa-approver.js +52 -8
- package/dist/relay/pwa-page.js +98 -5
- package/dist/server.js +9 -1
- package/dist/test/helpers/page.js +55 -0
- package/dist/test/pwa-biometric.test.js +170 -0
- package/dist/test/pwa-browser.test.js +1 -49
- package/dist/test/webauthn.test.js +100 -0
- package/dist/webauthn.js +83 -0
- package/package.json +1 -1
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
|
-
- [
|
|
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
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) {
|
|
@@ -423,6 +467,10 @@ async function main() {
|
|
|
423
467
|
return cmdRekey();
|
|
424
468
|
case "protect":
|
|
425
469
|
return cmdProtect();
|
|
470
|
+
case "enroll-biometric":
|
|
471
|
+
return cmdEnrollBiometric();
|
|
472
|
+
case "biometric":
|
|
473
|
+
return cmdBiometric(args[0]);
|
|
426
474
|
case "status":
|
|
427
475
|
return cmdStatus();
|
|
428
476
|
case "audit":
|
package/dist/pwa-approver.js
CHANGED
|
@@ -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
|
-
|
|
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,58 @@ 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
|
-
|
|
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
|
|
43
|
-
if (!
|
|
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
|
|
52
|
-
if (!
|
|
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
|
+
}
|
|
59
103
|
}
|
package/dist/relay/pwa-page.js
CHANGED
|
@@ -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
|
-
(
|
|
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 (
|
|
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
|
-
|
|
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
|
@@ -23,9 +23,17 @@ 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(
|
|
32
|
+
? new PwaApprover({
|
|
33
|
+
...vault.pwa,
|
|
34
|
+
biometric: vault.biometric,
|
|
35
|
+
requireBiometric: config.requireBiometric,
|
|
36
|
+
})
|
|
29
37
|
: vault.telegram
|
|
30
38
|
? new TelegramApprover(vault.telegram.botToken, vault.telegram.chatId)
|
|
31
39
|
: null;
|
|
@@ -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
|
+
});
|
package/dist/webauthn.js
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|