Skip to content

Local Keys & Gateway

Local Keys are API keys that live only on your machine, encrypted at rest under a master passphrase that LenserFight never sees. Execution reads the key through the LenserFight Gateway daemon running on your machine — no key material ever touches the cloud.

This page covers the architecture and security model in depth. For a quick-start comparison of all funding modes, see Funding Sources.


Architecture

┌──────────────────┐  HTTP (loopback, bearer auth)  ┌──────────────────┐  fs (0600/0700)
│  apps/web        │ ────────────────────────────>  │  apps/gateway    │ ──────────────> ~/.lenserfight/keys/
│  (browser)       │   /keys CRUD + /keys/:id/resolve│  (Node daemon)   │                <id>.json (AES-256-GCM)
└──────────────────┘                                 └──────────────────┘

                                                              │ direct fs

                                                     ┌──────────────────┐
                                                     │  apps/cli        │
                                                     │  lf keys *       │
                                                     └──────────────────┘

The browser holds no ciphertext and no persistent state about your keys. Every read goes through the gateway loopback daemon. The same store serves:

  • The browser via the gateway's /keys endpoints.
  • The CLI (lf keys *) directly via the filesystem.
  • Any future runner built on libs/data/local-keys.

There is no separate browser-only store — the previous IndexedDB design has been removed.


Setup

Complete these steps once, in order. Every step is required — skipping any one causes a specific error (listed in the Troubleshooting section below).

Step 0 — Prerequisites (one-time only)

Open a terminal and run the following in order:

bash
# 1. Generate the device Ed25519 keypair (stored in your OS keychain).
#    Required for `lf gateway serve` (full mode) to pass its identity check.
#    Skip this if you only ever run with `--keys-only`.
lf gateway identity init

# 2. Generate the master passphrase that encrypts your local keys at rest.
#    Required before you can save any key — the gateway refuses with
#    "passphrase_missing" (503) until this has been run.
lf keys init

Both commands are idempotent — re-running them on an existing setup prints a warning and exits without overwriting anything (use --force to rotate).

Step 1 — Start the gateway

In a terminal — leave this running:

bash
# Same machine — gateway and browser on the same box (most common).
lf gateway serve --keys-only

# Full mode (also syncs device state with the cloud).
lf gateway serve

# Tailscale or LAN — browser on another device, gateway on this one.
lf gateway serve --keys-only --bind 0.0.0.0

--keys-only skips the Ed25519 identity / Supabase session / lenser-active preconditions so you can use Local Keys without a full gateway identity. It also allows binding a non-loopback address without a Tailscale consent file.

When the daemon starts it prints a colored box:

┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  Gateway ready — pair the web app in 3 steps                 │
│                                                              │
│  Prerequisite: run `lf keys init` once (adds master passphrase).│
│                                                              │
│  Step 1 — copy this token:                                   │
│                                                              │
│  <your-43-char-base64url-token>                              │
│  ...                                                         │
│                                                              │
│  Step 2 — open the web app → any Funding panel → Local Keys  │
│  Step 3 — paste the token into "Pair gateway"                │
│                                                              │
│  Gateway: http://127.0.0.1:38080                             │
└──────────────────────────────────────────────────────────────┘

Copy the token from this box. You can always retrieve it later with:

bash
lf gateway pair --web       # print the current token
lf gateway pair --rotate    # rotate to a new token (invalidates the old one)

Step 2 — Add a provider key

In another terminal (the gateway must still be running):

bash
# Add a key — value is read from stdin so it never enters shell history.
lf keys add --provider openai --label "Prod"

# Verify it was stored:
lf keys list

Step 3 — Pair the browser

  1. Open the LenserFight web app on any lens, battle, or workflow page (anywhere with a Funding panel).
  2. In the Funding panel, click the Local Keys tile.
  3. A Paste your pairing token below ↓ box appears — paste the token printed in Step 1 and click Pair gateway.

The keys added with lf keys add will now appear in the picker.

Token lifetime: The pairing token lives in sessionStorage only. Close the tab and you'll need to re-run lf gateway pair --web to get a fresh token. The gateway re-uses the same token across restarts unless you rotate it with --rotate.


Troubleshooting

