Skip to content

fix(keystore): key iOS keystore salt by a stable id, not the DB path - #2526

Open
sbra0902 wants to merge 1 commit into
wireapp:mainfrom
sbra0902:fix/ios-keystore-salt-stable-id
Open

fix(keystore): key iOS keystore salt by a stable id, not the DB path#2526
sbra0902 wants to merge 1 commit into
wireapp:mainfrom
sbra0902:fix/ios-keystore-salt-stable-id

Conversation

@sbra0902

@sbra0902 sbra0902 commented Sep 3, 2026

Copy link
Copy Markdown

fix(keystore): key the iOS SQLCipher salt by a stable id, not the DB path

Problem

On iOS the keystore (Proteus and MLS) fails to open after the app's data-container path changes,
with:

CoreCryptoException.Mls: MlsException.Other: Error code 1: SQL error or missing database

This happens on reinstalls, updates, restores, migrations, and dev rebuilds/redeploys. The database
file itself is intact and migrates with the container — only the salt lookup fails.

Root cause

To keep iOS from killing backgrounded apps that hold a WAL database, we set
cipher_plaintext_header_size = 32 so the SQLite format 3\0 header stays readable. That plaintext
header displaces SQLCipher's in-file salt, so the salt is stored externally in the iOS keychain.

It was keyed by the database's absolute path: keystore_salt_<sha256(absolute_path)> (service
wire.com). iOS does not guarantee a stable container path — the …/Application/<UUID>/… segment
changes when the container is relocated. New path ⇒ different key ⇒ the salt is no longer found ⇒
SQLCipher derives the wrong key ⇒ open fails.

Fix

Key the salt by a stable, path-independent id instead of the path.

  • A random 128-bit id is stored once in a sidecar file next to the database (<db>.salt-id). The
    sidecar lives in the same container, so it travels with the database and the derived key survives
    any relocation.
  • The salt is stored under keystore_salt_v2_<id>.
  • Non-destructive migration / fallback: when nothing is found under the stable key, we fall back
    to reading the old keystore_salt_<sha256(path)> entry, adopt that salt, and copy it under the
    stable key. The legacy entry is never deleted or overwritten, so an interrupted migration (or a
    lost sidecar) can always fall back to it again.
  • A brand-new keystore persists SQLCipher's freshly generated salt directly under the stable key.

The sidecar is written atomically (temp file + rename); a present-but-corrupt (wrong-length) sidecar
fails loud rather than silently minting a new id on top of a keystore whose salt could then no longer
be located.

Why this is safe for existing installs

  • Existing installs keep working: their salt is found via the untouched legacy key on first open and
    copied to the stable key.
  • Nothing is deleted, so a partial/interrupted migration is always recoverable.
  • The caller-provided path (not the SQLite-canonicalized Connection::path()) is used, so relative
    vs. absolute paths still resolve the sidecar and legacy key consistently.

Verification

  • Unit tests (host, keystore::connection::ios_salt_id): create/read roundtrip, idempotency,
    id + keychain key survive a container move (the exact bug scenario), corrupt-sidecar error, no
    leftover temp files, legacy key matches the historical scheme, v2 key format.
  • Compile + clippy clean for aarch64-apple-ios.
  • Independent root-cause confirmation: an app-layer workaround in natrium
    (healKeystoreSaltAfterContainerMove) empirically confirms both the diagnosis and the mechanism —
    it copies the salt from the old-path key to the current-path key on a home-dir change, which
    resolves the same failure. This fix moves the fix down to core-crypto so every consumer is covered
    and such app-layer workarounds can be retired.

Files

  • keystore/src/connection/ios_salt_id.rs — new: sidecar-id + keychain-key derivation (+ unit tests).
  • keystore/src/connection/ios_wal_compat.rs — key by the stable id with the non-destructive legacy
    fallback.
  • keystore/src/connection/mod.rs — register the module; note on using the caller path.

On iOS the SQLCipher salt is kept in the keychain, since the plaintext header
needed for WAL background access displaces the in-file salt. It was keyed by the
database's absolute path, but iOS relocates the app data container (the path
UUID changes on reinstall, update, restore and migration), so the salt is no
longer found and the keystore fails to open with "SQL error or missing
database".

Key the salt by a stable, path-independent id kept in a sidecar file next to the
database instead. Pre-existing installs fall back to the legacy path-derived key
(never deleted) and copy the salt under the stable key; brand-new keystores
store the freshly generated salt directly.
@sbra0902
sbra0902 requested a review from a team September 3, 2026 13:17
@typfel

