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:
-
POST /index.php/login/v2→loginURL + poll token (auth::begin) -
User confirms in the browser
-
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
0600file. 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 decisions —
credentials::store_with/load_withtake 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
Secretsimplementation promises (notably: "no such entry" isOk(None), not an error) and is run against the fake and againstOs.mise run testprovides what the latter needs:scripts/test.shstarts a private D-Bus session with an empty, unlocked gnome-keyring under a temporaryXDG_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 orcurltrusts. Public and enterprise CAs installed system-wide work without configuration. -
Private CA / self-signed: set
tls.ca_certto 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.