EncryptedSharedPreferences — and survives an app restart.
Quick Start
1
Build the mobile shell — you already get the native store
There is no SDK call to make.
secretsFor(kind, bridge) in app/src/platform.ts picks the keychain for you the moment the app runs inside the Tauri shell.2
Paste a key in Settings
Under Engine → OpenAI API key, paste the key and blur the field. It is written straight to the keychain, and the presence label flips to Configured.
3
Restart the app
Reopen the app. The key is still there — the settings row reads Configured, and the next message is authenticated with no re-entry. See API Keys.
This page is for mobile-app builders — how the store is picked and why. For the end-user flow of pasting and rotating a key, read API Keys.
How It Works
A pasted key travels from the webview through Tauri IPC to a Rust command, which writes it to the platform’s native store. The webview reaches the store through four commands, declared once insrc-tauri/src/secrets.rs and matched on the TS side in adapters/src/tauri/secrets.ts.
The four names are pinned to agree across the seam by
tools/secrets-seam.test.mjs ("the four secret command names agree across the seam"), and each is registered with Tauri in src-tauri/src/lib.rs ("every command the adapter can send is registered with Tauri").
Per-platform behaviour
The store is picked from the detected platform kind; an unsupported host stores nothing.
Because the plugin refuses rather than falling back on an unsupported platform, “a secret stored through this adapter at all is in a hardware-backed store” is an invariant — which is why
isHardwareBacked can be answered synchronously during the first paint, with no async probe. Source: adapters/src/tauri/secrets.ts header, secretsFor in app/src/platform.ts.
Design decisions
Why not tauri-plugin-stronghold
Why not tauri-plugin-stronghold
Stronghold is a password-encrypted file, not a hardware store.
isHardwareBacked could not honestly become true, and the encryption password would itself have to be kept somewhere — the same problem one indirection down. Source: comment block at the top of src-tauri/plugins/secrets/src/lib.rs.Why not the keyring crate
Why not the keyring crate
The
keyring crate has macOS, Windows and Linux backends but no Android backend — half the platforms this app ships to. A store that works on the desktop dev machine and silently has nowhere to go on a phone is worse than an honest refusal.Why refuse on an unsupported platform instead of falling back
Why refuse on an unsupported platform instead of falling back
A silent fallback to an encrypted file would make
isHardwareBacked lie. Refusing keeps the invariant “anything stored here is in a hardware store”, so the settings warning and the isHardwareBacked flag stay honest without an async platform probe.Why has() is a separate command
Why has() is a separate command
has: (ref) => (await get(ref)) !== null returns the right booleans and copies the user’s key into the webview heap on every settings repaint. secret_has is its own command: on Apple it uses a presence query with no kSecReturnData, on Android contains(keyFor(...)), so the value never crosses the FFI boundary. Pinned by "presence has its own command on every side of the seam" and "asking whether a key is configured never sends the read command".Why the SecretSlot union lives in Rust too
Why the SecretSlot union lives in Rust too
A TS-only union is erased at runtime, so the webview could hand any string as a service name.
secrets.rs repeats the five slots as a pub const SLOTS allowlist and refuses anything else, so the two can never drift. Pinned by "the Rust slot allowlist and the port's closed union are the same five" and secrets::tests::a_slot_outside_the_union_is_refused_rather_than_named.Why the Android write is commit(), not apply()
Why the Android write is commit(), not apply()
apply() is asynchronous: it returns before the bytes hit disk, so a key written just before the OS kills the app could be lost. commit() is synchronous and returns a success boolean. commit() → apply() is a one-word edit that keeps every test green, so secrets-seam.test.mjs pins the string with "the Android write is committed rather than applied".Android backup & restore
The store self-heals after a Google auto-backup restores the app onto a new device. The master key that decryptsEncryptedSharedPreferences is generated inside AndroidKeyStore and is device-local — it does not travel with the backup. So the restored preferences file cannot be decrypted, and EncryptedSharedPreferences.create throws a KeyStoreException.
store() in SecretsPlugin.kt catches that exception, calls discard() — which uses context.deleteSharedPreferences(PREFS_FILE), not getSharedPreferences(...).edit().clear(), because a cleared handle still points at a file it cannot read — and rebuilds the store from scratch. Future writes work; any pre-restore secrets are gone and the user re-enters them once, exactly like a fresh install.
Verification on device
How you would check yourself that the key never leaks — the same evidence collected on the PR.logcat never carries the key
logcat never carries the key
Run
adb logcat while pasting and using a key. The value never appears — the plugin logs presence and errors, never the secret.No file under the app's data dir contains it
No file under the app's data dir contains it
adb shell run-as ai.praison.mobile and grep the data directory: no file under /data/data/ai.praison.mobile/ contains the plaintext key. It lives in EncryptedSharedPreferences, encrypted under a master key held in AndroidKeyStore.Even the entry names are encrypted
Even the entry names are encrypted
The shared-prefs XML does not even carry the readable slot name (
openai:default). EncryptedSharedPreferences encrypts keys with AES256-SIV, so the stored entry name is ciphertext too.On iOS the equivalent check is that the value is in the Keychain (
kSecClassGenericPassword), not in the app’s container — nothing in the sandbox holds the plaintext, because SecItemAdd hands it to the keychain daemon.Related
Storage & Secrets
SecretsPort, the per-platform table, and why storage and secrets are kept apart.API Keys
The user-facing flow: paste, rotate, and remove a key.
Settings Screen
How a secret row renders and where the software-secrets warning fires.
Adapter Conformance
The durability and presence branches this store must pass.

