Skip to content

Add credstore package for keychain-backed credential storage#3

Open
emsearcy wants to merge 11 commits into
mainfrom
lfxv2-2514-keyring-credential-storage
Open

Add credstore package for keychain-backed credential storage#3
emsearcy wants to merge 11 commits into
mainfrom
lfxv2-2514-keyring-credential-storage

Conversation

@emsearcy

@emsearcy emsearcy commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements internal/credstore, a credential storage abstraction for the LFX CLI backed by github.com/99designs/keyring, addressing LFXV2-2514.

  • Credentials (refresh token, cached access token + expiry) are stored in a real system credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service/KWallet/pass) by default. keyring's own file backend is deliberately excluded — it isn't a real system keychain, and its delete-on-missing-key behavior doesn't match keyring.ErrKeyNotFound. If no system backend is available, New returns an error directing the user to --insecure-storage.
  • When --insecure-storage is passed, the system keychain is bypassed entirely in favor of a plain (unencrypted), owner-only (0600 on POSIX; on Windows this relies on inherited directory permissions, since Chmod(0600) doesn't install a real ACL there) JSON file — for headless/CI use where a passphrase prompt is unacceptable. The 0600 mode is explicitly enforced on every write (not just file creation), and writes are atomic (temp file + rename) so a failed write can't corrupt a previously valid file.
  • Non-sensitive DeviceState (device ID, IdP domain) is always stored as plain JSON under the XDG state directory (~/.local/state/lfx-cli/ by default), per XDG Base Directory conventions. A relative $XDG_STATE_HOME is ignored per spec.
  • Wires the shared --insecure-storage flag onto the lfx auth command group. The login/token/status/logout actions remain stubs, to be implemented in LFXV2-2515 and LFXV2-2516, which will consume credstore.Store.

Release pipeline changes

This PR also replaces GoReleaser with a single hand-rolled GitHub Actions job (.github/workflows/release-tag.yml), triggered by review feedback that macOS release binaries had no working Keychain backend:

  • Why GoReleaser was dropped: KeychainBackend in github.com/99designs/keyring is compiled only under the darwin && cgo build tag. The original GoReleaser config cross-compiled every platform from ubuntu-latest with CGO_ENABLED=0, so released macOS binaries silently shipped without Keychain support. Fixing this requires a native macOS build with cgo enabled, at which point GoReleaser's remaining value (archive naming, checksums.txt, changelog, GitHub release upload) was either already being hand-rolled to support that native build, or dead weight — the changelog in particular was already inert, since gh release create --generate-notes sets the release body before the tag-push workflow runs, and GoReleaser's default keep-existing mode won't overwrite it.
  • New architecture: since CGO_ENABLED=0 cross-compilation works from any host OS, all 5 targets (linux/amd64, linux/arm64, windows/amd64, darwin/amd64, darwin/arm64) now build in a single job on macos-latest — linux/windows cross-compiled as before, darwin built natively with CGO_ENABLED=1. One checksums.txt, one gh release upload call, no cross-job artifact merging.
  • Reproducible builds: binaries are built with -trimpath and every archived file (binary + LICENSE/LICENSE-docs/README.md) has its mtime pinned to the commit timestamp, since actions/checkout sets file mtimes to checkout time (git doesn't track per-file mtimes at all). Byte-identical archive compression isn't attempted/guaranteed (gzip internals aren't necessarily deterministic across environments), but the binary content and all recorded metadata are.

Testing

  • make check (fmt, vet, lint, revive) passes.
  • Verified locally on macOS with a throwaway smoke test (not committed): real Keychain round-trip (save/load/delete, no passphrase prompt) and plain-file fallback round-trip both pass; confirmed via security find-generic-password that DeleteCredentials properly removes the Keychain item.
  • Verified the owner-only file-permission fix with a standalone repro (pre-existing 0644 file ends up 0600 after write).
  • Verified the KeyCtlBackend and pass/PassPrefix behavior against the vendored github.com/99designs/keyring@v1.2.2 source.
  • Verified the release workflow's build/archive/checksum logic locally end-to-end (all 5 targets, including a simulated fresh git clone to confirm mtime pinning works without relying on pre-existing local file timestamps).
  • No repo test suite exists yet; none added per current project conventions.

Jira

LFXV2-2514

🤖 Generated with GitHub Copilot (via OpenCode)

Introduces internal/credstore, a credential storage abstraction for the
LFX CLI backed by github.com/99designs/keyring:

- Credentials (refresh token, cached access token + expiry) are stored in
  the system keychain (macOS Keychain, Windows Credential Manager, Linux
  Secret Service/KWallet) by default, with keyring's own
  passphrase-encrypted file backend used automatically when no system
  keychain is available.
- When --insecure-storage is passed, the system keychain is bypassed
  entirely in favor of a plain (unencrypted), owner-only (0600) JSON file,
  for headless/CI use where a passphrase prompt is unacceptable.
- Non-sensitive DeviceState (device ID, IdP domain) is always stored as
  plain JSON under the XDG state directory (~/.local/state/lfx-cli/ by
  default), per XDG Base Directory conventions.

Wires the shared --insecure-storage flag onto the `lfx auth` command
group; the login/token/status/logout actions remain stubs pending
LFXV2-2515 and LFXV2-2516.

Verified locally on macOS: real Keychain round-trip (save/load/delete,
no passphrase prompt) and plain-file fallback round-trip both pass.

Assisted-by: github-copilot:claude-sonnet-5
Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 17, 2026 20:17
@emsearcy
emsearcy requested a review from a team as a code owner July 17, 2026 20:17
Comment thread go.mod
Comment thread go.mod

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds keychain-backed credential and device-state storage for future authentication commands.

Changes:

  • Adds secure keyring and optional plaintext credential backends.
  • Persists device state under the XDG state directory.
  • Adds the shared --insecure-storage auth flag and dependencies.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.

File Description
internal/credstore/credstore.go Implements credential and device-state storage.
internal/commands/auth.go Adds the insecure-storage flag.
go.mod Adds keyring dependencies.
go.sum Records dependency checksums.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/credstore/credstore.go
Comment thread internal/credstore/credstore.go
Comment thread internal/credstore/credstore.go Outdated
Comment thread internal/commands/auth.go
emsearcy added 2 commits July 17, 2026 14:11
- Restrict keyring.Open to real system credential stores (macOS Keychain,
  Windows Credential Manager, Linux Secret Service/KWallet/pass), dropping
  keyring's automatic passphrase-encrypted file fallback. If none of those
  backends are available, New now returns an error directing the user to
  --insecure-storage instead of silently falling back to a file.
- Add defensive os.ErrNotExist handling in keyringSecrets.Delete, since
  some backends (e.g. pass) may surface that instead of
  keyring.ErrKeyNotFound for a missing key.
- Reject relative $XDG_STATE_HOME per the XDG Base Directory
  Specification, falling back to ~/.local/state/lfx-cli instead of
  resolving state paths relative to the process's working directory.
- Document --insecure-storage usage in the README.

Addresses Copilot review comments on PR #3.

Assisted-by: github-copilot:claude-sonnet-5
Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Assisted-by: github-copilot:claude-sonnet-5
Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 17, 2026 21:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 4 comments.

Comment thread internal/credstore/credstore.go Outdated
Comment thread internal/credstore/credstore.go
Comment thread internal/credstore/credstore.go Outdated
Comment thread internal/credstore/credstore.go
Run go get -u ./... && go mod tidy, picking up minor version bumps for
transitive keyring dependencies (wincred, jose2go, x/sys, x/term,
go-md2man). Also documents this as a required step before every PR in
AGENTS.md's Contributing Guidelines.

Assisted-by: github-copilot:claude-sonnet-5
Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 17, 2026 21:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 6 changed files in this pull request and generated 4 comments.

Comment thread internal/credstore/credstore.go
Comment thread internal/credstore/credstore.go Outdated
Comment thread internal/credstore/credstore.go Outdated
Comment thread internal/credstore/credstore.go
emsearcy added 2 commits July 17, 2026 14:49
Fixes cspell warnings from make megalinter for words introduced by the
credstore package and existing Makefile content: credstore, fprintln,
gobin, gopath, mgechev, esac.

Assisted-by: github-copilot:claude-sonnet-5
Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Independently verified each of the following before fixing:

- os.WriteFile only applies its mode argument when creating a new file,
  silently preserving the mode of one that already exists. A pre-existing
  0644 credentials.json (or state.json) therefore stayed world-readable
  even after writing "owner-only" data. Added writeOwnerOnlyFile, which
  opens with O_TRUNC and explicitly chmods 0600 regardless of prior state,
  and use it in both plainFileSecrets.Save and SaveDeviceState. Verified
  with a standalone repro: a pre-existing 0644 file now ends up 0600 after
  a write.

- keyring.KeyCtlBackend's opener calls getKeyringForScope(cfg.KeyCtlScope);
  with the zero-value scope this package leaves unconfigured, that always
  errors, and keyring.Open silently skips the backend on error. Confirmed
  against the vendored keyring v1.2.2 source. Removed it from
  systemBackends since it can never actually open.

- keyring's pass backend namespaces entries via Config.PassPrefix, not
  ServiceName (confirmed against pass.go). Without it, our credentialsKey
  would be stored as a generic top-level "credentials" entry in the user's
  password store, risking collisions with unrelated pass entries. Set
  PassPrefix to serviceName.

- Fixed stale doc comments in Store.SaveCredentials and Options.Insecure
  still describing an encrypted-file fallback that no longer exists.

Re-verified the real macOS Keychain round-trip locally after these
changes.

Assisted-by: github-copilot:claude-sonnet-5
Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 17, 2026 21:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 7 changed files in this pull request and generated 3 comments.

Comment thread internal/credstore/credstore.go
Comment thread internal/credstore/credstore.go Outdated
Comment thread internal/credstore/credstore.go Outdated
Address review comments from copilot-pull-request-reviewer[bot]:

- .goreleaser.yaml, .github/workflows/release-tag.yml: build darwin
  binaries natively on macos-latest with CGO_ENABLED=1 instead of
  cross-compiling from ubuntu-latest with CGO_ENABLED=0. keyring's
  KeychainBackend is compiled only under the "darwin && cgo" build
  tag, so released macOS binaries previously had no working Keychain
  backend. Darwin archives and checksums are uploaded as additional
  release assets after the main GoReleaser job.
- internal/credstore/credstore.go: made writeOwnerOnlyFile atomic by
  writing to a temp file in the same directory and renaming it into
  place, instead of truncating the destination in place. A failed
  write can no longer erase a previously valid stored credentials
  file.
- internal/credstore/credstore.go: corrected doc comments to note
  that Chmod(0600) does not enforce owner-only access on Windows
  (Go maps it to the read-only attribute, not a real ACL), rather
  than overpromising confidentiality there.
- .cspell.json: added binpath, mktemp, pipefail to the word list for
  the new release-tag.yml darwin job.

Resolves 3 review threads.

Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 17, 2026 23:03
@emsearcy

Copy link
Copy Markdown
Contributor Author

Review Feedback Addressed

Commit: a0df0ec

Changes Made

  • .goreleaser.yaml, .github/workflows/release-tag.yml: darwin binaries are now built natively on a macos-latest runner with CGO_ENABLED=1 in a new darwin job, instead of cross-compiled from ubuntu-latest with CGO_ENABLED=0. keyring's KeychainBackend only compiles under darwin && cgo, so released macOS binaries previously shipped with no working Keychain backend. Darwin archives and checksums are uploaded as additional release assets (per copilot-pull-request-reviewer[bot])
  • internal/credstore/credstore.go: writeOwnerOnlyFile now writes to a temp file in the same directory and atomically renames it into place, instead of truncating the destination file before the write completes (per copilot-pull-request-reviewer[bot])
  • internal/credstore/credstore.go: corrected doc comments to accurately state that Chmod(0600) doesn't enforce owner-only access on Windows (Go maps it to the read-only attribute, not a real ACL) (per copilot-pull-request-reviewer[bot])
  • .cspell.json: added binpath, mktemp, pipefail for the new release-tag.yml darwin job

Threads Resolved

3 of 3 unresolved Copilot review threads addressed in this iteration.

Not Addressed

The 2 github-license-compliance threads on go.mod (dependency license policy flags) are left open, out of scope for this iteration per maintainer direction.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 2 comments.

Comment thread README.md Outdated
Comment thread .github/workflows/release-tag.yml Outdated
Address review comments from copilot-pull-request-reviewer[bot]:

- README.md: documented that the owner-only insecure-storage file's
  0600 permissions don't translate to a real ACL on Windows, so
  users there shouldn't assume the plaintext tokens are protected by
  file mode alone.
- .github/workflows/release-tag.yml: made the darwin job's release
  asset upload retry-safe. Added --clobber to the archive upload so
  a rerun doesn't fail on already-existing assets, and filter out
  any previously-appended darwin checksum lines before re-appending
  them so reruns don't duplicate entries in checksums.txt.

Resolves 2 review threads.

Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 17, 2026 23:10
@emsearcy

Copy link
Copy Markdown
Contributor Author

Additional Review Feedback Addressed

Commit: 6b363b9

Copilot ran another pass after the previous push and found 2 more items:

Changes Made

  • README.md: documented that the insecure-storage file's 0600 owner-only mode doesn't translate to a real ACL on Windows (per copilot-pull-request-reviewer[bot])
  • .github/workflows/release-tag.yml: made the darwin job's asset upload retry-safe — --clobber on the archive upload, and filtering to avoid duplicate checksum lines on rerun (per copilot-pull-request-reviewer[bot])

Threads Resolved

2 of 2 new unresolved threads addressed in this iteration.

Still Open

  • The 2 github-license-compliance threads on go.mod remain open, out of scope per maintainer direction.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.

Comment thread .github/workflows/release-tag.yml Outdated
Address review comment from copilot-pull-request-reviewer[bot]:

- .github/workflows/release-tag.yml: the previous full-line grep
  filter only removed a darwin checksums.txt entry if its hash
  exactly matched the newly built archive. Since rebuilt archives
  get new hashes on every run (from embedded timestamps), a rerun
  would leave the stale entry in place and append a conflicting one
  for the same filename. Filter by filename instead, so reruns
  always replace prior darwin entries rather than duplicating them.

Resolves 1 review thread.

Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 17, 2026 23:57
@emsearcy

Copy link
Copy Markdown
Contributor Author

Additional Review Feedback Addressed

Commit: 506d8fc

Copilot found one more issue on the previous fix:

Changes Made

  • .github/workflows/release-tag.yml: the checksums.txt dedup filter was matching on full line (hash+filename), so a rerun with a rebuilt (different-hash) archive would leave the stale entry in place. Now filters by filename only (per copilot-pull-request-reviewer[bot])

Threads Resolved

1 of 1 new unresolved thread addressed in this iteration.

Still Open

  • The 2 github-license-compliance threads on go.mod remain open, out of scope per maintainer direction.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 1 comment.

Comment thread .github/workflows/release-tag.yml Outdated
GoReleaser's only remaining value in this pipeline (archive naming,
checksums.txt, GitHub release asset upload) was already being
hand-rolled for the darwin-native job added to fix the cgo/Keychain
issue, plus a cross-job checksums.txt merge step. Its changelog
generation was dead code: gh release create --generate-notes already
sets the release body before the tag-push workflow runs, and
GoReleaser's default keep-existing mode won't overwrite it.

CGO_ENABLED=0 cross-compilation works from any host OS, so linux and
windows binaries don't need ubuntu-latest specifically. This
collapses the previous two-job split (ubuntu-latest running
GoReleaser for linux/windows, macos-latest natively building darwin
with a separate checksum-merge step) into a single macos-latest job
that builds and archives all 5 targets and uploads them in one pass,
with one checksums.txt and no merge logic needed.

Also carries forward GoReleaser's own recommended reproducible-builds
recipe (pin mtime to commit timestamp, -trimpath) for all 5 binaries,
and extends it to the non-binary archived files (LICENSE, README):
actions/checkout sets file mtimes to checkout time since git doesn't
track per-file mtimes at all, so those need pinning too for
consistent archives across runs.

- .goreleaser.yaml: removed.
- .github/workflows/release-tag.yml: single `release` job replacing
  the `goreleaser` + `darwin` jobs.
- Makefile, AGENTS.md: removed GoReleaser references.
- .cspell.json: updated word list for the new script's vocabulary.

Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 18, 2026 00:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.

bsdtar's built-in -z (gzip) writer embeds the current wall-clock time
in the gzip header, independent of the archived files' own mtimes.
Even with every archived file's mtime pinned to the commit timestamp,
this made the tar.gz bytes differ on every run. Fixed by piping an
uncompressed tar stream through `gzip -n`, which omits the timestamp
(and original filename) from the gzip header.

Verified locally: two separate builds from separate git clones (with
a 2-second gap) now produce byte-identical tar.gz archives; zip
archives were already reproducible without this change, since zip
timestamps are stored per-entry rather than in a container-level
header.

Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
Copilot AI review requested due to automatic review settings July 18, 2026 00:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.

return err
}

return os.Rename(tmpPath, path)
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.

2 participants