2026-07-31 15:41:57 +02:00
# 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" )
2026-08-07 21:20:48 +02:00
// { ok: true, backend: "Keyring", error: null }
// (Backend enum variants serialize verbatim: "Keyring" | "DpapiFile" | "EncryptedFile")
2026-07-31 15:41:57 +02:00
```
### 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.
2026-08-01 22:06:14 +02:00
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:
2026-07-31 15:41:57 +02:00
- 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
2026-08-01 22:06:14 +02:00
repaired machine returns to the real store with no migration step.
2026-07-31 15:41:57 +02:00
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.