Skip to content
Open
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
39 changes: 39 additions & 0 deletions .github/workflows/spike-keyring.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: spike keyring probe

# Throwaway spike for devlyft/devlyft#261: runs hack/keyring-probe against the
# OS credential store on each hosted runner. Manual only; uses no secrets.
on:
workflow_dispatch:

permissions:
contents: read

jobs:
probe:
name: probe (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
- name: Build probe
run: go build -o keyring-probe${{ runner.os == 'Windows' && '.exe' || '' }} ./hack/keyring-probe
- name: Probe (runner as-is)
shell: bash
run: ./keyring-probe -timeout 20s -sizes 512,1024,2048,2560,2561,2900,3000,4096,8192
- name: Probe (Linux, gnome-keyring unlocked on a private session bus)
if: runner.os == 'Linux'
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq gnome-keyring dbus-x11
dbus-run-session -- sh -c '
printf "probe-password" | gnome-keyring-daemon --unlock --components=secrets >/dev/null
./keyring-probe -timeout 20s -sizes 512,2560,8192,65536
'
89 changes: 89 additions & 0 deletions docs/spikes/2026-10-credential-storage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Spike: credential storage on headless Linux, CI containers, WSL and Windows

Issue: devlyft/devlyft#261 · Date: 2026-10-25 · Library: `github.com/zalando/go-keyring` v0.2.8

## Question

Can the CLI rely on `zalando/go-keyring` for stored credentials (design spec §5.2) everywhere it will run, and do our tokens fit in Windows Credential Manager's per-secret limit?

## Short answer

- **Size is not a problem.** Today's API tokens are 47 bytes. The largest realistic OAuth credential (a CLI access JWT, a refresh token and metadata, stored as one JSON blob) is about 0.8 KB, and 1.3 KB with a 254-character email. The tightest limit is Windows at 2560 bytes of raw blob, which go-keyring does not convert to UTF-16, so the limit is not halved. macOS caps out around 2.9 KB.
- **Availability is the problem.** On WSL, in containers, on GitHub-hosted Linux runners and on most headless servers there is no Secret Service. go-keyring fails there, quickly and with an error type we can't check with `errors.Is`. In one case it also spawns a stray `dbus-daemon` as a side effect.
- **Hangs are possible but did not reproduce.** go-keyring has no timeout or context support, and it waits on an unlock prompt forever (`secret_service.go:208`). The GitHub CLI wraps every call in a 60 s timeout because of reported hangs on WSL and Windows ([cli/cli#7580](https://github.com/cli/cli/pull/7580), [zalando/go-keyring#125](https://github.com/zalando/go-keyring/issues/125)). None of our headless cases hung, because they all failed before a prompt.

**Recommendation:** keep go-keyring, but put it behind our own `credstore` interface. Add a timeout, treat any keyring error as "unavailable", and fall back to the `0600` file with a one-time warning, as the spec already plans. Store only the refresh token and metadata in the keyring, and keep access tokens in memory. Details are in [Recommendations](#recommendations).

## Method

`hack/keyring-probe` stores, reads back and deletes base64url secrets of increasing size (512 B to 64 KB) through go-keyring. It wraps each call in a timeout and reports the outcome, latency and error type. It was run on WSL2 and in Docker. `.github/workflows/spike-keyring.yml` (manual dispatch only, hosted runners, no secrets) runs the same probe on Ubuntu, macOS and Windows to confirm the source-derived numbers below.

## Results

### Environment matrix (measured)

| # | Environment | Result | Error returned | Latency |
|---|---|---|---|---|
| A1 | WSL2 Ubuntu, systemd user session bus, no Secret Service provider installed (the WSL default) | Fails | `dbus.Error`: `The name org.freedesktop.secrets was not provided by any .service files` | ≤ 2 ms |
| A2 | Same, `DBUS_SESSION_BUS_ADDRESS` unset | Fails, same error. godbus falls back to `/run/user/<uid>/bus` ([conn_other.go:43-76](https://github.com/godbus/dbus/blob/v5.2.2/conn_other.go#L43-L76)) | as A1 | ≤ 2 ms |
| A3 | Same, `DBUS_SESSION_BUS_ADDRESS` and `XDG_RUNTIME_DIR` unset | Fails, same error. The socket is still found by uid | as A1 | ≤ 2 ms |
| B0 | Container `golang:1.26`, `CI=true`, no D-Bus at all | Fails | `*exec.Error`: `exec: "dbus-launch": executable file not found in $PATH`. godbus tries to **autolaunch** a bus ([conn_other.go:19](https://github.com/godbus/dbus/blob/v5.2.2/conn_other.go#L19)) | < 1 ms |
| B1 | Container `debian:bookworm-slim` with `dbus-x11`, no provider | Fails, after **spawning a `dbus-daemon` via `dbus-launch`** that outlives the CLI | `dbus.Error` as A1 | 9 ms |
| B2 | Container with `gnome-keyring` installed, fresh session bus, no unlocked keyring, no display | Fails. The daemon is D-Bus-activated, but no login keyring exists and there is no prompter | `*errors.errorString`: `failed to unlock correct collection '/org/freedesktop/secrets/aliases/default'` | 11 ms |
| B3 | Container with `gnome-keyring` unlocked from stdin (`gnome-keyring-daemon --unlock`) | **Works**, every size up to 64 KB, round trip exact | — | set 5–14 ms, get 1–7 ms |

Not reproduced here: a desktop Linux session with a **locked** keyring where the prompt appears on a screen nobody is looking at, for example over SSH into a workstation. `handlePrompt` then blocks on `<-promptSignal` with no timeout ([secret_service.go:187-219](https://github.com/zalando/go-keyring/blob/v0.2.8/secret_service/secret_service.go#L187-L219)). KeePassXC as the provider is also reported to freeze ([#88](https://github.com/zalando/go-keyring/issues/88)). Upstream has open requests for a context-aware API ([#131](https://github.com/zalando/go-keyring/issues/131)) and for failing fast when non-interactive ([#118](https://github.com/zalando/go-keyring/issues/118)).

### Per-platform limits (from source; confirm with the dispatch workflow)

| Platform | Backend | Limit on the secret | Notes |
|---|---|---|---|
| Windows | Credential Manager via `danieljoos/wincred` | **2560 bytes** (`CRED_MAX_CREDENTIAL_BLOB_SIZE` = 5 × 512), checked before the call, returns `keyring.ErrSetDataTooBig` ([keyring_windows.go:29](https://github.com/zalando/go-keyring/blob/v0.2.8/keyring_windows.go#L29)) | The blob is the raw bytes of the Go string (`CredentialBlob = []byte(password)`, [keyring_windows.go:46](https://github.com/zalando/go-keyring/blob/v0.2.8/keyring_windows.go#L46)). wincred converts only the target and user names to UTF-16, so the capacity is **not halved**. Stored with `CRED_PERSIST_LOCAL_MACHINE`, so it does not roam. |
| macOS | `/usr/bin/security -i` | The whole `add-generic-password …` command line must be ≤ 4096 bytes ([keyring_darwin.go:87](https://github.com/zalando/go-keyring/blob/v0.2.8/keyring_darwin.go#L87)). The secret is base64-encoded with a 18-byte prefix, so **≈ 2.9 KB** of raw secret with our service and account names | The secret goes to `security` **over stdin**, not argv ([keyring_darwin.go:76](https://github.com/zalando/go-keyring/blob/v0.2.8/keyring_darwin.go#L76)), so it does not appear in `ps`. Before [5d2b3ba](https://github.com/zalando/go-keyring/commit/5d2b3ba) (Oct 2021) go-keyring passed `-w <password>` on argv, so don't use versions that old. Minor upstream bug: the size check runs after `cmd.Start()` ([:82](https://github.com/zalando/go-keyring/blob/v0.2.8/keyring_darwin.go#L82)), so an oversized write leaves a `security -i` child blocked on stdin until the CLI exits. That is harmless for a short-lived CLI. |
| Linux/BSD | Secret Service over the D-Bus session bus | No practical limit (64 KB measured) | Uses a `"plain"` session ([secret_service.go:66](https://github.com/zalando/go-keyring/blob/v0.2.8/secret_service/secret_service.go#L66)), so the secret crosses the session bus unencrypted. Only processes of the same user can see it, which is the same trust boundary as the `0600` file. |

### Token sizes

Calculated from the monorepo: `pkg/user/jwt` signs ES256 with a numeric `kid`, and `pkg/domains/apitoken` generates `dvl_` plus 32 random bytes in base64url. CLI claims are taken from #223 and #224 (`aud`, `azp`, `jti`, `sid`, token type). The issuer is assumed to be `https://api.devlyft.io`.

| Credential | Typical email (34 chars) | 254-char email (RFC maximum) |
|---|---|---|
| API token `dvl_…` (`--with-token`, `DEVLYFT_API_TOKEN`) | 47 B | 47 B |
| Web JWT today (`sub`, `iss`, `iat`, `nbf`, `exp`, `email`) | 370 B | 663 B |
| CLI access JWT (+ `aud`, `jti`, `azp`, `sid`, `typ`) | 570 B | 863 B |
| Refresh token (assumed opaque, 32 random bytes plus prefix) | 48 B | 48 B |
| Full `oauth2.Token`-style JSON (access, refresh, expiry, account, api_url) | 802 B | 1315 B |
| Same, as macOS actually stores it (base64 plus prefix) | 1090 B | 1774 B |
| Refresh-only JSON (refresh, refresh expiry, account, api_url) | 190 B | 410 B |

Everything fits on every platform with at least 2× headroom, except a full token blob with a maximum-length email on Windows (1315 of 2560 bytes). The real risk is future growth of the JWT, such as scopes, org lists or roles.

## Recommendations

1. **Wrap go-keyring in `internal/auth/credstore`** with `Get`, `Set` and `Delete`, plus a `file` and a `keyring` implementation, mocked with gomock in tests.
- Run every keyring call in a goroutine with a timeout. Use 3 s when stdin is not a TTY, and 60 s at a terminal so the user can answer an unlock prompt, as `gh` does. On timeout, return a typed `ErrKeyringTimeout`.
- Map `keyring.ErrNotFound` to our own `ErrNotFound`. Treat **any other** error (`dbus.Error`, `*exec.Error`, `errors.errorString`, `ErrSetDataTooBig`, a timeout) as "keyring unavailable". go-keyring has no sentinel for "no Secret Service", so we cannot match on the error type.
2. **Decide the store once per credential write, and record it.** Try the keyring. If it is unavailable, write `~/.config/devlyft/credentials.json` (`0600`, directory `0700`, atomic rename) and print one warning to stderr naming the reason. Record `credential_store: keyring|file` for the account in config, so reads go straight to the right store and don't probe D-Bus on every command.
3. **Don't touch the keyring when it can't help:**
- Skip it when `DEVLYFT_API_TOKEN` is set.
- Skip it under `--insecure-storage`.
- Skip it on Linux when no session bus socket exists (`DBUS_SESSION_BUS_ADDRESS` unset and no `/run/user/<uid>/bus`). This avoids godbus **autolaunching `dbus-launch`** and leaving a daemon behind (case B1).
- Skip it when `CI` is set or a container marker exists (`/.dockerenv`, `/run/.containerenv`, or `container` set). Containers and CI never have a usable keyring and file storage is expected there; the warning can say so.
4. **Store only what needs to persist.** For OAuth logins, keep the **refresh token plus metadata** (≈ 0.2–0.4 KB) in the keyring and hold the 15-minute access token in memory. Re-mint it on start-up with one refresh call. This keeps us far from the Windows limit even if the JWT grows, and never persists a bearer credential that works against the API by itself. If start-up latency matters later, cache the access token in the file store with its expiry. It is low value once it expires.
5. **WSL:** treat it as headless Linux. A Linux binary in WSL cannot reach Windows Credential Manager, and no Secret Service is installed by default. The file fallback is the right default. In the warning, mention that installing `gnome-keyring` (and unlocking it) enables keyring storage.
6. **Warning text** (stderr, once per write, suppressed by `--quiet`):
```
Warning: no system keyring available (<reason>); credentials stored in ~/.config/devlyft/credentials.json (mode 0600).
Use DEVLYFT_API_TOKEN in CI instead of `auth login --with-token`.
```
`<reason>` is one of `no D-Bus session`, `no Secret Service provider`, `keyring is locked`, `keyring timed out`, `running in CI` or `running in a container`.
7. **Pin go-keyring ≥ v0.2.8** and watch [#131](https://github.com/zalando/go-keyring/issues/131) (context API). If it lands, drop our goroutine wrapper.

## Proposed changes to other issues

- **#245 (C4, token auth and credential store):** add the `credstore` wrapper and its timeout, the skip rules and the recorded `credential_store` choice to *What*. Add the warning text and reasons. Add tests for: no session bus (no D-Bus call made), `CI` set, container marker, keyring error leading to file fallback, timeout leading to file fallback, and the `0600`/`0700` modes.
- **#263 (C12a, browser login):** store only the refresh token, its expiry, the account and the API URL in the credential store. Keep the access token in memory and refresh it on first use per process. The refresh-token rotation write must handle a keyring failure mid-session by falling back to the file *and* deleting the stale keyring entry, so no old refresh token is left behind (a reused old token would revoke the whole family).

## Cleanup

`hack/keyring-probe` and `.github/workflows/spike-keyring.yml` exist only for this spike. Delete both once #245 lands with its own tests.
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/itchyny/gojq v0.12.19
github.com/oapi-codegen/runtime v1.6.0
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
go.uber.org/mock v0.6.0
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/mod v0.42.0
Expand All @@ -19,9 +20,11 @@ require (

require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.8 // indirect
github.com/josharian/intern v1.0.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand All @@ -27,6 +29,8 @@ github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16p
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
Expand Down Expand Up @@ -111,6 +115,8 @@ github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
Expand All @@ -123,6 +129,8 @@ github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
Expand Down
Loading
Loading