diff --git a/orchestrator/templates/page-passkey.js b/orchestrator/templates/page-passkey.js index d5fb085..5f2fefd 100644 --- a/orchestrator/templates/page-passkey.js +++ b/orchestrator/templates/page-passkey.js @@ -32,7 +32,9 @@ } } - async function signedProof(path, fields, label) { + let signing = false; + + async function prove(path, fields, label) { const result = await sendJson(path, fields); if (!result.ok || !result.data) { toast(`${label}: not possible`, fail(result), false); @@ -45,6 +47,19 @@ return signed ? {proof: {payload: result.data.payload, assertion: signed}} : null; } + async function signedProof(path, fields, label) { + if (signing) { + toast(`${label}: not signed`, ["Another passkey signature is still open: finish or cancel it first; nothing was sent."], false); + return null; + } + signing = true; + try { + return await prove(path, fields, label); + } finally { + signing = false; + } + } + function proofFor(entries, label) { return signedProof("/api/sign", {items: entries}, label); } diff --git a/tests/test_hub_passkey_page.py b/tests/test_hub_passkey_page.py new file mode 100644 index 0000000..6e1f8b1 --- /dev/null +++ b/tests/test_hub_passkey_page.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +import subprocess +import unittest + +from hub_support import HUB + +PASSKEY = HUB / "templates" / "page-passkey.js" +PROBE = """ +const source = require("fs").readFileSync(process.argv[1], "utf8"); +const toasts = []; +const gates = []; +let asked = 0; +let served = 0; +const window = {location: {hostname: "localhost", port: "8737", pathname: "/", hash: ""}}; +const navigator = {credentials: {get: () => { + asked += 1; + return new Promise((resolve, reject) => gates.push({resolve, reject})); +}}}; +const sendJson = async () => { + served += 1; + return {ok: true, data: {payload: {nonce: served}, challenge: "AAAA", credentials: ["AAAA"]}}; +}; +const toast = (title) => toasts.push(title); +const bytes = new Uint8Array([1]).buffer; +const credential = {rawId: bytes, response: {clientDataJSON: bytes, authenticatorData: bytes, signature: bytes}}; +const P = new Function("window", "navigator", "sendJson", "toast", "fail", "h", `${source}\\nreturn {signedProof};`)(window, navigator, sendJson, toast, () => [], () => null); +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const nonce = (signed) => (signed ? signed.proof.payload.nonce : null); +(async () => { + const first = P.signedProof("/api/board/sign", {action: "resume"}, "Resume"); + const second = P.signedProof("/api/board/sign", {action: "resume"}, "Resume"); + await tick(); + const open = {asked, served, toasts: [...toasts]}; + gates.splice(0).forEach((gate) => gate.resolve(credential)); + const both = [nonce(await first), nonce(await second)]; + const canceled = P.signedProof("/api/board/sign", {action: "drain"}, "Drain"); + await tick(); + gates.splice(0).forEach((gate) => gate.reject(new Error("canceled"))); + const dropped = nonce(await canceled); + const after = P.signedProof("/api/board/sign", {action: "suspend"}, "Suspend"); + await tick(); + gates.splice(0).forEach((gate) => gate.resolve(credential)); + console.log(JSON.stringify({open, both, canceled: dropped, after: nonce(await after), asked, toasts})); +})(); +""" + + +class TestPasskeyPage(unittest.TestCase): + @classmethod + def setUpClass(cls): + done = subprocess.run(["node", "-e", PROBE, str(PASSKEY)], capture_output=True, text=True, timeout=60, check=True) + cls.data = json.loads(done.stdout) + + def test_a_second_signature_while_one_is_open_asks_no_passkey_and_sends_nothing(self): + self.assertEqual(self.data["open"], {"asked": 1, "served": 1, "toasts": ["Resume: not signed"]}) + self.assertEqual(self.data["both"], [1, None]) + + def test_a_canceled_signature_frees_the_next_one(self): + self.assertIsNone(self.data["canceled"]) + self.assertEqual((self.data["after"], self.data["asked"]), (3, 3)) + self.assertEqual(self.data["toasts"], ["Resume: not signed", "Drain: not signed"]) + + +if __name__ == "__main__": + unittest.main()