Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/keychain.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
subtest:
- fedora-43-gnome-keyring
- ubuntu-24-gnome-keyring
- ubuntu-24-gnome-keyring-locked
# disabled kdewallet tests since it prompts for a password in a
# headless environment... need to still fix this
# - fedora-43-kdewallet
Expand Down
9 changes: 9 additions & 0 deletions store/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ RUN --mount=type=bind,target=. \
--mount=type=cache,target=/root/.cache/go-build \
bash -c "set -euxo pipefail; /app/store/scripts/gnome-keyring"

FROM ubuntu24 AS ubuntu-24-gnome-keyring-locked
ENV CGO_ENABLED=0
USER user
WORKDIR /app
RUN --mount=type=bind,target=. \
--mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
bash -c "set -euxo pipefail; bash /app/store/scripts/gnome-keyring-locked"

FROM ubuntu24 AS ubuntu-24-kdewallet
ENV CGO_ENABLED=0
USER user
Expand Down
12 changes: 11 additions & 1 deletion store/docker-bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ group "default" {
# it just prompts anyway...
# "fedora_43_kdewallet",
# "ubuntu_24_kdewallet",
"ubuntu_24_gnome_keyring"
"ubuntu_24_gnome_keyring",
"ubuntu_24_gnome_keyring_locked"
]
}

Expand Down Expand Up @@ -62,3 +63,12 @@ target "ubuntu_24_gnome_keyring" {
GO_VERSION = GO_VERSION
}
}

target "ubuntu_24_gnome_keyring_locked" {
dockerfile = "store/Dockerfile"
target = "ubuntu-24-gnome-keyring-locked"
context = "."
args = {
GO_VERSION = GO_VERSION
}
}
51 changes: 51 additions & 0 deletions store/docs/keychain/decision-logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,54 @@ every store operation (not only the probe) benefits.
thread it into the dial (via `operationService`) like the other operations.

---

2026-08-25 Locked collections fail fast with ErrCollectionLocked; unlock prompt bounded

On headless Linux hosts with SSH key-only logins, PAM has no password to
auto-unlock the login keyring, so the collection is locked after every
keyring-daemon restart. The store reacted to a locked collection by calling
Service.Unlock and waiting on the prompt. The failures surfaced as opaque
strings ("failed to prompt: prompt dismissed", "prompt timed out") that
callers could not classify, and the wait was a hardcoded 30 seconds.

Decisions:

- One exported sentinel, `ErrCollectionLocked`, declared in the cross-platform
`keychain.go` like `ErrKeychainUnavailable` and `ErrNoDefaultCollection`.
Every path that fails because the collection stayed locked wraps it: the
up-front unlock in `ensureCollectionUnlocked` (which names the collection),
the re-unlock in `withRelockRetry`, and a collection still locked after the
bounded retries. The prompt failure is kept as the wrapped cause. No
exported prompt-dismissed or prompt-timeout sentinels; those stay unexported
causes, the same pattern as `errSessionBusUnavailable`.
- The prompt wait is bounded by the operation's context. `PromptAndWait`,
`Unlock`, `LockItems`, `CreateItem` and `DeleteItem` now take a ctx. Store
operations pass their original ctx, not the `context.WithoutCancel`
connection ctx, so a caller deadline bounds the wait for the user while
in-flight D-Bus calls stay protected from teardown. The internal 30s cap
remains as an upper bound and is created once outside the receive loop;
previously any unrelated bus signal reset it. A null prompt returns before
the ctx check, so cleanup calls with cancelled contexts still work on
passwordless keyrings.
- Nothing else was added. Rejected: prompter-presence probes (the
`org.gnome.keyring.SystemPrompter` name is activatable but unstartable on
headless hosts with gcr installed, and KWallet/KeePassXC never own it, so a
probe misclassifies in both directions), password callbacks and
master-password unlock via the gnome-only
`InternalUnsupportedGuiltRiddenInterface`, TTY prompting in the library, and
a lock check in `New` (lock state is per-operation and mutable). The caller
detects `ErrCollectionLocked` with `errors.Is` and owns the remediation
message. A locked collection must not be treated as unavailable; falling
back to another store would split credentials across stores.
- Validated live on an Ubuntu 24.04 VM: a headless locked operation fails in
about 15ms with "prompt dismissed" (gnome-keyring dismisses immediately when
no prompter can start), and an answered prompt on a display still works. To
prove the caller's deadline is honored, a test probe with a deliberately
short 2 second context deadline aborted an unanswered prompt at 2 seconds.
Nothing changes for callers without a deadline: they still get the 30 second
cap, and gnome-keyring itself never times a prompt out. A new
`ubuntu-24-gnome-keyring-locked` CI target runs
`TestKeychainLiveLockedCollection` against a password-protected keyring with
the collection locked.

