Skip to content

feat: add a keyring provider for the OS credential store - #150

Open
husniadil wants to merge 7 commits into
securestart:mainfrom
husniadil:feat/keyring-provider
Open

feat: add a keyring provider for the OS credential store#150
husniadil wants to merge 7 commits into
securestart:mainfrom
husniadil:feat/keyring-provider

Conversation

@husniadil

Copy link
Copy Markdown

Stacked on #149. This branch builds on fix/json-value-stringification,
so until that merges the diff below also shows #149's commits. The keyring
work is the last four commits. Happy to rebase once #149 lands, or to close
this and reopen if you would rather take them separately.

What this adds

A keyring provider that reads a secret from the operating system's credential
store: Keychain on macOS, Credential Manager on Windows, Secret Service on Linux.

providers:
  - kind: keyring
    id: db
    service: myapp
    user: postgres

sstart already depends on zalando/go-keyring and already uses it for the
secrets cache and for OIDC token storage. Nothing exposed it as a source of
secrets, so the one credential store a developer's machine already has — backed
up by the OS backup tool, unlocked by the OS login — was the one place sstart
could not read from.

No build tags and no platform gating: go-keyring ships a native backend for
each platform, so there is no platform to reject.

Why it is named keyring

The codebase already says "keyring" everywhere, including the exported
StorageBackendKeyring = "keyring" in internal/oidc/storage.go. "Keychain" is
Apple's product name, and naming it that would hide the provider from Windows
and Linux users who are equally supported.

Reading into a payload

A credential item often holds a JSON blob rather than one secret. Read whole,
every secret inside it is exported to the child process. The optional pointer
field selects one node first:

  - kind: keyring
    id: claude
    service: Claude Code-credentials
    user: husni
    pointer: /claudeAiOauth/accessToken
    key: CLAUDE_TOKEN

pointer is an RFC 6901 JSON pointer rather than a dotted path because real key
names contain dots, pipes and colons — a live example is
plugin:engineering:github|1eea5f27. RFC 6901 already defines the escaping, and
gives array indices (/scopes/0) for free.

It is called pointer and not path because path already means "where the
secret lives" in dotenv, infisical and vault. A test asserts that a stray
path: is not silently read as a pointer.

Note that no application's schema is encoded in sstart. The pointer lives in the
user's config, so a different vendor's credential shape needs a different
pointer, not a new sstart release.

Behaviour

Without pointer, a JSON object is expanded into one variable per key, honouring
keys exactly as the cloud providers do; anything else becomes a single variable
named <PROVIDER_ID>_SECRET, or key if given. With pointer, the selected node
goes through the same rule. Values follow the conversion in #149.

service and user are both required: go-keyring exposes only
Get/Set/Delete/DeleteAll, so without enumeration an item is unreachable
without its exact identity.

Two deliberate choices worth flagging for review:

An unavailable keyring is an error, not a silent skip. The cache disables
itself quietly because a miss only costs a re-fetch. A provider that yields
nothing instead hands the child process an environment with no secrets in it.

No sync.Once availability probe, unlike internal/cache/cache.go. The
provider makes exactly one keyring call regardless, so a probe would only add a
second credential-store access — and on macOS a second access prompt — to learn
what the real call is about to report. Mapping the error from Get gives the
same two outcomes with no package-level mutable state.

Read-only

There is no write path, and no sstart keyring set. Adding a write command
changes what sstart is, and that is a call for you to make rather than something
to slip in with a provider. CONFIGURATION.md documents how to populate the
store with each platform's own tool.

Verification

  • 27 unit tests for the provider, against go-keyring's MockInit() and
    MockInitWithError() — no OS keyring and no container needed, including the
    keyring-unavailable path.
  • Pointer resolver tested separately: escaped ~0/~1, array indices, keys
    containing | and :, and the error cases.
  • One test asserts a pointer does not leak its siblings, which is the entire
    reason the field exists.
  • Real macOS Keychain: whole-item read, pointer narrowing, and a missing-node
    error all verified against an item created with security add-generic-password
    and deleted afterwards.
  • Real Linux, inside golang:1.25: both packages pass, the binary builds with
    CGO, and a headless host with no Secret Service produces
    exec: "dbus-launch": executable file not found in $PATH. On Linux this usually means no Secret Service is running, which is common on headless hosts.
    errors.Is(err, ErrNotFound) is false there, so the unavailable branch is
    taken rather than the not-found one.
  • Full -short ./tests/end2end/... passes locally with Docker (365s).
  • go build ./... and go vet clean.

Not verified: Windows Credential Manager. I have no Windows machine to test
on, so the docs make no claim about it beyond the backend go-keyring uses.

internal/cache, internal/provider/gcsm and internal/provider/vault unit
tests fail on main today. I confirmed they fail identically at this branch's
starting commit, before any of this work. #148 fixes them.

Registration

internal/cli/root.go gets the blank import. Without it the package compiles and
every test passes while the binary reports unknown provider kind: keyring
the same failure #146 fixes for azure_keyvault. Verified by building the binary
and running it against a keyring config.

husniadil and others added 7 commits August 2, 2026 13:10
Secrets whose payload is JSON were rendered with fmt.Sprintf("%v"), which is
Go's debug formatting rather than JSON. The value a process received was then
not the value that was stored:

  1754110382          -> "1.754110382e+09"
  ["a","b"]           -> "[a b]"
  {"token":"secret"}  -> "map[token:secret]"

The first case is the damaging one. JSON has no integer type, so every number
decodes as float64, and %v prints large float64 values in scientific notation.
Any large integer in a secret — a Unix timestamp, an account id, a port —
arrived corrupted, with no error to indicate it.

Nothing surfaced this because the conversion cannot fail: %v accepts any value
and always produces a string, so a type it handles badly still looks like a
successful fetch. The failure appears later, in the process that receives the
value.

DecodeSecretJSON keeps numbers as json.Number so their original text survives,
and StringifyValue re-encodes arrays and objects as JSON so the receiving
process can parse them back. Scalars keep their literal form.

Four providers decode JSON payloads and are affected: aws_secretsmanager,
gcloud_secretmanager, azure_keyvault, and bitwarden in 'note' format.
1password, infisical and bitwarden_sm build string-only maps from their SDKs,
so %v was already a no-op there; they move to the shared helper so the
behaviour cannot drift apart later.

Payloads that are not JSON still fall back to being treated as a single value,
including the case of trailing content after a JSON object, which
json.Unmarshal rejected and a bare json.Decoder would not.

CONFIGURATION.md gains a Value Types table under Key Mappings, since the
conversion applies to every provider that parses JSON rather than to one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL
A JSON pointer can address an array element or a bare scalar, neither of
which fits map[string]interface{}. DecodeSecretJSON keeps its object-only
contract and is now expressed in terms of the general decoder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL
Credential blobs nest, and their keys contain dots, pipes and colons, so a
dotted path would need an escaping rule invented here. RFC 6901 already
defines one, and gives array indexing for free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL
sstart already depends on go-keyring for its cache and OIDC tokens but never
exposed the OS credential store as a source of secrets. Reading fails loudly
when the store is unavailable: unlike the cache, an empty result here hands
the child process an environment with no secrets in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL
A flat JSON secret behaves the same whichever provider holds it; a keyring
item that yielded one opaque blob instead would read as a bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL
Reading a credential blob whole exposes every secret inside it to the child
process. A pointer lets a config ask for the one value it needs; the schema
knowledge lives in the user's config, not in sstart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL
Includes how to populate the store on each platform, since the provider only
reads and there is no sstart command that writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmh2p2Bg6kmxxvzpFDW2WL
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