Skip to content

feat(keychain): classify locked collections with ErrCollectionLocked and bound the unlock prompt - #637

Draft
Benehiko wants to merge 8 commits into
mainfrom
fix/keychain-locked-collection-error
Draft

feat(keychain): classify locked collections with ErrCollectionLocked and bound the unlock prompt#637
Benehiko wants to merge 8 commits into
mainfrom
fix/keychain-locked-collection-error

Conversation

@Benehiko

@Benehiko Benehiko commented Aug 25, 2026

Copy link
Copy Markdown
Member

Problem

On headless Linux hosts with SSH key-only logins, PAM has no password to auto-unlock the login keyring, so the Secret Service collection is locked after every keyring-daemon restart. A downstream consumer hit this in production. Operations on a locked collection failed with opaque strings ("failed to prompt: prompt dismissed", "prompt timed out") that cannot be matched with errors.Is, so nothing could tell the user their keychain is locked. The prompt wait was also a hardcoded 30 seconds, ignoring the caller's deadline.

Changes

Classification:

  • New exported sentinel keychain.ErrCollectionLocked, declared in the cross-platform keychain.go like the existing sentinels (Linux-only match).
  • Every path that fails because the collection stayed locked wraps it and names the collection: the up-front unlock (new ensureCollectionUnlocked helper, replacing five duplicated blocks), the re-unlock inside withRelockRetry, and a collection still locked after the bounded retries. The underlying prompt failure is kept as the cause.
_, err := st.Get(ctx, id)
if errors.Is(err, keychain.ErrCollectionLocked) {
    // Tell the user to unlock their keyring. Do not fall back to another
    // store; that would split credentials across stores.
}

Bounded prompt wait:

  • PromptAndWait, Unlock, LockItems, CreateItem and DeleteItem now take a context. Store operations pass their original operation context, so a caller deadline bounds the wait for the user. The internal 30s cap remains as an upper bound and is now created once outside the receive loop; previously any unrelated bus signal reset it.
  • A null prompt returns before the context check, so cleanup calls with cancelled contexts still work on passwordless keyrings.

Deliberately not added (see the decision log): prompter detection, password callbacks, master-password unlock, and TTY prompting in the library. The caller owns the remediation message.

Tests

  • Fake-seam unit tests: all five store operations against a locked collection return ErrCollectionLocked with the collection path and the preserved cause, plus unlock-succeeds and retry-loop cases.
  • New ubuntu-24-gnome-keyring-locked CI target: creates a password-protected keyring with gnome-keyring-daemon --login, locks the collection, and runs the gated TestKeychainLiveLockedCollection. Fails fast in about 60ms in the container.
  • The existing passwordless suites stay green.

Live validation (Ubuntu 24.04 VM, gnome-keyring)

Scenario Before After
Headless, locked, prompter cannot start 15ms, opaque "prompt dismissed" 15ms, classified error naming the collection
Display alive, dialog unanswered fixed 30s caller deadline honored
Display alive, prompt answered works works

The deadline test used a deliberately short 2 second context to prove propagation; it aborted the unanswered prompt at 2 seconds. Callers without a deadline still get the 30 second cap, and gnome-keyring itself never times a prompt out.

gnome-keyring dismisses the prompt immediately when no prompter can start, so the library never hung on a locked collection. The failure was fast but unclassifiable. This change makes it detectable and puts the unanswered-dialog case under the caller's control.

🤖 Generated with Claude Code

Benehiko and others added 8 commits August 25, 2026 17:30
…and bound the unlock prompt

On headless Linux hosts (SSH key-only logins), PAM has no password to
auto-unlock the login keyring with, so the Secret Service collection comes
up locked after every keyring-daemon restart. The store's only reaction to
a locked collection was Service.Unlock -> PromptAndWait, whose failures
surfaced as opaque strings ("failed to prompt: prompt dismissed" /
"prompt timed out") that callers cannot classify, and whose wait was a
hardcoded 30s regardless of the caller's deadline.

- Export ErrCollectionLocked in the cross-platform keychain.go (mirroring
  ErrKeychainUnavailable / ErrNoDefaultCollection; Linux-only match). Every
  path that fails because the collection is locked and could not be
  unlocked now wraps it, naming the collection and preserving the prompt
  failure as the cause: the up-front unlock (new ensureCollectionUnlocked
  helper, replacing five duplicated blocks), the re-unlock inside
  withRelockRetry, and a collection still locked after the bounded retries.
- Bound the prompt wait by the operation's context: PromptAndWait (and the
  prompt-capable Unlock/LockItems/CreateItem/DeleteItem) now take a ctx.
  Store operations pass their original ctx - deliberately not the
  context.WithoutCancel connection ctx - so a caller deadline bounds the
  human-wait while in-flight D-Bus calls stay protected from teardown. The
  internal 30s cap remains as an upper bound and is now created once
  outside the receive loop, so unrelated bus signals no longer reset it.
  A null prompt still returns before the ctx check, keeping best-effort
  cleanup calls working on passwordless keyrings.
- Add fake-seam tests for every operation against a locked collection, and
  a new ubuntu-24-gnome-keyring-locked CI target that runs an env-gated
  live test against a password-protected keyring (created PAM-style via
  gnome-keyring-daemon --login) with the collection locked, asserting the
  operation fails fast with ErrCollectionLocked.

Validated live on an Ubuntu 24.04 VM: headless locked operations fail in
~15ms with the classified error (gnome-keyring dismisses the prompt
immediately when no prompter can be shown), an interactive unlock prompt
on a display still completes, and a 2s caller deadline aborts an
unanswered prompt at 2s. Deliberately not added (see the decision log):
prompter-presence probes, password callbacks, programmatic
master-password unlock, and library TTY prompting - the caller owns
remediation, and a locked collection must not be treated as "unavailable,
fall back", which would split credentials across stores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…efault

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant