Native Secrets

The in-repo Tauri plugin that keeps your API key in the phone's keychain, not in process memory.

An API key you paste into the app lives in the platform keychain — iOS / macOS Keychain, Android EncryptedSharedPreferences — and survives an app restart.

Diagram

🔐 SecretsPort

🧩 tauri secrets adapter

📨 Tauri IPC

⚙️ src-tauri/plugins/secrets

🗝️ Keychain / EncryptedSharedPreferences

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.

    ts
    // app/src/platform.ts — the store is chosen from the detected platform kind.
    export function secretsFor(kind, bridge): SecretsPort {
      if (kind !== "tauri") return createWebSecrets();       // browser: a Map
      return createTauriSecrets({ invoke: bridge.invokeStrict }); // phone: the keychain
    }
  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.


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.

Diagram
Keychain / Prefssecrets pluginTauri IPCtauri secrets adapterUserKeychain / Prefssecrets pluginTauri IPCtauri secrets adapterUserset({slot, account}, value)invoke(“secret_write”, {slot, account, value})secret_writeSecItemAdd / EncryptedSharedPreferences.commit()okstored

The webview reaches the store through four commands, declared once in src-tauri/src/secrets.rs and matched on the TS side in adapters/src/tauri/secrets.ts.

CommandBodyNotes
secret_readReturns the value or nullCalled only when the engine needs the key; never on paint.
secret_writeWrites a valueAn empty string is a stored value, not a delete.
secret_removeRemovesDeleting an absent secret succeeds.
secret_hasPresence onlyIts own command — does NOT call read().

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.

Platform kindStoreBacked by
"tauri" on iOS / macOScreateTauriSecrets({ invoke })SecItemAdd / SecItemCopyMatching (kSecClassGenericPassword) — value held by the keychain daemon, nothing in the app sandbox
"tauri" on AndroidcreateTauriSecrets({ invoke })EncryptedSharedPreferences: keys AES256-SIV, values AES256-GCM, master key from AndroidKeyStore (non-extractable)
"tauri" on any other hostrefusesnothing, ever
"web"createWebSecrets()Module-scoped Map — lost on every reload

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

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

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

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

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

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()

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 decrypts EncryptedSharedPreferences 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.

Diagram

yes

no, KeyStoreException

🔓 open store

✅ readable?

🗝️ use it

🧹 discard: deleteSharedPreferences

🆕 recreate empty

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

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

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

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.


Related