diff --git a/hub/client/passkey.py b/hub/client/passkey.py index c3dde44..a4b7c4f 100644 --- a/hub/client/passkey.py +++ b/hub/client/passkey.py @@ -20,6 +20,8 @@ except ImportError: FLAG_UP = 0x01 FLAG_AT = 0x40 EC_PREFIX = bytes.fromhex("3059301306072a8648ce3d020106082a8648ce3d030107034200") +P256_PRIME = 2**256 - 2**224 + 2**192 + 2**96 - 1 +P256_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B ED_PREFIX = bytes.fromhex("302a300506032b6570032100") RSA_IDENTIFIER = bytes.fromhex("300d06092a864886f70d0101010500") B64URL = re.compile(r"[A-Za-z0-9_-]+") @@ -101,12 +103,22 @@ def octets(value: object, size: int) -> bool: return isinstance(value, bytes) and len(value) == size +def p256_point(x: object, y: object) -> bytes | None: + # KeePassXC 2.7.12 drops leading zero bytes of x and y (Botan BigInt as hex), against RFC 9053; pad them back to 32. + if not all(isinstance(value, bytes) and 1 <= len(value) <= 32 for value in (x, y)): + return None + left, right = int.from_bytes(x, "big"), int.from_bytes(y, "big") + if left >= P256_PRIME or right >= P256_PRIME or (right * right - left**3 + 3 * left - P256_B) % P256_PRIME: + return None + return b"\x04" + left.to_bytes(32, "big") + right.to_bytes(32, "big") + + def cose_key(key: object) -> tuple[int, bytes]: if not isinstance(key, dict): raise Refused("the credential public key is no COSE map") kty, alg = key.get(1), key.get(3) - if kty == 2 and alg == -7 and key.get(-1) == 1 and octets(key.get(-2), 32) and octets(key.get(-3), 32): - return alg, EC_PREFIX + b"\x04" + key[-2] + key[-3] + if kty == 2 and alg == -7 and key.get(-1) == 1 and (point := p256_point(key.get(-2), key.get(-3))): + return alg, EC_PREFIX + point if kty == 1 and alg == -8 and key.get(-1) == 6 and octets(key.get(-2), 32): return alg, ED_PREFIX + key[-2] if kty == 3 and alg == -257 and isinstance(key.get(-1), bytes) and isinstance(key.get(-2), bytes) and key[-1] and key[-2]: diff --git a/hub/templates/page-passkey.js b/hub/templates/page-passkey.js index f3556f7..1f321ce 100644 --- a/hub/templates/page-passkey.js +++ b/hub/templates/page-passkey.js @@ -5,6 +5,7 @@ const b64url = (buffer) => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); const unb64url = (text) => Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(text.length / 4) * 4, "=")), (char) => char.charCodeAt(0)); const localLink = () => h("a", {href: localUrl()}, localUrl()); + const KEEPASSXC_LOCALHOST = "KeePassXC lehnt Passkeys auf http://localhost ab, bis in KeePassXC unter Werkzeuge → Einstellungen → Browser-Integration → Fortgeschritten „Nutzung von localhost mit Passkeys zulassen“ (Allow using localhost with passkeys) angehakt ist; die Datenbank muss entsperrt sein. Sonst meldet KeePassXC-Browser „Passkeys request canceled.“ und Chrome öffnet seinen eigenen Passkey-Dialog."; async function assertion(prepared, label) { if (!onLocalhost()) { @@ -67,7 +68,7 @@ h("div", {class: "command"}, h("code", {}, command), copyButton(command)), h("label", {}, "Credential", h("textarea", {class: "field", readonly: true, rows: 3}, blob))); } catch (error) { - output.replaceChildren(h("p", {class: "muted"}, `nicht registriert: ${(error && error.name) || error}`)); + output.replaceChildren(h("p", {class: "muted"}, `nicht registriert: ${(error && error.name) || error}`), h("p", {class: "muted"}, KEEPASSXC_LOCALHOST)); } } @@ -79,6 +80,7 @@ return h("section", {}, h("h3", {}, "Passkey"), h("p", {class: "muted"}, "Freigaben von Asks, Reviews und Operator-Befehlen signiert ein Passkey in KeePassXC; Runner, Hook und Commit-Skills prüfen die Signatur selbst gegen die gepinnten Schlüssel."), keys, + h("p", {class: "muted"}, KEEPASSXC_LOCALHOST), onLocalhost() ? null : h("p", {class: "muted"}, "WebAuthn braucht die Domain: ", localLink()), h("div", {}, btn("Passkey registrieren", "btn small soft", () => registerPasskey(output))), output); diff --git a/tests/hub_support.py b/tests/hub_support.py index 05e17b2..8dbe72c 100644 --- a/tests/hub_support.py +++ b/tests/hub_support.py @@ -1,6 +1,7 @@ from __future__ import annotations import base64 +import concurrent.futures import datetime import functools import hashlib @@ -80,6 +81,7 @@ KEYS = tempfile.TemporaryDirectory() ORIGIN = "http://localhost:8737" PRESENT = 0x05 REGISTERED = 0x45 +ZERO_LED_TRIES = 4096 KEY_SPECS = { -7: ["-algorithm", "EC", "-pkeyopt", "ec_paramgen_curve:P-256"], -8: ["-algorithm", "ED25519"], @@ -140,7 +142,10 @@ class Key: return openssl(["dgst", "-sha256", "-sign", str(self.pem), str(message)], b"") def credential(self, origin: str, rp: str, flags: int) -> dict: - data = hashlib.sha256(rp.encode()).digest() + bytes([flags]) + bytes(4) + bytes(16) + len(self.raw_id).to_bytes(2, "big") + self.raw_id + cbor_encode(self.cose()) + return self.registration(self.cose(), origin, rp, flags) + + def registration(self, cose: dict, origin: str, rp: str, flags: int) -> dict: + data = hashlib.sha256(rp.encode()).digest() + bytes([flags]) + bytes(4) + bytes(16) + len(self.raw_id).to_bytes(2, "big") + self.raw_id + cbor_encode(cose) client = json.dumps({"type": "webauthn.create", "challenge": b64(os.urandom(32)), "origin": origin}).encode() return {"id": self.id, "attestationObject": b64(cbor_encode({"fmt": "none", "attStmt": {}, "authData": data})), "clientDataJSON": b64(client)} @@ -163,3 +168,20 @@ class Key: @functools.cache def key(alg: int, name: str) -> Key: return Key(alg, name) + + +@functools.cache +def zero_led(label: int) -> Key: + """Args: + label: the COSE label of the coordinate, -2 for x or -3 for y. + + Returns: + A fresh P-256 key whose coordinate starts with a zero byte, as + about one key in 256 does. + """ + with concurrent.futures.ThreadPoolExecutor() as pool: + for start in range(0, ZERO_LED_TRIES, 64): + for signer in pool.map(lambda attempt: Key(-7, f"zero{label}-{attempt}"), range(start, start + 64)): + if signer.cose()[label][0] == 0: + return signer + raise AssertionError(f"no P-256 key with a zero-led coordinate {label} in {ZERO_LED_TRIES} tries") diff --git a/tests/test_hub_approval.py b/tests/test_hub_approval.py index 4e936b5..509ca6c 100644 --- a/tests/test_hub_approval.py +++ b/tests/test_hub_approval.py @@ -19,6 +19,7 @@ from unittest import mock from hub_support import ( APPROVAL, + HUB, ORIGIN, PRESENT, REGISTERED, @@ -29,9 +30,28 @@ from hub_support import ( passkey, signing, verifier, + zero_led, ) from kvb_hub import language +PASSKEY_PAGE = HUB / "templates" / "page-passkey.js" +PASSKEY_PROBE = """ +const fs = require("fs"); +const vm = require("vm"); +const h = (tag, attrs, ...children) => ({tag, attrs, children: children.flat(Infinity)}); +const text = (node) => node === null || node === undefined ? "" : typeof node === "string" ? node : node.children.map(text).join(" "); +const output = {children: [], replaceChildren(...nodes) { this.children = nodes; }}; +const closed = Object.assign(new Error("closed"), {name: "NotAllowedError"}); +const page = vm.createContext({ + h, btoa, atob, TextEncoder, crypto: globalThis.crypto, state: {approvers: []}, + btn: (label) => h("button", {}, label), pill: (label) => h("span", {}, label), copyButton: () => null, toast: () => null, + window: {location: {hostname: "localhost", port: "8737", pathname: "/", hash: ""}}, + navigator: {credentials: {create: async () => { throw closed; }}}, +}); +vm.runInContext(fs.readFileSync(process.argv[1], "utf8"), page); +page.registerPasskey(output).then(() => console.log(JSON.stringify({section: text(page.passkeySection()), failed: text(output)}))); +""" + class Redirect(http.server.BaseHTTPRequestHandler): def do_GET(self): @@ -209,6 +229,38 @@ class TestApproval(unittest.TestCase): with self.subTest(name=name), self.assertRaisesRegex(signing.Refused, pattern): passkey.attested(credential, ORIGIN) + def test_a_coordinate_keepassxc_sent_without_its_leading_zero_bytes_pins_and_verifies(self): + for label, name in ((-2, "x"), (-3, "y")): + with self.subTest(coordinate=name): + signer = zero_led(label) + cose = signer.cose() + short = {**cose, label: cose[label].lstrip(b"\x00")} + self.assertLess(len(short[label]), 32) + blob = b64(json.dumps(signer.registration(short, ORIGIN, "localhost", REGISTERED)).encode()) + argv = [sys.executable, str(APPROVAL), "pin", "--port", "8737", "--approvers", str(self.approvers), "--credential", blob] + done = subprocess.run(argv, capture_output=True, text=True, timeout=60, check=False) + self.assertEqual(done.returncode, 0, done.stderr) + pinned = verifier.pinned(self.approvers)[-1] + self.assertEqual((pinned["id"], pinned["alg"], pinned["spki"]), (signer.id, -7, b64(signer.spki))) + verifier.check(signer.proof(self.payload([self.item], int(self.now) + 60), ORIGIN), self.item, "", self.trust, self.now) + + def test_a_coordinate_must_be_1_to_32_bytes_below_p_and_on_the_curve(self): + signer = key(-7, "main") + cose = signer.cose() + prime = 2**256 - 2**224 + 2**192 + 2**96 - 1 + x, y = (int.from_bytes(cose[label], "big") for label in (-2, -3)) + root = pow((y * y - x**3 + 3 * x) % prime, (prime + 1) // 4, prime).to_bytes(32, "big") + cases = [("x of p", {**cose, -2: prime.to_bytes(32, "big"), -3: root}), ("one-byte x off the curve", {**cose, -2: b"\x01"})] + for label, name in ((-2, "x"), (-3, "y")): + flipped = cose[label][:-1] + bytes([cose[label][-1] ^ 1]) + cases += [(f"empty {name}", {**cose, label: b""}), (f"33-byte {name}", {**cose, label: b"\x00" + cose[label]}), (f"{name} off the curve", {**cose, label: flipped})] + for name, given in cases: + with self.subTest(name=name), self.assertRaisesRegex(signing.Refused, "none of ES256"): + passkey.attested(signer.registration(given, ORIGIN, "localhost", REGISTERED), ORIGIN) + with self.subTest(name="one-byte x of 0 on the curve"): + zero_x = passkey.attested(signer.registration({**cose, -2: b"\x00", -3: root}, ORIGIN, "localhost", REGISTERED), ORIGIN) + self.assertEqual(zero_x["spki"], b64(passkey.EC_PREFIX + b"\x04" + bytes(32) + root)) + def test_the_cbor_decoder_reads_what_webauthn_sends_and_nothing_else(self): value = {"fmt": "none", 1: -7, -2: b"\x00\xff", "list": [0, 23, 24, 255, 256, 65536, 2**32, True, False, None], "text": "ä"} encoded = cbor_encode(value) @@ -273,5 +325,16 @@ class TestApproval(unittest.TestCase): verifier.pinned(approvers) +class TestPasskeyPage(unittest.TestCase): + def test_the_section_and_a_failed_registration_name_the_keepassxc_localhost_setting(self): + done = subprocess.run(["node", "-e", PASSKEY_PROBE, str(PASSKEY_PAGE)], capture_output=True, text=True, timeout=60, check=True) + shown = json.loads(done.stdout) + self.assertIn("nicht registriert: NotAllowedError", shown["failed"]) + for where in ("section", "failed"): + for text in ("Nutzung von localhost mit Passkeys zulassen", "Allow using localhost with passkeys", "Passkeys request canceled.", "entsperrt"): + with self.subTest(where=where, text=text): + self.assertIn(text, shown[where]) + + if __name__ == "__main__": unittest.main()