Skip to content

Local Keys — security model

This page is the canonical security reference for the user_byok_local funding source. It covers where the keys live on disk, how they are encrypted, how the browser reaches them without ever caching ciphertext, and the threats the design does and does not defend against.

If you only need usage instructions, see Funding Sources and the lf keys CLI reference.

What "Local Keys" means now

Local BYOK keys live in ~/.lenserfight/keys/ on the user's machine, encrypted at rest under a master passphrase held in the OS keychain.

The browser never holds ciphertext, never holds the master passphrase, and only holds plaintext for the lifetime of a single in-flight request. All access goes through the LenserFight Gateway loopback daemon (apps/gateway, default 127.0.0.1:38080). The same store is accessed by:

  • apps/web via the gateway's HTTP /keys surface, authenticated by a bearer token paired once per origin.
  • apps/cli via direct filesystem access from the lf keys * commands — same code path, no HTTP.

The earlier IndexedDB-encrypted store is gone. On first load after upgrade, the browser deletes the legacy database (lenserfight-local-keys) and surfaces a one-time pointer to the new pairing flow.

Encryption at rest

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

json
{
  "v": 1,
  "alg": "aes-256-gcm",
  "kdf": "scrypt",
  "salt": "<base64(16 bytes)>",
  "iv":   "<base64(12 bytes)>",
  "ciphertext": "...",
  "tag":  "<base64(16 bytes)>",
  "meta": {
    "id": "<20-40 char [A-Za-z0-9_-]>",
    "provider": "openai",
    "label": "Prod",
    "createdAt": "..."
  }
}

Key design choices:

  • Per-key salt + scrypt KDF. N=2^15, r=8, p=1, dkLen=32. One derivation per envelope. An attacker who steals the directory has to grind scrypt independently for every key — there is no global key to recover.
  • AES-256-GCM with a fresh 12-byte IV per encryption. Any tampering with ciphertext, iv, or tag causes decryption_failed.
  • Atomic writes. Envelopes are written via O_EXCL | O_NOFOLLOW to a temp file, then renamed over the target on POSIX. There is never a half-written envelope on disk.
  • File mode 0600, parent dir 0700. The store refuses to read or write through a symlink and rejects ids that don't match ^[A-Za-z0-9_-]{20,40}$.
  • Master passphrase is held in the OS keychain (lenserfight-keys service). Never written into any LenserFight file. CI may set LENSERFIGHT_KEYS_PASSPHRASE instead; setting both is refused unless LENSERFIGHT_KEYS_PASSPHRASE_FORCE_ENV=1 is also set.

Browser ↔ gateway boundary

