# Credential storage The desktop client persists two secrets per server, both in the OS credential store under the service name `com.owncord.client`: | Secret | Account name | Contents | | ------------------------------- | ----------------- | -------------------------------------- | | Login credential | `{host}` | JSON `{"username","token","password"}` | | Voice-E2EE identity private key | `identity:{host}` | base64 JWK (P-256 private key) | The identity key is the long-term key peers pin under trust-on-first-use. Its public half is published to the server (`users.identity_public_key`) and its private half signs the ephemeral-key announce in a voice session. **If the key is not the same one across a reconnect, the published key is not the key that signed, and peers correctly reject the announce** — voice E2EE key exchange then times out and the client log shows: ``` peer announce signature invalid - rejecting (MITM?) ``` That rejection is the fail-closed behaviour working as intended. The bug in that scenario is always on the storage side, never the verification side. ## Root cause of the 2026-07 identity-key regression The client depended on `keyring = "3"` with no feature list. **The `keyring` crate declares no `default` feature.** Each platform arm in its `lib.rs` selects a backend only when that platform's feature is enabled, and otherwise falls through to the mock store: ```rust #[cfg(all(target_os = "windows", not(feature = "windows-native")))] pub use mock as default; #[cfg(all(target_os = "macos", not(feature = "apple-native")))] pub use mock as default; // ...and the equivalent Linux arm for the secret-service / keyutils features ``` So the shipped client used the **mock credential store on Windows, macOS and Linux alike**. The mock is documented as "platform-independent, provides no persistence… there is no persistence other than in the entry itself", and its builder returns a fresh `MockCredential` for every `Entry::new`. Because each command built its own `Entry`, the sequence was: ``` save_identity_key -> Entry::new(..) -> set_password -> Ok(()) // Entry dropped here load_identity_key -> Entry::new(..) -> get_password -> NoEntry // brand-new empty cell ``` This reproduces every reported symptom exactly: - The save returns `Ok`, so no "Failed to save identity key" line is ever logged. - The very next read **in the same process** returns nothing. - `NoEntry` is mapped to `Ok(None)`, so the read logs nothing either. - Windows Credential Manager and `cmdkey /list` show no `com.owncord.client` entries **on any machine**, including ones where the client otherwise behaves — because nothing was ever written to Credential Manager. Whether a given user visibly regenerates their key therefore depends on process lifetime and on the publish step being idempotent, not on their machine. It is not a Windows policy fault, a roaming-profile fault, or a target-name fault. ### The fix `src-tauri/Cargo.toml` now names the backends explicitly: ```toml keyring = { version = "3", default-features = false, features = [ "windows-native", # Windows Credential Manager "apple-native", # macOS Keychain "sync-secret-service", # Linux Secret Service (GNOME Keyring / KWallet) "crypto-rust", # pure-Rust session crypto for Secret Service ] } ``` `sync-secret-service` links libdbus, so Linux builds need `libdbus-1-dev`. Two guards keep this from regressing silently: - `secret_store::tests::compiled_keyring_backend_is_persistent` fails the build if the compiled backend is not disk-persistent. It inspects the backend, so it needs no live keychain and runs in CI. - Every write is read back before `save_*` returns (see below). ## Verifying the credential store on a machine ### From the client The client logs its compiled backend at startup, to the rotating log file in the OS app-log dir: ``` credential store: OS keyring, persists until deleted (on disk) ``` Anything else is an error line naming the problem. For a live end-to-end check there is a `probe_credential_store` command. It writes, reads back and deletes a throwaway entry and reports which backend served it, touching no real credential: ```js await invoke("probe_credential_store"); // { ok: true, backend: "Keyring", error: null } // (Backend enum variants serialize verbatim: "Keyring" | "DpapiFile" | "EncryptedFile") ``` ### From Windows directly Use `cmdkey`, **not** the Credential Manager control panel — the control panel filters out generic credentials written by applications, so it shows nothing even on a perfectly healthy machine. `keyring` composes the Windows target name as `{account}.{service}`, so the identity key for `test.example` is stored as: ``` identity:test.example.com.owncord.client ``` Note the order: account first, service second. To check for a real entry: ``` cmdkey /list | findstr /i owncord ``` A hand-rolled `cmdkey /generic:... ` round-trip is a **weak** test and can pass while the client still fails, for two reasons: - `cmdkey /generic:` writes with `CRED_PERSIST_LOCAL_MACHINE`, whereas `keyring` hardcodes `CRED_PERSIST_ENTERPRISE` (`windows.rs`, `save_credential`). Only the latter is subject to roaming-credential policy. - It exercises a target name the client never uses unless you spell it in the `{account}.{service}` order above. If you do want the exact repro: ``` cmdkey /generic:identity:test.example.com.owncord.client /user:x /pass:y cmdkey /list | findstr /i owncord ``` ## Environment causes that remain possible These were not the cause of the 2026-07 regression, but they can genuinely stop Windows persisting credentials, and the client now detects and reports them instead of silently regenerating keys. | Cause | Check | Fix | | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Credential Manager service stopped | `sc query VaultSvc` | `sc config VaultSvc start= auto && sc start VaultSvc` | | "Network access: Do not allow storage of passwords and credentials for network authentication" | `reg query HKLM\SYSTEM\CurrentControlSet\Control\Lsa /v DisableDomainCreds` | Set the policy to _Disabled_ (`secpol.msc` → Local Policies → Security Options), i.e. `DisableDomainCreds = 0`. Note this blocks _domain_ credentials and makes writes fail with `ERROR_NO_SUCH_LOGON_SESSION`, which the client surfaces as an error rather than silently. | | No roaming profile, with `CRED_PERSIST_ENTERPRISE` | — | Documented Windows behaviour: the credential simply persists locally instead of roaming. Harmless. | | App running as a different user than the vault being inspected | `whoami` in the app's context vs. the one running `cmdkey` | Credentials are per-user; compare like for like. | Blob size is not a plausible cause: `CRED_MAX_CREDENTIAL_BLOB_SIZE` is 2560 bytes and `keyring` stores the secret as UTF-16, so the ceiling is ~1280 characters. The identity blob is a ~256-character base64 JWK (~512 bytes), and an oversized secret would be rejected up front with a `TooLong` error, not silently dropped. ## Write verification and the fallback store `secret_store::set` reads every write back and compares it before reporting success. A store that accepts a write and does not return it is the one failure a `Result` cannot express, and it is exactly what caused this incident. When that check fails, the secret is sealed and parked in `credential_fallback.json` in the app data dir: - **Windows**: DPAPI (`CryptProtectData`, user-scoped, `CRYPTPROTECT_UI_FORBIDDEN`). The encryption key is held by the OS. - **macOS / Linux**: ChaCha20-Poly1305 under a per-install random key stored in `credential_fallback.key` beside it (owner-only, 0600). There is no OS-held key available on these platforms when the OS secret store is itself the thing that failed, so this is honest damage control rather than a vault: same-user malware that can read both files has the secrets, exactly as it could invoke DPAPI on Windows. What it guarantees is that secrets at rest are never plaintext, that a copied `credential_fallback.json` is useless without the key file next to it, and that the common no-Secret-Service Linux desktop (no gnome-keyring / KWallet, e.g. a bare window manager) can still persist credentials and the voice-E2EE identity key at all — previously those machines had nowhere to save, so logins and identity keys silently vanished on every restart. In both cases the account name is mixed in (DPAPI entropy / AEAD associated data), so a blob cannot be moved between entries and still decrypt. The fallback: - engages **only** after a write has been proven not to round-trip — never as the default; - is cleared automatically as soon as the OS credential store works again, so a repaired machine returns to the real store with no migration step. None of this weakens the fail-closed E2EE posture: a peer whose announce signature does not verify is still rejected. The fallback only affects whether _our own_ key survives a restart.