mirror of
https://github.com/morgan9e/virtual-webauthn
synced 2026-04-14 16:24:21 +09:00
Rewrite
This commit is contained in:
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>"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user