/**
* 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;
}
}