CheckWhat happensWhy
Origin allow-listOrigin must match lenserfight.com, a subdomain, localhost, or 127.0.0.1 on any port. Anything else → 403.A malicious open tab on any other origin can fetch('http://127.0.0.1:38080/...'). The allow-list blocks that.
Bearer token32 random bytes minted by lf gateway pair, stored in OS keychain (server side) and sessionStorage (browser). Constant-time compare.Without a token, no other process on the machine can call /keys/*.
Token storage in the browsersessionStorage only — not cookie, not localStorage, not IndexedDB.sessionStorage dies with the tab, the user re-pairs anytime. No cross-tab leakage, no automatic refresh from disk.
Rate limit/keys/:id/resolve is capped at 60/min per token, burst 5. Excess → 429 + audit log.A compromised origin that can reach the gateway shouldn't be able to exfiltrate every key in a few seconds.
Body sizeHard 64 KiB cap. Larger payloads → 413.Catch obvious abuse early; keys are small.
Loopback onlyServer refuses to bind 0.0.0.0 or ::.The gateway is for this machine; never put it on the network.

Threat model

ThreatDefenseResidual risk
Disk theft (laptop lost)AES-256-GCM at rest under a passphrase held in the OS keychain.If your OS login + keychain unlock are weak, an attacker with physical access can decrypt. Same risk as any password manager — pick a strong login and require auth-after-sleep.
Same-user malware reading ~/.lenserfight/keys/Mode 0600 only blocks unprivileged peers; same-UID processes can read ciphertext but need OS-keychain access to decrypt.Defense-in-depth is OS sandboxing (macOS TCC, Linux AppArmor / cgroups).
Cross-origin browser JS calling the gatewayOrigin allow-list + bearer token in sessionStorage (not cookie, not localStorage). CSRF impossible without Origin spoof, which fetch blocks.A compromised lenserfight.com origin can pull keys via XSS — mitigations are strict CSP and SRI on the LenserFight web app.
Loopback eavesdroppingLoopback traffic never leaves the kernel. No on-the-wire risk.None.
Path traversal / symlink swapStrict id regex; O_NOFOLLOW; refuse symlinks on read, write, and unlink.None.
Envelope tamperingAES-GCM auth tag — any modification fails decryption with decryption_failed.None.
Cloud backup ingesting ~/.lenserfight/The directory is not in default home-backup roots on macOS/Windows.A misconfigured cloud backup of $HOME would include ciphertext — useless without the passphrase.
Brute force on /keys/:id/resolve60/min per token, burst 5; audit log on each failure.None for casual abuse. A patient attacker can still grind across many resolves — pair with strong origin + token.
Master passphrase exposed via env varLENSERFIGHT_KEYS_PASSPHRASE only honored when the OS keychain is unavailable, unless LENSERFIGHT_KEYS_PASSPHRASE_FORCE_ENV=1.Linux /proc/<pid>/environ is readable by the same UID — keep the env var in CI only, never in your shell rc.

Accessing the gateway over Tailscale or a LAN

By default the gateway binds to 127.0.0.1:38080 (loopback only) and the allow-list permits browser origins on lenserfight.com, localhost, 127.0.0.1, Tailscale CGNAT (100.64.0.0/10), RFC 1918 private ranges (10/8, 172.16/12, 192.168/16), and .local mDNS hostnames. The web app's gateway client derives its target URL from window.location.hostname — so a page served at http://100.88.58.68:3000 automatically reaches the gateway at http://100.88.58.68:38080.

For the gateway to answer on a non-loopback address, run it in keys-only mode with an explicit bind. Keys-only mode is the right default for the Local Keys feature — it skips the identity / session / lenser / kill_switch preconditions and the heartbeat / sync loops, which are only relevant to the unrelated signed-coordination surface:

bash
# Bind everywhere — bearer + origin allow-list still gate every /keys call.
lf gateway serve --keys-only --bind 0.0.0.0

# Or pin to a specific Tailscale IP.
lf gateway serve --keys-only --bind 100.88.58.68

The full-coordination daemon (lf gateway serve without --keys-only) still refuses to bind on 0.0.0.0 / :: unless Tailscale consent exists. Keys-only mode relaxes that because every /keys/* request is already authenticated with a bearer token + origin allow-list + per-token rate limit, so there is no accidental public exposure from a wider bind alone.

Self-hosters with a custom domain can extend the origin allow-list via LF_GATEWAY_EXTRA_ORIGINS (comma-separated regex bodies). Example:

bash
LF_GATEWAY_EXTRA_ORIGINS='^https://app\.mycompany\.local$' lf gateway serve

Browsers running on Chrome's Private Network Access path also need the gateway to answer the PNA preflight — the daemon emits Access-Control-Allow-Private-Network: true automatically when it sees Access-Control-Request-Private-Network on a preflight.

If your gateway is on a different host/port from the web app, override the target URL in the browser session:

js
// In the browser devtools, before pairing:
sessionStorage.setItem('lf-gateway-url', 'http://my-gateway.local:38080')

Recovery

If you lose the master passphrase, the keys are unrecoverable. scrypt is designed to be expensive enough that brute force is infeasible, and the passphrase is the only way to derive any of the per-key keys. Re-add the keys with lf keys add.

If your OS keychain is wiped (system reinstall, profile reset), set the env var to recover access — but the OS-keychain copy of the passphrase is gone, so you should re-pair the gateway and reseat the passphrase via lf keys init --force.

Opting out of cloud backup

  • macOS: xattr -w com.apple.metadata:com_apple_backup_excludeItem com.apple.backupd ~/.lenserfight excludes the directory from Time Machine. For iCloud Drive, place ~/.lenserfight/ outside ~/Documents (the default — no action needed).
  • Windows: ~/.lenserfight/ lives in %USERPROFILE%. Add it to OneDrive's "Exclude folders" list under Settings → Account if your %USERPROFILE% is OneDrive-synced.
  • Linux: Most desktop sync clients exclude dotfiles by default. Verify your tool's behavior before storing real keys.

What this design does NOT protect against

  • A compromised LenserFight web app origin running JS with fetch access to the paired gateway (defense: CSP, SRI, browser sandboxing — the same issues face any web wallet).
  • Native malware running as the same OS user with keychain access.
  • A determined attacker with physical access to an unlocked machine.

If any of those are in scope for your threat model, run Local Keys only on machines you fully control, and consider isolating the gateway in a separate user / VM.

See also