mirror of
https://github.com/morgan9e/virtual-webauthn
synced 2026-04-14 00:04:09 +09:00
Rewrite
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
*.json
|
||||
__pycache__/*
|
||||
passkey.json
|
||||
__pycache__/
|
||||
dist/
|
||||
|
||||
29
Makefile
Normal file
29
Makefile
Normal 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
50
extension/background.js
Normal 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
18
extension/content.js
Normal 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
4
extension/icon-green.svg
Normal 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
4
extension/icon-red.svg
Normal 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
206
extension/inject.js
Normal 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
31
extension/manifest.json
Normal 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>"]
|
||||
}
|
||||
]
|
||||
}
|
||||
382
passkey.py
382
passkey.py
@@ -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
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
cbor2
|
||||
pycryptodome
|
||||
fido2
|
||||
fastapi
|
||||
uvicorn
|
||||
147
server/main.py
Normal file
147
server/main.py
Normal 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
445
server/passkey.py
Normal 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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user