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)