DEV Community

Cover image for Staying signed in on Linux when the keyring doesn't survive a reboot
Frank Milvus
Frank Milvus

Posted on Originally published at frankmilvus.github.io

Staying signed in on Linux when the keyring doesn't survive a reboot

We keep people signed in on desktop the same way we do on mobile: the session is
an access/refresh token pair, and we would much rather store it in the operating
system's secure store than in a plaintext file. On Linux that secure store is the
keyring — libsecret talking to the Secret Service that gnome-keyring
implements. Getting Flutter Linux secure storage to behave on every machine
turned out to be harder than the plugin's tidy write/read API suggests. The
last bug we fixed was the most interesting one: a keyring that kept its promise
for exactly as long as the machine stayed switched on.

This is the story of that bug, why the "obvious" proofs that a keyring works were
all wrong, and the small piece of Linux kernel state that finally made the check
honest.

The setup: Flutter Linux secure storage and a proof-gated fallback

On desktop, tonGues persists the session through flutter_secure_storage. On
Android and iOS that is the OS keystore, which is always present. On desktop it
is the OS keyring — libsecret / gnome-keyring on Linux, Credential Manager on
Windows — and a keyring is a much less certain thing. It can be missing, locked,
or reachable only from inside a graphical session.

The plugin makes this worse in a quiet way:
flutter_secure_storage can
accept a write and keep nothing. No exception, no error — the call returns
normally and the value is simply not there later. A store backed by a transient
collection returns the value immediately and drops it when the process exits.

So we do two things:

  • We write the tokens to the secure store.
  • Until that store proves it is durable, we also keep a plaintext copy in SharedPreferences, so the session is always restorable.

The moment the store is proved durable, we delete the plaintext copy, and the
keyring becomes the only place the session lives. That "proved durable" step is
the whole game, and it is where both of this year's desktop session bugs came
from.

The fallback is proof-gated, not permanent

A plaintext fallback that never goes away would be the simplest, safest thing for
the session — and we rejected it. It would leave a refresh token unencrypted on
every machine, including the many whose keyring works perfectly. That is the
wrong default.

Instead the fallback is proof-gated. We trust the secure store only after we
have evidence that it actually keeps values across launches, and until then we
keep the encrypted copy alongside the plaintext one. The evidence is deliberately
not a same-process write→read round-trip, which as we said can succeed against
a store that never persists. The proof is a value written on one launch and read
back on the next one:

// A value written now and compared on the next launch is the only proof that
// outlives the process that wrote it. A same-process round-trip proves nothing:
// a transient collection passes it and loses the value anyway.
final proofMatches = expected != null && seen == expected;
Enter fullscreen mode Exit fullscreen mode

If the value we wrote last launch is still there this launch, the store kept
something across a restart, and we can trust it. That was the design after our
first desktop bug, and it fixed the "login screen on every launch" report. Then
came Linux.

The bug: a keyring that only lives for the running session

A user on niri/Arch had gnome-keyring installed and running — it owns
org.freedesktop.secrets, via
D-Bus activation
— but its default collection was never unlocked at login, because niri starts the
daemon outside any PAM unlock.

The daemon accepted a write and returned it on the next read within the same
session
. So our one-launch proof matched. The store looked durable. We deleted
the plaintext fallback.

But the value only ever lived inside the running daemon. After a reboot it was
gone — and so was the session, because we had already thrown away the only copy
that would have survived. The fallback had protected the user right up until the
keyring "passed the test", and then stopped.

The diagnostics on that machine told the story plainly: the app's keyring entry
held an empty secret, the real tokens sat in the plaintext file with the
marker recording that the last durable save went to prefs, and the proof value
in prefs matched the proof value in the keyring. Everything agreed that the store
was fine. The machine simply disagreed at the next boot.

Why the one-launch proof was wrong

Our proof answered the question "did this value survive the process that wrote
it?" That is the right question for a store that can drop data when an app exits.
It is the wrong question on Linux, where a keyring can drop data when the
session exits — a different, later, more destructive event.

An app restart and a reboot are not the same boundary. On a machine whose keyring
caches writes in memory, an app restart inside one boot proves nothing about the
reboot that follows it.

The fix: make the proof cross a boot

The fix is small and, in hindsight, obvious: the proof record now carries the
kernel boot id.

Linux exposes a fresh, random identifier for the running boot at
/proc/sys/kernel/random/boot_id. It changes on every boot. So we write the proof
as "<bootId>:<nonce>" — the boot the value was written in, plus a nonce that
makes it unique — and we trust the store only when a proof written in one boot is
read back in a later boot:

final bootId = await _linuxBootId();          // null off Linux, or unreadable
final proofMatches = expected != null && seen == expected;

