From 5556ed8a0de2f7506f6d32081e92744138ce32c4 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 11 Nov 2026 08:21:52 +0000 Subject: [PATCH] fix(orchestrator): one passkey request at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With two passkey requests open at once, the hub received one request's payload with the other request's assertion and refused it with the challenge is not the one of the payload. signedProof now refuses a second signing while one runs and tells the operator to finish the first. 🎯 Fix: 89 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 85 🚦 Gate: ⛔ fix 89 % < 90: operator approved the commit Co-Authored-By: Claude Opus 5.5 --- orchestrator/templates/page-passkey.js | 17 ++++++- tests/test_hub_passkey_page.py | 67 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 tests/test_hub_passkey_page.py 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()