This commit is contained in:
2026-03-30 11:39:50 +09:00
parent 323a7e21a7
commit b3ba21129c
14 changed files with 942 additions and 638 deletions

5
.gitignore vendored
View File

@@ -1,2 +1,3 @@
*.json
__pycache__/*
passkey.json
__pycache__/
dist/

29
Makefile Normal file
View File

@@ -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

50
extension/background.js Normal file
View File

@@ -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;
}
});

18
extension/content.js Normal file
View File

@@ -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 }, "*");
}
});

4
extension/icon-green.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="M7,23c0,.552-.448,1-1,1h-1c-2.757,0-5-2.243-5-5V9.724c0-1.665,.824-3.214,2.203-4.145L9.203,.855c1.699-1.146,3.895-1.146,5.593,0l7,4.724c.315,.213,.607,.462,.865,.74,.376,.405,.353,1.037-.052,1.413-.405,.375-1.037,.353-1.413-.052-.155-.167-.329-.315-.519-.443L13.678,2.513c-1.019-.688-2.336-.688-3.356,0L3.322,7.237c-.828,.559-1.322,1.488-1.322,2.487v9.276c0,1.654,1.346,3,3,3h1c.552,0,1,.448,1,1Zm10.937-10.046c-.586,.586-.586,1.536,0,2.121s1.536,.586,2.121,0c.586-.586,.586-1.536,0-2.121s-1.536-.586-2.121,0Zm4.45,5.45c-1.168,1.168-2.786,1.739-4.413,1.584l-3.133,3.133c-.566,.566-1.32,.878-2.121,.878h-1.71c-1.099,0-1.996-.893-2-1.991l-.009-1.988c-.001-.403,.154-.781,.438-1.066,.284-.284,.661-.441,1.062-.441h.49l.004-.511c.006-.821,.679-1.489,1.5-1.489h.761v-.393c-.71-2.31,.136-4.763,2.138-6.15,1.803-1.251,4.244-1.288,6.072-.094,2.92,1.798,3.394,6.167,.922,8.525Zm-.408-4.253c-.111-1.071-.682-1.993-1.608-2.598-1.154-.755-2.697-.728-3.839,.062-1.307,.906-1.841,2.524-1.33,4.026,.035,.104,.053,.212,.053,.322v1.55c0,.552-.448,1-1,1h-1.265l-.007,1.007c-.004,.549-.451,.993-1,.993h-.98l.006,1.486h1.71c.268,0,.519-.104,.708-.293l3.487-3.487c.236-.235,.574-.338,.901-.274,1.15,.227,2.331-.13,3.158-.957,.749-.749,1.116-1.784,1.006-2.839Z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

4
extension/icon-red.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24">
<path d="M7,23c0,.552-.448,1-1,1h-1c-2.757,0-5-2.243-5-5V9.724c0-1.665,.824-3.214,2.203-4.145L9.203,.855c1.699-1.146,3.895-1.146,5.593,0l7,4.724c.315,.213,.607,.462,.865,.74,.376,.405,.353,1.037-.052,1.413-.405,.375-1.037,.353-1.413-.052-.155-.167-.329-.315-.519-.443L13.678,2.513c-1.019-.688-2.336-.688-3.356,0L3.322,7.237c-.828,.559-1.322,1.488-1.322,2.487v9.276c0,1.654,1.346,3,3,3h1c.552,0,1,.448,1,1Zm10.937-10.046c-.586,.586-.586,1.536,0,2.121s1.536,.586,2.121,0c.586-.586,.586-1.536,0-2.121s-1.536-.586-2.121,0Zm4.45,5.45c-1.168,1.168-2.786,1.739-4.413,1.584l-3.133,3.133c-.566,.566-1.32,.878-2.121,.878h-1.71c-1.099,0-1.996-.893-2-1.991l-.009-1.988c-.001-.403,.154-.781,.438-1.066,.284-.284,.661-.441,1.062-.441h.49l.004-.511c.006-.821,.679-1.489,1.5-1.489h.761v-.393c-.71-2.31,.136-4.763,2.138-6.15,1.803-1.251,4.244-1.288,6.072-.094,2.92,1.798,3.394,6.167,.922,8.525Zm-.408-4.253c-.111-1.071-.682-1.993-1.608-2.598-1.154-.755-2.697-.728-3.839,.062-1.307,.906-1.841,2.524-1.33,4.026,.035,.104,.053,.212,.053,.322v1.55c0,.552-.448,1-1,1h-1.265l-.007,1.007c-.004,.549-.451,.993-1,.993h-.98l.006,1.486h1.71c.268,0,.519-.104,.708-.293l3.487-3.487c.236-.235,.574-.338,.901-.274,1.15,.227,2.331-.13,3.158-.957,.749-.749,1.116-1.784,1.006-2.839Z" fill="#ef4444" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

206
extension/inject.js Normal file
View File

@@ -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 =
`<div style="display:flex;align-items:center;gap:8px">` +
`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#555" stroke-width="2">` +
`<path d="M12 2a4 4 0 0 0-4 4v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2h-2V6a4 4 0 0 0-4-4z"/>` +
`<circle cx="12" cy="15" r="2"/></svg>` +
`<span>${message}</span></div>`;
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 =
`<strong>${cred.username || "Unknown"}</strong>` +
`<div style="font-size:.8em;color:#666;margin-top:2px">${date}</div>`;
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");
})();

31
extension/manifest.json Normal file
View File

@@ -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": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_start",
"all_frames": true
}
],
"web_accessible_resources": [
{
"resources": ["inject.js"],
"matches": ["<all_urls>"]
}
]
}

View File

@@ -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())

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
cbor2
pycryptodome
fido2
fastapi
uvicorn

147
server/main.py Normal file
View File

@@ -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")

445
server/passkey.py Normal file
View File

@@ -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)

View File

@@ -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 = `
<strong>${cred.username || 'Unknown user'}</strong>
<div style="font-size: 0.8em; color: #666;">Created: ${createdDate}</div>
`;
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")

View File

@@ -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)