diff --git a/.gitignore b/.gitignore
index 6aff574..2c96eb1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,2 @@
-passkey.json
-__pycache__/
-dist/
+target/
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..90b5ac2
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,23 @@
+[package]
+name = "virtual-webauthn"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+p256 = { version = "0.13", features = ["ecdsa", "pem"] }
+ecdsa = { version = "0.16", features = ["signing", "der"] }
+aes-gcm = "0.10"
+scrypt = "0.11"
+sha2 = "0.10"
+ciborium = "0.2"
+base64ct = { version = "1", features = ["std"] }
+rand = "0.8"
+log = "0.4"
+env_logger = "0.11"
+dirs = "6"
+
+[profile.release]
+lto = true
+strip = true
diff --git a/Makefile b/Makefile
index cfc5cea..6b94a9c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,29 +1,21 @@
-MODE ?= virtual
+NMH_DIR ?= $(HOME)/.librewolf/native-messaging-hosts
+BIN_DIR ?= $(HOME)/.librewolf/external_application
+EXT_ID ?= com.example.virtual_webauthn
-.PHONY: build chrome firefox clean run run-physical install
+.PHONY: build clean install extension
-build: chrome firefox
+build:
+ cargo build --release
-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 '.*'
+extension:
+ @mkdir -p target
+ cd extension && zip -r ../target/virtual-webauthn.xpi . -x '.*'
clean:
- rm -rf dist/
+ cargo clean
-run:
- cd server && python main.py --mode $(MODE)
-
-run-physical:
- cd server && python main.py --mode physical
-
-install:
- pip install -r requirements.txt
+install: build
+ @mkdir -p $(BIN_DIR) $(NMH_DIR)
+ install -m755 target/release/virtual-webauthn $(BIN_DIR)/virtual-webauthn
+ cp virtual_webauthn.json $(NMH_DIR)/$(EXT_ID).json
+ @sed -i "s,/PLACEHOLDER,$(BIN_DIR)," $(NMH_DIR)/$(EXT_ID).json
diff --git a/extension/background.js b/extension/background.js
index 674c049..94dfbf9 100644
--- a/extension/background.js
+++ b/extension/background.js
@@ -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;
});
diff --git a/extension/content.js b/extension/content.js
index 8563aee..2edae6d 100644
--- a/extension/content.js
+++ b/extension/content.js
@@ -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 = `
+
+
+ `;
+
+ 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 }, "*");
diff --git a/extension/inject.js b/extension/inject.js
index 7bc4afa..16de2e0 100644
--- a/extension/inject.js
+++ b/extension/inject.js
@@ -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 =
`` +
`