From b3ba21129c0014c1907454e6e304d8dbd9acdf21 Mon Sep 17 00:00:00 2001 From: Morgan Date: Mon, 30 Mar 2026 11:39:50 +0900 Subject: [PATCH] Rewrite --- .gitignore | 5 +- Makefile | 29 +++ extension/background.js | 50 +++++ extension/content.js | 18 ++ extension/icon-green.svg | 4 + extension/icon-red.svg | 4 + extension/inject.js | 206 ++++++++++++++++++ extension/manifest.json | 31 +++ passkey.py | 382 --------------------------------- requirements.txt | 5 + server/main.py | 147 +++++++++++++ server/passkey.py | 445 +++++++++++++++++++++++++++++++++++++++ webauthn_server.js | 200 ------------------ webauthn_server.py | 54 ----- 14 files changed, 942 insertions(+), 638 deletions(-) create mode 100644 Makefile create mode 100644 extension/background.js create mode 100644 extension/content.js create mode 100644 extension/icon-green.svg create mode 100644 extension/icon-red.svg create mode 100644 extension/inject.js create mode 100644 extension/manifest.json delete mode 100644 passkey.py create mode 100644 requirements.txt create mode 100644 server/main.py create mode 100644 server/passkey.py delete mode 100644 webauthn_server.js delete mode 100644 webauthn_server.py diff --git a/.gitignore b/.gitignore index 7c126c9..6aff574 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -*.json -__pycache__/* +passkey.json +__pycache__/ +dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cfc5cea --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +MODE ?= virtual + +.PHONY: build chrome firefox clean run run-physical install + +build: chrome firefox + +chrome: dist/chrome +firefox: dist/virtual-webauthn.xpi + +dist/chrome: extension/* + @rm -rf $@ + @mkdir -p $@ + cp extension/* $@/ + +dist/virtual-webauthn.xpi: extension/* + @mkdir -p dist + cd extension && zip -r ../$@ . -x '.*' + +clean: + rm -rf dist/ + +run: + cd server && python main.py --mode $(MODE) + +run-physical: + cd server && python main.py --mode physical + +install: + pip install -r requirements.txt diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..674c049 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,50 @@ +const API_URL = "http://127.0.0.1:20492"; + +async function apiFetch(method, path, body) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers["Content-Type"] = "application/json"; + opts.body = JSON.stringify(body); + } + const response = await fetch(API_URL + path, opts); + if (!response.ok) { + const detail = await response.json().catch(() => ({})); + throw new Error(detail.detail || `Server error: ${response.status}`); + } + return response.json(); +} + +// --- Icon status polling --- + +let lastStatus = null; + +async function updateIcon() { + try { + await apiFetch("GET", "/ping"); + if (lastStatus !== "ok") { + chrome.action.setIcon({ path: "icon-green.svg" }); + chrome.action.setTitle({ title: "Virtual WebAuthn — Connected" }); + lastStatus = "ok"; + } + } catch { + if (lastStatus !== "err") { + chrome.action.setIcon({ path: "icon-red.svg" }); + chrome.action.setTitle({ title: "Virtual WebAuthn — Disconnected" }); + lastStatus = "err"; + } + } +} + +updateIcon(); +setInterval(updateIcon, 5000); + +// --- Message relay --- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === "VWEBAUTHN_REQUEST") { + apiFetch("POST", "", { type: message.action, data: message.payload }) + .then((data) => sendResponse({ success: true, data })) + .catch((error) => sendResponse({ success: false, error: error.message })); + return true; + } +}); diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..8563aee --- /dev/null +++ b/extension/content.js @@ -0,0 +1,18 @@ +const s = document.createElement("script"); +s.src = chrome.runtime.getURL("inject.js"); +s.onload = () => s.remove(); +(document.documentElement || document.head).appendChild(s); + +window.addEventListener("message", async (event) => { + if (event.source !== window || event.data?.type !== "VWEBAUTHN_REQUEST") return; + + const { id, action, payload } = event.data; + try { + const response = await chrome.runtime.sendMessage({ + type: "VWEBAUTHN_REQUEST", action, payload, + }); + window.postMessage({ type: "VWEBAUTHN_RESPONSE", id, ...response }, "*"); + } catch (error) { + window.postMessage({ type: "VWEBAUTHN_RESPONSE", id, success: false, error: error.message }, "*"); + } +}); diff --git a/extension/icon-green.svg b/extension/icon-green.svg new file mode 100644 index 0000000..f3b258e --- /dev/null +++ b/extension/icon-green.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/icon-red.svg b/extension/icon-red.svg new file mode 100644 index 0000000..36e68df --- /dev/null +++ b/extension/icon-red.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extension/inject.js b/extension/inject.js new file mode 100644 index 0000000..7bc4afa --- /dev/null +++ b/extension/inject.js @@ -0,0 +1,206 @@ +(function () { + "use strict"; + + const origGet = navigator.credentials.get.bind(navigator.credentials); + const origCreate = navigator.credentials.create.bind(navigator.credentials); + + function toB64url(buffer) { + const bytes = new Uint8Array(buffer); + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } + + function fromB64url(str) { + const bin = atob(str.replace(/-/g, "+").replace(/_/g, "/")); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes.buffer; + } + + const STYLE = { + popup: { + position: "fixed", top: "20px", right: "20px", + background: "#fff", color: "#000", border: "1px solid #bbb", + borderRadius: "8px", padding: "16px", zIndex: "2147483647", + maxWidth: "320px", boxShadow: "0 4px 16px rgba(0,0,0,.18)", + fontFamily: "system-ui, -apple-system, sans-serif", + fontSize: "14px", lineHeight: "1.4", + }, + title: { + margin: "0 0 12px", fontSize: "15px", fontWeight: "600", + }, + option: { + padding: "10px 12px", cursor: "pointer", borderRadius: "6px", + transition: "background .1s", + }, + }; + + function createPopup() { + const el = document.createElement("div"); + Object.assign(el.style, STYLE.popup); + return el; + } + + function showToast(message) { + const toast = createPopup(); + Object.assign(toast.style, { padding: "12px 16px", cursor: "default" }); + toast.innerHTML = + `
` + + `` + + `` + + `` + + `${message}
`; + document.body.appendChild(toast); + return toast; + } + + function showCredentialSelector(credentials) { + return new Promise((resolve) => { + const popup = createPopup(); + + const title = document.createElement("div"); + title.textContent = "Select a passkey"; + Object.assign(title.style, STYLE.title); + popup.appendChild(title); + + credentials.forEach((cred) => { + const opt = document.createElement("div"); + Object.assign(opt.style, STYLE.option); + const date = new Date(cred.created * 1000).toLocaleString(); + opt.innerHTML = + `${cred.username || "Unknown"}` + + `
${date}
`; + opt.onmouseover = () => (opt.style.background = "#f0f0f0"); + opt.onmouseout = () => (opt.style.background = "transparent"); + opt.onclick = () => { popup.remove(); resolve(cred); }; + popup.appendChild(opt); + }); + + const cancel = document.createElement("div"); + Object.assign(cancel.style, { + ...STYLE.option, textAlign: "center", color: "#888", + marginTop: "4px", borderTop: "1px solid #eee", paddingTop: "10px", + }); + cancel.textContent = "Cancel"; + cancel.onmouseover = () => (cancel.style.background = "#f0f0f0"); + cancel.onmouseout = () => (cancel.style.background = "transparent"); + cancel.onclick = () => { popup.remove(); resolve(null); }; + popup.appendChild(cancel); + + document.body.appendChild(popup); + }); + } + + const pending = new Map(); + let seq = 0; + + window.addEventListener("message", (e) => { + if (e.source !== window || e.data?.type !== "VWEBAUTHN_RESPONSE") return; + const resolve = pending.get(e.data.id); + if (resolve) { + pending.delete(e.data.id); + resolve(e.data); + } + }); + + function request(action, payload) { + return new Promise((resolve, reject) => { + const id = ++seq; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error("Timed out")); + }, 120_000); + + pending.set(id, (resp) => { + clearTimeout(timer); + resp.success ? resolve(resp.data) : reject(new Error(resp.error)); + }); + + window.postMessage({ type: "VWEBAUTHN_REQUEST", id, action, payload }, "*"); + }); + } + + navigator.credentials.create = async function (options) { + const toast = showToast("Waiting for passkey..."); + try { + const pk = options.publicKey; + const resp = await request("create", { + publicKey: { + ...pk, + challenge: toB64url(pk.challenge), + user: { ...pk.user, id: toB64url(pk.user.id) }, + excludeCredentials: pk.excludeCredentials?.map((c) => ({ ...c, id: toB64url(c.id) })), + }, + origin: location.origin, + }); + + return { + id: resp.id, + type: resp.type, + rawId: fromB64url(resp.rawId), + authenticatorAttachment: resp.authenticatorAttachment, + response: { + attestationObject: fromB64url(resp.response.attestationObject), + clientDataJSON: fromB64url(resp.response.clientDataJSON), + getAuthenticatorData: () => fromB64url(resp.response.authenticatorData), + getPublicKey: () => fromB64url(resp.response.publicKey), + getPublicKeyAlgorithm: () => Number(resp.response.pubKeyAlgo), + getTransports: () => resp.response.transports, + }, + getClientExtensionResults: () => ({}), + }; + } catch (err) { + console.warn("[VirtualWebAuthn] create fallback:", err.message); + return origCreate(options); + } finally { + toast.remove(); + } + }; + + navigator.credentials.get = async function (options) { + const toast = showToast("Waiting for passkey..."); + try { + const pk = options.publicKey; + let resp = await request("get", { + publicKey: { + ...pk, + challenge: toB64url(pk.challenge), + allowCredentials: pk.allowCredentials?.map((c) => ({ ...c, id: toB64url(c.id) })), + }, + origin: location.origin, + }); + + toast.remove(); + + if (Array.isArray(resp)) { + resp = await showCredentialSelector(resp); + if (!resp) throw new Error("User cancelled"); + } + + const cred = { + id: resp.id, + type: resp.type, + rawId: fromB64url(resp.rawId), + authenticatorAttachment: resp.authenticatorAttachment, + response: { + authenticatorData: fromB64url(resp.response.authenticatorData), + clientDataJSON: fromB64url(resp.response.clientDataJSON), + signature: fromB64url(resp.response.signature), + }, + getClientExtensionResults: () => ({}), + }; + if (resp.response.userHandle) { + cred.response.userHandle = fromB64url(resp.response.userHandle); + } + return cred; + } catch (err) { + console.warn("[VirtualWebAuthn] get fallback:", err.message); + return origGet(options); + } finally { + toast.remove(); + } + }; + + console.log("[VirtualWebAuthn] Active"); +})(); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..77cda89 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,31 @@ +{ + "manifest_version": 3, + "name": "Virtual WebAuthn", + "version": "1.0", + "description": "Not your keys, not your credential", + "host_permissions": [ + "http://127.0.0.1:20492/*" + ], + "action": { + "default_icon": "icon-red.svg", + "default_title": "Virtual WebAuthn — Disconnected" + }, + "background": { + "service_worker": "background.js", + "scripts": ["background.js"] + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_start", + "all_frames": true + } + ], + "web_accessible_resources": [ + { + "resources": ["inject.js"], + "matches": [""] + } + ] +} diff --git a/passkey.py b/passkey.py deleted file mode 100644 index 64ad112..0000000 --- a/passkey.py +++ /dev/null @@ -1,382 +0,0 @@ -import json -import base64 -import os -import time -import hashlib -import struct -import cbor2 -from Crypto.PublicKey import ECC -from Crypto.Signature import DSS -from Crypto.Hash import SHA256 -from typing import Dict, Any, Optional - -import getpass -from fido2.hid import CtapHidDevice -from fido2.client import Fido2Client, UserInteraction -from fido2.webauthn import PublicKeyCredentialCreationOptions, PublicKeyCredentialDescriptor, PublicKeyCredentialRequestOptions -from fido2.utils import websafe_encode, websafe_decode - - -class PhysicalPasskey: - def __init__(self, dummy = ""): - devices = list(CtapHidDevice.list_devices()) - if not devices: - raise Exception("No FIDO2 devices found.") - self.device = devices[0] - print(f"Using FIDO2 device: {self.device}") - - class InputDataError(Exception): - def __init__(self, message="", error_code=None): - self.message = f"Input data insufficient or malformed: {message}" - self.error_code = error_code - super().__init__(self.message) - - def get_client(self, origin): - class MyUserInteraction(UserInteraction): - def prompt_up(self): - print("\nPlease touch your security key...\n") - def request_pin(self, permissions, rp_id): - print(f"PIN requested for {rp_id}") - return getpass.getpass("Enter your security key's PIN: ") - - client = Fido2Client(self.device, origin, user_interaction=MyUserInteraction()) - return client - - def create(self, create_options, origin = ""): - print("WEBAUTHN_START_REGISTER") - options = {"publicKey": create_options} - - if not origin: - origin = f'https://{options["publicKey"]["rp"]["id"]}' - if not origin: - raise self.InputDataError("origin") - - client = self.get_client(origin) - - options["publicKey"]["challenge"] = websafe_decode(options["publicKey"]["challenge"]) - options["publicKey"]["user"]["id"] = websafe_decode(options["publicKey"]["user"]["id"]) - - if "excludeCredentials" in options["publicKey"]: - for cred in options["publicKey"]["excludeCredentials"]: - cred["id"] = websafe_decode(cred["id"]) - - pk_create_options = options["publicKey"] - challenge = pk_create_options["challenge"] - rp = pk_create_options["rp"] - user = pk_create_options["user"] - pub_key_cred_params = pk_create_options["pubKeyCredParams"] - - pk_options = PublicKeyCredentialCreationOptions(rp, user, challenge, pub_key_cred_params) - - print(f"WEBAUTHN_MAKE_CREDENTIAL(RP={rp})") - - attestation = client.make_credential(pk_options) - - client_data_b64 = attestation.client_data.b64 - attestation_object = attestation.attestation_object - credential = attestation.attestation_object.auth_data.credential_data - if not credential: - raise Exception() - - result = { - "id": websafe_encode(credential.credential_id), - "rawId": websafe_encode(credential.credential_id), - "type": "public-key", - "response": { - "attestationObject": websafe_encode(attestation_object), - "clientDataJSON": client_data_b64 - } - } - print(f"WEBAUTHN_ATTESTATION(ID={result['id']})") - return result - - def get(self, get_options, origin = ""): - print("WEBAUTHN_START_AUTHENTICATION") - options = {"publicKey": get_options} - - if not origin: - origin = f'https://{options["publicKey"]["rpId"]}' - if not origin: - raise self.InputDataError("origin") - - client = self.get_client(origin) - - options["publicKey"]["challenge"] = websafe_decode(options["publicKey"]["challenge"]) - - rp_id = options["publicKey"].get("rpId", "webauthn.io") - challenge = options["publicKey"]["challenge"] - - if "allowCredentials" in options["publicKey"]: - for cred in options["publicKey"]["allowCredentials"]: - cred["id"] = websafe_decode(cred["id"]) - - allowed = [PublicKeyCredentialDescriptor(cred["type"], cred["id"]) - for cred in options["publicKey"]["allowCredentials"]] - - pk_options = PublicKeyCredentialRequestOptions(challenge, rp_id=rp_id, allow_credentials=allowed) - - print(f"WEBAUTHN_GET_ASSERTION(RPID={rp_id})") - - assertion_response = client.get_assertion(pk_options) - - assertion = assertion_response.get_response(0) - if not assertion.credential_id: - raise Exception() - - result = { - "id": websafe_encode(assertion.credential_id), - "rawId": websafe_encode(assertion.credential_id), - "type": "public-key", - "response": { - "authenticatorData": websafe_encode(assertion.authenticator_data), - "clientDataJSON": assertion.client_data.b64, - "signature": websafe_encode(assertion.signature), - "userHandle": websafe_encode(assertion.user_handle) if assertion.user_handle else None - } - } - print(f"WEBAUTHN_AUTHENTICATION(ID={result['id']})") - return result - - -class VirtualPasskey: - def __init__(self, file: str = "passkey.json"): - self.file = file - self.credentials = {} - self._load_credentials() - - class InputDataError(Exception): - def __init__(self, message="", error_code=None): - super().__init__(f"Input data insufficient or malformed: {message}") - - class CredNotFoundError(Exception): - def __init__(self, message="No available credential found", error_code=None): - super().__init__(message) - - def _load_credentials(self): - if os.path.exists(self.file): - try: - with open(self.file, 'r') as f: - self.credentials = json.load(f) - except os.FileNotExistsError: - self.credentials = {} - - def _save_credentials(self): - with open(self.file, 'w') as f: - json.dump(self.credentials, f, indent=4) - - def _create_authenticator_data(self, rp_id: bytes, counter: int = 0, - user_present: bool = True, - user_verified: bool = True, - credential_data: Optional[bytes] = None) -> bytes: - - rp_id_hash = hashlib.sha256(rp_id).digest() - - flags = 0 - if user_present: - flags |= 1 << 0 - if user_verified: - flags |= 1 << 2 - if credential_data is not None: - flags |= 1 << 6 - - counter_bytes = struct.pack(">I", counter) - - auth_data = rp_id_hash + bytes([flags]) + counter_bytes - - if credential_data is not None: - auth_data += credential_data - - return auth_data - - def _get_public_key_cose(self, key) -> bytes: - x = key.pointQ.x.to_bytes(32, byteorder='big') - y = key.pointQ.y.to_bytes(32, byteorder='big') - cose_key = {1: 2, 3: -7, -1: 1, -2: x, -3: y} - return cbor2.dumps(cose_key) - - def _b64url(self, d): - if isinstance(d, bytes): - return base64.urlsafe_b64encode(d).decode('utf-8').rstrip('=') - elif isinstance(d, str): - return base64.urlsafe_b64decode(d + "===") - - - def create(self, data: Dict[str, Any], origin: str = "") -> Dict[str, Any]: - challenge = data.get("challenge") - if isinstance(challenge, str): - challenge = self._b64url(challenge) - - rp = data.get("rp", {}) - user = data.get("user", {}) - - pub_key_params = data.get("pubKeyCredParams", []) - - alg = -7 - for param in pub_key_params: - if param.get('type') == 'public-key' and param.get('alg') == -7: - alg = -7 - break - - if not origin: - origin = data.get("origin") - if not origin: - raise self.InputDataError("origin") - - rp_id = rp.get("id").encode() - - user_id = user.get("id") - if isinstance(user_id, str): - user_id = self._b64url(user_id) - - key = ECC.generate(curve='P-256') - private_key = key.export_key(format='PEM') - public_key = key.public_key().export_key(format='PEM') # noqa: F841 - - credential_id = os.urandom(16) - credential_id_b64 = self._b64url(credential_id) - - cose_pubkey = self._get_public_key_cose(key) - - cred_id_length = struct.pack(">H", len(credential_id)) - - aaguid = b'\x00' * 16 - attested_data = aaguid + cred_id_length + credential_id + cose_pubkey - - auth_data = self._create_authenticator_data(rp_id, counter=0, credential_data=attested_data) - - attestation_obj = { - "fmt": "none", - "authData": auth_data, - "attStmt": {} - } - attestation_cbor = cbor2.dumps(attestation_obj) - - client_data = { - "challenge": self._b64url(challenge), - "origin": origin, - "type": "webauthn.create", - "crossOrigin": False, - } - - client_data_json = json.dumps(client_data).encode() - - self.credentials[credential_id_b64] = { - "private_key": private_key, - "rp_id": self._b64url(rp_id), - "user_id": self._b64url(user_id), - "user_name": user.get('displayName', ''), - "created": int(time.time()), - "counter": 0 - } - self._save_credentials() - - response = { - "authenticatorAttachment": "cross-platform", - "id": credential_id_b64, - "rawId": credential_id_b64, - "response": { - "attestationObject": self._b64url(attestation_cbor), - "clientDataJSON": self._b64url(client_data_json), - "publicKey": self._b64url(cose_pubkey), - "pubKeyAlgo": str(alg), - "transports": ["internal"] - }, - "type": "public-key" - } - return response - - - def get(self, data: Dict[str, Any], origin: str = "") -> Dict[str, Any]: - - challenge = data.get("challenge") - if isinstance(challenge, str): - challenge = self._b64url(challenge) - - allowed_credential = data.get("allowCredentials") - - for credential in allowed_credential: - credential_id_b64 = credential["id"] - if self.credentials.get(credential_id_b64): - cred = self.credentials[credential_id_b64] - break - else: - raise self.CredNotFoundError() - - rp_id = data.get("rpId", "").encode('utf-8') - if not rp_id: - raise self.InputDataError("rp_id") - - if not origin: - origin = data.get("origin") - if not origin: - raise self.InputDataError("origin") - - counter = cred.get("counter", 0) + 1 - cred["counter"] = counter - - auth_data = self._create_authenticator_data( - rp_id=rp_id, - counter=counter, - user_present=True, - user_verified=True - ) - - client_data = ('{"type":"%s","challenge":"%s","origin":"%s","crossOrigin":false}' - % ("webauthn.get", self._b64url(challenge), origin)).encode() - client_data_hash = hashlib.sha256(client_data).digest() - - signature_data = auth_data + client_data_hash - - key = ECC.import_key(cred["private_key"]) - h = SHA256.new(signature_data) - signer = DSS.new(key, 'fips-186-3', encoding='der') - signature = signer.sign(h) - - self._save_credentials() - - response = { - "authenticatorAttachment": "cross-platform", - "id": credential_id_b64, - "rawId": credential_id_b64, - "response": { - "authenticatorData": self._b64url(auth_data), - "clientDataJSON": self._b64url(client_data), - "signature": self._b64url(signature) - }, - "type": "public-key" - } - return response - - -Passkey = VirtualPasskey - - -if __name__=="__main__": - import requests - - sess = requests.Session() - passkey = Passkey() - - payload = { - "algorithms": ["es256"], "attachment": "all", "attestation": "none", "discoverable_credential": "preferred", - "hints": [], "user_verification": "preferred", "username": "asdf" - } - resp = sess.post("https://webauthn.io/registration/options", json=payload) - print(resp.json()) - data = passkey.create(resp.json(), origin="https://webauthn.io") - data["rawId"] = data["id"] - print(data) - resp = sess.post("https://webauthn.io/registration/verification", json={"response": data, "username": "asdf"}) - print(resp.json()) - print() - - sess.get("https://webauthn.io/logout") - - payload = {"username":"asdf", "user_verification":"preferred", "hints":[]} - resp = sess.post("https://webauthn.io/authentication/options", json=payload, headers={"origin": "https://webauthn.io"}) - print(resp.json()) - data = passkey.get(resp.json(), origin="https://webauthn.io") - print(data) - data["rawId"] = data["id"] - resp = sess.post("https://webauthn.io/authentication/verification", json={"response": data, "username": "asdf"}) - print(resp.json()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..35c3483 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +cbor2 +pycryptodome +fido2 +fastapi +uvicorn diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..8351dc5 --- /dev/null +++ b/server/main.py @@ -0,0 +1,147 @@ +import argparse +import logging +import traceback +from typing import Dict, Any +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import uvicorn +from passkey import VirtualPasskey, PhysicalPasskey, _AuthError, _b64url_decode + +log = logging.getLogger("vwebauthn") + +app = FastAPI(title="Virtual WebAuthn") +passkey_cls = VirtualPasskey + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["GET", "POST", "DELETE"], + allow_headers=["Content-Type"], +) + + +class WebAuthnRequest(BaseModel): + type: str + data: Dict[str, Any] + + +@app.post("/") +async def handle(req: WebAuthnRequest): + webauthn = passkey_cls() + options = req.data.get("publicKey", {}) + origin = req.data.get("origin", "") + log.info("POST / type=%s origin=%s", req.type, origin) + rp = options.get("rp", {}).get("id") or options.get("rpId", "") + if rp: + log.info(" rp_id=%s", rp) + + try: + if req.type == "create": + user = options.get("user", {}) + log.info(" create user=%s", user.get("displayName") or user.get("name", "?")) + result = webauthn.create(options, origin) + log.info(" created credential id=%s", result.get("id", "?")[:16] + "...") + return result + elif req.type == "get": + allowed = options.get("allowCredentials", []) + log.info(" get allowCredentials=%d", len(allowed)) + result = webauthn.get(options, origin) + log.info(" authenticated credential id=%s counter=%s", + result.get("id", "?")[:16] + "...", + result.get("response", {}).get("authenticatorData", "?")) + return result + else: + raise HTTPException(status_code=400, detail=f"Unknown type: {req.type}") + except HTTPException: + raise + except _AuthError as e: + log.warning(" auth error: %s", e) + raise HTTPException(status_code=401, detail=str(e)) + except (VirtualPasskey.CredNotFoundError, VirtualPasskey.InputDataError, + PhysicalPasskey.InputDataError) as e: + log.warning(" client error: %s", e) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + log.error(" unhandled error: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/ping") +def ping(): + mode = "physical" if passkey_cls is PhysicalPasskey else "virtual" + log.debug("GET /ping mode=%s", mode) + return {"status": "ok", "mode": mode} + + +@app.get("/credentials") +def list_credentials(): + log.info("GET /credentials") + if passkey_cls is PhysicalPasskey: + raise HTTPException(status_code=400, detail="Not available in physical mode") + webauthn = VirtualPasskey() + try: + password = webauthn._ask_password("Virtual WebAuthn — List Credentials") + creds = webauthn._load_credentials(password) + except _AuthError as e: + log.warning(" auth error: %s", e) + raise HTTPException(status_code=401, detail=str(e)) + log.info(" loaded %d credentials", len(creds)) + return [ + { + "id": cid, + "rp_id": _b64url_decode(c["rp_id"]).decode("utf-8", errors="ignore"), + "user_name": c.get("user_name", ""), + "created": c.get("created", 0), + "counter": c.get("counter", 0), + } + for cid, c in creds.items() + ] + + +@app.delete("/credentials/{credential_id}") +def delete_credential(credential_id: str): + log.info("DELETE /credentials/%s", credential_id[:16] + "...") + if passkey_cls is PhysicalPasskey: + raise HTTPException(status_code=400, detail="Not available in physical mode") + webauthn = VirtualPasskey() + try: + password = webauthn._ask_password("Virtual WebAuthn — Delete Credential") + webauthn.credentials = webauthn._load_credentials(password) + except _AuthError as e: + log.warning(" auth error: %s", e) + raise HTTPException(status_code=401, detail=str(e)) + if credential_id not in webauthn.credentials: + log.warning(" credential not found") + raise HTTPException(status_code=404, detail="Credential not found") + del webauthn.credentials[credential_id] + webauthn._save_credentials(password) + log.info(" deleted successfully") + return {"status": "deleted"} + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Virtual WebAuthn Server") + parser.add_argument( + "--mode", choices=["virtual", "physical"], default="virtual", + help="Passkey mode: virtual (software keys) or physical (USB FIDO2 device)" + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=20492) + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging") + args = parser.parse_args() + + level = logging.DEBUG if args.verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%H:%M:%S", + ) + + if args.mode == "physical": + passkey_cls = PhysicalPasskey + else: + passkey_cls = VirtualPasskey + + log.info("Mode: %s", args.mode) + uvicorn.run(app, host=args.host, port=args.port, log_level="debug" if args.verbose else "info") diff --git a/server/passkey.py b/server/passkey.py new file mode 100644 index 0000000..8528c38 --- /dev/null +++ b/server/passkey.py @@ -0,0 +1,445 @@ +import json +import base64 +import logging +import os +import time +import hashlib +import struct +import subprocess +import cbor2 +from Crypto.PublicKey import ECC +from Crypto.Signature import DSS +from Crypto.Hash import SHA256 +from Crypto.Cipher import AES +from typing import Dict, Any, Optional + +log = logging.getLogger("vwebauthn.passkey") + +ZENITY_BINARY = os.environ.get("ZENITY_BINARY", "zenity") + + +def _b64url_encode(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip('=') + +def _b64url_decode(data: str) -> bytes: + return base64.urlsafe_b64decode(data + "===") + +def _zenity(args: list, timeout: int = 120) -> str: + try: + result = subprocess.run( + [ZENITY_BINARY] + args, + capture_output=True, text=True, timeout=timeout + ) + except FileNotFoundError: + raise RuntimeError(f"{ZENITY_BINARY} is not installed") + if result.returncode != 0: + return None + return result.stdout.strip() + +def _zenity_password(title: str) -> str: + pw = _zenity(["--password", "--title", title]) + if pw is None: + raise _AuthError("Password prompt cancelled") + if not pw: + raise _AuthError("Empty password") + return pw + +def _zenity_entry(title: str, text: str, hide: bool = False) -> str: + args = ["--entry", "--title", title, "--text", text] + if hide: + args.append("--hide-text") + return _zenity(args) + + +class _AuthError(Exception): + def __init__(self, message="Authentication failed"): + super().__init__(message) + + +class PhysicalPasskey: + class InputDataError(Exception): + def __init__(self, message=""): + super().__init__(f"Input data insufficient or malformed: {message}") + + AuthenticationError = _AuthError + + def __init__(self): + from fido2.hid import CtapHidDevice + devices = list(CtapHidDevice.list_devices()) + if not devices: + raise RuntimeError("No FIDO2 devices found") + self.device = devices[0] + + def _get_client(self, origin): + from fido2.client import Fido2Client, DefaultClientDataCollector, UserInteraction + + device = self.device + + class ZenityInteraction(UserInteraction): + def prompt_up(self): + _zenity(["--notification", "--text", "Touch your security key..."], timeout=1) + + def request_pin(self, permissions, rp_id): + pin = _zenity_entry( + "Physical WebAuthn", + f"Enter PIN for your security key\n\n{device}", + hide=True + ) + if pin is None: + raise _AuthError("PIN prompt cancelled") + return pin + + collector = DefaultClientDataCollector(origin) + return Fido2Client(self.device, collector, user_interaction=ZenityInteraction()) + + def create(self, create_options, origin=""): + from fido2.utils import websafe_encode, websafe_decode + + options = create_options + if not origin: + origin = f'https://{options["rp"]["id"]}' + if not origin: + raise self.InputDataError("origin") + + client = self._get_client(origin) + + options["challenge"] = websafe_decode(options["challenge"]) + options["user"]["id"] = websafe_decode(options["user"]["id"]) + + for cred in options.get("excludeCredentials", []): + cred["id"] = websafe_decode(cred["id"]) + + reg = client.make_credential(options) + + return { + "authenticatorAttachment": "cross-platform", + "id": reg.id, + "rawId": reg.id, + "type": "public-key", + "response": { + "attestationObject": _b64url_encode(bytes(reg.response.attestation_object)), + "clientDataJSON": _b64url_encode(bytes(reg.response.client_data)), + }, + } + + def get(self, get_options, origin=""): + from fido2.utils import websafe_encode, websafe_decode + + options = get_options + if not origin: + origin = f'https://{options["rpId"]}' + if not origin: + raise self.InputDataError("origin") + + client = self._get_client(origin) + + options["challenge"] = websafe_decode(options["challenge"]) + + for cred in options.get("allowCredentials", []): + cred["id"] = websafe_decode(cred["id"]) + + assertion = client.get_assertion(options).get_response(0) + + return { + "authenticatorAttachment": "cross-platform", + "id": assertion.id, + "rawId": assertion.id, + "type": "public-key", + "response": { + "authenticatorData": _b64url_encode(bytes(assertion.response.authenticator_data)), + "clientDataJSON": _b64url_encode(bytes(assertion.response.client_data)), + "signature": _b64url_encode(bytes(assertion.response.signature)), + "userHandle": _b64url_encode(bytes(assertion.response.user_handle)) if assertion.response.user_handle else None, + }, + } + + +class VirtualPasskey: + SCRYPT_N = 2**18 + SCRYPT_R = 8 + SCRYPT_P = 1 + SCRYPT_KEYLEN = 32 + + def __init__(self, file: str = "passkey.json"): + self.file = file + self.credentials = {} + + class InputDataError(Exception): + def __init__(self, message=""): + super().__init__(f"Input data insufficient or malformed: {message}") + + class CredNotFoundError(Exception): + def __init__(self, message="No matching credential found"): + super().__init__(message) + + AuthenticationError = _AuthError + + def _ask_password(self, title: str = "Virtual WebAuthn") -> str: + if not os.path.exists(self.file): + log.info("No credential file, prompting new password") + pw = _zenity_password(f"{title} — Set Password") + pw2 = _zenity_password(f"{title} — Confirm Password") + if pw != pw2: + raise self.AuthenticationError("Passwords do not match") + self._save_credentials(pw) + log.info("Created credential file %s", self.file) + return pw + log.debug("Prompting password for %s", self.file) + return _zenity_password(title) + + def _derive_key(self, password: str, salt: bytes) -> bytes: + return hashlib.scrypt( + password.encode(), salt=salt, + n=self.SCRYPT_N, r=self.SCRYPT_R, p=self.SCRYPT_P, dklen=self.SCRYPT_KEYLEN, + maxmem=128 * self.SCRYPT_N * self.SCRYPT_R * 2, + ) + + def _load_credentials(self, password: str) -> dict: + if not os.path.exists(self.file): + log.debug("Credential file not found, starting fresh") + return {} + with open(self.file, 'r') as f: + try: + envelope = json.load(f) + except (json.JSONDecodeError, ValueError): + log.warning("Credential file is corrupted, starting fresh") + return {} + # Unencrypted legacy format + if "salt" not in envelope: + log.debug("Loaded unencrypted legacy credentials") + return envelope + log.debug("Deriving key and decrypting credentials") + salt = _b64url_decode(envelope["salt"]) + nonce = _b64url_decode(envelope["nonce"]) + ciphertext = _b64url_decode(envelope["ciphertext"]) + tag = _b64url_decode(envelope["tag"]) + key = self._derive_key(password, salt) + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + try: + plaintext = cipher.decrypt_and_verify(ciphertext, tag) + except (ValueError, KeyError): + raise self.AuthenticationError("Wrong password") + creds = json.loads(plaintext.decode()) + log.debug("Decrypted %d credentials", len(creds)) + return creds + + def _save_credentials(self, password: str): + log.debug("Encrypting and saving %d credentials to %s", len(self.credentials), self.file) + salt = os.urandom(32) + nonce = os.urandom(12) + key = self._derive_key(password, salt) + cipher = AES.new(key, AES.MODE_GCM, nonce=nonce) + plaintext = json.dumps(self.credentials, indent=4).encode() + ciphertext, tag = cipher.encrypt_and_digest(plaintext) + envelope = { + "salt": _b64url_encode(salt), + "nonce": _b64url_encode(nonce), + "ciphertext": _b64url_encode(ciphertext), + "tag": _b64url_encode(tag), + } + with open(self.file, 'w') as f: + json.dump(envelope, f, indent=4) + log.debug("Credentials saved") + + @staticmethod + def _build_authenticator_data( + rp_id: bytes, counter: int = 0, + user_present: bool = True, + user_verified: bool = True, + credential_data: Optional[bytes] = None, + ) -> bytes: + rp_id_hash = hashlib.sha256(rp_id).digest() + flags = 0 + if user_present: + flags |= 0x01 + if user_verified: + flags |= 0x04 + if credential_data is not None: + flags |= 0x40 + auth_data = rp_id_hash + bytes([flags]) + struct.pack(">I", counter) + if credential_data is not None: + auth_data += credential_data + return auth_data + + @staticmethod + def _cose_public_key(key) -> bytes: + x = key.pointQ.x.to_bytes(32, byteorder='big') + y = key.pointQ.y.to_bytes(32, byteorder='big') + return cbor2.dumps({1: 2, 3: -7, -1: 1, -2: x, -3: y}) + + def _find_credential(self, data: Dict[str, Any]) -> tuple: + allowed = data.get("allowCredentials") or [] + + if allowed: + for entry in allowed: + cred_id = entry["id"] + if cred_id in self.credentials: + return cred_id, self.credentials[cred_id] + raise self.CredNotFoundError() + + rp_id = data.get("rpId", "") + for cred_id, cred_data in self.credentials.items(): + stored_rp = _b64url_decode(cred_data["rp_id"]).decode('utf-8', errors='ignore') + if stored_rp == rp_id: + return cred_id, cred_data + raise self.CredNotFoundError() + + def create(self, data: Dict[str, Any], origin: str = "") -> Dict[str, Any]: + password = self._ask_password("Virtual WebAuthn — Create Credential") + self.credentials = self._load_credentials(password) + + challenge = data.get("challenge") + if isinstance(challenge, str): + challenge = _b64url_decode(challenge) + + rp = data.get("rp", {}) + user = data.get("user", {}) + + alg = -7 + for param in data.get("pubKeyCredParams", []): + if param.get("type") == "public-key" and param.get("alg") == -7: + break + + if not origin: + origin = data.get("origin") + if not origin: + raise self.InputDataError("origin") + + rp_id = rp.get("id", "").encode() + + user_id = user.get("id") + if isinstance(user_id, str): + user_id = _b64url_decode(user_id) + + key = ECC.generate(curve='P-256') + credential_id = os.urandom(16) + credential_id_b64 = _b64url_encode(credential_id) + cose_pubkey = self._cose_public_key(key) + + attested_data = ( + b'\x00' * 16 + + struct.pack(">H", len(credential_id)) + + credential_id + + cose_pubkey + ) + auth_data = self._build_authenticator_data(rp_id, counter=0, credential_data=attested_data) + + attestation_cbor = cbor2.dumps({ + "fmt": "none", + "authData": auth_data, + "attStmt": {} + }) + + client_data_json = json.dumps({ + "challenge": _b64url_encode(challenge), + "origin": origin, + "type": "webauthn.create", + "crossOrigin": False, + }).encode() + + self.credentials[credential_id_b64] = { + "private_key": key.export_key(format='PEM'), + "rp_id": _b64url_encode(rp_id), + "user_id": _b64url_encode(user_id), + "user_name": user.get('displayName', ''), + "created": int(time.time()), + "counter": 0, + } + self._save_credentials(password) + + return { + "authenticatorAttachment": "cross-platform", + "id": credential_id_b64, + "rawId": credential_id_b64, + "type": "public-key", + "response": { + "attestationObject": _b64url_encode(attestation_cbor), + "clientDataJSON": _b64url_encode(client_data_json), + "authenticatorData": _b64url_encode(auth_data), + "publicKey": _b64url_encode(cose_pubkey), + "pubKeyAlgo": str(alg), + "transports": ["internal"], + }, + } + + def get(self, data: Dict[str, Any], origin: str = "") -> Dict[str, Any]: + password = self._ask_password("Virtual WebAuthn — Authenticate") + self.credentials = self._load_credentials(password) + + challenge = data.get("challenge") + if isinstance(challenge, str): + challenge = _b64url_decode(challenge) + + credential_id_b64, cred = self._find_credential(data) + + rp_id = data.get("rpId", "").encode('utf-8') + if not rp_id: + raise self.InputDataError("rpId") + + if not origin: + origin = data.get("origin") + if not origin: + raise self.InputDataError("origin") + + counter = cred.get("counter", 0) + 1 + cred["counter"] = counter + + auth_data = self._build_authenticator_data(rp_id, counter=counter) + + client_data = json.dumps({ + "type": "webauthn.get", + "challenge": _b64url_encode(challenge), + "origin": origin, + "crossOrigin": False, + }, separators=(',', ':')).encode() + client_data_hash = hashlib.sha256(client_data).digest() + + key = ECC.import_key(cred["private_key"]) + h = SHA256.new(auth_data + client_data_hash) + signature = DSS.new(key, 'fips-186-3', encoding='der').sign(h) + + self._save_credentials(password) + + return { + "authenticatorAttachment": "cross-platform", + "id": credential_id_b64, + "rawId": credential_id_b64, + "type": "public-key", + "response": { + "authenticatorData": _b64url_encode(auth_data), + "clientDataJSON": _b64url_encode(client_data), + "signature": _b64url_encode(signature), + }, + } + + +Passkey = VirtualPasskey + + +if __name__ == "__main__": + import requests + + sess = requests.Session() + passkey = Passkey() + + reg_payload = { + "algorithms": ["es256"], "attachment": "all", "attestation": "none", + "discoverable_credential": "preferred", "hints": [], + "user_verification": "preferred", "username": "test", + } + options = sess.post("https://webauthn.io/registration/options", json=reg_payload).json() + cred = passkey.create(options, origin="https://webauthn.io") + cred["rawId"] = cred["id"] + result = sess.post("https://webauthn.io/registration/verification", + json={"response": cred, "username": "test"}).json() + print("Registration:", result) + + sess.get("https://webauthn.io/logout") + + auth_payload = {"username": "test", "user_verification": "preferred", "hints": []} + options = sess.post("https://webauthn.io/authentication/options", json=auth_payload).json() + assertion = passkey.get(options, origin="https://webauthn.io") + assertion["rawId"] = assertion["id"] + result = sess.post("https://webauthn.io/authentication/verification", + json={"response": assertion, "username": "test"}).json() + print("Authentication:", result) diff --git a/webauthn_server.js b/webauthn_server.js deleted file mode 100644 index c1a444f..0000000 --- a/webauthn_server.js +++ /dev/null @@ -1,200 +0,0 @@ -// ==UserScript== -// @name WebAuthnOffload -// @description -// @version 1.0 -// @author @morgan9e -// @include * -// @connect 127.0.0.1 -// @grant GM_xmlhttpRequest -// ==/UserScript== - -function abb64(buffer) { - const bytes = new Uint8Array(buffer); - let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -} - -function b64ab(input) { - const binary = atob(input.replace(/-/g, '+').replace(/_/g, '/')); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes.buffer; -} - -function myFetch(url, options = {}) { - return new Promise((resolve, reject) => { - GM_xmlhttpRequest({ - method: options.method || 'GET', - url: url, - headers: options.headers || {}, - data: options.body || undefined, - responseType: options.responseType || 'json', - onload: function(response) { - const responseObj = { - ok: response.status >= 200 && response.status < 300, - status: response.status, - statusText: response.statusText, - headers: response.responseHeaders, - text: () => Promise.resolve(response.responseText), - json: () => Promise.resolve(JSON.parse(response.responseText)), - response: response - }; - resolve(responseObj); - }, - onerror: function(error) { - reject(new Error(`Request to ${url} failed`)); - } - }); - }); -} - -function showCredentialSelectionPopup(credentials) { - return new Promise((resolve) => { - const popup = document.createElement("div"); - popup.style.position = "fixed"; - popup.style.top = "20px"; - popup.style.right = "20px"; - popup.style.backgroundColor = "#fff"; - popup.style.color = "#000"; - popup.style.border = "1px solid #bbb"; - popup.style.borderRadius = "5px"; - popup.style.padding = "15px"; - popup.style.zIndex = "9999"; - popup.style.maxWidth = "300px"; - - const title = document.createElement("h3"); - title.textContent = "Select credential"; - title.style.margin = "0 0 10px 0"; - popup.appendChild(title); - - credentials.forEach((cred, index) => { - const option = document.createElement("div"); - option.style.padding = "8px 10px"; - option.style.cursor = "pointer"; - const createdDate = new Date(cred.created * 1000).toLocaleString(); - option.innerHTML = ` - ${cred.username || 'Unknown user'} -
Created: ${createdDate}
- `; - option.addEventListener("mouseover", () => { option.style.backgroundColor = "#f0f0f0"; }); - option.addEventListener("mouseout", () => { option.style.backgroundColor = "transparent"; }); - option.addEventListener("click", () => { document.body.removeChild(popup); resolve(cred); }); - popup.appendChild(option); - }); - document.body.appendChild(popup); - }); -} - -const origGet = navigator.credentials.get; -const origCreate = navigator.credentials.create; - -navigator.credentials.get = async function(options) { - console.log("navigator.credentials.get", options) - try { - const authOptions = {publicKey: Object.assign({}, options.publicKey)}; - authOptions.publicKey.challenge = abb64(authOptions.publicKey.challenge) - authOptions.publicKey.allowCredentials = authOptions.publicKey.allowCredentials.map(credential => ({ - ...credential, id: abb64(credential.id) - })); - const response = await myFetch('http://127.0.0.1:20492', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - type: "get", - data: { ...authOptions, origin: window.origin } - }) - }); - if (!response.ok) throw new Error(`server error: ${response.status}`) - const resp = await response.json() - console.log("server response:", resp) - let cred = resp; - if (Array.isArray(resp) && resp.length > 0) { - cred = await showCredentialSelectionPopup(resp); - } - const credential = { - id: cred.id, - type: cred.type, - rawId: b64ab(cred.rawId), - response: { - authenticatorData: b64ab(cred.response.authenticatorData), - clientDataJSON: b64ab(cred.response.clientDataJSON), - signature: b64ab(cred.response.signature) - }, - getClientExtensionResults: () => { return {} } - } - if (cred.response.userHandle) { - credential.response.userHandle = b64ab(cred.response.userHandle); - } - console.log(credential) - return credential; - } catch (error) { - console.error(`Error: ${error.message}, falling back to browser`); - let r = await origGet.call(navigator.credentials, options); - console.log(r); - return r; - } -}; - -navigator.credentials.create = async function(options) { - console.log("navigator.credentials.create", options) - try { - if (!confirm("Creating new credential on userWebAuthn. Continue?")) { - throw new Error('user cancel'); - } - const authOptions = { publicKey: Object.assign({}, options.publicKey) }; - authOptions.publicKey.challenge = abb64(authOptions.publicKey.challenge) - authOptions.publicKey.user = Object.assign({}, options.publicKey.user) - authOptions.publicKey.user.id = abb64(authOptions.publicKey.user.id) - if (authOptions.publicKey.excludeCredentials) { - authOptions.publicKey.excludeCredentials = authOptions.publicKey.excludeCredentials.map(credential => ({ - ...credential, id: abb64(credential.id) - })); - } - const response = await myFetch('http://127.0.0.1:20492', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - type: "create", - data: { ...authOptions, origin: window.origin } - }) - }); - if (!response.ok) throw new Error(`server error: ${response.status}`) - const resp = await response.json() - console.log("server response:", resp) - const credential = { - id: resp.id, - type: resp.type, - rawId: b64ab(resp.rawId), - response: { - attestationObject: b64ab(resp.response.attestationObject), - clientDataJSON: b64ab(resp.response.clientDataJSON), - pubKeyAlgo: resp.response.pubKeyAlgo, - publicKey: b64ab(resp.response.publicKey), - transports: resp.response.transports, - authenticatorData: b64ab(resp.response.authenticatorData), - getAuthenticatorData:() => { return b64ab(resp.response.authenticatorData) }, - getPublicKey: () => { return b64ab(resp.response.publicKey) }, - getPublicKeyAlgorithm: () => { return resp.response.pubKeyAlgo }, - getTransports: () => { return resp.response.transports } - }, - getClientExtensionResults: () => { return {} } - } - console.log(credential) - return credential; - } catch (error) { - console.error(`Error: ${error.message}, falling back to browser`); - let r = await origCreate.call(navigator.credentials, options); - console.log(r); - return r; - } -}; - -console.log("Injected WebAuthn") \ No newline at end of file diff --git a/webauthn_server.py b/webauthn_server.py deleted file mode 100644 index 347ed68..0000000 --- a/webauthn_server.py +++ /dev/null @@ -1,54 +0,0 @@ -from fastapi import FastAPI, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel -from typing import Dict, Any -import uvicorn -import json -from passkey import VirtualPasskey as Passkey - -app = FastAPI() - -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -class WebAuthnRequest(BaseModel): - type: str - data: Dict[str, Any] - -@app.post('/') -async def handle(param: WebAuthnRequest): - if param.type == "get": - try: - options = param.data.get("publicKey", {}) - print(f"webauthn.get {json.dumps(options, indent=4)}") - webauthn = Passkey() - assertion = webauthn.get(options, param.data.get("origin", "")) - return assertion - - except Exception as e: - import traceback - print(f"error.webauthn.get: {e}") - print(traceback.format_exc()) - raise HTTPException(status_code=500, detail=str(e)) - - elif param.type == "create": - try: - options = param.data.get("publicKey", {}) - print(f"webauthn.create {json.dumps(options, indent=4)}") - webauthn = Passkey() - attestation = webauthn.create(options, param.data.get("origin", "")) - return attestation - - except Exception as e: - import traceback - print(f"error.webauthn.create: {e}") - print(traceback.format_exc()) - raise HTTPException(status_code=500, detail=str(e)) - -if __name__ == "__main__": - uvicorn.run(app, host="127.0.0.1", port=20492)