Swoff Swoff

Local Security

PIN protection, biometric authentication, and auto-lock for apps with client-side data.

Local Security

Prerequisites: Auth Store.

For apps where all data lives on the client (IndexedDB), local security protects data from physical access.

This pattern is not needed if your app always requires a backend connection — auth is handled server-side.

PIN Lock

PIN Store

Uses PBKDF2 with a random salt and 600,000 iterations — the recommended minimum by OWASP. PINs are short (4-6 digits), so iteration count and salt are critical.

swoff/auth/lock-screen.js
const PIN_STORE = "lock-pin";

function openLockDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open("swoff-lock", 1);
    request.onupgradeneeded = (e) => {
      const db = e.target.result;
      if (!db.objectStoreNames.contains(PIN_STORE)) {
        db.createObjectStore(PIN_STORE, { keyPath: "key" });
      }
    };
    request.onsuccess = (e) => resolve(e.target.result);
    request.onerror = (e) => reject(e.target.error);
  });
}

async function hashPin(pin, salt) {
  const encoder = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    "raw",
    encoder.encode(pin),
    "PBKDF2",
    false,
    ["deriveBits"],
  );
  const hash = await crypto.subtle.deriveBits(
    {
      name: "PBKDF2",
      salt,
      iterations: 600000,
      hash: "SHA-256",
    },
    keyMaterial,
    256,
  );
  return new Uint8Array(hash);
}

export async function setPin(pin) {
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const hash = await hashPin(pin, salt);
  const db = await openLockDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(PIN_STORE, "readwrite");
    const store = tx.objectStore(PIN_STORE);
    store.put({
      key: "pinData",
      value: { hash: Array.from(hash), salt: Array.from(salt) },
    });
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
}

export async function verifyPin(pin) {
  const db = await openLockDB();
  return new Promise((resolve, reject) => {
    const tx = db.transaction(PIN_STORE, "readonly");
    const store = tx.objectStore(PIN_STORE);
    const request = store.get("pinData");
    request.onsuccess = async () => {
      const stored = request.result?.value;
      if (!stored) return resolve(true); // No PIN set

      const salt = new Uint8Array(stored.salt);
      const hash = await hashPin(pin, salt);
      const match = stored.hash.every((b, i) => b === hash[i]);
      resolve(match);
    };
    request.onerror = () => reject(request.error);
  });
}

Biometric / WebAuthn

swoff/auth/biometric-auth.js
/**
 * Check if biometric auth is available on this device.
 */
export function isBiometricAvailable() {
  return (
    window.PublicKeyCredential &&
    PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable?.()
  );
}

/**
 * Authenticate using device biometrics (fingerprint, face, etc.).
 * Returns true if the user verified successfully.
 */
export async function authenticateWithBiometric() {
  if (!isBiometricAvailable()) return false;

  try {
    // WebAuthn assertion (simplified — your server handles challenge/response)
    const credential = await navigator.credentials.get({
      publicKey: {
        challenge: new Uint8Array(32), // In production, get from your server
        rpId: window.location.hostname,
        userVerification: "required",
        timeout: 60000,
      },
    });
    return credential !== null;
  } catch {
    return false;
  }
}

Auto-Lock Timer

Lock the app after a period of inactivity. User sets the timeout in settings.

swoff/auth/auto-lock.js
let lockTimer = null;
let lockTimeout = 5 * 60 * 1000; // Default: 5 minutes

export function setLockTimeout(ms) {
  lockTimeout = ms;
}

export function startLockTimer() {
  stopLockTimer();
  if (lockTimeout <= 0) return; // Disabled

  lockTimer = setTimeout(() => {
    window.dispatchEvent(new CustomEvent("sw-lock-required"));
  }, lockTimeout);
}

export function stopLockTimer() {
  if (lockTimer) clearTimeout(lockTimer);
  lockTimer = null;
}

export function resetLockTimer() {
  startLockTimer(); // Restart the countdown
}

// Reset on user activity
document.addEventListener("click", resetLockTimer);
document.addEventListener("keydown", resetLockTimer);
document.addEventListener("touchstart", resetLockTimer);

Lock on App Visibility Change

Lock when the app goes to background and returns after a threshold.

// In your app initialization
const LOCK_THRESHOLD = 30000; // 30 seconds

document.addEventListener("visibilitychange", () => {
  if (document.hidden) {
    // App went to background — remember time
    sessionStorage.setItem("lock-background-timestamp", Date.now());
  } else {
    // App came to foreground
    const bgTime = sessionStorage.getItem("lock-background-timestamp");
    if (bgTime && Date.now() - Number(bgTime) > LOCK_THRESHOLD) {
      window.dispatchEvent(new CustomEvent("sw-lock-required"));
    }
    sessionStorage.removeItem("lock-background-timestamp");
  }
});

Lock Screen Component

When your app receives sw-lock-required, show a lock screen:

window.addEventListener("sw-lock-required", () => {
  showLockScreen();
});

async function showLockScreen() {
  // 1. Render PIN input / biometric prompt
  // 2. On submit:
  const valid = await verifyPin(enteredPin);
  if (valid) {
    hideLockScreen();
    resetLockTimer();
  } else {
    showError("Wrong PIN");
  }
}

Events

EventWhen
sw-lock-requiredAuto-lock timer expired, or app resumed from background

Integration Checklist

  • PIN storage with PBKDF2 + random salt (600,000 iterations minimum)
  • Biometric availability check + authentication
  • Auto-lock timer with configurable timeout
  • Visibility change detection
  • Lock screen UI component
  • PIN setup in app settings

Next Steps

On this page