Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Resolving a sync conflict as "keep remote" now actually re-applies the remote change (via the Swift/iOS bindings it previously only marked the conflict resolved without materializing the remote version)
- Sync against a self-hosted `ldgr-server` now lists and pulls every encrypted batch and snapshot in large vaults: blob listings were previously capped at a single server page (~1000 entries), so a vault with more than one page of batches or snapshots would silently drop — and never pull — the overflow. The client now follows the server's continuation cursor across all pages
- The `apps/web` admin panel static export builds again: the build now targets the webpack compiler the project's WASM config requires, and Tailwind CSS is aligned to the v3 toolchain its config is written against, so `npm run build` produces the static panel instead of failing
- `ldgr sync pull` no longer reports "Already up to date" while remote batches exist. The CLI built its server transport from the vault id recorded in `sync-config.json` but handed the push/pull engine a freshly recomputed one; when those diverged (after moving or renaming a vault directory) the transport matched neither list prefix and silently returned an empty page. Both now come from a single persisted identifier
- `ldgr sync status` now shows the vault ID for every provider, not just when the config happens to record one

### Security

- The local working store (`vault.db`) is now encrypted at rest with SQLCipher (AES-256): the database is keyed by a subkey derived from your vault key via HKDF, so account names, transaction descriptions, amounts, and commodities are no longer readable from disk without your master password. This applies to both the CLI and the shared Rust core used by the iOS/iPadOS app, so anyone with filesystem access to a locked vault — including another local user or a stolen locked laptop — sees only ciphertext. Existing plaintext CLI vaults are upgraded with `ldgr migrate`
- The unlocked session key is no longer written to a plaintext `session.json`; it is stored in the operating system keystore (macOS Keychain, Linux kernel keyring, or Windows Credential Manager) while `session.json` holds only non-secret metadata. `ldgr lock` now removes the key from the keystore and clears the session, so locking renders the working store unreadable rather than merely dropping a marker
- Vault identifiers are now random, unguessable, and scoped to the account that owns them, so accounts can no longer collide or interfere on a shared server (ADR-011). Previously the CLI derived a vault's identifier as a djb2 hash of its **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, so the first account to register it claimed it server-wide. Any other account then got a conflict that `ldgr sync setup` silently swallowed, after which every push and pull failed with a 404: a permanent, unexplained denial of service, and one an attacker could trigger deliberately since the identifiers were trivially guessable. Identifiers are now 128 bits of CSPRNG output, minted once and persisted inside the vault, and a request for an identifier another account already owns is answered with a freshly minted one instead of a conflict — so squatting achieves nothing and no account can be locked out. Existing vaults keep their identifiers (including Dropbox/WebDAV vaults, which store none, by adopting their path-derived value once and freezing it), so nothing already synced is orphaned. A new device now converges by *adopting* your vault — `ldgr sync setup` adopts the vault registered to your account (asking which, if you have several), and `ldgr devices join` identifies the paired vault by trial-decrypting a batch with the key it just received — instead of relying on two devices happening to hash the same path. The typed "Vault ID" field is gone from the iOS and web sync settings
- Explicit cross-tenant authorization tests now cover every vault-scoped endpoint — batches, snapshots, and devices (read, write, list, and delete) — plus relay offers, asserting that a second authenticated account is refused with `404 Not Found` rather than `403 Forbidden` so the identifier namespace cannot be probed for existence. Authorization was already enforced on all of these; the tests fail loudly if a future handler forgets the ownership check
- First-admin bootstrap is now atomic on servers with no `LDGR_ADMIN_EMAIL` configured: the first registered user is elected admin inside a single guarded database transaction, so under concurrent sign-ups at most one admin can ever be created. The previous fallback counted existing users and inserted the new user in separate steps, so two simultaneous registrations could both read zero users and both become admin (and on an unconfigured internet-exposed instance, whoever registered first silently became admin). The env-seeded `LDGR_ADMIN_EMAIL` path is unchanged

