diff --git a/frontend/index.html b/frontend/index.html index 85ee33b..1ea6387 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,9 @@ + + + GNEXUS CREDS diff --git a/frontend/public/icons/icon-192-maskable.png b/frontend/public/icons/icon-192-maskable.png new file mode 100644 index 0000000..ece5fa2 --- /dev/null +++ b/frontend/public/icons/icon-192-maskable.png Binary files differ diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000..48ba06e --- /dev/null +++ b/frontend/public/icons/icon-192.png Binary files differ diff --git a/frontend/public/icons/icon-512-maskable.png b/frontend/public/icons/icon-512-maskable.png new file mode 100644 index 0000000..09a5822 --- /dev/null +++ b/frontend/public/icons/icon-512-maskable.png Binary files differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000..24d49bd --- /dev/null +++ b/frontend/public/icons/icon-512.png Binary files differ diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..7addb2e --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "id": "/", + "name": "GNEXUS CREDS", + "short_name": "CREDS", + "description": "Encrypted secret vault with MCP integration.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#16161E", + "theme_color": "#16161E", + "lang": "en", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-192-maskable.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, + { "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} \ No newline at end of file diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..6fc5a95 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,153 @@ +/* GNEXUS CREDS service worker — hand-rolled, no build plugin. + * + * Security rule #1: API/auth/MCP traffic (cookie + secret payloads) is NEVER + * intercepted — the fetch handler returns before any Cache Storage call. + * + * Asset freshness: hashed /assets and public files use stale-while-revalidate + * — the cached copy is served immediately while a fresh copy is fetched in + * the background and replaces the cached entry only if bytes differ, so a + * server-side update propagates without a page reload. + */ + +const VERSION = "gc-shell-v1"; +const CACHE = `${VERSION}-shell`; +const SHELL = [ + "/", + "/index.html", + "/logo.svg", + "/manifest.webmanifest", + "/icons/icon-192.png", + "/icons/icon-512.png", +]; +const BYPASS_PREFIXES = ["/api/", "/auth/", "/mcp/", "/webhooks/", "/sw.js"]; + +function isBypass(url, request) { + if (request.method !== "GET") return true; + if (url.pathname === "/sw.js" || url.pathname === "/mcp-protocol") return true; + return BYPASS_PREFIXES.some((prefix) => url.pathname.startsWith(prefix)); +} + +self.addEventListener("install", (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(CACHE); + await Promise.all( + SHELL.map((url) => + cache.add(new Request(url, { cache: "reload" })).catch(() => {}) + ) + ); + await self.skipWaiting(); + })() + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + for (const key of await caches.keys()) { + if (key !== CACHE) await caches.delete(key); + } + await self.clients.claim(); + })() + ); +}); + +self.addEventListener("message", (e) => { + if (e.data === "SKIP_WAITING") self.skipWaiting(); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + if (url.origin !== self.location.origin || isBypass(url, event.request)) return; + + if (event.request.mode === "navigate") { + event.respondWith(networkFirstNavigation(event)); + } else if ( + url.pathname.startsWith("/assets/") || + url.pathname === "/logo.svg" || + url.pathname.startsWith("/icons/") || + url.pathname === "/manifest.webmanifest" + ) { + event.respondWith(staleWhileRevalidate(event)); + } + // everything else same-origin: default network handling +}); + +// Offline: network-first on navigations, cached shell as fallback. + +const NAV_TIMEOUT_MS = 4000; + +async function networkFirstNavigation(event) { + const request = event.request; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), NAV_TIMEOUT_MS); + try { + const fresh = await fetch(request, { signal: controller.signal }); + clearTimeout(timer); + const cache = await caches.open(CACHE); + // index.html is the canonical shell for every SPA route + cache.put("/", fresh.clone()).catch(() => {}); + cache.put("/index.html", fresh.clone()).catch(() => {}); + return fresh; + } catch { + clearTimeout(timer); + const cache = await caches.open(CACHE); + return ( + (await cache.match("/")) || + (await cache.match("/index.html")) || + offlineFallbackPage() + ); + } +} + +function offlineFallbackPage() { + return new Response( + `GNEXUS CREDS` + + `` + + `

GNEXUS CREDS — OFFLINE

` + + `

Reconnect to load the vault shell.

`, + { status: 504, headers: { "Content-Type": "text/html; charset=utf-8" } } + ); +} + +function bufEqual(a, b) { + if (a.byteLength !== b.byteLength) return false; + const viewA = new Uint8Array(a); + const viewB = new Uint8Array(b); + for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false; + return true; +} + +async function staleWhileRevalidate(event) { + const request = event.request; + const cache = await caches.open(CACHE); + const cached = await cache.match(request, { + ignoreSearch: request.url.includes("/assets/fonts/"), + }); + // Serve a clone of `cached` and keep the original for the byte-compare: + // respondWith hands the clone to the page, which consumes its body, so + // cloning `cached` again below would race and throw "body is already used". + const served = cached ? cached.clone() : null; + // keep the background revalidation alive after we hand back a response + event.waitUntil( + (async () => { + try { + const fresh = await fetch(request); + if (!fresh.ok) return; + const body = await fresh.clone().arrayBuffer(); + if (!cached || !bufEqual(await cached.arrayBuffer(), body)) { + await cache.put(request, fresh); + } + } catch { + // offline: keep whatever is cached + } + })() + ); + if (served) return served; + try { + return await fetch(request); + } catch { + return new Response("", { status: 504 }); + } +} \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 61d3838..248e057 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -36,6 +36,14 @@ import { api } from "./api"; import SecretDetailPanel from "./components/SecretDetailPanel.vue"; +import { + assertGate, + clearStore, + enrollGate, + gateState, + readStore as bioGateStore, + refreshGateState +} from "./biometric"; const baseTabs = [ { id: "secrets", label: "Secrets" }, @@ -681,10 +689,15 @@ } async function reveal(secret) { + if (!(await biometryAllowed())) return; revealed.value = await api.revealSecret(secret.id); revealedVisibleFields.value = new Set(); } +function hideRevealed() { + revealed.value = null; +} + function maskValue(value) { return "*".repeat(Math.max(8, String(value || "").length)); } @@ -710,9 +723,85 @@ async function revealVersion(version) { if (!selected.value) return; + if (!(await biometryAllowed())) return; revealedVersion.value = await api.revealVersion(selected.value.id, version.id); } +function bioGateUserLabel() { + return me.value?.email || me.value?.display_name || "GNEXUS CREDS user"; +} + +// The one choke point in front of both reveal entry points; a failure of any +// kind (cancelled prompt, tampered claim, deleted passkey) blocks the API call. +async function biometryAllowed() { + const result = await assertGate(bioGateUserLabel()); + if (result.ok) return true; + if (result.code === "cancelled") { + toastProvider.value?.info({ + title: "Canceled", + text: "Biometric verification canceled — nothing revealed.", + lifetime: 2500 + }); + } else if (result.code === "no-credential") { + toastProvider.value?.error({ + title: "Biometric gate", + text: "Passkey not found on this device — re-enroll the gate in Settings.", + lifetime: 4000 + }); + } else if (result.code !== "no-gate") { + toastProvider.value?.error({ + title: "Biometric gate", + text: "Verification failed — the secret was not revealed.", + lifetime: 3500 + }); + } + return false; +} + +async function toggleBioGate() { + const store = bioGateStore(); + if (store?.enabled) { + // Turning the gate off requires the same biometric proof as a reveal: + // nobody should be able to switch it off from an unattended session. + const proof = await assertGate(bioGateUserLabel()); + if (!proof.ok) { + toastProvider.value?.error({ + title: "Biometric gate", + text: proof.code === "cancelled" ? "Canceled — the gate stays on." : "Verification failed — the gate stays on.", + lifetime: 3000 + }); + return; + } + clearStore(); + await refreshGateState(); + toastProvider.value?.success({ + title: "Saved", + text: "Biometric gate disabled — reveals no longer require verification.", + lifetime: 2500 + }); + return; + } + const result = await enrollGate(bioGateUserLabel()); + if (!result.ok) { + toastProvider.value?.info({ + title: "Biometric gate", + text: + result.code === "cancelled" + ? "Enrollment canceled — the gate stays off." + : result.message || "Enrollment failed.", + lifetime: 3500 + }); + await refreshGateState(); + return; + } + await refreshGateState(); + toastProvider.value?.success({ + title: "Saved", + text: "Biometric gate enabled — every reveal will ask for the sensor.", + lifetime: 3000 + }); +} + async function loadAudit() { auditLoading.value = true; try { @@ -943,6 +1032,7 @@ onMounted(async () => { try { me.value = await api.me(); + await refreshGateState(); await loadSecrets(); await syncTabFromRoute(true); window.addEventListener("popstate", onPopState); @@ -1130,7 +1220,7 @@ :visible-field-keys="revealedVisibleFields" @back="showSecretsList" @reveal="reveal(selected)" - @hide="revealed.value = null" + @hide="hideRevealed" @edit-metadata="openEditMetadata" @toggle-field-visibility="toggleRevealedField" @status-change="setStatus" @@ -1379,6 +1469,31 @@ +
+ +

+ Require a fingerprint (Android), Touch ID or Windows Hello before every secret reveal. + Verification runs entirely on this browser — the server never sees biometric data. + Disable needs the same proof, so the gate cannot be switched off from an unattended session. +

+ + {{ gateState.detail || "Biometric authentication is unavailable on this device." }} + + + The enrolled passkey is no longer on this device. Re-enroll the gate to restore protection. + +
+ + {{ gateState.status === 'enrolled' ? "Disable biometric gate" : "Enable biometric gate" }} + +
+
+

diff --git a/frontend/src/biometric.js b/frontend/src/biometric.js new file mode 100644 index 0000000..a572cb0 --- /dev/null +++ b/frontend/src/biometric.js @@ -0,0 +1,296 @@ +/** + * Client-side WebAuthn biometric gate on secret reveal. + * + * The gate is enforced entirely in this browser: a platform-authenticator + * passkey (Android fingerprint via BiometricPrompt, Touch ID, Windows Hello) + * is enrolled once, and every reveal runs a fresh assertion whose signature + * is verified against the stored public key — no server involvement. + * + * Honest scope: this protects the interface (like the biometric prompt in a + * banking app), not the data. Anyone able to tamper with this tab's JS or + * storage could bypass it; the vault's own defenses are unchanged. + */ + +import { reactive } from "vue"; + +export const STORE_KEY = "gc_biometric_gate.v1"; + +export const gateState = reactive({ + status: "unknown", // "unknown" | "not-enrolled" | "enrolled" | "unavailable" | "broken" + detail: null, // human note for "unavailable" +}); + +// --- byte helpers ---------------------------------------------------------- + +function b64uEncode(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +function b64uDecode(text) { + const base64 = text.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +function randomChallenge() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return bytes; +} + +function concatBuffers(first, second) { + const out = new Uint8Array(first.byteLength + second.byteLength); + out.set(new Uint8Array(first), 0); + out.set(new Uint8Array(second), first.byteLength); + return out; +} + +// --- persisted state (localStorage; guarded — private mode may refuse) ------ + +export function readStore() { + try { + const raw = localStorage.getItem(STORE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function writeStore(store) { + try { + localStorage.setItem(STORE_KEY, JSON.stringify(store)); + return true; + } catch { + return false; + } +} + +export function clearStore() { + try { + localStorage.removeItem(STORE_KEY); + } catch { + // best effort + } +} + +// --- availability detection ------------------------------------------------- + +export async function refreshGateState() { + if (typeof window.PublicKeyCredential === "undefined" || !window.isSecureContext) { + gateState.status = "unavailable"; + gateState.detail = "Biometric authentication is not available in this browser."; + return; + } + try { + const available = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + if (!available) { + gateState.status = "unavailable"; + gateState.detail = "A fingerprint / screen-lock biometric sensor is not available on this device."; + return; + } + } catch { + gateState.status = "unavailable"; + gateState.detail = "The browser could not check for a biometric sensor."; + return; + } + const store = readStore(); + if (store?.enabled && store?.credentialId) { + gateState.status = "enrolled"; + } else { + gateState.status = "not-enrolled"; + } + gateState.detail = null; +} + +// --- enrollment --------------------------------------------------------------- + +const GATE_TIMEOUT_MS = 60000; + +function webauthnError(err) { + if (err instanceof DOMException) { + if (["NotAllowedError", "AbortError"].includes(err.name)) return "cancelled"; + if (["UnknownError", "InvalidStateError"].includes(err.name)) return "no-credential"; + if (err.name === "SecurityError") return "failed"; + } + return "failed"; +} + +// importKey() needs the curve (ECDSA) or nothing (RSA); the hash belongs to +// the verify call, not to the key import — mixing them up throws silently. +function importAlgorithm(alg) { + if (alg === -257) return { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }; + return { name: "ECDSA", namedCurve: "P-256" }; +} + +// verify() needs the hash variant of the algorithm identifier. +function signatureAlgorithm(alg) { + if (alg === -257) return { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }; + return { name: "ECDSA", hash: "SHA-256" }; +} + +export async function enrollGate(userLabel) { + let credential; + const challenge = randomChallenge(); + try { + credential = await navigator.credentials.create({ + publicKey: { + challenge, + rp: { id: location.hostname, name: "GNEXUS CREDS" }, + user: { + id: crypto.getRandomValues(new Uint8Array(16)), + name: userLabel, + displayName: userLabel, + }, + pubKeyCredParams: [ + { alg: -7, type: "public-key" }, + { alg: -257, type: "public-key" }, + ], + authenticatorSelection: { + authenticatorAttachment: "platform", + residentKey: "preferred", + userVerification: "required", + }, + timeout: GATE_TIMEOUT_MS, + attestation: "none", + }, + }); + } catch (err) { + const code = err instanceof DOMException && ["NotAllowedError", "AbortError"].includes(err.name) + ? "cancelled" + : err instanceof DOMException && ["InvalidStateError", "NotSupportedError", "SecurityError"].includes(err.name) + ? "unsupported" + : "failed"; + // IP-based origins (http://127.0.0.1:8018) are not valid WebAuthn RP ids — + // real deployments use a hostname (creds.gnexus.space, localhost in dev). + console.error("[gnexus-creds] biometric enrollment failed:", err.name, err.message); + return { ok: false, code, message: "Enrollment failed — the browser refused registration (check the site's origin)." }; + } + + // Exporting the public key is the only way to verify assertions offline in + // this same tab later; browsers that don't support getPublicKey() cannot + // run this gate at all. + if (typeof credential.response.getPublicKey !== "function") { + return { + ok: false, + code: "unsupported-export", + message: "This browser cannot export the passkey's public key, so client-side verification is impossible.", + }; + } + + const alg = credential.response.getPublicKeyAlgorithm?.() ?? -7; + try { + const spki = await credential.response.getPublicKey(); + const key = await crypto.subtle.importKey("spki", spki, importAlgorithm(alg), true, ["verify"]); + const pubKeyJwk = await crypto.subtle.exportKey("jwk", key); + const store = { + enabled: true, + credentialId: b64uEncode(credential.rawId), + alg, + pubKeyJwk, + userLabel, + createdAt: new Date().toISOString(), + }; + if (!writeStore(store)) { + return { ok: false, code: "failed", message: "Could not persist the gate in this browser." }; + } + gateState.status = "enrolled"; + gateState.detail = null; + return { ok: true, store }; + } catch { + return { + ok: false, + code: "unsupported-export", + message: "The passkey was created, but its public key could not be exported for verification.", + }; + } +} + +// --- reveal gate ------------------------------------------------------------- + +export async function assertGate(expectedUserLabel) { + const store = readStore(); + if (!store?.enabled || !store?.credentialId) return { ok: true }; // gate off — never in the way + + const challenge = randomChallenge(); + let assertion; + try { + assertion = await navigator.credentials.get({ + publicKey: { + challenge, + rpId: location.hostname, + timeout: GATE_TIMEOUT_MS, + userVerification: "required", + allowCredentials: [{ id: b64uDecode(store.credentialId), type: "public-key" }], + }, + }); + } catch (err) { + const code = webauthnError(err); + if (code === "no-credential") gateState.status = "broken"; + return { ok: false, code }; + } + + const sentChallenge = b64uEncode(challenge); + if (!verifyAssertion(assertion, store, sentChallenge)) { + return { ok: false, code: "denied" }; + } + + // The gate is device-scoped (WebAuthn is per-origin), so a new account on + // the same device simply re-labels the store after a successful proof. + if (expectedUserLabel && store.userLabel !== expectedUserLabel) { + store.userLabel = expectedUserLabel; + writeStore(store); + } + return { ok: true }; +} + +/** + * Offline verification of a WebAuthn assertion against the enrolled store: + * clientData (type/challenge/origin), authenticatorData (rpIdHash, UP/UV/AT + * flags) and the signature over authenticatorData || SHA-256(clientDataJSON). + */ +export async function verifyAssertion(assertion, store, sentChallengeB64u) { + try { + const clientData = JSON.parse(new TextDecoder().decode(assertion.response.clientDataJson)); + if (clientData.type !== "webauthn.get") return false; + if (clientData.challenge !== sentChallengeB64u) return false; // replay = wrong challenge + if (clientData.origin !== location.origin) return false; + + const authData = assertion.response.authenticatorData; + if (!authData || authData.byteLength < 37) return false; + const rpIdHash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(location.hostname) + ); + if (!bufEqual(authData.slice(0, 32), rpIdHash)) return false; + + const flags = new DataView(authData).getUint8(32); + if (!(flags & 0x01)) return false; // UP — user present + if (!(flags & 0x04)) return false; // UV — fingerprint/screen-lock proof happened + if (flags & 0x40) return false; // AT — no attested data should ride a get() + + const clientHash = await crypto.subtle.digest("SHA-256", assertion.response.clientDataJson); + const key = await crypto.subtle.importKey( + "jwk", + store.pubKeyJwk, + signatureAlgorithm(store.alg), + false, + ["verify"] + ); + // signature covers authenticatorData followed by the clientDataJSON hash + const signed = concatBuffers(authData, clientHash); + return await crypto.subtle.verify( + signatureAlgorithm(store.alg), + key, + assertion.response.signature, + signed + ); + } catch (err) { + console.error("[gnexus-creds] assertion verification failed:", err.name, err.message); + return false; + } +} \ No newline at end of file diff --git a/frontend/src/main.js b/frontend/src/main.js index 869be98..f4065b8 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -8,3 +8,29 @@ import "./styles.css"; createApp(App).use(GnexusUiVue).mount("#app"); + +// PWA service worker (public/sw.js). localhost/127.0.0.1 are secure contexts, +// so local runs register too; plain-http LAN origins stay unregistered — same +// condition the biometric gate uses. Updates are silent: sw.js uses +// stale-while-revalidate, so no reload prompt is needed. +if ("serviceWorker" in navigator && window.isSecureContext) { + window.addEventListener("load", () => { + navigator.serviceWorker + .register("/sw.js", { scope: "/" }) + .then((registration) => { + // No update tick right after register(): Chrome already update-checks + // on every navigation, and racing install/claim with one more check + // once left an offline reload unintercepted. Background ticks below + // cover assets changed server-side while the app stays open. + const updateTick = () => registration.update().catch(() => {}); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) updateTick(); + }); + window.addEventListener("online", updateTick); + setInterval(updateTick, 15 * 60 * 1000); + }) + .catch(() => { + // unreachable SW (old bundle, restrictive proxy) — app still works + }); + }); +} diff --git a/tools/rasterize_icons.py b/tools/rasterize_icons.py new file mode 100644 index 0000000..67bffb4 --- /dev/null +++ b/tools/rasterize_icons.py @@ -0,0 +1,58 @@ +"""Rasterize frontend/public/logo.svg into PWA icons. + +Outputs (committed): + frontend/public/icons/icon-192.png / icon-512.png — any purpose + frontend/public/icons/icon-192-maskable.png / icon-512-maskable.png + +Maskable variants keep the full-bleed background with the logo scaled to the +80% safe zone, so Android's circular adaptive-icon cut never clips the shield. + +Usage: python tools/rasterize_icons.py (needs the project .venv's playwright) +""" + +from pathlib import Path +from tempfile import TemporaryDirectory + +from playwright.sync_api import sync_playwright + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SVG_PATH = PROJECT_ROOT / "frontend" / "public" / "logo.svg" +OUT_DIR = PROJECT_ROOT / "frontend" / "public" / "icons" + +SIZES = [(192, False), (512, False), (192, True), (512, True)] +SAFE_SCALE = 0.8 + +HTML = """

{svg}
""" + + +def write_icons() -> None: + svg = SVG_PATH.read_text(encoding="utf-8") + OUT_DIR.mkdir(parents=True, exist_ok=True) + + with sync_playwright() as p, TemporaryDirectory() as tmp: + browser = p.chromium.launch( + executable_path="/usr/bin/chromium", + args=["--no-sandbox", "--force-color-profile=srgb"], + ) + page = browser.new_page() + for size, maskable in SIZES: + inner = int(size * SAFE_SCALE) if maskable else size + html = HTML.format(size=size, inner=inner, svg=svg) + html_path = Path(tmp) / f"icon-{size}-{int(maskable)}.html" + html_path.write_text(html, encoding="utf-8") + page.set_viewport_size({"width": size, "height": size}) + page.goto(f"file://{html_path}") + name = f"icon-{size}{'-maskable' if maskable else ''}.png" + page.screenshot(path=str(OUT_DIR / name), omit_background=False) + print(f"wrote {name}") + browser.close() + + +if __name__ == "__main__": + write_icons() \ No newline at end of file