// `expected` is "<bootId>:<nonce>". A record with no separator is a legacy
// value, which can never prove a reboot was survived.
final expectedBootId = _proofBootId(expected);
final crossedBoot =
    bootId != null && expectedBootId != null && expectedBootId != bootId;

_keystoreTrusted = proofMatches && (!isLinuxDesktop || crossedBoot);
Enter fullscreen mode Exit fullscreen mode

A keyring that merely caches for the running session can never satisfy
crossedBoot: the proof it holds was written in the current boot, so the store
never reaches the durable state. The plaintext fallback stays, and the session
survives the reboot. A keyring that genuinely persists reaches durable exactly
one reboot after sign-in, then becomes the sole copy, as designed.

Two details matter here:

  • Legacy records can never pass. A proof written by the old build has no : separator, so _proofBootId returns null and crossedBoot is false. An upgrade cannot falsely trust a store; the next launch simply replaces the record with the boot-id format.
  • The check is scoped to Linux. Off Linux the platform keystore (Android/iOS Keystore, Windows Credential Manager) is reliable, so the boot-id requirement is disabled and a matching proof is enough — unchanged behaviour. We added isLinuxDesktop to platform_support.dart precisely to keep this to the one flaky platform, with isDesktop still covering Windows and macOS.

Failing safe: an unreadable boot id keeps you signed in

_linuxBootId() returns null if it cannot read the file, and a null boot id can
never satisfy crossedBoot. That means a read failure is treated as "not
durable" — the conservative direction, the one that keeps the user signed in
rather than out. We would rather leave a fallback in place than risk trusting a
store we could not assess.

The same principle runs through the whole check: every uncertain outcome resolves
to keeping the session, and only positive, cross-boot evidence moves the app to
keystore-only.

Why the obvious alternatives don't work

We considered the two shortcuts and rejected both:

  • Never drop the plaintext fallback on desktop. Simplest, and it would have avoided this bug — but it leaves the refresh token unencrypted on every healthy machine, forever. We rejected it once before and kept it rejected; it trades a real, permanent weakening of secret storage for a bug that has a precise fix.
  • Require N consecutive launches. More launches do not help. A daemon that keeps the value in memory passes any number of same-boot restarts; it only fails at the boundary a reboot represents. N consecutive launches only ever prove same-boot survival.

The boot boundary is the honest version of "durable across a restart": it is
precise, it keeps the keystore-only path for healthy machines, and it costs those
machines exactly one extra launch carrying a plaintext copy.

What actually changes for users

Not much, which is the point:

  • On a machine whose keyring never proves durable, the fallback stays and the session survives reboots. The user stays signed in, at the cost of an unencrypted local copy — which they can remove by unlocking the keyring's default collection at login.
  • On a healthy keyring, the plaintext copy is kept for one launch cycle after sign-in, then dropped; after that the keyring is the only copy, as before.

The change is covered by a test that launches the app twice inside one boot (the
fallback is kept) and once in a later boot (the store is trusted and the fallback
dropped). The rest of the storage suite still passes, so the Linux path stayed
additive.

If you want the surrounding work, we shipped a
photo album flow,
a shared media gallery
and the Linux desktop video and media kit
the same day, and there's a
full overview of tonGues
if you're new here.

FAQ

Does this log me out on a healthy Linux keyring?
No. It keeps the plaintext copy for one launch cycle after you sign in, then drops
it once a proof crosses a reboot. After that the keyring is the only copy, exactly
as before.

Why not just always keep the plaintext fallback on Linux?
Because it would leave the refresh token unencrypted on every Linux machine,
including the ones whose keyring is perfectly fine. Staying signed in matters, but
not at the permanent cost of plaintext secrets everywhere.

What if the app can't read the boot id?
It treats the store as not durable and keeps the fallback. That is the safe
direction — we would rather keep you signed in than trust a store we could not
assess.

Does this affect Android, iOS, Windows or macOS?
No. Off Linux the boot-id requirement is disabled and a proof that survives a
restart is enough, because the platform keystore there is reliable. Legacy proof
records behave the same way.

Is the boot id sensitive?
No. It is a random per-boot identifier whose only use here is answering "is this a
different boot?" It is not tied to a user or a machine, and it carries no data
beyond the boot boundary itself.

— the tonGues team


Get tonGues — a language-exchange messenger with live translation.

Top comments (1)

Collapse
 
saqrelfirgany profile image
Ahmed ElFirgany •

Gating on a proof that survived a different boot_id is a clean, honest check. After the first cross-boot pass, the plaintext fallback can go. Do you drop it at once, or after a few boots?