Skip to content

fix(sync): provider API keys never leave the device they were entered on, and a peer's address change waits for approval - #790

Merged
kh0pper merged 4 commits into
mainfrom
fix/provider-keys-stay-local
Nov 6, 2026
Merged

kh0pper merged 4 commits into
mainfrom
fix/provider-keys-stay-local

Conversation

@kh0pper

@kh0pper kh0pper commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Security audit N1b-7, following the operator's ruling: provider settings sync, keys do not.

Before this change, the providers table synced to every paired device with api_key in plaintext, and a peer could write rows back. So one compromised peer got every key and could overwrite a local key with its own.

Keys stay local. One map, LOCAL_ONLY_COLUMNS = { providers: ["api_key"] } in instance-sync.js, drives every door.

Outbound, the key is stripped from every emit path:

  • emitChange (exclusions and the outbound transform);
  • the stdio outbox queue and its drain;
  • upsertProvider and disableProvider;
  • the new-peer backfill;
  • a conflict restore.

There is no full resync, history send or per-row digest for providers. The report checked convergence.js and sync-stamp.js, and neither hashes rows.

Inbound:

  • A key is dropped after the signature check and before anything is applied. An old or hostile peer can't write or overwrite a local key, and a missing key on the wire never clears one.
  • The equivalence check never compares the key, so two devices with different keys aren't divergent.

Needs a key here. A provider that arrives from a peer, needs a key (a cloud preset or SDK type, not a LAN llama.cpp), and has none on this device is named once in the bell, with a link to the providers settings. Entering a key quiets it. The key field now says "Keys stay on this device; they are not shared with paired devices." A peer's row without a key here says "Needs an API key on this device." (en + es)

A peer's address change waits for approval (operator ruling, 2026-11-06). The provider URL still syncs, so without this a compromised peer could repoint a provider and this device would send its key there.

  • An inbound insert or update that moves the endpoint (scheme, host, port or path; a trailing slash is ignored) of a provider that holds a key on this device is not applied. It is recorded in the existing sync_conflicts table, with this device's row kept, and named once in the bell.
  • The conflicts page says: "Address change: old → new. Accepting sends this device's API key to the new address."
  • Accepting is the existing restore, so it keeps the local key. A newer address from the same peer replaces the held one, and a re-delivered entry raises nothing.
  • Keyless providers update as before.
  • The stored conflict never contains the local key, because the conflicts page and crow_list_sync_conflicts both show it.

There is no schema change.

Tests (tests/providers-key-locality.test.js, real InstanceSyncManager, real signed entries):

  • two instances, both directions: B gets the provider without A's key; B's own key and A's key both survive later syncs and model edits;
  • an old peer's inbound key never writes on insert or overwrites on update;
  • an update that omits the key keeps it;
  • a conflict restore never writes a key back;
  • every emit path's wire payload has no key;
  • the needs-a-key rules;
  • the settings line;
  • the full endpoint-hold set: held, replaced, accepted (keeps the key), dismissed, keyless applies, models-only applies, trailing slash, insert collision, page line escaped, no key in the stored conflict.

Review follow-ups (two more rounds):

  • Stripped on write. No stored sync conflict ever holds a key. Every snapshot is stripped of local-only columns where it is written: one choke point in _insertConflictRow, plus the held-change replace and the restore's stale check. The ordinary conflict path used to store this device's key.
  • Stripped on read. crow_list_sync_conflicts and the conflicts page also strip keys when they read a snapshot, so a host that never runs the gateway shows no key either.
  • Older rows cleaned at boot. A boot step removes keys from conflict rows saved before this change. It never deletes a row, never changes resolved, reads only rows that can contain a key, and writes back only if the row is unchanged since it was read.
  • The "needs a key" notice rings once per provider, even after it has been read or dismissed. Entering a key re-arms it.
  • The address hold is tested in both directions.

Host: 971 pass, 0 fail across the provider, sync, instance-sync, convergence, sync-conflict, peer-trust, boot, instances, i18n, design-system and public-hygiene files.

Not in this PR (each tracked separately):

  • AI, voice, speech-to-text and vision profile blobs also carry keys and still sync. The operator ruled they get the same rule in a separate PR next.
  • Context-section sync (the home box decides) is its own item.
  • syncProvidersFromModelsJson can clear a key typed on this device. That is a pre-existing local write path.

https://claude.ai/code/session_015Vve3soBJ9qTcyeCCLyYyh

Provider rows still sync to paired devices (name, URL, models), but the
API key is stripped from every outbound path: live emits, the stdio
outbox, the new-peer backfill, the drain replay and a conflict restore.
An inbound row's key is dropped before it is applied, so an older or
hostile peer can never write or overwrite a local key, and a missing key
on the wire never clears one. Keys are not compared, so two devices with
different keys are not divergent.

A provider that arrives from a peer, needs a key and has none here is
named once in the bell, linking to the providers settings, and the key
field says keys stay on this device.

Claude-Session: https://claude.ai/code/session_015Vve3soBJ9qTcyeCCLyYyh
…e waits for approval

An inbound change that moves a provider's endpoint, on a device that holds
a key for it, is no longer applied. It is recorded as a sync conflict with
this device's row kept, named once in the bell, and the conflicts page
says that accepting sends this device's key to the new address. A newer
address from the same peer replaces the held one, a re-delivered entry
raises nothing, and keyless providers update as before. The stored
conflict never contains the local key.

Claude-Session: https://claude.ai/code/session_015Vve3soBJ9qTcyeCCLyYyh
… ones are cleaned at boot

Review follow-ups: every conflict snapshot is stripped of local-only
columns where it is written (one choke point in _insertConflictRow, plus
the held-change replace and the restore's stale check); a boot step
removes keys from conflict rows saved before this change, never deleting
a row; the needs-a-key notice rings once per provider, even after it is
read or dismissed; the address hold is tested in both directions.

Claude-Session: https://claude.ai/code/session_015Vve3soBJ9qTcyeCCLyYyh
@kh0pper
kh0pper force-pushed the fix/provider-keys-stay-local branch from 72b2742 to bbf6691 Compare November 6, 2026 15:29
…, too

Review follow-ups: crow_list_sync_conflicts and the conflicts page strip
local-only columns before showing a snapshot, so an old row on a host that
never ran the gateway's boot redaction shows no key; a 'null' snapshot no
longer throws; the boot redaction reads only rows that can hold a key and
writes back only if the row is unchanged since it was read.

Claude-Session: https://claude.ai/code/session_015Vve3soBJ9qTcyeCCLyYyh
@kh0pper
kh0pper merged commit 4e3b8e5 into main Nov 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant