fix(orchestrator): one passkey request at a time

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-11 08:21:53 +00:00
1 parent 57ab29eb0d
commit 5556ed8a0d
2 files changed
+83 -1

No files matched your search

+67
View File
@@ -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()