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
|
passkey.json
|
||||||
__pycache__/*
|
__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