typfel commented Sep 4, 2026

Copy link
Copy Markdown
Member

Hi @sbra0902

Before we fix anything I'd like to understand the use case. You have a use case where you are moving the core crypto database around?

@sbra0902

sbra0902 commented Sep 4, 2026

Copy link
Copy Markdown
Author

Hi @sbra0902

Before we fix anything I'd like to understand the use case. You have a use case where you are moving the core crypto database around?

No — we're not moving the database. iOS relocates the app's data container, and CoreCrypto keys the SQLCipher salt in the keychain by the keystore's absolute path (keystore_salt_<sha256(abs_path)>, service wire.com). When iOS changes the …/Application//… segment (reinstall, dev redeploy, restore, device transfer, some OS migrations), the DB file migrates with the container and is intact — but the salt is still keyed under the old path, so open fails with MlsException.Other: Error code 1: SQL error or missing database.

The app is doing the right thing: it resolves the container path fresh each launch and never persists it. The bug is that a transient absolute path got baked into a persistent keychain key. Same binary, no reinstall (path unchanged) → works; any container relocation → fails. Verified on device:

before: …/Application/E6B1DD90-CFF9-43CD-9854-B5F6037B4AED/…
after : …/Application/CF0C162D-B6F4-44D9-929D-ABBFFEC344DF/…

This is documented iOS behaviour, and Apple's recommended mitigations are exactly the options on the table:

▎ "the path to … your app's container … can change. There's no way to prevent this. Your app must be able to cope with this … Store a path relative to the root of your container … [or] store an absolute path but be prepared to 'fix' that when the container changes path."
▎ — Quinn "The Eskimo!" (Apple DTS), Developer Forums 92693 (https://developer.apple.com/forums/thread/92693); see also TN2406 (https://developer.apple.com/library/archive/technotes/tn2406/_index.html)

This PR takes the first option: key the salt by a stable, container-relative id, with a non-destructive fallback to the old path key so existing installs keep working. If you'd prefer to solve it at the API boundary (caller passes a stable id instead of the absolute path), we're happy to go that way instead.

@SimonThormeyer

Copy link
Copy Markdown
Member

To be precise, CoreCrypto doesn’t necessarily key the SQLCipher salt by the keystore’s absolute path. It hashes the database-path string supplied by the caller verbatim. That may be either relative or absolute; in fact, the code deliberately avoids SQLite’s canonicalized absolute path. A stable relative path would avoid the container-UUID problem, provided it is resolved consistently across launches.

@sbra0902

sbra0902 commented Sep 4, 2026

Copy link
Copy Markdown
Author

To be precise, CoreCrypto doesn’t necessarily key the SQLCipher salt by the keystore’s absolute path. It hashes the database-path string supplied by the caller verbatim. That may be either relative or absolute; in fact, the code deliberately avoids SQLite’s canonicalized absolute path. A stable relative path would avoid the container-UUID problem, provided it is resolved consistently across launches.

You're right — CoreCrypto hashes the caller's string verbatim and deliberately doesn't canonicalize, so a stable string would give a stable salt key.

The crux is that the path has two jobs: it's both where the database file lives and the input to the salt key. In open_internal the same string goes to std::fs::exists(path) and Connection::open(path), and only then to the salt keying:

let exists = std::fs::exists(path)?;
let mut conn = Connection::open(path)?;
...
handle_ios_wal_compat(&conn, path)?; // salt key = sha256(path)

On iOS that couples them. SQLite (and Rust std::fs) resolve a relative filename against the process working directory, not the app container — and an iOS app's working directory isn't its container. So a relative path makes the database resolve outside the sandbox and open/create fails. To make a relative path land in the container you'd have to chdir() the whole process into it on every launch — which still requires resolving the absolute, UUID-bearing container path first, and leans on fragile process-global CWD state that doesn't fit the kalium storage layer.

So the string we can actually pass is the absolute container path (NSHomeDirectory()/...), and that's what ends up keying the salt — hence the instability. The fix decouples the two roles: keep the absolute path for file I/O, but key the salt by a stable, container-relative id (this PR), or add a stable-id parameter to the API. Same outcome; the PR just avoids the API change.

@typfel

typfel commented Sep 4, 2026

Copy link
Copy Markdown
Member

Ok, we think this makes sense but we'll discuss internally a bit how we want do it.

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.

3 participants