Newer
Older
gnexus-creds / frontend / src / biometric.js
/**
 * 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;
  }
}