Skip to content

feat(sync): random, server-issued, tenant-scoped vault identifiers - #324

Merged
kafkade merged 1 commit into
mainfrom
kafkade-tenant-scoped-vault-ids
Jul 28, 2026
Merged

feat(sync): random, server-issued, tenant-scoped vault identifiers#324
kafkade merged 1 commit into
mainfrom
kafkade-tenant-scoped-vault-ids

Conversation

@kafkade

@kafkade kafkade commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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 409 that ldgr sync setup silently swallowed, after which every push and pull failed with 404. 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

  • Unguessable identifiersv1_ plus 128 bits of CSPRNG output, minted once and persisted inside the vault (sync_state, mirroring the existing device_id). Nothing is derived from a path or anything else reproducible by a third party.
  • Squatting is impossible by constructionPOST /vaults now 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.
  • Devices converge by adopting, not by re-derivingldgr sync setup adopts the vault already registered to your account (prompting when you own several), and ldgr devices join identifies 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.
  • No typed identifiers — the free-text "Vault ID" field is gone from the iOS and web sync settings, replaced by the same adopt-or-create flow.
  • Cross-tenant authorization tests — a new tenant_isolation.rs suite covers all nine vault-scoped endpoints (batches, snapshots, devices — read, write, list, delete) plus relay offers, asserting a second authenticated account is refused with 404 rather than 403 so the identifier namespace cannot be probed. Authorization was already enforced everywhere; these tests fail loudly if a future handler forgets the ownership check.
  • Two latent bugs fixed along the wayldgr sync pull could 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), and ldgr sync status now 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 join installs the paired vault key as the session key but never re-keys the joining device's local store, so the first sync pull afterwards fails. It is not caused or worsened by this PR and is deliberately left out of scope.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Documentation update
  • Refactoring (no functional changes)
  • CI / infrastructure
  • Other (describe below)

Component

  • crates/ldgr-core/ — Core library (crypto, accounting, storage)
  • crates/ldgr-cli/ — CLI tool
  • crates/ldgr-server/ — Sync server
  • bindings/swift/ — UniFFI Swift bindings
  • apps/ios/ — iOS / iPadOS / watchOS app
  • apps/web/ — Web app (Next.js + WASM)
  • docs/ — Documentation

Also touches crates/ldgr-ffi/ and crates/ldgr-wasm/, which gain list_vaults and now return the authoritative identifier from create_vault (the WASM binding previously discarded it).

Privacy Checklist

  • No plaintext financial data is sent to or stored on the server — identifiers are opaque handles; blob contents are unchanged AES-256-GCM ciphertext
  • No new metadata exposure introduced (or documented if unavoidable) — this reduces exposure: identifiers no longer leak that they are path-derived, and are no longer enumerable or shared across accounts
  • Any new external service interaction is documented in the trust boundary model — N/A, no new external service interactions; the threat model gains a "co-tenant on a shared sync server" actor covering the surface this PR addresses
  • Crypto changes use audited RustCrypto crates — no custom primitives — identifier generation uses the existing rand CSPRNG path already used for key material; no new primitives
  • Key material is never exposed in error messages, logs, or Debug output — vault identifiers are not key material, and no new logging of secrets was added

Checklist

  • I have read CONTRIBUTING.md
  • Tests pass (cargo test --workspace) — 792 passed, 0 failed
  • Clippy passes (cargo clippy --workspace -- -D warnings) — clean
  • Formatting passes (cargo fmt --check) — clean
  • I have updated documentation (if applicable) — new ADR plus the sync-setup, self-hosting, and threat-model guides; markdownlint clean

Also verified beyond the required gates: WASM bundle 266 KB gzip (2 MB budget), apps/web 32/32 tests and static export, swift build + swift test in bindings/swift, and the macOS app target builds.

@kafkade
kafkade enabled auto-merge (squash) July 28, 2026 04:11
@kafkade
kafkade merged commit ceee03b into main Jul 28, 2026
14 checks passed
@kafkade
kafkade deleted the kafkade-tenant-scoped-vault-ids branch July 28, 2026 04:15
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.

M0/0d: Stable, tenant-scoped vault identifiers

1 participant