feat(sync): random, server-issued, tenant-scoped vault identifiers - #324
Merged
Conversation
kafkade
enabled auto-merge (squash)
July 28, 2026 04:11
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.
Description
Every vault that syncs is known to the server by a vault identifier — it appears in the URL of every vault-scoped endpoint and is the first path segment of every blob (
{vault_id}/batches/…). Until now that identifier was derived, not stored: the CLI computed it as a djb2 hash of the vault directory path and recomputed it on every command, while the iOS and web apps asked the user to type one in.Because nearly every vault lives at the default path, nearly every user derived the same identifier — and it was a global primary key. The first account to register it claimed it server-wide; any other account then got a
409thatldgr sync setupsilently swallowed, after which every push and pull failed with404. The result was a permanent, unexplained denial of service on any shared or multi-tenant host, and one an attacker could trigger deliberately since the identifiers were trivially guessable and enumerable.This replaces that scheme with random, persisted, server-issued identifiers.
What's included
v1_plus 128 bits of CSPRNG output, minted once and persisted inside the vault (sync_state, mirroring the existingdevice_id). Nothing is derived from a path or anything else reproducible by a third party.POST /vaultsnow takes an optional id. Requesting one you already own returns it unchanged (setup stays idempotent); requesting a free one grants it; requesting one owned by another account mints a fresh substitute and returns that, instead of failing. A squatter gains nothing and a victim is never blocked.ldgr sync setupadopts the vault already registered to your account (prompting when you own several), andldgr devices joinidentifies the paired vault by trial-decrypting a batch with the key it just received. Previously two devices agreed only by accident, because they hashed the same default path.tenant_isolation.rssuite covers all nine vault-scoped endpoints (batches, snapshots, devices — read, write, list, delete) plus relay offers, asserting a second authenticated account is refused with404rather than403so the identifier namespace cannot be probed. Authorization was already enforced everywhere; these tests fail loudly if a future handler forgets the ownership check.ldgr sync pullcould report "Already up to date" while remote batches existed (the transport and the sync engine were handed different identifiers after a vault directory was moved), andldgr sync statusnow shows the vault ID for every provider.Compatibility
No existing user is orphaned from synced data. The server migration is a single guarded
CREATE UNIQUE INDEX IF NOT EXISTS— additive, no table rebuild, no blob rewrite, safe to roll back. Clients adopt the identifier an upgrading vault is already filed under, in order: stored value →sync-config.json→ the legacy path-derived value recomputed once and frozen (this is what keeps Dropbox/WebDAV vaults, which record no identifier anywhere, pointed at their existing blobs) → otherwise a fresh random one. Old client against a new server is unchanged; new client against an old server handles both that server's rejection modes.Related Issues
Closes #298
Relates to #312
While investigating, a separate pre-existing defect was found and reported as #323:
ldgr devices joininstalls the paired vault key as the session key but never re-keys the joining device's local store, so the firstsync pullafterwards fails. It is not caused or worsened by this PR and is deliberately left out of scope.Type of Change
Component
crates/ldgr-core/— Core library (crypto, accounting, storage)crates/ldgr-cli/— CLI toolcrates/ldgr-server/— Sync serverbindings/swift/— UniFFI Swift bindingsapps/ios/— iOS / iPadOS / watchOS appapps/web/— Web app (Next.js + WASM)docs/— DocumentationAlso touches
crates/ldgr-ffi/andcrates/ldgr-wasm/, which gainlist_vaultsand now return the authoritative identifier fromcreate_vault(the WASM binding previously discarded it).Privacy Checklist
randCSPRNG path already used for key material; no new primitivesChecklist
cargo test --workspace) — 792 passed, 0 failedcargo clippy --workspace -- -D warnings) — cleancargo fmt --check) — cleanAlso verified beyond the required gates: WASM bundle 266 KB gzip (2 MB budget),
apps/web32/32 tests and static export,swift build+swift testinbindings/swift, and the macOS app target builds.