Files
orca/src/main/startup/linux-keyring-backend-selection.test.ts
T
Neil 23a874a2a1 fix(secrets): seal credentials on Linux desktops Chromium cannot detect (#24035)
* fix(secrets): stop telling Linux users to install a keyring they already run

On a desktop Chromium does not recognise (Hyprland, sway, river, niri) the
selected backend is basic_text and sealing is unavailable, so the at-rest
protection report told the user to install and unlock gnome-keyring — which is
usually already running and serving org.freedesktop.secrets. Chromium simply
never looked, because it picks the backend from XDG_CURRENT_DESKTOP.

Split the Linux unavailable path on the selected backend: an unrecognised
desktop now says so, names XDG_CURRENT_DESKTOP, and points at
--password-store. A backend that did resolve but cannot seal keeps the
install-and-unlock text, which is correct there.

The backend read stays after isEncryptionAvailable(), the call that performs
the D-Bus probe, so nothing new blocks (STA-5765 timing unchanged).

* fix(secrets): seal credentials on Linux desktops Chromium cannot detect

Chromium picks its os_crypt backend from the desktop-environment env vars and
recognises none of the tiling compositors (Hyprland, sway, river, niri). On
those it selects basic_text, whose key Electron only exposes after an explicit
setUsePlainTextEncryption() this app never calls — so isEncryptionAvailable()
is false and every credential store takes its plaintext fallback, while
gnome-keyring sits on the session bus unasked (#21827).

Name gnome-libsecret ourselves, but only where it provably cannot hurt:

- Never for a desktop Chromium does resolve. Overriding a working selection is
  the one change that could strand already-sealed credentials, and a KDE
  session identified only by KDE_FULL_SESSION is the case that matters.
- Only when the secret service's default collection is present AND unlocked,
  measured out of process with a killable 1.5s deadline. A locked collection
  with no unlock prompter is what made isEncryptionAvailable() block for 76s to
  first window (STA-5765); selecting libsecret there would trade silent
  plaintext for a frozen app. The probe reads the Locked property, which cannot
  itself trigger a prompt.

Anything unexpected — no session bus, no gdbus, no name owner, timeout — leaves
Chromium's own choice alone, so the worst case is today's behaviour.

The desktop-presence env vars come from a review finding by @Raajik on #21831,
confirmed there against base/nix/xdg_util.cc.
2026-09-30 02:36:22 -07:00

133 lines
4.7 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import {
identifiesResolvedDesktop,
resolveLinuxPasswordStore,
type SecretServiceCollectionState
} from './linux-keyring-backend-selection'
const SESSION_BUS = { DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus' }
function resolve(
env: NodeJS.ProcessEnv,
collection: SecretServiceCollectionState = 'unlocked',
passwordStoreAlreadySelected = false
) {
const probeCollection = vi.fn(() => collection)
const decision = resolveLinuxPasswordStore({
env: { ...SESSION_BUS, ...env },
passwordStoreAlreadySelected,
probeCollection
})
return { ...decision, probeCollection }
}
describe('identifiesResolvedDesktop', () => {
it.each([
'GNOME',
'gnome',
'GNOME:ubuntu',
'ubuntu:GNOME',
'X-Cinnamon',
'KDE',
'XFCE',
'Pantheon',
'UKUI',
'Deepin',
'unity',
// Chromium identifies LXQt and then picks basic_text for it. That is still a
// resolved desktop, and overriding a deliberate choice is not this fix's job.
'LXQt'
])('treats %s as a desktop Chromium resolves', (desktop) => {
expect(identifiesResolvedDesktop({ XDG_CURRENT_DESKTOP: desktop })).toBe(true)
})
it.each(['Hyprland', 'sway', 'river', 'niri', '', 'wlroots:custom'])(
'treats %s as unrecognised',
(desktop) => {
expect(identifiesResolvedDesktop({ XDG_CURRENT_DESKTOP: desktop })).toBe(false)
}
)
it('reads the DESKTOP_SESSION fallback Chromium also consults', () => {
expect(
identifiesResolvedDesktop({ XDG_CURRENT_DESKTOP: 'Hyprland', DESKTOP_SESSION: 'plasma' })
).toBe(true)
})
// Why this case has its own test: missing it is what would strand sealed credentials.
// A KDE session identified only by KDE_FULL_SESSION gets kwallet from Chromium, and
// overriding that with libsecret makes every existing secret unreadable.
it.each(['GNOME_DESKTOP_SESSION_ID', 'KDE_FULL_SESSION', 'KDE_SESSION_VERSION'])(
'treats a bare %s as identifying a desktop',
(variable) => {
expect(identifiesResolvedDesktop({ XDG_CURRENT_DESKTOP: 'Hyprland', [variable]: '1' })).toBe(
true
)
}
)
it('ignores a presence variable that is set but empty', () => {
expect(
identifiesResolvedDesktop({ XDG_CURRENT_DESKTOP: 'Hyprland', KDE_FULL_SESSION: ' ' })
).toBe(false)
})
})
describe('resolveLinuxPasswordStore', () => {
it('selects libsecret on an unrecognised desktop with an unlocked secret service', () => {
expect(resolve({ XDG_CURRENT_DESKTOP: 'Hyprland' }).store).toBe('gnome-libsecret')
})
it('leaves a resolved desktop alone without probing at all', () => {
const { store, probeCollection } = resolve({ XDG_CURRENT_DESKTOP: 'GNOME' })
expect(store).toBeNull()
expect(probeCollection).not.toHaveBeenCalled()
})
// Why this is the whole point of the design: selecting libsecret against a locked
// collection with no prompter is what made isEncryptionAvailable() block for 76s to
// first window (STA-5765). Plaintext plus an accurate warning beats a frozen app.
it('declines to select a locked secret service rather than risk a startup stall', () => {
const { store, reason } = resolve({ XDG_CURRENT_DESKTOP: 'sway' }, 'locked')
expect(store).toBeNull()
expect(reason).toMatch(/locked/)
})
it('declines when no secret service answers', () => {
expect(resolve({ XDG_CURRENT_DESKTOP: 'river' }, 'unavailable').store).toBeNull()
})
it('defers to an explicit --password-store without probing', () => {
const { store, probeCollection } = resolve({ XDG_CURRENT_DESKTOP: 'niri' }, 'unlocked', true)
expect(store).toBeNull()
expect(probeCollection).not.toHaveBeenCalled()
})
it('does not probe when there is no session bus to probe', () => {
const probeCollection = vi.fn((): SecretServiceCollectionState => 'unlocked')
const decision = resolveLinuxPasswordStore({
env: { XDG_CURRENT_DESKTOP: 'Hyprland' },
passwordStoreAlreadySelected: false,
probeCollection
})
expect(decision.store).toBeNull()
expect(probeCollection).not.toHaveBeenCalled()
})
it('treats an empty session bus address as no session bus', () => {
const probeCollection = vi.fn((): SecretServiceCollectionState => 'unlocked')
resolveLinuxPasswordStore({
env: { XDG_CURRENT_DESKTOP: 'Hyprland', DBUS_SESSION_BUS_ADDRESS: ' ' },
passwordStoreAlreadySelected: false,
probeCollection
})
expect(probeCollection).not.toHaveBeenCalled()
})
it('gives a reason for every outcome, since the log line is the only trace', () => {
for (const collection of ['unlocked', 'locked', 'unavailable'] as const) {
expect(resolve({ XDG_CURRENT_DESKTOP: 'Hyprland' }, collection).reason).not.toBe('')
}
})
})