---
41 changes: 41 additions & 0 deletions store/docs/keychain/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,44 @@ collection exists, so a reachable-but-uninitialized keyring still passes `New`
and surfaces `ErrNoDefaultCollection` lazily on the first operation, as before.
On macOS and Windows the check is a no-op (`New` never returns
`ErrKeychainUnavailable` there).

### Locked collections and the bounded unlock prompt

A reachable backend can still hold a locked collection. This is the default
state on headless hosts with SSH key-only logins: PAM has no password to
auto-unlock the login keyring, so it is locked after every keyring-daemon
restart.

Every store operation checks the lock state up front
(`ensureCollectionUnlocked`) and, when locked, issues a Secret Service
`Unlock`. On a passwordless keyring this completes silently via the null
prompt. On a password-protected keyring it opens the backend's unlock prompt.
The prompt wait is bounded twice:

- by the operation's `ctx`. This is the caller's original context, not the
`context.WithoutCancel` connection context, so a caller deadline bounds the
wait for the user while in-flight D-Bus calls stay protected from teardown.
- by an internal 30 second cap (`promptTimeout`), so a prompt nobody can
answer cannot block an operation forever.

When the unlock fails (prompt dismissed, timed out, or ctx expired), the
operation returns an error wrapping the exported `ErrCollectionLocked`
sentinel, naming the collection and keeping the prompt failure as the cause.
The same wrapping applies in the relock-retry loop (`withRelockRetry`) and
when a collection is still locked after the bounded retries.

Validated live on Ubuntu 24.04 with gnome-keyring: on a headless host the
unlock prompt does not hang. gnome-keyring dismisses it within milliseconds
when no prompter can start, so the locked error surfaces in about 15ms. The
30 second cap and the ctx bound cover the remaining case of a live prompter
with nobody answering.

Deliberately not built (see the decision log): prompter-presence probes (the
`org.gnome.keyring.SystemPrompter` name is activatable but unstartable on
headless hosts with gcr installed, and KWallet/KeePassXC never own it, so a
probe misclassifies in both directions), password callbacks, master-password
unlock via `InternalUnsupportedGuiltRiddenInterface`, and TTY prompting in
the library. The library reports the locked state; the caller owns the
remediation message. A locked collection must not be treated as unavailable;
it still holds the user's credentials, and falling back to another store
would split credentials across stores.
30 changes: 30 additions & 0 deletions store/keychain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,36 @@ daemon whether the Secret Service is registered and never touches your stored
secrets. On macOS and Windows the check is a no-op (and `ctx` is unused). See
[../docs/keychain/design.md](../docs/keychain/design.md) for details.

### Locked collections (Linux)

A reachable keychain can still hold a locked collection. This is the default
state on headless Linux hosts with SSH key-only logins: PAM has no password to
auto-unlock the login keyring, so it is locked after every keyring-daemon
restart.

A store operation that finds the collection locked asks the Secret Service to
unlock it. On a passwordless keyring this succeeds silently. On a
password-protected keyring it opens the backend's unlock prompt. If the prompt
is dismissed (gnome-keyring does this immediately when no prompter can be
shown), times out, or the operation's context expires, the operation fails
with an error matching `keychain.ErrCollectionLocked`:

```go
_, err := st.Get(ctx, id)
if errors.Is(err, keychain.ErrCollectionLocked) {
// The collection still holds the user's credentials. Tell the user how
// to unlock it, for example by logging in to the desktop session or
// running gnome-keyring-daemon --unlock. Do not fall back to another
// store; that would split credentials across stores.
}
```

The operation's `ctx` bounds the prompt wait, so a caller can set its own
deadline; an internal 30 second cap always applies. Unavailable means there is
no keychain to use, so fall back. Locked means the keychain and credentials
exist but need the user's help, so surface the remediation and do not fall
back.

### Secrets

The `keychain` assumes that any secret stored would conform to the `store.Secret`
Expand Down
45 changes: 31 additions & 14 deletions store/keychain/internal/go-keychain/secretservice/secretservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,14 @@ const ReplaceBehaviorDoNotReplace = 0
// ReplaceBehaviorReplace
const ReplaceBehaviorReplace = 1

// CreateItem
func (s *SecretService) CreateItem(collection dbus.ObjectPath, properties map[string]dbus.Variant, secret Secret, replaceBehavior ReplaceBehavior) (item dbus.ObjectPath, err error) {
// CreateItem creates an item in the collection; ctx bounds the prompt wait.
func (s *SecretService) CreateItem(
ctx context.Context,
collection dbus.ObjectPath,
properties map[string]dbus.Variant,
secret Secret,
replaceBehavior ReplaceBehavior,
) (item dbus.ObjectPath, err error) {
var replace bool
switch replaceBehavior {
case ReplaceBehaviorDoNotReplace:
Expand All @@ -431,23 +437,23 @@ func (s *SecretService) CreateItem(collection dbus.ObjectPath, properties map[st
if err != nil {
return "", fmt.Errorf("failed to create item: %w", err)
}
_, err = s.PromptAndWait(prompt)
_, err = s.PromptAndWait(ctx, prompt)
if err != nil {
return "", err
}
return item, nil
}

// DeleteItem
func (s *SecretService) DeleteItem(item dbus.ObjectPath) (err error) {
// DeleteItem deletes an item; ctx bounds the prompt wait.
func (s *SecretService) DeleteItem(ctx context.Context, item dbus.ObjectPath) (err error) {
var prompt dbus.ObjectPath
err = s.Obj(item).
Call("org.freedesktop.Secret.Item.Delete", NilFlags).
Store(&prompt)
if err != nil {
return fmt.Errorf("failed to delete item: %w", err)
}
_, err = s.PromptAndWait(prompt)
_, err = s.PromptAndWait(ctx, prompt)
if err != nil {
return err
}
Expand Down Expand Up @@ -501,8 +507,8 @@ func (s *SecretService) GetSecret(item dbus.ObjectPath, session Session) (secret
// NullPrompt
const NullPrompt = "/"

// Unlock
func (s *SecretService) Unlock(items []dbus.ObjectPath) (err error) {
// Unlock unlocks the given collections or items; ctx bounds the prompt wait.
func (s *SecretService) Unlock(ctx context.Context, items []dbus.ObjectPath) (err error) {
var dummy []dbus.ObjectPath
var prompt dbus.ObjectPath
err = s.ServiceObj().
Expand All @@ -511,15 +517,15 @@ func (s *SecretService) Unlock(items []dbus.ObjectPath) (err error) {
if err != nil {
return fmt.Errorf("failed to unlock items: %w", err)
}
_, err = s.PromptAndWait(prompt)
_, err = s.PromptAndWait(ctx, prompt)
if err != nil {
return fmt.Errorf("failed to prompt: %w", err)
}
return nil
}

// LockItems
func (s *SecretService) LockItems(items []dbus.ObjectPath) (err error) {
// LockItems locks the given collections or items; ctx bounds the prompt wait.
func (s *SecretService) LockItems(ctx context.Context, items []dbus.ObjectPath) (err error) {
var dummy []dbus.ObjectPath
var prompt dbus.ObjectPath
err = s.ServiceObj().
Expand All @@ -528,7 +534,7 @@ func (s *SecretService) LockItems(items []dbus.ObjectPath) (err error) {
if err != nil {
return fmt.Errorf("failed to lock items: %w", err)
}
_, err = s.PromptAndWait(prompt)
_, err = s.PromptAndWait(ctx, prompt)
if err != nil {
return fmt.Errorf("failed to prompt: %w", err)
}
Expand All @@ -545,15 +551,24 @@ func (p PromptDismissedError) Error() string {
return p.err.Error()
}

// promptTimeout caps how long PromptAndWait waits for a prompt to complete.
const promptTimeout = 30 * time.Second

// PromptAndWait displays the prompt and blocks until it completes, is
// dismissed, ctx is done, or promptTimeout elapses. A NullPrompt returns
// immediately.
//
// PromptAndWait is NOT thread-safe.
func (s *SecretService) PromptAndWait(prompt dbus.ObjectPath) (paths *dbus.Variant, err error) {
func (s *SecretService) PromptAndWait(ctx context.Context, prompt dbus.ObjectPath) (paths *dbus.Variant, err error) {
if prompt == NullPrompt {
return nil, nil
}
call := s.Obj(prompt).Call("org.freedesktop.Secret.Prompt.Prompt", NilFlags, "Keyring Prompt")
if call.Err != nil {
return nil, fmt.Errorf("failed to prompt: %w", call.Err)
}
// created once, outside the loop, so unrelated signals cannot reset it
timeout := time.After(promptTimeout)
for {
var result PromptCompletedResult
select {
Expand All @@ -575,7 +590,9 @@ func (s *SecretService) PromptAndWait(prompt dbus.ObjectPath) (paths *dbus.Varia
return nil, PromptDismissedError{errors.New("prompt dismissed")}
}
return &result.Paths, nil
case <-time.After(30 * time.Second):
case <-ctx.Done():
return nil, fmt.Errorf("prompt aborted: %w", ctx.Err())
case <-timeout:
return nil, errors.New("prompt timed out")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ func testKeyring(t *testing.T, mode AuthenticationMode) {
secret, err := session.NewSecret([]byte("secret"))
require.NoError(t, err)

err = srv.Unlock([]dbus.ObjectPath{collection})
err = srv.Unlock(t.Context(), []dbus.ObjectPath{collection})
require.NoError(t, err)

_, err = srv.CreateItem(collection, NewSecretProperties("testlabel", map[string]string{"foo": "bar"}), secret, ReplaceBehaviorReplace)
_, err = srv.CreateItem(t.Context(), collection, NewSecretProperties("testlabel", map[string]string{"foo": "bar"}), secret, ReplaceBehaviorReplace)
require.NoError(t, err)

items, err = srv.SearchCollection(collection, map[string]string{"foo": "bar"})
Expand All @@ -52,10 +52,10 @@ func testKeyring(t *testing.T, mode AuthenticationMode) {
require.NoError(t, err)
require.Equal(t, secretPlaintext, []byte("secret"))

err = srv.DeleteItem(gotItem)
err = srv.DeleteItem(t.Context(), gotItem)
require.NoError(t, err)

err = srv.LockItems([]dbus.ObjectPath{collection})
err = srv.LockItems(t.Context(), []dbus.ObjectPath{collection})
require.NoError(t, err)
}

Expand All @@ -71,16 +71,16 @@ func TestGetAll(t *testing.T) {
secret, err := session.NewSecret([]byte("secret"))
require.NoError(t, err)

err = srv.Unlock([]dbus.ObjectPath{collection})
err = srv.Unlock(t.Context(), []dbus.ObjectPath{collection})
require.NoError(t, err)

item, err := srv.CreateItem(collection, NewSecretProperties("testlabel", map[string]string{"username": "testuser"}), secret, ReplaceBehaviorReplace)
item, err := srv.CreateItem(t.Context(), collection, NewSecretProperties("testlabel", map[string]string{"username": "testuser"}), secret, ReplaceBehaviorReplace)
require.NoError(t, err)

attrs, err := srv.GetAttributes(item)
require.NoError(t, err)
require.Equal(t, attrs["username"], "testuser")

err = srv.DeleteItem(item)
err = srv.DeleteItem(t.Context(), item)
require.NoError(t, err)
}
13 changes: 13 additions & 0 deletions store/keychain/keychain.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ var ErrNoDefaultCollection = errors.New("no default keychain collection availabl
// ErrNoDefaultCollection lazily on the first operation, exactly as before.
var ErrKeychainUnavailable = errors.New("keychain backend unavailable")

// ErrCollectionLocked is returned by store operations when the keychain
// collection is locked and could not be unlocked: the unlock prompt was
// dismissed (gnome-keyring does this immediately when no prompter can be
// shown, e.g. on a headless host), timed out, or was aborted by the
// operation's context. Detect it with [errors.Is].
//
// Unlike [ErrKeychainUnavailable], the collection exists and still holds the
// user's credentials. Tell the user how to unlock it.
//
// It is declared in the cross-platform file so callers can reference it
// without build tags; it only matches on Linux.
var ErrCollectionLocked = errors.New("keychain collection is locked")

type (
Option interface{ apply(any) error }
optionFunc[K any] func(K) error
Expand Down
Loading
Loading