Repository navigation
fix(sync): provider API keys never leave the device they were entered on, and a peer's address change waits for approval - #790
Merged
Conversation
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
force-pushed
the
fix/provider-keys-stay-local
branch
from
November 6, 2026 15:29
72b2742 to
bbf6691
Compare
…, 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security audit N1b-7, following the operator's ruling: provider settings sync, keys do not.
Before this change, the
providerstable synced to every paired device withapi_keyin 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"] }ininstance-sync.js, drives every door.Outbound, the key is stripped from every emit path:
emitChange(exclusions and the outbound transform);upsertProvideranddisableProvider;There is no full resync, history send or per-row digest for providers. The report checked
convergence.jsandsync-stamp.js, and neither hashes rows.Inbound:
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.
sync_conflictstable, with this device's row kept, and named once in the bell.crow_list_sync_conflictsboth show it.There is no schema change.
Tests (
tests/providers-key-locality.test.js, realInstanceSyncManager, real signed entries):Review follow-ups (two more rounds):
_insertConflictRow, plus the held-change replace and the restore's stale check. The ordinary conflict path used to store this device's key.crow_list_sync_conflictsand the conflicts page also strip keys when they read a snapshot, so a host that never runs the gateway shows no key either.resolved, reads only rows that can contain a key, and writes back only if the row is unchanged since it was read.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):
syncProvidersFromModelsJsoncan clear a key typed on this device. That is a pre-existing local write path.https://claude.ai/code/session_015Vve3soBJ9qTcyeCCLyYyh