Credentials and trust

Wusel never sees your Nextcloud password. It asks the server for a separate, revocable app password, and then has to keep that somewhere a background service can read it at login without anybody typing anything. Both halves have a non-obvious answer, and both are on this page — together with how Wusel decides which server it is willing to talk to at all.

Authentication: Login Flow v2

Browser-based, without ever seeing the real user password:

  1. POST /index.php/login/v2login URL + poll token (auth::begin)

  2. User confirms in the browser

  3. Poll until the app password arrives (auth::poll)

The app password is revocable server-side (it is not the account password), so its blast radius is limited by design.

Credential storage: keyring with a file fallback

By default the app password goes into the OS keyring — via the freedesktop Secret Service, so any provider works: gnome-keyring on GNOME, KWallet on KDE Plasma, or e.g. KeePassXC (the pure-Rust async-secret-service backend talks org.freedesktop.secrets, tied to no single desktop). The 0600 file (credentials.json) is the fail-soft fallback and the opt-out ([auth] keyring = false, or wusel login --keyring false). The non-obvious question is how the background service reads the keyring back with nobody typing a password at start:

  • At graphical login, PAM (pam_gnome_keyring / pam_kwallet) unlocks the login keyring using the login password the user just entered.

  • Wusel runs as a systemd user service (wusel@.service), so it lives inside that session and reaches the Secret Service over the session D-Bus. The collection is already unlocked → it reads the secret with no prompt. (A system/root service could not: it has no access to the user keyring. Choosing a user service is the precondition, not an accident.)

This has a hard limit: headless / SSH-only / enable-linger / autologin without a PAM unlock never unlock the keyring, and a background service cannot prompt anyone — so the secret is unreachable there. This is inherent to the Secret Service model, not a bug (the reference client hits it too).

Therefore the design is try the keyring, else fall back to the 0600 file:

  • Store the secret in the auto-unlocked login collection; read via the Secret Service when it is available and unlocked.

  • When it is absent or locked (servers, headless), keep using the 0600 file. Because the secret is a revocable app password, the file fallback is acceptable hardening-wise — it is a "better by default on the desktop", not a hard gate.

How that fallback is tested

The rule above is only worth as much as the evidence for it, and its interesting half — what happens when the keyring misbehaves — cannot be staged on a real machine. You cannot ask a running Secret Service to be absent, to be locked on demand, or (the case store actually guards against) to accept a write and then not have it. Tests that simply used the machine’s keyring inherited its state instead: green in a container, red on an unlocked desktop, and a statement about neither.

So the keyring sits behind the keyring::Secrets trait, with exactly one production implementation (keyring::Os). Both halves are then covered, and both run on every machine, in every run:

  • The decisionscredentials::store_with / load_with take the store as a parameter, and the tests pass an in-memory keyring that is absent, locked, or forgetful. No D-Bus is reachable from those tests at all, so the host’s keyring is not merely irrelevant, it is unobservable.

  • The backend — one contract test states what a Secrets implementation promises (notably: "no such entry" is Ok(None), not an error) and is run against the fake and against Os. mise run test provides what the latter needs: scripts/test.sh starts a private D-Bus session with an empty, unlocked gnome-keyring under a temporary XDG_DATA_HOME.

Deliberately not #[ignore]: a test that only runs when someone remembers to ask for it does not run. Just as deliberately not skipped-when-absent: a green run must mean the thing was checked. Without the tools the suite stops and names the package to install.

The throwaway session has a second benefit. No test can reach the developer’s real login keyring even by accident — which is not hypothetical: the credential tests used to write a dummy secret over the wusel entry of the default account, because a test and the product naturally pick the same account key.

TLS & certificates

All network I/O — WebDAV, OCS, Login Flow v2, and the notify_push WebSocket — rides a single reqwest client (the WebSocket via reqwest-websocket), so TLS is configured in exactly one place (wusel_core::tls). rustls with the ring provider is the only TLS stack; no OpenSSL.

Trust policy, safest to loosest:

  • Default: the OS trust store (rustls-tls-native-roots) — the same certificates a browser or curl trusts. Public and enterprise CAs installed system-wide work without configuration.

  • Private CA / self-signed: set tls.ca_cert to a PEM file; those certificates are trusted in addition to the OS store. The clean path for self-hosters.

  • tls.insecure = true: disables verification entirely. A testing-only escape hatch; the daemon logs a prominent warning at start. Never a fallback — a failed handshake never silently downgrades.