ErrorCauseFix
[precondition] FAIL identity_presentNo Ed25519 keypair on this machinelf gateway identity init
[precondition] FAIL keys_passphraself keys init has never been runlf keys init
Failed to save key … passphrase_missing (503)Master passphrase missing in keychainlf keys init
[keychain] OS keychain (keytar) is unavailableNative keytar binding not compiled for this Node versionWarning only — the file-backend fallback at ~/.lenserfight/gateway/keys/ is used automatically. Functionality is identical.
lf-gateway-init: command not foundThe gateway binary wasn't builtpnpm nx run gateway:build-init && node dist/apps/gateway/init.jsor use lf gateway identity init which works without building the binary.
Gateway not running in web applf gateway serve is not runningStart it and leave it running in a dedicated terminal.
Gateway not paired in web appBrowser has no pairing tokenRun lf gateway pair --web and paste the token into the Funding panel.
Gateway refused this origin (403)App is running on a non-whitelisted URLUse https://lenserfight.com, localhost, or 127.0.0.1. Custom origins: set LF_GATEWAY_EXTRA_ORIGINS.

Key management commands

CommandPurpose
lf keys initOne-time setup: creates ~/.lenserfight/keys/, stores master passphrase in OS keychain
lf keys add --provider <p> --label <l>Add a new key (value read from stdin)
lf keys listList all stored key IDs and metadata
lf keys rotate <id>Re-encrypt a key under a new IV
lf keys remove <id>Delete a key envelope
lf keys doctorVerify all envelopes can be decrypted

See the CLI keys reference for the full surface.


Encryption at rest

Each key lives in its own envelope at ~/.lenserfight/keys/<id>.json:

json
{
  "v": 1,
  "alg": "aes-256-gcm",
  "kdf": "scrypt",
  "salt": "<16 bytes>",
  "iv": "<12 bytes>",
  "ciphertext": "...",
  "tag": "<16 bytes>",
  "meta": { "id": "...", "provider": "openai", "label": "Prod", "createdAt": "..." }
}
  • Per-key salt + scrypt KDF (N=2¹⁵, r=8, p=1, dkLen=32). Brute force must grind scrypt for every individual key.
  • AES-256-GCM with a fresh 12-byte IV per encryption. The auth tag detects tampering and refuses to decrypt.
  • File mode 0600, parent directory mode 0700. The store refuses to follow symlinks and rejects any ID that does not match ^[A-Za-z0-9_-]{20,40}$.
  • Master passphrase lives in the OS keychain under service lenserfight-keys. Never written to any file. CI may use the env var LENSERFIGHT_KEYS_PASSPHRASE instead.

Browser ↔ gateway authentication

CheckDefense
Cross-origin browser JS calling the gatewayOrigin allow-list (lenserfight.com, subdomains, localhost/127.0.0.1). All others → 403.
Disallowed-origin preflightReturns 403 — browser never sees the response body.
Bearer token32 random bytes generated by lf gateway pair, held in the OS keychain by the gateway and in sessionStorage by the browser. Constant-time comparison.
Brute force on /keys/:id/resolve60/min per token, burst of 5 — 429 + audit log.
Body abuseHard 64 KiB cap; larger payloads → 413.

The gateway only binds to loopback by default. The --bind flag accepts a specific IP — it will not bind to 0.0.0.0 unless you explicitly pass it.

For the full threat-model breakdown, see Local Keys security model.


Ollama (local models)

Ollama runs AI models entirely on your machine. For local Ollama models, no API key is required — Ollama connects to localhost:11434. An optional key field is available for cloud-routed Ollama models only.


Migrating from the old IndexedDB store

If you used Local Keys before this version, the old IndexedDB database (lenserfight-local-keys) is auto-deleted on first load after upgrade. Re-add your keys with lf keys add. There is no export path from the legacy store.


When to use Local Keys

  • You are self-hosting LenserFight and prefer to keep all secrets on your machine.
  • You move between the CLI and the browser and want a single source of truth for provider keys.
  • You want to test AI providers without a LenserFight account.

Cloud BYOK key decryption

Cloud BYOK keys are stored in Supabase Vault and decrypted server-side via fn_get_my_key_secret, a Postgres RPC that returns the plaintext to the authenticated browser client. The function works in both local and cloud Supabase environments.

Security model

Access is controlled by two server-side guards enforced inside the function:

  • Ownership check: lenser_id must match the authenticated caller — you can only decrypt your own keys.
  • Active-only: revoked or inactive keys (is_active = false) are rejected before any vault lookup.

The function is granted only to the authenticated role — anonymous callers cannot reach it.

Error reference

ErrorCodeCauseFix
Key not found, revoked, or not owned by callerP0001Wrong key_id or key revokedCheck ai.keys table for an active key owned by the current user
Failed to decrypt key from vaultP0001Vault entry missingRe-add the key via Settings → BYOK
Unauthenticated: no lenser profile foundP0001No authenticated lenser sessionSign in before calling the function

See also