## [1.2.0] - 2026-05-30
Expand Down
52 changes: 38 additions & 14 deletions apps/ios/ldgr/Sources/Views/Sync/SyncSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ struct SyncSettingsView: View {
// Server connection form (non-secret fields are persisted; secrets are not).
@State private var baseURL = ""
@State private var username = ""
/// Identifier this device's vault is known by on the server. Issued by the
/// server or adopted from the account (ADR-011) — never typed by the user.
@State private var vaultId = ""
@State private var password = ""
@State private var secretKeyInput = ""
Expand Down Expand Up @@ -142,7 +144,11 @@ struct SyncSettingsView: View {
private var connectedServerView: some View {
LabeledContent("Server", value: baseURL)
LabeledContent("Account", value: username)
LabeledContent("Vault", value: vaultId)
LabeledContent("Vault") {
Text(vaultId)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Button(role: .destructive) {
signOut()
} label: {
Expand Down Expand Up @@ -191,11 +197,6 @@ struct SyncSettingsView: View {
.autocorrectionDisabled()
SecureField("Password", text: $password)
.textContentType(.password)
TextField("Vault ID", text: $vaultId)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()

if info.twoSecretAuth {
twoSecretButtons(info)
Expand Down Expand Up @@ -297,9 +298,7 @@ struct SyncSettingsView: View {
// MARK: - Form helpers

private var canSubmit: Bool {
!username.trimmingCharacters(in: .whitespaces).isEmpty
&& !vaultId.trimmingCharacters(in: .whitespaces).isEmpty
&& !password.isEmpty
!username.trimmingCharacters(in: .whitespaces).isEmpty && !password.isEmpty
}

private func loadConfig() {
Expand Down Expand Up @@ -451,7 +450,7 @@ struct SyncSettingsView: View {

// MARK: - Shared sign-in tail

/// Persist the session token + device id, enroll the vault, save the config,
/// Persist the session token + device id, claim the vault, save the config,
/// and (re)configure the sync manager. Shared by every auth path.
private func finishSignIn(
session: LdgrSyncSession,
Expand All @@ -462,23 +461,48 @@ struct SyncSettingsView: View {
throw SyncSettingsError.noToken
}

// Best-effort enrollment: create the vault if it doesn't exist yet.
_ = try? await session.createVault(vaultId: vault)

let resolvedVault = try await claimVault(session: session, existing: vault)
let deviceId = try client.syncStatus().deviceId

try KeychainManager.storeServerAuthToken(token)
try KeychainManager.storeServerDeviceId(deviceId)
ServerConfigStore.save(
ServerConfig(baseURL: baseURL, username: user, vaultId: vault)
ServerConfig(baseURL: baseURL, username: user, vaultId: resolvedVault)
)
vaultId = resolvedVault

password = ""
serverInfo = nil
syncManager.configure(client: client)
await syncManager.refreshStatus(client: client)
}

/// Decide which server vault this device syncs to, and claim it (ADR-011).
///
/// A device that has already synced keeps its identifier. One that has not
/// adopts the account's existing vault rather than creating a second, empty
/// one it would never converge out of. With several vaults on the account
/// the newest is taken, which is right for the case that actually occurs —
/// a fresh device joining the vault its owner most recently created.
///
/// The identifier the server returns is authoritative: when the requested
/// one is already owned by another account the server mints a substitute
/// instead of failing, so no account can lock another out.
private func claimVault(session: LdgrSyncSession, existing: String) async throws -> String {
var requested: String? = existing.isEmpty ? nil : existing

if requested == nil {
// A listing failure must not be read as "this account owns no
// vaults" — that would mint a second, empty vault and persist it,
// stranding this device away from the user's real data with no way
// back. Fail the sign-in instead so it can simply be retried.
let owned = try await session.listVaults()
requested = owned.max(by: { $0.createdAt < $1.createdAt })?.id
}

return try await session.createVault(vaultId: requested)
}

private func signOut() {
try? KeychainManager.deleteServerAuthToken()
try? KeychainManager.deleteServerDeviceId()
Expand Down
133 changes: 106 additions & 27 deletions apps/web/src/app/vault/settings/SyncSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
import { useVault } from '@/contexts/VaultContext';
import EmergencyKitView from '@/components/EmergencyKitView';
import type { EmergencyKit, ServerInfo } from '@/lib/wasm';
import { NEW_VAULT, type RemoteVault } from '@/lib/sync';

const cardClass =
'rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-5 space-y-3';
Expand Down Expand Up @@ -32,12 +33,21 @@ export default function SyncSettings() {
signInServer,
logoutServer,
createRemoteVault,
listRemoteVaults,
sync,
resolveSyncConflict,
} = useVault();

const [serverUrl, setServerUrl] = useState('');
// The vault identifier is issued by the server and adopted from the account
// (ADR-011) — never typed. `vaultId` is empty until this browser has claimed
// or adopted one.
const [vaultId, setVaultId] = useState('');
const [ownedVaults, setOwnedVaults] = useState<RemoteVault[]>([]);
// Distinguishes "no vaults on this account" from "we haven't looked yet", so
// the connect button can't be clicked into minting a vault before the picker
// has had a chance to appear.
const [vaultsLoading, setVaultsLoading] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [secretKey, setSecretKey] = useState('');
Expand All @@ -56,6 +66,32 @@ export default function SyncSettings() {
}
}, [serverConfig]);

// Once signed in, discover which vaults the account already owns so a browser
// with no identifier of its own can adopt one instead of stranding itself in a
// second, empty vault.
useEffect(() => {
if (!serverAuthenticated || vaultId) {
setOwnedVaults([]);
setVaultsLoading(false);
return;
}
let cancelled = false;
setVaultsLoading(true);
listRemoteVaults()
.then((vaults) => {
if (!cancelled) setOwnedVaults(vaults);
})
.catch(() => {
if (!cancelled) setOwnedVaults([]);
})
.finally(() => {
if (!cancelled) setVaultsLoading(false);
});
return () => {
cancelled = true;
};
}, [serverAuthenticated, vaultId, listRemoteVaults]);

const run = async (label: string, fn: () => Promise<void>) => {
setBusy(label);
setError(null);
Expand Down Expand Up @@ -113,10 +149,23 @@ export default function SyncSettings() {
setMessage('Logged in.');
});

// Claim a vault for this browser. With no identifier yet and exactly one
// vault on the account, `createRemoteVault` adopts it; otherwise the server
// mints a fresh, random, unguessable one (ADR-011).
const handleCreateVault = () =>
run('create-vault', async () => {
await createRemoteVault();
setMessage('Remote vault ready.');
const granted = await createRemoteVault(
ownedVaults.length > 1 ? NEW_VAULT : undefined,
);
setVaultId(granted);
setMessage(`Vault ready: ${granted}`);
});

const handleAdoptVault = (id: string) =>
run('create-vault', async () => {
const granted = await createRemoteVault(id);
setVaultId(granted);
setMessage(`Now syncing vault ${granted}.`);
});

const handleSync = () =>
Expand Down Expand Up @@ -175,18 +224,14 @@ export default function SyncSettings() {
}}
/>
</label>
<label className="block text-xs text-[var(--color-text-secondary)]">
Vault ID
<input
className={inputClass}
type="text"
placeholder="my-vault"
value={vaultId}
onChange={(e) => setVaultId(e.target.value)}
/>
</label>
</div>

{vaultId && (
<p className="text-xs text-[var(--color-text-secondary)]">
Vault <span className="font-mono">{vaultId}</span>
</p>
)}

{!serverInfo && (
<button
className={btnPrimary}
Expand Down Expand Up @@ -374,21 +419,55 @@ export default function SyncSettings() {
</div>

{serverAuthenticated && (
<div className="flex flex-wrap gap-2 border-t border-[var(--color-border)] pt-3">
<button
className={btnSecondary}
onClick={handleCreateVault}
disabled={busy !== null || !vaultId}
>
{busy === 'create-vault' ? 'Creating…' : 'Create remote vault'}
</button>
<button
className={btnPrimary}
onClick={handleSync}
disabled={busy !== null || syncing || !vaultId}
>
{syncing || busy === 'sync' ? 'Syncing…' : '🔄 Sync now'}
</button>
<div className="space-y-2 border-t border-[var(--color-border)] pt-3">
{/* Several vaults on one account (#296): the user picks which one
this browser syncs to, rather than typing an identifier. */}
{!vaultId && ownedVaults.length > 1 && (
<div className="space-y-1">
<p className="text-xs text-[var(--color-text-secondary)]">
This account has more than one vault. Choose the one to sync
in this browser:
</p>
{ownedVaults.map((v) => (
<button
key={v.id}
className={`${btnSecondary} w-full text-left font-mono`}
onClick={() => handleAdoptVault(v.id)}
disabled={busy !== null}
>
{v.id}{' '}
<span className="opacity-60">
· created {v.created_at.slice(0, 10)}
</span>
</button>
))}
</div>
)}

<div className="flex flex-wrap gap-2">
{!vaultId && (
<button
className={btnSecondary}
onClick={handleCreateVault}
disabled={busy !== null || vaultsLoading}
>
{vaultsLoading
? 'Checking…'
: busy === 'create-vault'
? 'Connecting…'
: ownedVaults.length > 1
? 'Create a new vault instead'
: 'Connect this browser to a vault'}
</button>
)}
<button
className={btnPrimary}
onClick={handleSync}
disabled={busy !== null || syncing || !vaultId}
>
{syncing || busy === 'sync' ? 'Syncing…' : '🔄 Sync now'}
</button>
</div>
</div>
)}
</>
Expand Down
Loading
Loading