mirror of
https://github.com/morgan9e/virtual-webauthn
synced 2026-04-14 16:24:21 +09:00
Rewrite in Rust, refine extension
This commit is contained in:
@@ -1,50 +1,124 @@
|
||||
const API_URL = "http://127.0.0.1:20492";
|
||||
const HOST_NAME = "com.example.virtual_webauthn";
|
||||
|
||||
async function apiFetch(method, path, body) {
|
||||
const opts = { method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(body);
|
||||
let port = null;
|
||||
let seq = 0;
|
||||
const pending = new Map();
|
||||
let sessionKey = null;
|
||||
|
||||
function connect() {
|
||||
if (port) return;
|
||||
try {
|
||||
port = chrome.runtime.connectNative(HOST_NAME);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
|
||||
port.onMessage.addListener((msg) => {
|
||||
if (msg.sessionKey) {
|
||||
sessionKey = msg.sessionKey;
|
||||
}
|
||||
const cb = pending.get(msg.id);
|
||||
if (cb) {
|
||||
pending.delete(msg.id);
|
||||
cb(msg);
|
||||
}
|
||||
});
|
||||
|
||||
port.onDisconnect.addListener(() => {
|
||||
port = null;
|
||||
for (const [id, cb] of pending) {
|
||||
cb({ id, success: false, error: "Host disconnected" });
|
||||
}
|
||||
pending.clear();
|
||||
updateIcon(false);
|
||||
});
|
||||
|
||||
updateIcon(true);
|
||||
}
|
||||
|
||||
// --- Icon status polling ---
|
||||
function sendNative(msg) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connect();
|
||||
if (!port) {
|
||||
reject(new Error("Cannot connect to native host"));
|
||||
return;
|
||||
}
|
||||
const id = ++seq;
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("Timed out"));
|
||||
}, 120_000);
|
||||
|
||||
pending.set(id, (resp) => {
|
||||
clearTimeout(timer);
|
||||
resolve(resp);
|
||||
});
|
||||
|
||||
port.postMessage({ ...msg, id });
|
||||
});
|
||||
}
|
||||
|
||||
// --- Icon status ---
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
function updateIcon(connected) {
|
||||
const status = connected ? "ok" : "err";
|
||||
if (status === lastStatus) return;
|
||||
lastStatus = status;
|
||||
const icon = connected ? "icon-green.svg" : "icon-red.svg";
|
||||
const title = connected
|
||||
? "Virtual WebAuthn — Connected"
|
||||
: "Virtual WebAuthn — Disconnected";
|
||||
chrome.action.setIcon({ path: icon });
|
||||
chrome.action.setTitle({ title });
|
||||
}
|
||||
|
||||
updateIcon();
|
||||
setInterval(updateIcon, 5000);
|
||||
async function pingLoop() {
|
||||
try {
|
||||
const resp = await sendNative({ type: "ping" });
|
||||
updateIcon(resp.success === true);
|
||||
} catch {
|
||||
updateIcon(false);
|
||||
}
|
||||
setTimeout(pingLoop, 10_000);
|
||||
}
|
||||
|
||||
// --- Message relay ---
|
||||
pingLoop();
|
||||
|
||||
// --- Message relay from content script ---
|
||||
|
||||
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;
|
||||
if (message.type !== "VWEBAUTHN_REQUEST") return;
|
||||
|
||||
const msg = {
|
||||
type: message.action,
|
||||
data: message.payload,
|
||||
};
|
||||
|
||||
// Password from content.js (already in isolated context)
|
||||
if (message.password) {
|
||||
msg.password = message.password;
|
||||
} else if (sessionKey) {
|
||||
msg.sessionKey = sessionKey;
|
||||
}
|
||||
|
||||
if (message.action === "list" && message.rpId) {
|
||||
msg.rpId = message.rpId;
|
||||
}
|
||||
|
||||
sendNative(msg)
|
||||
.then((resp) => {
|
||||
if (!resp.success) {
|
||||
const err = resp.error || "";
|
||||
if (err.includes("session") || err.includes("Session")) {
|
||||
sessionKey = null;
|
||||
}
|
||||
}
|
||||
sendResponse(resp);
|
||||
})
|
||||
.catch((error) => {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -3,14 +3,146 @@ s.src = chrome.runtime.getURL("inject.js");
|
||||
s.onload = () => s.remove();
|
||||
(document.documentElement || document.head).appendChild(s);
|
||||
|
||||
// --- Password prompt (closed shadow DOM, isolated context) ---
|
||||
|
||||
function showPasswordPrompt(title, needsConfirm) {
|
||||
return new Promise((resolve) => {
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647";
|
||||
const shadow = host.attachShadow({ mode: "closed" });
|
||||
|
||||
shadow.innerHTML = `
|
||||
<style>
|
||||
.overlay { position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.3); }
|
||||
.popup {
|
||||
position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
|
||||
background:#fff;color:#000;border:1px solid #bbb;border-radius:8px;padding:16px;
|
||||
max-width:320px;box-shadow:0 4px 16px rgba(0,0,0,.18);
|
||||
font-family:system-ui,-apple-system,sans-serif;font-size:14px;line-height:1.4;
|
||||
}
|
||||
.title { margin:0 0 12px;font-size:15px;font-weight:600; }
|
||||
input {
|
||||
width:100%;padding:8px 10px;border:1px solid #ccc;border-radius:4px;
|
||||
font-size:14px;margin-bottom:8px;box-sizing:border-box;
|
||||
}
|
||||
.err { color:#dc2626;font-size:12px;margin-bottom:8px;display:none; }
|
||||
.btns { display:flex;gap:8px;justify-content:flex-end; }
|
||||
button {
|
||||
padding:8px 16px;border:none;border-radius:4px;font-size:13px;cursor:pointer;font-weight:500;
|
||||
}
|
||||
.cancel { background:#f0f0f0;color:#333; }
|
||||
.ok { background:#222;color:#fff; }
|
||||
</style>
|
||||
<div class="overlay"></div>
|
||||
<div class="popup">
|
||||
<div class="title"></div>
|
||||
<input type="password" class="pw" placeholder="Password">
|
||||
${needsConfirm ? '<input type="password" class="pw2" placeholder="Confirm password">' : ""}
|
||||
<div class="err"></div>
|
||||
<div class="btns">
|
||||
<button class="cancel">Cancel</button>
|
||||
<button class="ok">Unlock</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const cleanup = () => host.remove();
|
||||
|
||||
shadow.querySelector(".title").textContent = title;
|
||||
const pw = shadow.querySelector(".pw");
|
||||
const pw2 = shadow.querySelector(".pw2");
|
||||
const errEl = shadow.querySelector(".err");
|
||||
|
||||
const submit = () => {
|
||||
if (!pw.value) {
|
||||
errEl.textContent = "Password required";
|
||||
errEl.style.display = "";
|
||||
return;
|
||||
}
|
||||
if (needsConfirm && pw.value !== pw2.value) {
|
||||
errEl.textContent = "Passwords do not match";
|
||||
errEl.style.display = "";
|
||||
return;
|
||||
}
|
||||
const val = pw.value;
|
||||
cleanup();
|
||||
resolve(val);
|
||||
};
|
||||
|
||||
shadow.querySelector(".ok").onclick = submit;
|
||||
shadow.querySelector(".cancel").onclick = () => { cleanup(); resolve(null); };
|
||||
shadow.querySelector(".overlay").onclick = () => { cleanup(); resolve(null); };
|
||||
|
||||
const onKey = (e) => { if (e.key === "Enter") submit(); };
|
||||
pw.addEventListener("keydown", onKey);
|
||||
if (pw2) pw2.addEventListener("keydown", onKey);
|
||||
|
||||
document.body.appendChild(host);
|
||||
pw.focus();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Message relay with auth handling ---
|
||||
|
||||
async function sendToHost(msg) {
|
||||
const response = await chrome.runtime.sendMessage(msg);
|
||||
if (chrome.runtime.lastError) throw new Error(chrome.runtime.lastError.message);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function handleRequest(action, payload, rpId) {
|
||||
const msg = { type: "VWEBAUTHN_REQUEST", action, payload };
|
||||
if (rpId) msg.rpId = rpId;
|
||||
|
||||
// No-auth actions pass through directly
|
||||
if (action === "list" || action === "status" || action === "ping") {
|
||||
return sendToHost(msg);
|
||||
}
|
||||
|
||||
// Try with session first (no password)
|
||||
let response = await sendToHost(msg);
|
||||
|
||||
// If session worked, done
|
||||
if (response.success) return response;
|
||||
|
||||
// Need password — check if first-time setup
|
||||
const isSessionError = response.error?.includes("session") || response.error?.includes("Session")
|
||||
|| response.error?.includes("Password or session");
|
||||
if (!isSessionError) return response; // real error, don't retry
|
||||
|
||||
let statusResp;
|
||||
try {
|
||||
statusResp = await sendToHost({ type: "VWEBAUTHN_REQUEST", action: "status", payload: {} });
|
||||
} catch {
|
||||
return response;
|
||||
}
|
||||
const needsSetup = statusResp.success && statusResp.data?.needsSetup;
|
||||
|
||||
const title = needsSetup
|
||||
? "Virtual WebAuthn — Set Password"
|
||||
: `Virtual WebAuthn — ${action === "create" ? "Create Credential" : "Authenticate"}`;
|
||||
|
||||
// Retry loop — allow 3 password attempts
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const password = await showPasswordPrompt(
|
||||
attempt > 0 ? "Wrong password — try again" : title,
|
||||
needsSetup,
|
||||
);
|
||||
if (!password) return { success: false, error: "Password prompt cancelled" };
|
||||
|
||||
msg.password = password;
|
||||
const retry = await sendToHost(msg);
|
||||
if (retry.success || !retry.error?.includes("password")) return retry;
|
||||
}
|
||||
|
||||
return { success: false, error: "Too many failed attempts" };
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
const response = await handleRequest(action, payload, event.data.rpId);
|
||||
window.postMessage({ type: "VWEBAUTHN_RESPONSE", id, ...response }, "*");
|
||||
} catch (error) {
|
||||
window.postMessage({ type: "VWEBAUTHN_RESPONSE", id, success: false, error: error.message }, "*");
|
||||
|
||||
@@ -18,33 +18,20 @@
|
||||
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",
|
||||
},
|
||||
// --- UI (toast + credential selector only, no password) ---
|
||||
|
||||
const POPUP_STYLE = {
|
||||
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",
|
||||
};
|
||||
|
||||
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" });
|
||||
const toast = document.createElement("div");
|
||||
Object.assign(toast.style, { ...POPUP_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">` +
|
||||
@@ -57,19 +44,22 @@
|
||||
|
||||
function showCredentialSelector(credentials) {
|
||||
return new Promise((resolve) => {
|
||||
const popup = createPopup();
|
||||
const popup = document.createElement("div");
|
||||
Object.assign(popup.style, POPUP_STYLE);
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.textContent = "Select a passkey";
|
||||
Object.assign(title.style, STYLE.title);
|
||||
Object.assign(title.style, { margin: "0 0 12px", fontSize: "15px", fontWeight: "600" });
|
||||
popup.appendChild(title);
|
||||
|
||||
const optStyle = { padding: "10px 12px", cursor: "pointer", borderRadius: "6px", transition: "background .1s" };
|
||||
|
||||
credentials.forEach((cred) => {
|
||||
const opt = document.createElement("div");
|
||||
Object.assign(opt.style, STYLE.option);
|
||||
Object.assign(opt.style, optStyle);
|
||||
const date = new Date(cred.created * 1000).toLocaleString();
|
||||
opt.innerHTML =
|
||||
`<strong>${cred.username || "Unknown"}</strong>` +
|
||||
`<strong>${cred.user_name || "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");
|
||||
@@ -79,7 +69,7 @@
|
||||
|
||||
const cancel = document.createElement("div");
|
||||
Object.assign(cancel.style, {
|
||||
...STYLE.option, textAlign: "center", color: "#888",
|
||||
...optStyle, textAlign: "center", color: "#888",
|
||||
marginTop: "4px", borderTop: "1px solid #eee", paddingTop: "10px",
|
||||
});
|
||||
cancel.textContent = "Cancel";
|
||||
@@ -92,6 +82,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- Messaging (no password in postMessage) ---
|
||||
|
||||
const pending = new Map();
|
||||
let seq = 0;
|
||||
|
||||
@@ -121,8 +113,49 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- Response builders ---
|
||||
|
||||
function buildCreateResponse(resp) {
|
||||
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: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGetResponse(resp) {
|
||||
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;
|
||||
}
|
||||
|
||||
// --- WebAuthn overrides ---
|
||||
|
||||
navigator.credentials.create = async function (options) {
|
||||
const toast = showToast("Waiting for passkey...");
|
||||
const toast = showToast("Creating passkey...");
|
||||
try {
|
||||
const pk = options.publicKey;
|
||||
const resp = await request("create", {
|
||||
@@ -134,22 +167,7 @@
|
||||
},
|
||||
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: () => ({}),
|
||||
};
|
||||
return buildCreateResponse(resp);
|
||||
} catch (err) {
|
||||
console.warn("[VirtualWebAuthn] create fallback:", err.message);
|
||||
return origCreate(options);
|
||||
@@ -159,9 +177,20 @@
|
||||
};
|
||||
|
||||
navigator.credentials.get = async function (options) {
|
||||
const toast = showToast("Waiting for passkey...");
|
||||
const pk = options.publicKey;
|
||||
|
||||
// Check if we have credentials for this rpId (no auth needed)
|
||||
try {
|
||||
const creds = await request("list", { rpId: pk.rpId || "" });
|
||||
if (Array.isArray(creds) && creds.length === 0) {
|
||||
return origGet(options);
|
||||
}
|
||||
} catch {
|
||||
return origGet(options);
|
||||
}
|
||||
|
||||
const toast = showToast("Authenticating...");
|
||||
try {
|
||||
const pk = options.publicKey;
|
||||
let resp = await request("get", {
|
||||
publicKey: {
|
||||
...pk,
|
||||
@@ -178,22 +207,7 @@
|
||||
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;
|
||||
return buildGetResponse(resp);
|
||||
} catch (err) {
|
||||
console.warn("[VirtualWebAuthn] get fallback:", err.message);
|
||||
return origGet(options);
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
"name": "Virtual WebAuthn",
|
||||
"version": "1.0",
|
||||
"description": "Not your keys, not your credential",
|
||||
"host_permissions": [
|
||||
"http://127.0.0.1:20492/*"
|
||||
"permissions": [
|
||||
"nativeMessaging"
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "virtual-webauthn@local"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"default_icon": "icon-red.svg",
|
||||
"default_title": "Virtual WebAuthn — Disconnected"
|
||||
|
||||
Reference in New Issue
Block a user