RFC-0003: LenserFight Trust Gateway (LTG)
| Status | Draft |
| Author | LenserFight Core |
| Phase | Phase 36–42 (Trust Gateway A–G) |
| Created | 2026-05-09 |
| Supersedes | — |
| Superseded by | — |
Summary
This RFC promotes the existing lf gateway command into a documented, layered platform — the LenserFight Trust Gateway (LTG) — that mediates secure execution and synchronization across:
- local devices owned by one Lenser account,
- trusted devices reachable over a private network (typically Tailscale / WireGuard),
- LenserFight Cloud (Supabase + Edge Functions + the platform-api).
The LTG preserves the current provider/model routing behavior of lf gateway models (see apps/cli/src/commands/gateway.ts) and the current device + lenser trust RPCs introduced in:
supabase/migrations/20270511200000_devices_schema.sqlsupabase/migrations/20270511300000_runner_device_bindings.sqlsupabase/migrations/20270511400000_execution_attestations_and_trust.sqlsupabase/migrations/20270511500000_xp_rules_device_runner_local_execution.sql
It adds three new things on top, and nothing else:
- Per-device cryptographic identity (Ed25519 keypair, OS-keychain-resident).
- A signed envelope protocol for execution requests, attestations, and sync messages with replay protection.
- A long-running daemon (
apps/gateway/) that enforces those contracts on the developer's machine and coordinates with peer devices and the cloud.
Motivation
Today the Gateway is a stateless CLI:
- Provider/model routing is a presentation-only concern in
gateway.ts(classifyRoute). - Device approval and lenser binding are short bursts of REST calls against
devices.fn_device_*andexecution.fn_runner_*. - Battle attestation booleans (
signed,gateway_verified,device_trusted,policy_passed) are self-reported by the client infn_compute_submission_trust. - There is no enforced separation between "transport" and "identity"; private-network trust (Tailscale, mDNS) does not exist as a first-class concept.
- Sync between devices is implicit; the only durable client-side state is
~/.lenserfight/and project-local.lenserfight/(seeapps/cli/src/config/project-config.tsandapps/cli/src/utils/automation-objects.ts).
The product needs the Gateway to grow into the secure boundary that enables:
- One human Lenser to own many AI agents, many trusted devices, many runners, and many battle participations.
- Verified local execution that materially raises battle trust and reputation.
- Auditable device trust and revocable local execution.
- A conflict-aware, bidirectional sync model that works offline-first on local machines and reconciles with cloud.
We solve this without breaking the current CLI, by layering crypto, signed envelopes, an outbox-based sync engine, and a daemon under the existing command namespace.
Goals
- G1 — Preserve every existing
lf gatewaysubcommand and its observable behavior. - G2 — Establish per-device cryptographic identity (Ed25519) without storing private keys in the database, the project, or
~/.lenserfight/lenserfight.json. - G3 — Introduce a signed envelope used uniformly for: execution attestations, sync push, sync pull, peer-to-peer messages, and any future signed RPC.
- G4 — Enforce server-side trust evaluation: clients can never set
gateway_verified = truedirectly. - G5 — Make sync deterministic via an outbox + watermarks model with explicit per-object-class authority and merge policy.
- G6 — Allow opt-in private-network exposure via Tailscale (or any private interface), with Tailscale presence treated as a transport channel and never as an authentication assertion.
- G7 — Mint XP for new platform/battle rules only after server-verified attestations.
Non-goals
- NG1 — End-to-end encryption between peers beyond TLS-on-loopback / WireGuard channel encryption. (Future RFC.)
- NG2 — Cross-Lenser federation. The LTG is single-account by design in v1.
- NG3 — Replacing Supabase Realtime with a custom mesh. We build on top.
- NG4 — Public battles enablement. Still gated by
docs/reference/platform-api/beta-roadmap.md. - NG5 — Migrating existing local battle storage encryption (
apps/cli/src/utils/local-battle-storage.ts) — those AES-256-GCM envelopes remain authoritative for local-only material.
Glossary
| Term | Definition |
|---|---|
| LTG | LenserFight Trust Gateway. The named system this RFC defines. |
| Lenser | A human account in lensers.profiles. |
| Device | A row in devices.registered_devices. Has its own Ed25519 keypair once approved. |
| Lenser | A row in execution.runners (TBD migration); bound to a device via execution.runner_device_bindings. |
| Daemon | The apps/gateway/ long-running process. One instance per device. |
| Envelope | A signed JSON object with v, alg, kid, iat, nonce, body, sig. |
| Attestation | A execution.attestations row plus its corresponding signed envelope. |
| Outbox | devices.sync_outbox — append-only log of local changes pending cloud propagation. |
| Watermark | devices.sync_watermarks — per-(device, object_class) cursor into the cloud changefeed. |
| Object class | The category of a sync object (e.g. agent, workflow, lens_draft, pref). |
Detailed design
1. Trust topology
┌───────────────────────┐
│ Human Lenser │
└─────────┬─────────────┘
│
┌──────┼──────┬──────────────────┐
│ │ │
┌──▼──┐ ┌────▼─────┐ ┌────▼────┐
│Agents│ │ Devices │ │Runners │
└──────┘ │(+pubkey) │ │(bind→D) │
└────┬─────┘ └────┬────┘
│ │
▼ ▼
Daemon (apps/gateway) Execution
│ │
▼ ▼
Sync Outbox/Pull Attestation
│ │
▼ ▼
LenserFight Cloud (Supabase)- One Lenser → many devices → many runners → many runs → many attestations → many submissions.
- The daemon is the only process trusted to sign on behalf of the device.
- Cloud is authoritative for verdicts (XP, trust evaluation, kill-switch state); the device is authoritative for drafts and BYOK material.
2. Identity
2.1 Algorithm
- Ed25519 (RFC 8032). 32-byte private key, 32-byte public key.
- Key generation occurs on the device, ideally inside the daemon's
initcommand. The private key never leaves the device.
2.2 Storage
- Private key lives in the OS keychain, accessed via
libs/utils/keychain. Backends:- macOS: Keychain Services
- Linux: libsecret / Secret Service API
- Windows: Credential Manager
- The
keytarpackage is the canonical Node binding. The library wrapskeytarlazily so web bundles never pull it in. - A fallback file-based backend is provided only for ephemeral CI environments; it requires explicit
LF_GATEWAY_KEY_FILE_FALLBACK=1and warns loudly.
2.3 DB binding
devices.registered_devices gains:
| Column | Type | Notes |
|---|---|---|
public_key | TEXT | base64 (raw 32 bytes) |
signing_algo | TEXT | always ed25519 in v1 |
last_heartbeat_at | TIMESTAMPTZ | updated by signed fn_device_heartbeat |
daemon_version | TEXT | reported by daemon |
public_key is set during device approval (two-step flow, §5).
3. Signed envelope
interface SignedEnvelope<T> {
v: 1
alg: 'ed25519'
kid: string // device id
iat: number // unix seconds at sign time
nonce: string // 128-bit random, base64url
body: T
sig: string // ed25519(SHA-256(JCS({v,alg,kid,iat,nonce,body})))
}- Canonicalization: JCS (RFC 8785) over the unsigned object
{v, alg, kid, iat, nonce, body}. - Hash: SHA-256 of the canonicalization output.
- Signature: detached Ed25519 over the hash, encoded as base64url.
- Replay window: server rejects envelopes where
|now - iat| > 300s. - Nonce uniqueness:
devices.nonce_cache(nonce, device_id, expires_at)rejects replay within 600s. - kid scope:
kidMUST equal adevices.registered_devices.idbelonging to the JWT-authenticated Lenser.
The TypeScript implementation lives in libs/utils/signing; the Postgres verification lives in extensions.fn_verify_attestation_signature (using pgsodium if available, else a Deno Edge Function fallback at supabase/functions/verify-attestation/).
4. Three sync scopes
| Scope | Transport | Authn | Authz | Conflict policy |
|---|---|---|---|---|
| A. Local mesh | 127.0.0.1 HTTP/WS, mDNS discovery | Ed25519 + JWT | Same-Lenser, trust_level ∈ {approved, trusted} | Leader (oldest trusted device) wins for write coordination |
| B. Tailscale / private network | WireGuard via Tailscale (100.64.0.0/10) | Ed25519 + JWT (Tailscale ID is ignored for authn) | Same-Lenser, explicit --tailscale daemon flag | Same as Local |
| C. Cloud | Supabase REST / RPC / Realtime | Supabase JWT + per-RPC signed envelope when mutating trust state | RLS deny-by-default, SECURITY DEFINER RPCs | Per-class merge policy, default LWW + vector clock |
5. Two-step device approval
Replaces the current single-RPC fn_device_approve with a challenge/response that anchors the device's public key.
┌──────────────┐ ┌──────────────┐ ┌──────────┐
│ Daemon │ │ Cloud │ │ Owner │
│ (new device) │ │ (Supabase) │ │ CLI │
└──────┬───────┘ └──────┬───────┘ └────┬─────┘
│ │ │
│ fn_device_register_with_key │ │
│ (name, type, public_key, │ │
│ capabilities) │ │
├──────────────────────────────────►│ │
│ ◄ device_id, challenge_nonce ◄───┤ │
│ │ │
│ fn_device_post_challenge │ │
│ (envelope signed with │ │
│ body={device_id, challenge}) │ │
├──────────────────────────────────►│ │
│ │ │
│ │ owner reviews `lf gateway │
│ │ devices --pending` │
│ │ ◄────────────────────────────┤
│ │ fn_device_approve(device_id) │
│ ├─────────────────────────────►│
│ │ │
│ │ trust_level: pending → approved
│ │ public_key storedFailure modes:
- Signature does not verify: device stays
pending, returnssignature_mismatch. - Owner approves but no challenge has been answered: returns
awaiting_device_challenge. - Owner approves a device whose
lenser_iddiffers from caller's profile: rejected by RLS-backed RPC (existing pattern).
6. Attestation hardening
execution.attestations and execution.trust_evaluations keep their schema but the trust path is rebuilt:
fn_record_execution_attestationrequires a signed envelope; the server (re)derives:device_trusted— fromdevices.registered_devices.trust_levelof thedevice_idat insert time.gateway_verified— only set TRUE iffn_verify_attestation_signaturesucceeds.policy_passed— only set TRUE ifagents.fn_evaluate_pre_run_policy(Phase 8) for the corresponding run returnedallow.signed— derived from envelope verification, not fromp_signed.
fn_compute_submission_trustIGNORES caller-supplied flags and reads fromexecution.attestationscolumns the server itself wrote.- The trust ladder thresholds (
unverified→account_verified→agent_verified→device_verified→runner_verified→execution_verified→fully_trusted) keep their meaning, butexecution_verifiedandfully_trustednow require a signature-verified attestation and a server-evaluated policy verdict.
7. Sync engine
7.1 Tables
| Table | Purpose |
|---|---|
devices.sync_outbox | Append-only log of pending local→cloud changes per device |
devices.sync_watermarks | Per-(device, object_class) cursor into the cloud changefeed for pull |
devices.nonce_cache | Replay protection (10-minute retention) |
7.2 RPCs
devices.fn_sync_push(p_envelope JSONB)— verifies envelope, walksbody.entries[], applies each per its object class.devices.fn_sync_pull(p_object_classes TEXT[], p_limit INT)— returns rows newer than the device's watermark for each requested class, advances watermarks atomically.
7.3 Object classes
| Authority | Class | Examples |
|---|---|---|
| Cloud-only | xp_total, trust_evaluation, battle_result, policy, budget, kill_switch, dark_launch, ai_catalog | Server-verified verdicts |
| Local-only (never sync raw) | byok_key, local_battle, scratchpad_draft, keychain_entry | Material that must never leave the device |
| Conflict-aware (bidirectional) | agent_config, agent_team_graph, workflow_definition, lens_draft, runner_metadata, non_secret_pref, automation_registry_entry | Default policy: LWW per field with vector clock |
Object class metadata is declared in libs/infra/gateway/src/lib/object-classes.ts with the merge function pinned per class. Conflicts that cannot be resolved automatically are surfaced via lf gateway sync status and the web Devices feature for interactive merge.
8. Daemon (apps/gateway/)
A new Nx Node application. Two binaries:
lf-gatewayd— long-running daemon. Started vialf gateway serve.lf-gateway-init— one-shot bootstrapper that creates the Ed25519 keypair, registers the device, and writes daemon-side state under~/.lenserfight/gateway/.
Daemon boot checklist (refuses to start on any failure):
- Clock skew ≤ 5 minutes from
Date.now()vsDate.now()after a network round-trip to Supabase. - Ed25519 private key reachable from the keychain.
- A live Supabase session (or
LENSERFIGHT_API_KEYenv). - Owner Lenser is not paused; workspace
global_kill_switch = false. - Bind target is loopback unless
--tailscalewas supplied.
Daemon responsibilities:
- Local HTTP/WS server on
127.0.0.1:38080(default port; configurable). - Optional Tailscale interface bind (auto-detect interface in CGNAT range).
- Heartbeat every 30 s via
fn_device_heartbeat. - Outbox flush loop (every 5 s; debounced batch up to 100 entries).
- Pull loop (every 10 s; per-class watermark advance).
- Leader election among same-Lenser peers via
devices.fn_acquire_leader_lease(p_lease_seconds INT).
9. CLI surface
Preserved (verbatim, no behavior change):
lf gateway models [--provider X] [--json]lf gateway devices [--trust X] [--json]lf gateway approve-device <id>— now triggers two-step flow if the target device has apublic_key; legacy single-step still works for legacy rows for one release.lf gateway runners [--json]lf gateway status [--json]
Added:
lf gateway serve [--bind 127.0.0.1] [--port 38080] [--tailscale]lf gateway doctor [--check clock|keychain|identity|daemon|sync|policy]lf gateway identity show|rotate|export-publiclf gateway peers [--json]lf gateway sync status|pull|pushlf gateway policy show|test
10. Threat model
| Adversary | Capability | Mitigation |
|---|---|---|
| Compromised CLI running with valid JWT | Submit forged attestations | Signature required; private key in OS keychain, not env |
| Hostile peer on Tailscale | Connect to daemon over tailnet | JWT + Ed25519 required; Tailscale presence is not authn |
| Replay of stale attestation | Resubmit captured envelope | iat window + nonce_cache |
| Owner with revoked device | Continue submitting from revoked device | RLS + DEFINER RPCs check trust_level; daemon refuses |
| Cloud DB read by attacker | Get private keys | Private keys never written to DB; only public_key |
| Local file exfiltration | Read ~/.lenserfight/gateway/ | OS keychain holds the private key, not the directory |
| Kill-switch evasion | Continue executions after global pause | Daemon polls policy state; refuses to start on global_kill_switch = true; signed attestations rejected if device revoked/blocked/unhealthy |
| XP minting via forged attestation | Forge gateway_verified = true | Server ignores client booleans; xp.apply granted only to service_role; trigger requires server-verified attestation |
11. Audit chain
audit.hash_chains is extended (additive — new chain_kind = 'gateway'):
- Device trust transitions (
pending → approved → trusted → revoked). - Sync outbox flush batches (per-batch hash).
- Daemon lifecycle events (start, stop, key rotation).
Each new entry includes prev_hash, payload_hash, lenser_id, device_id, kind, created_at. Verification helpers shipped in libs/data/repositories/auditRepository.
12. Rollout
See docs/explanation/gateway/roadmap.md for the phased rollout (A → G).
The rollout is strictly additive until Phase F (attestation hardening), at which point client-supplied trust booleans are server-overwritten. A two-release deprecation window publishes a warning before flipping the verification gate.
Drawbacks
- Operator burden — running a daemon is a new requirement for verified local execution. Existing flows continue to work without it (CLI-only) but cannot reach
execution_verifiedorfully_trusted. - Keychain backend variance —
keytar's native bindings can be brittle on Linux without libsecret. The fallback file path is intentionally noisy. - Ed25519 in Postgres — requires either
pgsodium(Supabase has it) or an Edge Function. Cold-path latency for verification adds a few ms per attestation; acceptable. - Complexity surface — the LTG introduces sync, identity, and a daemon all at once. We mitigate via strict phasing and additive migrations.
Alternatives considered
- Stay with self-reported trust booleans, harden via documentation only. Rejected: anti-cheat invariants must be enforceable, not advisory.
- Use TLS client certs (mTLS) for device identity. Rejected for v1: certificate lifecycle is heavier than Ed25519 keys; we will revisit if peer-to-peer E2E becomes a goal.
- Adopt JOSE / JWS for envelopes. Considered. Rejected for v1 because we want minimal dependencies in the daemon and the database verification path; a small custom JCS+Ed25519 envelope is auditable in a few dozen lines. JWS remains a future option.
- Use CRDTs for all conflict-aware sync. Rejected for v1: per-class LWW-with-vector-clock covers the catalog and is much simpler. CRDTs may be revisited per object class as needed.
- Run the daemon inside the CLI process. Rejected: long-running and one-shot lifecycles must be separable so CI environments stay simple.
Unresolved questions
- U1 — Final port number for the daemon.
38080is a placeholder; we want a port that does not collide with common dev tools. - U2 — Whether to require
pgsodiumor to ship the verification path via an Edge Function only. Decision after Phase A. - U3 — Whether
agents.workspace_settings.runner_paused(docs) oragent_paused(code) is the canonical column name. Resolved inrequirements.mdand a follow-up migration that renames if needed. - U4 — Whether
devices.sync_outboxshould be partitioned bylenser_idfrom day one. Defer until we measure write rate. - U5 — Browser-based devices (the web app on a developer's machine) — are they "devices" in the LTG sense, or only daemons are? v1: only the daemon counts as a device for trust elevation; browsers remain
account_verifiedat most.
Implementation notes
Phased rollout, with each phase shipping behind feature flags and migration timestamps later than 20270511500000.
| Phase | Theme | Migrations | Apps / libs touched |
|---|---|---|---|
| A | Repair gaps | *_fn_runner_register_and_probe.sql, RLS tightening, execution.links reconciliation | apps/cli, eslint.config.js |
| B | Device identity | *_device_identity_and_heartbeat.sql | libs/types, libs/utils/signing, libs/utils/keychain, apps/cli/src/commands/gateway.ts |
| C | Sync engine | *_sync_outbox_and_watermarks.sql | libs/data/repositories/gateway*, libs/infra/gateway, apps/cli |
| D | Local daemon | — | apps/gateway (new), libs/infra/gateway |
| E | Tailscale transport | — | apps/gateway, libs/infra/gateway |
| F | Attestation hardening | *_signed_attestation_verification.sql, optional supabase/functions/verify-attestation/ | RPCs only |
| G | XP wiring | *_xp_rules_invocation_triggers.sql | DB triggers + DEFINER RPCs |
Acceptance criteria across phases:
- Every new RPC ships with
SECURITY DEFINER,SET search_path, explicitGRANT EXECUTE, RLS policies on touched tables, append-only triggers where appropriate, and at least one negative-path SQL test. lf gateway doctorexit code is non-zero on any failed precondition; CI runs it on a smoke-test profile.- No client may set
gateway_verified = truedirectly. service_roleis never present inapps/gateway/orapps/cli/runtime config.