diff --git a/README.md b/README.md
index ab4c02903..d1a6b4572 100644
--- a/README.md
+++ b/README.md
@@ -72,6 +72,8 @@ The following config options are supported:
| Option | Default | Description |
| ------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| credentialCacheMode | | Optional credential caching mode. If set to `system`, signing credentials are cached in the system keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service) for the lifetime of the certificate — no daemon required. If set to `socket`, the [gitsign-credential-cache](cmd/gitsign-credential-cache/README.md) daemon socket configured by `credentialCache` is used. See [docs/keyring-cache.md](./docs/keyring-cache.md) for more details. |
+| credentialCache | | Optional path to the [gitsign-credential-cache](cmd/gitsign-credential-cache/README.md) socket. |
| fulcio | https://fulcio.sigstore.dev | Address of Fulcio server |
| logPath | | Path to log status output. Helpful for debugging when no TTY is available in the environment. |
| clientID | sigstore | OIDC client ID for application |
@@ -94,6 +96,7 @@ The following config options are supported:
| Environment Variable | Sigstore
Prefix | Default | Description |
| ---------------------------- | ------------------ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GITSIGN_CREDENTIAL_CACHE | | | Optional path to [gitsign-credential-cache](cmd/gitsign-credential-cache/README.md) socket. |
+| GITSIGN_CREDENTIAL_CACHE_MODE | ❌ | | Optional credential caching mode. If set to `system`, signing credentials are cached in the system keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service) for the lifetime of the certificate — no daemon required. If set to `socket`, the [gitsign-credential-cache](cmd/gitsign-credential-cache/README.md) daemon socket configured by `GITSIGN_CREDENTIAL_CACHE` is used. See [docs/keyring-cache.md](./docs/keyring-cache.md) for more details. |
| GITSIGN_CONNECTOR_ID | ✅ | | Optional Connector ID to auto-select to pre-select auth flow to use. For the public sigstore instance, valid values are:
- `https://github.com/login/oauth`
- `https://accounts.google.com`
- `https://login.microsoftonline.com` |
| GITSIGN_TOKEN_PROVIDER | ✅ | | Optional OIDC token provider to use to fetch tokens. If not set, any available providers are used. valid values are:
- `interactive`
- `device` (OAuth 2.0 device flow — for headless / remote-SSH workflows)
- `spiffe`
- `google-workload-identity`
- `google-impersonation`
- `github-actions`
- `filesystem`
- `buildkite-agent` |
| GITSIGN_FULCIO_URL | ✅ | https://fulcio.sigstore.dev | Address of Fulcio server |
@@ -211,10 +214,16 @@ things you can do to make this process a bit easier!
to use. Assuming you're already signed in, in most cases you'll bounce
directly to the auth success screen! (and you can clean up the browser tabs
later)
-2. Use the [Credential Cache](cmd/gitsign-credential-cache/README.md). This uses
- an in-memory credential cache over a file socket that allows you to persist
- keys and certificates for their full lifetime (meaning you only need to auth
- once every 10 minutes).
+2. Use the built-in [system keyring cache](docs/keyring-cache.md)
+ (`git config --global gitsign.credentialCacheMode system`). This stores
+ keys and certificates in the OS keyring (macOS Keychain, Windows Credential
+ Manager, Linux Secret Service) for their full lifetime (meaning you only
+ need to auth once per certificate lifetime — ~10 minutes on the public
+ instance), with no extra daemon required.
+3. Use the [Credential Cache daemon](cmd/gitsign-credential-cache/README.md).
+ This uses an in-memory credential cache over a file socket that allows you
+ to persist keys and certificates for their full lifetime. Useful when you
+ want credentials to live in memory only, or to forward the cache over SSH.
### Why doesn't GitHub show commits as [verified](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification)?
diff --git a/cmd/gitsign-credential-cache/README.md b/cmd/gitsign-credential-cache/README.md
index 001724117..6944a7e9d 100644
--- a/cmd/gitsign-credential-cache/README.md
+++ b/cmd/gitsign-credential-cache/README.md
@@ -4,6 +4,11 @@
cache signing credentials. This can be helpful in situations where you need to
perform multiple signing operations back to back.
+💡 Gitsign also has a built-in [system keyring cache](../../docs/keyring-cache.md)
+(`git config --global gitsign.credentialCacheMode system`) that needs no
+daemon. The daemon remains useful when you want credentials to live in memory
+only, or to forward the cache over SSH.
+
Credentials are stored in memory, and the cache is exposed via a Unix socket.
Credentials stored in this cache are only as secure as the unix socket
implementation on your OS - any user that can access the socket can access the
@@ -24,8 +29,16 @@ If you understand the risks, read on!
- Ephemeral Private Key
- Fulcio Code Signing certificate + chain
-All data is stored in memory, keyed to your Git working directory (i.e.
-different repo paths will cache different keys)
+All data is stored in memory, keyed to the identity configuration used to
+obtain the credential (Fulcio URL, OIDC issuer, client ID, connector ID, and
+committer email). Repositories that share the same configuration share a
+cached credential; repositories with a different `user.email` (or issuer,
+connector, etc.) get their own entry, so multiple identities can be cached at
+once. Entries expire with the signing certificate.
+
+Cached credentials can be inspected and removed with
+`gitsign credentials list` / `gitsign credentials clear` (with
+`GITSIGN_CREDENTIAL_CACHE` pointing at the socket).
The data that is cached would allow any user with access to sign artifacts as
you, until the signing certificate expires, typically in ten minutes.
diff --git a/docs/cli/gitsign.md b/docs/cli/gitsign.md
index 0afd542da..dda24eaa5 100644
--- a/docs/cli/gitsign.md
+++ b/docs/cli/gitsign.md
@@ -23,6 +23,7 @@ gitsign [flags]
### SEE ALSO
* [gitsign attest](gitsign_attest.md) - add attestations to Git objects
+* [gitsign credentials](gitsign_credentials.md) - Manage cached signing credentials
* [gitsign initialize](gitsign_initialize.md) - Initializes Sigstore root to retrieve trusted certificate and key targets for verification.
* [gitsign show](gitsign_show.md) - Show source predicate information
* [gitsign verify](gitsign_verify.md) - Verify a commit
diff --git a/docs/cli/gitsign_credentials.md b/docs/cli/gitsign_credentials.md
new file mode 100644
index 000000000..fc9bf5c1d
--- /dev/null
+++ b/docs/cli/gitsign_credentials.md
@@ -0,0 +1,24 @@
+## gitsign credentials
+
+Manage cached signing credentials
+
+### Synopsis
+
+Manage cached signing credentials.
+
+The credential cache backend is selected by gitsign.credentialCacheMode:
+the system keyring (`system`), or the gitsign-credential-cache daemon
+(`socket`). When no mode is configured, the system keyring is used.
+
+### Options
+
+```
+ -h, --help help for credentials
+```
+
+### SEE ALSO
+
+* [gitsign](gitsign.md) - Keyless Git signing with Sigstore!
+* [gitsign credentials clear](gitsign_credentials_clear.md) - Remove cached signing credentials
+* [gitsign credentials list](gitsign_credentials_list.md) - List cached signing credentials
+
diff --git a/docs/cli/gitsign_credentials_clear.md b/docs/cli/gitsign_credentials_clear.md
new file mode 100644
index 000000000..d42fbb0cc
--- /dev/null
+++ b/docs/cli/gitsign_credentials_clear.md
@@ -0,0 +1,27 @@
+## gitsign credentials clear
+
+Remove cached signing credentials
+
+### Synopsis
+
+Remove cached signing credentials.
+
+By default only the credential for the current configuration
+(Fulcio URL, OIDC issuer, client ID, connector ID, and committer email)
+is removed. Use --all to remove all cached credentials.
+
+```
+gitsign credentials clear [flags]
+```
+
+### Options
+
+```
+ --all remove all cached credentials
+ -h, --help help for clear
+```
+
+### SEE ALSO
+
+* [gitsign credentials](gitsign_credentials.md) - Manage cached signing credentials
+
diff --git a/docs/cli/gitsign_credentials_list.md b/docs/cli/gitsign_credentials_list.md
new file mode 100644
index 000000000..8c4a700ba
--- /dev/null
+++ b/docs/cli/gitsign_credentials_list.md
@@ -0,0 +1,18 @@
+## gitsign credentials list
+
+List cached signing credentials
+
+```
+gitsign credentials list [flags]
+```
+
+### Options
+
+```
+ -h, --help help for list
+```
+
+### SEE ALSO
+
+* [gitsign credentials](gitsign_credentials.md) - Manage cached signing credentials
+
diff --git a/docs/keyring-cache.md b/docs/keyring-cache.md
new file mode 100644
index 000000000..fd69c327b
--- /dev/null
+++ b/docs/keyring-cache.md
@@ -0,0 +1,124 @@
+# System keyring credential cache
+
+Gitsign can cache signing credentials (the ephemeral private key and the
+Fulcio-issued certificate) in the operating system keyring:
+
+- macOS Keychain
+- Windows Credential Manager
+- Linux [Secret Service](https://specifications.freedesktop.org/secret-service/latest/)
+ (GNOME Keyring, etc.) or KWallet
+
+Unlike the [gitsign-credential-cache](../cmd/gitsign-credential-cache/README.md)
+daemon, no long-running helper process is required. Credentials are cached for
+the lifetime of the certificate (~10 minutes on the public Sigstore instance),
+so you only need to complete the OIDC browser flow once per certificate
+lifetime instead of once per signature.
+
+## Setup
+
+```sh
+git config --global gitsign.credentialCacheMode system
+```
+
+or via environment variable:
+
+```sh
+export GITSIGN_CREDENTIAL_CACHE_MODE=system
+```
+
+The first `git commit -S` runs the normal OIDC flow and stores the resulting
+credential; subsequent signatures reuse it until the certificate expires.
+Expired or invalid entries are removed automatically the next time they are
+read. If the keyring is unavailable (e.g. locked, or no D-Bus session on a
+headless Linux host), gitsign falls back to the normal OIDC flow.
+
+## Platform notes
+
+- **macOS**: the Keychain is accessed via the `/usr/bin/security` CLI
+ (gitsign is built without cgo, which the native Security.framework API
+ would require). This is the same model as other CLI tools that use the
+ Keychain - entries are readable by any process that can run `security` in
+ your session.
+- **Linux**: requires a running Secret Service (GNOME Keyring, etc.) or
+ KWallet with a D-Bus session. Headless hosts should use the
+ [daemon](../cmd/gitsign-credential-cache/README.md) or no cache.
+- **Windows**: uses Credential Manager; credentials are chunked to stay under
+ its per-credential size limits.
+
+## Multiple identities
+
+Credentials are cached per identity. Because the OIDC identity is only known
+after the auth flow completes, the cache key is derived from the configuration
+used to obtain it:
+
+- Fulcio URL (`gitsign.fulcio`)
+- OIDC issuer (`gitsign.issuer`)
+- OIDC client ID (`gitsign.clientID`)
+- Connector ID (`gitsign.connectorID`)
+- Committer email (`user.email`)
+
+Repositories that share the same configuration share a cached credential;
+repositories with a different `user.email` (or issuer, connector, etc.) get
+their own entry. Multiple identities can be cached at the same time.
+
+Note that the key is derived from configuration, not from the identity in the
+issued certificate. If you authenticate as a different account without
+changing any of the config values above, the previously cached credential is
+reused until it expires (use `gitsign credentials clear` to evict it
+immediately; `gitsign.matchCommitter` can also be used to reject certificates
+that don't match `user.email`).
+
+## Managing cached credentials
+
+```sh
+# List cached credentials.
+$ gitsign credentials list
+EMAIL ISSUER CLIENTID CONNECTOR FULCIO EXPIRES STATUS
+you@example.com https://oauth2.sigstore.dev/auth sigstore - https://fulcio.sigstore.dev 2026-08-04T12:10:00-04:00 valid
+
+# Remove the credential for the current configuration.
+$ gitsign credentials clear
+
+# Remove all cached credentials.
+$ gitsign credentials clear --all
+```
+
+Entries can also be inspected/removed with the platform's native tools — they
+are stored under the service/label `gitsign` (e.g. Keychain Access on macOS,
+`secret-tool`/Seahorse on Linux, Credential Manager on Windows).
+
+`gitsign credentials` operates on whichever backend
+`gitsign.credentialCacheMode` selects — the commands also work against the
+[gitsign-credential-cache](../cmd/gitsign-credential-cache/README.md) daemon
+in `socket` mode (or when `GITSIGN_CREDENTIAL_CACHE` is set).
+
+## Security considerations
+
+⚠️ The cached private key and certificate are only as secure as your OS
+keyring.
+
+- Any process running in your user session that can access the keyring (any
+ process on Linux once the Secret Service collection is unlocked; anything
+ that can invoke `/usr/bin/security` on macOS) can read the cached private
+ key and sign artifacts as you. This is comparable to the
+ gitsign-credential-cache daemon's threat model, where any process that can
+ open the socket can use your credentials.
+- Unlike the in-memory daemon, keyring entries are persisted (encrypted at
+ rest by the OS) and survive reboots. The exposure window is bounded by the
+ certificate lifetime — expired entries are useless for signing and are
+ cleaned up lazily on the next read — but entries for identities you stop
+ using may linger until then (or until you run
+ `gitsign credentials clear --all`).
+- Do not use credential caching on shared systems.
+- Environments with ambient OIDC credentials (e.g. CI providers) generally
+ don't need credential caching.
+
+## When to prefer the daemon instead
+
+- You want credentials to live in memory only and never touch disk.
+- You want to forward a credential cache over SSH
+ (`RemoteForward` of the socket — see the
+ [daemon docs](../cmd/gitsign-credential-cache/README.md#forwarding-cache-over-ssh)).
+- Headless Linux hosts without a Secret Service / D-Bus session.
+- SSH sessions to macOS hosts, where keychain access may require unlocking the
+ login keychain.
diff --git a/go.mod b/go.mod
index 759b05b2d..e49ddb2e8 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/sigstore/gitsign
go 1.26.0
require (
+ github.com/99designs/keyring v1.2.2
github.com/coreos/go-oidc/v3 v3.20.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/github/smimesign v0.2.0
@@ -44,6 +45,7 @@ require (
connectrpc.com/connect v1.20.0 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
+ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/provider v0.20.0 // indirect
github.com/AliyunContainerService/ack-ram-tool/pkg/ecsmetadata v0.0.10 // indirect
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect
@@ -111,6 +113,7 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
+ github.com/danieljoos/wincred v1.2.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c // indirect
github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea // indirect
@@ -118,6 +121,7 @@ require (
github.com/docker/cli v29.5.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/dvsekhvalnov/jose2go v1.5.0 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -149,6 +153,7 @@ require (
github.com/go-openapi/swag/yamlutils v0.27.3 // indirect
github.com/go-openapi/validate v0.26.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
@@ -165,6 +170,7 @@ require (
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
+ github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@@ -193,6 +199,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/mozillazg/docker-credential-acr-helper v0.4.0 // indirect
+ github.com/mtibben/percent v0.2.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect
diff --git a/go.sum b/go.sum
index 4770977c4..c5f1f00be 100644
--- a/go.sum
+++ b/go.sum
@@ -25,6 +25,10 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e h1:VsUbObBMxXlc23Eb9VeeJYE4jvTs87qa5RqSN2U5FJU=
filippo.io/mldsa v0.0.0-20260215214346-43d0283efc3e/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY=
+github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
+github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4=
+github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0=
+github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk=
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg=
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM=
github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/provider v0.20.0 h1:LU830/Tuj5c6xSpEjyrymfY5fGInchwMWRp1aSBXbS8=
@@ -247,6 +251,8 @@ github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0
github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM=
+github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
@@ -360,6 +366,8 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0=
+github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4=
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/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
@@ -433,6 +441,8 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDa
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
+github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU=
+github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -525,6 +535,8 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mozillazg/docker-credential-acr-helper v0.4.0 h1:Uoh3Z9CcpEDnLiozDx+D7oDgRq7X+R296vAqAumnOcw=
github.com/mozillazg/docker-credential-acr-helper v0.4.0/go.mod h1:2kiicb3OlPytmlNC9XGkLvVC+f0qTiJw3f/mhmeeQBg=
+github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs=
+github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
@@ -944,6 +956,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
diff --git a/internal/cache/api/api.go b/internal/cache/api/api.go
index 0823f0768..fecd08de1 100644
--- a/internal/cache/api/api.go
+++ b/internal/cache/api/api.go
@@ -14,7 +14,11 @@
package api // nolint:revive
-import "github.com/sigstore/gitsign/internal/config"
+import (
+ "time"
+
+ "github.com/sigstore/gitsign/internal/config"
+)
type Credential struct {
PrivateKey []byte
@@ -22,12 +26,42 @@ type Credential struct {
Chain []byte
}
+// Metadata describes the configuration a cached credential was derived from.
+type Metadata struct {
+ Fulcio string `json:"fulcio,omitempty"`
+ Issuer string `json:"issuer,omitempty"`
+ ClientID string `json:"clientID,omitempty"`
+ ConnectorID string `json:"connectorID,omitempty"`
+ CommitterEmail string `json:"committerEmail,omitempty"`
+}
+
+// CredentialInfo describes a stored credential for enumeration
+// (e.g. `gitsign credentials list`).
+type CredentialInfo struct {
+ ID string `json:"id"`
+ NotAfter time.Time `json:"notAfter"`
+ Meta Metadata `json:"meta"`
+}
+
type StoreCredentialRequest struct {
ID string
Credential *Credential
+ // Meta describes the identity configuration the credential was obtained
+ // with. Optional: older clients don't send it.
+ Meta Metadata
}
type GetCredentialRequest struct {
ID string
Config *config.Config
}
+
+type ListCredentialsRequest struct{}
+
+type DeleteCredentialRequest struct {
+ ID string
+}
+
+type DeleteAllCredentialsRequest struct{}
+
+type DeleteCredentialsResponse struct{}
diff --git a/internal/cache/cache.go b/internal/cache/cache.go
new file mode 100644
index 000000000..45b61b191
--- /dev/null
+++ b/internal/cache/cache.go
@@ -0,0 +1,55 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cache
+
+import (
+ "context"
+ "crypto"
+ "errors"
+
+ "github.com/sigstore/gitsign/internal/config"
+)
+
+// ErrNotFound is returned by Cache implementations when no credential is
+// stored for the requested identity. Callers can use this to distinguish a
+// plain cache miss from a real error.
+var ErrNotFound = errors.New("credential not found in cache")
+
+// Cache stores and retrieves signing credentials (ephemeral private key,
+// Fulcio certificate, and chain).
+type Cache interface {
+ // GetCredentials returns the cached private key, PEM-encoded leaf
+ // certificate, and PEM-encoded chain for the identity described by the
+ // given config. Implementations should return an error wrapping
+ // ErrNotFound on a cache miss.
+ GetCredentials(ctx context.Context, cfg *config.Config) (crypto.PrivateKey, []byte, []byte, error)
+ // StoreCert stores the private key, PEM-encoded leaf certificate, and
+ // PEM-encoded chain.
+ StoreCert(ctx context.Context, priv crypto.PrivateKey, cert, chain []byte) error
+}
+
+// Manager extends Cache with enumeration and removal, used by
+// `gitsign credentials`.
+type Manager interface {
+ Cache
+ // List returns info about the stored credentials.
+ List(ctx context.Context) ([]CredentialInfo, error)
+ // Delete removes the credential for the identity described by the
+ // backend's configured Config. It returns an error wrapping ErrNotFound
+ // if no entry exists.
+ Delete(ctx context.Context) error
+ // DeleteAll removes all stored credentials.
+ DeleteAll(ctx context.Context) error
+}
diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go
index 94348eb25..a8a4517b7 100644
--- a/internal/cache/cache_test.go
+++ b/internal/cache/cache_test.go
@@ -19,12 +19,12 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
- "fmt"
+ "errors"
"net"
"net/rpc"
- "os"
"path/filepath"
"testing"
+ "time"
"github.com/github/smimesign/fakeca"
"github.com/google/go-cmp/cmp"
@@ -34,14 +34,15 @@ import (
"github.com/sigstore/sigstore/pkg/cryptoutils"
)
-func TestCache(t *testing.T) {
- ctx := context.Background()
+func newTestClient(t *testing.T) *cache.Client {
+ t.Helper()
path := filepath.Join(t.TempDir(), "cache.sock")
l, err := net.Listen("unix", path)
if err != nil {
t.Fatal(err)
}
+ t.Cleanup(func() { l.Close() })
srv := rpc.NewServer()
srv.Register(service.NewService())
go func() {
@@ -50,16 +51,27 @@ func TestCache(t *testing.T) {
}
}()
- rpcClient, _ := rpc.Dial("unix", path)
- defer rpcClient.Close()
- ca := fakeca.New()
- client := &cache.Client{
+ rpcClient, err := rpc.Dial("unix", path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { rpcClient.Close() })
+ return &cache.Client{
Client: rpcClient,
- Roots: ca.ChainPool(),
}
+}
- if _, _, _, err := client.GetCredentials(ctx, nil); err == nil {
- t.Fatal("GetSignerVerifier: expected err, got not")
+func TestCache(t *testing.T) {
+ ctx := context.Background()
+
+ client := newTestClient(t)
+ ca := fakeca.New()
+ client.Roots = ca.ChainPool()
+
+ // Cache miss is reported as ErrNotFound. Note: the client's Config is
+ // nil, so the service does not fall back to the interactive flow.
+ if _, _, _, err := client.GetCredentials(ctx, nil); !errors.Is(err, cache.ErrNotFound) {
+ t.Fatalf("GetCredentials: want ErrNotFound, got %v", err)
}
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -69,9 +81,8 @@ func TestCache(t *testing.T) {
t.Fatalf("StoreCert: %v", err)
}
- host, _ := os.Hostname()
- wd, _ := os.Getwd()
- id := fmt.Sprintf("%s@%s", host, wd)
+ // The credential is stored under the shared config-derived key.
+ id := cache.CredentialKey(client.Config)
cred := new(api.Credential)
if err := client.Client.Call("Service.GetCredential", &api.GetCredentialRequest{ID: id}, cred); err != nil {
t.Fatal(err)
@@ -97,4 +108,81 @@ func TestCache(t *testing.T) {
if ok := cmp.Equal(certPEM, gotCert); !ok {
t.Error("stored cert does not match")
}
+
+ // Re-storing within the credential lifetime overwrites without error.
+ if err := client.StoreCert(ctx, priv, certPEM, nil); err != nil {
+ t.Fatalf("StoreCert (second): %v", err)
+ }
+}
+
+func TestCacheExpiredCert(t *testing.T) {
+ ctx := context.Background()
+
+ client := newTestClient(t)
+ ca := fakeca.New(fakeca.NotAfter(time.Now().Add(-time.Hour)))
+ client.Roots = ca.ChainPool()
+
+ priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ certPEM, _ := cryptoutils.MarshalCertificateToPEM(ca.Certificate)
+
+ // The service refuses to store an already-expired cert.
+ if err := client.StoreCert(ctx, priv, certPEM, nil); err == nil {
+ t.Fatal("StoreCert: expected error for expired cert")
+ }
+}
+
+func TestCacheManagement(t *testing.T) {
+ ctx := context.Background()
+
+ client := newTestClient(t)
+ ca := fakeca.New()
+ client.Roots = ca.ChainPool()
+
+ priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ certPEM, _ := cryptoutils.MarshalCertificateToPEM(ca.Certificate)
+
+ // Deleting a missing entry reports a miss.
+ if err := client.Delete(ctx); !errors.Is(err, cache.ErrNotFound) {
+ t.Fatalf("Delete: want ErrNotFound, got %v", err)
+ }
+
+ if err := client.StoreCert(ctx, priv, certPEM, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ entries, err := client.List(ctx)
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("List: want 1 entry, got %d", len(entries))
+ }
+ if entries[0].ID != cache.CredentialKey(client.Config) {
+ t.Errorf("List: unexpected ID %q", entries[0].ID)
+ }
+ if entries[0].NotAfter.IsZero() {
+ t.Error("List: NotAfter not set")
+ }
+
+ if err := client.Delete(ctx); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+ if _, _, _, err := client.GetCredentials(ctx, nil); !errors.Is(err, cache.ErrNotFound) {
+ t.Fatalf("GetCredentials after delete: want ErrNotFound, got %v", err)
+ }
+
+ // DeleteAll clears everything.
+ if err := client.StoreCert(ctx, priv, certPEM, nil); err != nil {
+ t.Fatal(err)
+ }
+ if err := client.DeleteAll(ctx); err != nil {
+ t.Fatalf("DeleteAll: %v", err)
+ }
+ entries, err = client.List(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Errorf("List after DeleteAll: want 0 entries, got %d", len(entries))
+ }
}
diff --git a/internal/cache/client.go b/internal/cache/client.go
index 4685ad7ce..d7169c2ae 100644
--- a/internal/cache/client.go
+++ b/internal/cache/client.go
@@ -18,107 +18,126 @@ import (
"context"
"crypto"
"crypto/x509"
- "encoding/asn1"
"fmt"
"net/rpc"
- "os"
- "time"
+ "path/filepath"
+ "strings"
"github.com/sigstore/gitsign/internal/cache/api"
"github.com/sigstore/gitsign/internal/config"
- "github.com/sigstore/sigstore/pkg/cryptoutils"
)
+// Client talks to the gitsign-credential-cache daemon over its unix socket.
type Client struct {
Client *rpc.Client
Roots *x509.CertPool
Intermediates *x509.CertPool
+ // Config is used to derive the identity key for credentials.
+ Config *config.Config
}
-func (c *Client) GetCredentials(_ context.Context, cfg *config.Config) (crypto.PrivateKey, []byte, []byte, error) {
- id, err := id()
+var _ Manager = (*Client)(nil)
+
+// NewClient dials the gitsign-credential-cache daemon socket. Roots and
+// intermediates are only needed for GetCredentials validation and may be nil
+// for management operations (List/Delete/DeleteAll).
+func NewClient(socketPath string, cfg *config.Config, roots, intermediates *x509.CertPool) (*Client, error) {
+ absPath, err := filepath.Abs(socketPath)
if err != nil {
- return nil, nil, nil, fmt.Errorf("error getting credential ID: %w", err)
+ return nil, fmt.Errorf("error resolving cache path: %w", err)
+ }
+ rpcClient, err := rpc.Dial("unix", absPath)
+ if err != nil {
+ return nil, fmt.Errorf("error creating RPC socket client: %w", err)
+ }
+ return &Client{
+ Client: rpcClient,
+ Roots: roots,
+ Intermediates: intermediates,
+ Config: cfg,
+ }, nil
+}
+
+func (c *Client) GetCredentials(_ context.Context, cfg *config.Config) (crypto.PrivateKey, []byte, []byte, error) {
+ if cfg == nil {
+ cfg = c.Config
}
resp := new(api.Credential)
if err := c.Client.Call("Service.GetCredential", api.GetCredentialRequest{
- ID: id,
+ ID: CredentialKey(cfg),
Config: cfg,
}, resp); err != nil {
+ // net/rpc flattens errors to strings, so a plain miss can only be
+ // recognized by message.
+ if strings.Contains(err.Error(), "not found") {
+ return nil, nil, nil, fmt.Errorf("%w: %v", ErrNotFound, err)
+ }
return nil, nil, nil, err
}
- privateKey, err := cryptoutils.UnmarshalPEMToPrivateKey(resp.PrivateKey, cryptoutils.SkipPassword)
+ privateKey, cert, chain, err := DecodeCredential(resp)
if err != nil {
- return nil, nil, nil, fmt.Errorf("error unmarshalling private key: %w", err)
+ return nil, nil, nil, err
}
// Check that the cert is in fact still valid.
- certs, err := cryptoutils.UnmarshalCertificatesFromPEM(resp.Cert)
- if err != nil {
- return nil, nil, nil, fmt.Errorf("error unmarshalling cert: %w", err)
- }
- // There should really only be 1 cert, but check them all anyway.
- for _, cert := range certs {
- if len(cert.UnhandledCriticalExtensions) > 0 {
- var unhandledExts []asn1.ObjectIdentifier
- for _, oid := range cert.UnhandledCriticalExtensions {
- if !oid.Equal(cryptoutils.SANOID) {
- unhandledExts = append(unhandledExts, oid)
- }
- }
-
- cert.UnhandledCriticalExtensions = unhandledExts
- }
-
- if _, err := cert.Verify(x509.VerifyOptions{
- Roots: c.Roots,
- Intermediates: c.Intermediates,
- KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning},
- // We're going to be using this key immediately, so we don't need a long window.
- // Just make sure it's not about to expire.
- CurrentTime: time.Now().Add(30 * time.Second),
- }); err != nil {
- return nil, nil, nil, fmt.Errorf("stored cert no longer valid: %w", err)
- }
+ if err := ValidateCert(cert, c.Roots, c.Intermediates); err != nil {
+ return nil, nil, nil, err
}
- return privateKey, resp.Cert, resp.Chain, nil
+ return privateKey, cert, chain, nil
}
func (c *Client) StoreCert(_ context.Context, priv crypto.PrivateKey, cert, chain []byte) error {
- id, err := id()
- if err != nil {
- return fmt.Errorf("error getting credential ID: %w", err)
- }
- privPEM, err := cryptoutils.MarshalPrivateKeyToPEM(priv)
+ cred, err := EncodeCredential(priv, cert, chain)
if err != nil {
return err
}
- if err := c.Client.Call("Service.StoreCredential", api.StoreCredentialRequest{
- ID: id,
- Credential: &api.Credential{
- PrivateKey: privPEM,
- Cert: cert,
- Chain: chain,
- },
- }, new(api.Credential)); err != nil {
- return err
- }
+ return c.Client.Call("Service.StoreCredential", api.StoreCredentialRequest{
+ ID: CredentialKey(c.Config),
+ Credential: cred,
+ Meta: MetadataFromConfig(c.Config),
+ }, new(api.Credential))
+}
- return err
+// List returns info about the credentials stored in the daemon. Requires a
+// daemon new enough to support the ListCredentials RPC.
+func (c *Client) List(_ context.Context) ([]CredentialInfo, error) {
+ var resp []api.CredentialInfo
+ if err := c.Client.Call("Service.ListCredentials", api.ListCredentialsRequest{}, &resp); err != nil {
+ if strings.Contains(err.Error(), "can't find method") {
+ return nil, fmt.Errorf("the gitsign-credential-cache daemon does not support listing credentials - upgrade the daemon: %w", err)
+ }
+ return nil, err
+ }
+ return resp, nil
}
-func id() (string, error) {
- // Prefix host name in case cache socket is being shared over a SSH session.
- host, err := os.Hostname()
- if err != nil {
- return "", fmt.Errorf("error getting hostname: %w", err)
+// Delete removes the daemon's credential for the identity described by the
+// configured Config.
+func (c *Client) Delete(_ context.Context) error {
+ if err := c.Client.Call("Service.DeleteCredential", api.DeleteCredentialRequest{
+ ID: CredentialKey(c.Config),
+ }, new(api.DeleteCredentialsResponse)); err != nil {
+ if strings.Contains(err.Error(), "not found") {
+ return fmt.Errorf("%w: %v", ErrNotFound, err)
+ }
+ if strings.Contains(err.Error(), "can't find method") {
+ return fmt.Errorf("the gitsign-credential-cache daemon does not support deleting credentials - upgrade the daemon: %w", err)
+ }
+ return err
}
- wd, err := os.Getwd()
- if err != nil {
- return "", fmt.Errorf("error getting working directory: %w", err)
+ return nil
+}
+
+// DeleteAll removes all credentials stored in the daemon.
+func (c *Client) DeleteAll(_ context.Context) error {
+ if err := c.Client.Call("Service.DeleteAllCredentials", api.DeleteAllCredentialsRequest{}, new(api.DeleteCredentialsResponse)); err != nil {
+ if strings.Contains(err.Error(), "can't find method") {
+ return fmt.Errorf("the gitsign-credential-cache daemon does not support deleting credentials - upgrade the daemon: %w", err)
+ }
+ return err
}
- return fmt.Sprintf("%s@%s", host, wd), nil
+ return nil
}
diff --git a/internal/cache/credential.go b/internal/cache/credential.go
new file mode 100644
index 000000000..9ba2f1f74
--- /dev/null
+++ b/internal/cache/credential.go
@@ -0,0 +1,59 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cache
+
+import (
+ "crypto"
+ "fmt"
+ "time"
+
+ "github.com/sigstore/gitsign/internal/cache/api"
+ "github.com/sigstore/sigstore/pkg/cryptoutils"
+)
+
+// EncodeCredential marshals the private key to PEM and wraps the credential
+// parts for storage.
+func EncodeCredential(priv crypto.PrivateKey, cert, chain []byte) (*api.Credential, error) {
+ privPEM, err := cryptoutils.MarshalPrivateKeyToPEM(priv)
+ if err != nil {
+ return nil, fmt.Errorf("error marshalling private key: %w", err)
+ }
+ return &api.Credential{
+ PrivateKey: privPEM,
+ Cert: cert,
+ Chain: chain,
+ }, nil
+}
+
+// DecodeCredential unmarshals a stored credential back into its parts.
+func DecodeCredential(cred *api.Credential) (crypto.PrivateKey, []byte, []byte, error) {
+ privateKey, err := cryptoutils.UnmarshalPEMToPrivateKey(cred.PrivateKey, cryptoutils.SkipPassword)
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("error unmarshalling private key: %w", err)
+ }
+ return privateKey, cred.Cert, cred.Chain, nil
+}
+
+// NotAfter returns the expiry of the (first) PEM-encoded certificate.
+func NotAfter(certPEM []byte) (time.Time, error) {
+ certs, err := cryptoutils.UnmarshalCertificatesFromPEM(certPEM)
+ if err != nil {
+ return time.Time{}, fmt.Errorf("error parsing certificate: %w", err)
+ }
+ if len(certs) == 0 {
+ return time.Time{}, fmt.Errorf("no certificate found")
+ }
+ return certs[0].NotAfter, nil
+}
diff --git a/internal/cache/key.go b/internal/cache/key.go
new file mode 100644
index 000000000..20fd558f2
--- /dev/null
+++ b/internal/cache/key.go
@@ -0,0 +1,67 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cache
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+
+ "github.com/sigstore/gitsign/internal/cache/api"
+ "github.com/sigstore/gitsign/internal/config"
+)
+
+// Metadata describes the configuration a cached credential was derived from.
+type Metadata = api.Metadata
+
+// CredentialInfo describes a stored credential for enumeration.
+type CredentialInfo = api.CredentialInfo
+
+// credentialKeyPrefix versions the key format so incompatible changes can
+// rotate the namespace.
+const credentialKeyPrefix = "credential/v1/"
+
+// CredentialKey derives the cache key for the identity described by the given
+// config. The OIDC identity is not known until after the auth flow completes,
+// so the key is derived from the configuration used to obtain it. All cache
+// backends share this derivation.
+func CredentialKey(cfg *config.Config) string {
+ h := sha256.New()
+ for _, s := range keyFields(MetadataFromConfig(cfg)) {
+ h.Write([]byte(s))
+ // NUL separator avoids ambiguity between adjacent fields.
+ h.Write([]byte{0})
+ }
+ return credentialKeyPrefix + hex.EncodeToString(h.Sum(nil))
+}
+
+// MetadataFromConfig extracts the identity-defining configuration fields.
+func MetadataFromConfig(cfg *config.Config) Metadata {
+ if cfg == nil {
+ return Metadata{}
+ }
+ return Metadata{
+ Fulcio: cfg.Fulcio,
+ Issuer: cfg.Issuer,
+ ClientID: cfg.ClientID,
+ ConnectorID: cfg.ConnectorID,
+ CommitterEmail: cfg.CommitterEmail,
+ }
+}
+
+// keyFields returns the metadata fields in the (stable) order used for key
+// derivation.
+func keyFields(m Metadata) []string {
+ return []string{m.Fulcio, m.Issuer, m.ClientID, m.ConnectorID, m.CommitterEmail}
+}
diff --git a/internal/cache/key_test.go b/internal/cache/key_test.go
new file mode 100644
index 000000000..6654560e1
--- /dev/null
+++ b/internal/cache/key_test.go
@@ -0,0 +1,64 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cache
+
+import (
+ "testing"
+
+ "github.com/sigstore/gitsign/internal/config"
+)
+
+func TestCredentialKey(t *testing.T) {
+ base := func() *config.Config {
+ return &config.Config{
+ Fulcio: "https://fulcio.example.com",
+ Issuer: "https://oauth2.example.com/auth",
+ ClientID: "sigstore",
+ ConnectorID: "connector",
+ CommitterEmail: "user@example.com",
+ }
+ }
+
+ // Golden value guards against accidental changes to the key derivation -
+ // changing it silently orphans users' existing cache entries.
+ const want = "credential/v1/9d2bd6aace24024e8f5a7b3472ce2f1a074883aa6fd8bab989692fc18c57f931"
+ if got := CredentialKey(base()); got != want {
+ t.Errorf("CredentialKey changed:\nwant %s\ngot %s", want, got)
+ }
+
+ // Each config field contributes to the key.
+ mutations := []func(*config.Config){
+ func(c *config.Config) { c.Fulcio = "other" },
+ func(c *config.Config) { c.Issuer = "other" },
+ func(c *config.Config) { c.ClientID = "other" },
+ func(c *config.Config) { c.ConnectorID = "other" },
+ func(c *config.Config) { c.CommitterEmail = "other" },
+ }
+ seen := map[string]bool{CredentialKey(base()): true}
+ for i, mutate := range mutations {
+ cfg := base()
+ mutate(cfg)
+ key := CredentialKey(cfg)
+ if seen[key] {
+ t.Errorf("mutation %d did not change the key", i)
+ }
+ seen[key] = true
+ }
+
+ // A nil config must not panic.
+ if CredentialKey(nil) == CredentialKey(base()) {
+ t.Error("nil config key should differ from populated config key")
+ }
+}
diff --git a/internal/cache/keyring/keyring.go b/internal/cache/keyring/keyring.go
new file mode 100644
index 000000000..57f0827e0
--- /dev/null
+++ b/internal/cache/keyring/keyring.go
@@ -0,0 +1,390 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package keyring implements a credential cache backed by the operating
+// system keyring (macOS Keychain, Windows Credential Manager, Linux Secret
+// Service / KWallet). Unlike the gitsign-credential-cache daemon, no
+// long-running process is required.
+//
+// Credentials are cached per identity, keyed by cache.CredentialKey (the
+// gitsign configuration used to obtain them). Multiple identities can be
+// stored concurrently; entries live for the lifetime of the certificate and
+// are removed lazily on read once expired.
+//
+// On macOS the Keychain is accessed via the /usr/bin/security CLI (the
+// native Security.framework API requires cgo, which gitsign builds don't
+// use); other platforms use the native credential store APIs.
+package keyring
+
+import (
+ "context"
+ "crypto"
+ "crypto/x509"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/99designs/keyring"
+ "github.com/sigstore/gitsign/internal/cache"
+ "github.com/sigstore/gitsign/internal/cache/api"
+ "github.com/sigstore/gitsign/internal/config"
+)
+
+const (
+ // serviceName is the keyring service name all gitsign entries are stored
+ // under.
+ serviceName = "gitsign"
+ // credentialKeyPrefix mirrors the prefix used by cache.CredentialKey;
+ // used to recognize gitsign entries when enumerating keys.
+ credentialKeyPrefix = "credential/v1/"
+ // chainKeyMarker distinguishes chain chunk entries from credential
+ // entries.
+ chainKeyMarker = "/chain/"
+ // defaultMaxEntrySize bounds individual keyring entry values. Windows
+ // Credential Manager limits credential blobs to 2560 bytes, so larger
+ // payloads (typically the certificate chain) are split across entries.
+ defaultMaxEntrySize = 2000
+
+ envelopeVersion = 1
+)
+
+// envelope is the JSON payload stored under the credential key.
+type envelope struct {
+ Version int `json:"version"`
+ NotAfter time.Time `json:"notAfter"`
+ PrivateKey string `json:"privateKey"`
+ Cert string `json:"cert"`
+ // ChainChunks is the number of chain chunk entries stored alongside the
+ // credential (0 if there is no chain).
+ ChainChunks int `json:"chainChunks"`
+ Meta cache.Metadata `json:"meta"`
+}
+
+// Cache implements cache.Cache backed by the OS keyring.
+type Cache struct {
+ Roots *x509.CertPool
+ Intermediates *x509.CertPool
+ // Config is used to derive the identity key when storing credentials.
+ Config *config.Config
+
+ // Keyring overrides the backing keyring. For testing.
+ Keyring keyring.Keyring
+ // maxEntrySize overrides the per-entry size limit. For testing.
+ maxEntrySize int
+
+ kr keyring.Keyring
+}
+
+var _ cache.Manager = (*Cache)(nil)
+
+// keyring lazily opens the OS keyring, so that construction never fails and
+// unavailable keyrings (e.g. headless Linux) surface as soft errors on use.
+// The backend is selected per-platform by openSystemKeyring: the native
+// credential store on most builds, or the /usr/bin/security CLI on macOS
+// builds without cgo.
+func (c *Cache) keyring() (keyring.Keyring, error) {
+ if c.Keyring != nil {
+ return c.Keyring, nil
+ }
+ if c.kr == nil {
+ kr, err := openSystemKeyring()
+ if err != nil {
+ return nil, fmt.Errorf("error opening system keyring: %w", err)
+ }
+ c.kr = kr
+ }
+ return c.kr, nil
+}
+
+func (c *Cache) entrySize() int {
+ if c.maxEntrySize > 0 {
+ return c.maxEntrySize
+ }
+ return defaultMaxEntrySize
+}
+
+// GetCredentials returns the cached credential for the identity described by
+// cfg. Expired or invalid entries are deleted and reported as a miss/error.
+func (c *Cache) GetCredentials(_ context.Context, cfg *config.Config) (crypto.PrivateKey, []byte, []byte, error) {
+ if cfg == nil {
+ cfg = c.Config
+ }
+ kr, err := c.keyring()
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ key := cache.CredentialKey(cfg)
+ item, err := kr.Get(key)
+ if err != nil {
+ if errors.Is(err, keyring.ErrKeyNotFound) {
+ return nil, nil, nil, fmt.Errorf("%w: no entry for identity", cache.ErrNotFound)
+ }
+ return nil, nil, nil, fmt.Errorf("error reading credential from keyring: %w", err)
+ }
+
+ env := new(envelope)
+ if err := json.Unmarshal(item.Data, env); err != nil {
+ c.deleteEntry(kr, key, 0)
+ return nil, nil, nil, fmt.Errorf("error unmarshalling stored credential (entry deleted): %w", err)
+ }
+ if env.Version != envelopeVersion {
+ c.deleteEntry(kr, key, env.ChainChunks)
+ return nil, nil, nil, fmt.Errorf("%w: unsupported credential version %d (entry deleted)", cache.ErrNotFound, env.Version)
+ }
+
+ // Cheap expiry check before doing any crypto - the credential is only
+ // useful for the lifetime of the cert.
+ if time.Now().Add(30 * time.Second).After(env.NotAfter) {
+ c.deleteEntry(kr, key, env.ChainChunks)
+ return nil, nil, nil, fmt.Errorf("%w: stored cert expired", cache.ErrNotFound)
+ }
+
+ chain, err := readChain(kr, key, env.ChainChunks)
+ if err != nil {
+ c.deleteEntry(kr, key, env.ChainChunks)
+ return nil, nil, nil, fmt.Errorf("error reading stored chain (entry deleted): %w", err)
+ }
+
+ certPEM := []byte(env.Cert)
+ if err := cache.ValidateCert(certPEM, c.Roots, c.Intermediates); err != nil {
+ c.deleteEntry(kr, key, env.ChainChunks)
+ return nil, nil, nil, err
+ }
+
+ privateKey, _, _, err := cache.DecodeCredential(&api.Credential{
+ PrivateKey: []byte(env.PrivateKey),
+ Cert: certPEM,
+ Chain: chain,
+ })
+ if err != nil {
+ c.deleteEntry(kr, key, env.ChainChunks)
+ return nil, nil, nil, fmt.Errorf("error unmarshalling private key (entry deleted): %w", err)
+ }
+
+ return privateKey, certPEM, chain, nil
+}
+
+// StoreCert stores the credential under the identity derived from the
+// configured Config, overwriting any previous entry.
+func (c *Cache) StoreCert(_ context.Context, priv crypto.PrivateKey, cert, chain []byte) error {
+ kr, err := c.keyring()
+ if err != nil {
+ return err
+ }
+ cfg := c.Config
+ key := cache.CredentialKey(cfg)
+
+ cred, err := cache.EncodeCredential(priv, cert, chain)
+ if err != nil {
+ return err
+ }
+
+ notAfter, err := cache.NotAfter(cert)
+ if err != nil {
+ return err
+ }
+
+ chunks := chunk(chain, c.entrySize())
+ meta := cache.MetadataFromConfig(cfg)
+ env := &envelope{
+ Version: envelopeVersion,
+ NotAfter: notAfter,
+ PrivateKey: string(cred.PrivateKey),
+ Cert: string(cert),
+ ChainChunks: len(chunks),
+ Meta: meta,
+ }
+ raw, err := json.Marshal(env)
+ if err != nil {
+ return fmt.Errorf("error marshalling credential: %w", err)
+ }
+
+ label := serviceName
+ if meta.CommitterEmail != "" {
+ label = fmt.Sprintf("%s (%s)", serviceName, meta.CommitterEmail)
+ }
+
+ // Store chain chunks first so that a reader never sees a credential
+ // entry pointing at chunks that don't exist yet.
+ for i, ch := range chunks {
+ if err := kr.Set(keyring.Item{
+ Key: chainKey(key, i),
+ Data: ch,
+ Label: label,
+ Description: "gitsign signing certificate chain",
+ }); err != nil {
+ return fmt.Errorf("error storing chain in keyring: %w", err)
+ }
+ }
+ if err := kr.Set(keyring.Item{
+ Key: key,
+ Data: raw,
+ Label: label,
+ Description: "gitsign signing credential",
+ }); err != nil {
+ return fmt.Errorf("error storing credential in keyring: %w", err)
+ }
+
+ return nil
+}
+
+// List enumerates stored credentials.
+func (c *Cache) List(_ context.Context) ([]cache.CredentialInfo, error) {
+ kr, err := c.keyring()
+ if err != nil {
+ return nil, err
+ }
+ keys, err := kr.Keys()
+ if err != nil {
+ return nil, fmt.Errorf("error listing keyring entries: %w", err)
+ }
+ out := []cache.CredentialInfo{}
+ for _, key := range keys {
+ if !isCredentialKey(key) {
+ continue
+ }
+ item, err := kr.Get(key)
+ if err != nil {
+ continue
+ }
+ env := new(envelope)
+ if err := json.Unmarshal(item.Data, env); err != nil {
+ continue
+ }
+ out = append(out, cache.CredentialInfo{
+ ID: key,
+ NotAfter: env.NotAfter,
+ Meta: env.Meta,
+ })
+ }
+ return out, nil
+}
+
+// Delete removes the credential for the identity described by the configured
+// Config. It returns cache.ErrNotFound if no entry exists.
+func (c *Cache) Delete(_ context.Context) error {
+ kr, err := c.keyring()
+ if err != nil {
+ return err
+ }
+ key := cache.CredentialKey(c.Config)
+ // Check existence explicitly - Remove semantics for missing keys vary
+ // between backends.
+ item, err := kr.Get(key)
+ if err != nil {
+ if errors.Is(err, keyring.ErrKeyNotFound) {
+ return fmt.Errorf("%w: no entry for identity", cache.ErrNotFound)
+ }
+ return fmt.Errorf("error reading credential from keyring: %w", err)
+ }
+ chunks := 0
+ env := new(envelope)
+ if err := json.Unmarshal(item.Data, env); err == nil {
+ chunks = env.ChainChunks
+ }
+ if err := kr.Remove(key); err != nil && !errors.Is(err, keyring.ErrKeyNotFound) {
+ return fmt.Errorf("error deleting credential from keyring: %w", err)
+ }
+ deleteChain(kr, key, chunks)
+ return nil
+}
+
+// DeleteAll removes every gitsign credential entry (including chain chunks).
+func (c *Cache) DeleteAll(_ context.Context) error {
+ kr, err := c.keyring()
+ if err != nil {
+ return err
+ }
+ keys, err := kr.Keys()
+ if err != nil {
+ return fmt.Errorf("error listing keyring entries: %w", err)
+ }
+ // Best-effort: keep deleting remaining entries even if one fails (e.g.
+ // an entry another tool created that we don't have access to remove).
+ var errs []error
+ for _, key := range keys {
+ if !strings.HasPrefix(key, credentialKeyPrefix) {
+ continue
+ }
+ if err := kr.Remove(key); err != nil && !errors.Is(err, keyring.ErrKeyNotFound) {
+ errs = append(errs, fmt.Errorf("error deleting keyring entry %q: %w", key, err))
+ }
+ }
+ return errors.Join(errs...)
+}
+
+// isCredentialKey reports whether the key names a credential envelope entry
+// (as opposed to a chain chunk or an unrelated entry).
+func isCredentialKey(key string) bool {
+ return strings.HasPrefix(key, credentialKeyPrefix) && !strings.Contains(key, chainKeyMarker)
+}
+
+// chainChunkCount reads the stored envelope to discover how many chain chunk
+// entries accompany the credential. Returns 0 if the entry is missing or
+// malformed.
+func chainChunkCount(kr keyring.Keyring, key string) int {
+ item, err := kr.Get(key)
+ if err != nil {
+ return 0
+ }
+ env := new(envelope)
+ if err := json.Unmarshal(item.Data, env); err != nil {
+ return 0
+ }
+ return env.ChainChunks
+}
+
+func readChain(kr keyring.Keyring, key string, chunks int) ([]byte, error) {
+ if chunks == 0 {
+ return nil, nil
+ }
+ var chain []byte
+ for i := range chunks {
+ item, err := kr.Get(chainKey(key, i))
+ if err != nil {
+ return nil, fmt.Errorf("error reading chain chunk %d: %w", i, err)
+ }
+ chain = append(chain, item.Data...)
+ }
+ return chain, nil
+}
+
+// deleteEntry removes the credential entry and its chain chunks. All
+// deletions are best-effort.
+func (c *Cache) deleteEntry(kr keyring.Keyring, key string, chunks int) {
+ _ = kr.Remove(key)
+ deleteChain(kr, key, chunks)
+}
+
+func deleteChain(kr keyring.Keyring, key string, chunks int) {
+ for i := range chunks {
+ _ = kr.Remove(chainKey(key, i))
+ }
+}
+
+func chainKey(key string, i int) string {
+ return fmt.Sprintf("%s%s%d", key, chainKeyMarker, i)
+}
+
+func chunk(b []byte, size int) [][]byte {
+ var out [][]byte
+ for len(b) > 0 {
+ n := min(size, len(b))
+ out = append(out, b[:n])
+ b = b[n:]
+ }
+ return out
+}
diff --git a/internal/cache/keyring/keyring_test.go b/internal/cache/keyring/keyring_test.go
new file mode 100644
index 000000000..fc490963b
--- /dev/null
+++ b/internal/cache/keyring/keyring_test.go
@@ -0,0 +1,327 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package keyring
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/99designs/keyring"
+ "github.com/github/smimesign/fakeca"
+ "github.com/google/go-cmp/cmp"
+ "github.com/sigstore/gitsign/internal/cache"
+ "github.com/sigstore/gitsign/internal/config"
+ "github.com/sigstore/sigstore/pkg/cryptoutils"
+)
+
+func testConfig(email string) *config.Config {
+ return &config.Config{
+ Fulcio: "https://fulcio.example.com",
+ Issuer: "https://oauth2.example.com/auth",
+ ClientID: "sigstore",
+ ConnectorID: "connector",
+ CommitterEmail: email,
+ }
+}
+
+// newTestCredential issues a leaf cert from a fresh fake CA and returns a
+// cache backed by the given in-memory keyring along with the credential
+// parts.
+func newTestCredential(t *testing.T, kr keyring.Keyring, caOpts ...fakeca.Option) (*Cache, *ecdsa.PrivateKey, []byte, []byte) {
+ t.Helper()
+
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ca := fakeca.New(append([]fakeca.Option{fakeca.IsCA}, caOpts...)...)
+ leaf := ca.Issue(append([]fakeca.Option{fakeca.PrivateKey(priv)}, caOpts...)...)
+
+ certPEM, err := cryptoutils.MarshalCertificateToPEM(leaf.Certificate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ chainPEM, err := cryptoutils.MarshalCertificateToPEM(ca.Certificate)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ c := &Cache{
+ Roots: ca.ChainPool(),
+ Config: testConfig("user@example.com"),
+ Keyring: kr,
+ }
+ return c, priv, certPEM, chainPEM
+}
+
+func TestRoundtrip(t *testing.T) {
+ kr := keyring.NewArrayKeyring(nil)
+ ctx := context.Background()
+
+ c, priv, certPEM, chainPEM := newTestCredential(t, kr)
+
+ // Miss before store.
+ if _, _, _, err := c.GetCredentials(ctx, c.Config); !errors.Is(err, cache.ErrNotFound) {
+ t.Fatalf("GetCredentials before store: want ErrNotFound, got %v", err)
+ }
+
+ if err := c.StoreCert(ctx, priv, certPEM, chainPEM); err != nil {
+ t.Fatalf("StoreCert: %v", err)
+ }
+
+ gotPriv, gotCert, gotChain, err := c.GetCredentials(ctx, c.Config)
+ if err != nil {
+ t.Fatalf("GetCredentials: %v", err)
+ }
+ gotSigner, ok := gotPriv.(*ecdsa.PrivateKey)
+ if !ok || !priv.Equal(gotSigner) {
+ t.Error("private key did not match")
+ }
+ if diff := cmp.Diff(certPEM, gotCert); diff != "" {
+ t.Errorf("cert mismatch (-want +got):\n%s", diff)
+ }
+ if diff := cmp.Diff(chainPEM, gotChain); diff != "" {
+ t.Errorf("chain mismatch (-want +got):\n%s", diff)
+ }
+
+ // Storing again overwrites without error.
+ if err := c.StoreCert(ctx, priv, certPEM, chainPEM); err != nil {
+ t.Fatalf("StoreCert (second): %v", err)
+ }
+
+ // Enumeration should show exactly one credential for this identity.
+ entries, err := c.List(ctx)
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(entries) != 1 {
+ t.Fatalf("List: want 1 entry, got %d", len(entries))
+ }
+ if entries[0].Meta.CommitterEmail != "user@example.com" {
+ t.Errorf("List: unexpected meta %+v", entries[0].Meta)
+ }
+}
+
+func TestMultipleIdentities(t *testing.T) {
+ kr := keyring.NewArrayKeyring(nil)
+ ctx := context.Background()
+
+ a, privA, certA, chainA := newTestCredential(t, kr)
+ b, privB, certB, chainB := newTestCredential(t, kr)
+ b.Config = testConfig("other@example.com")
+
+ if err := a.StoreCert(ctx, privA, certA, chainA); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.StoreCert(ctx, privB, certB, chainB); err != nil {
+ t.Fatal(err)
+ }
+
+ _, gotA, _, err := a.GetCredentials(ctx, a.Config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, gotB, _, err := b.GetCredentials(ctx, b.Config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if diff := cmp.Diff(certA, gotA); diff != "" {
+ t.Errorf("identity A cert mismatch (-want +got):\n%s", diff)
+ }
+ if diff := cmp.Diff(certB, gotB); diff != "" {
+ t.Errorf("identity B cert mismatch (-want +got):\n%s", diff)
+ }
+
+ entries, err := a.List(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 2 {
+ t.Fatalf("List: want 2 entries, got %d", len(entries))
+ }
+}
+
+func TestExpiredCert(t *testing.T) {
+ kr := keyring.NewArrayKeyring(nil)
+ ctx := context.Background()
+
+ c, priv, certPEM, chainPEM := newTestCredential(t, kr,
+ fakeca.NotBefore(time.Now().Add(-2*time.Hour)),
+ fakeca.NotAfter(time.Now().Add(-time.Hour)),
+ )
+
+ if err := c.StoreCert(ctx, priv, certPEM, chainPEM); err != nil {
+ t.Fatal(err)
+ }
+
+ // Expired cert is a miss, not an error...
+ if _, _, _, err := c.GetCredentials(ctx, c.Config); !errors.Is(err, cache.ErrNotFound) {
+ t.Fatalf("GetCredentials: want ErrNotFound, got %v", err)
+ }
+
+ // ...and the entries are removed.
+ if _, err := kr.Get(cache.CredentialKey(c.Config)); !errors.Is(err, keyring.ErrKeyNotFound) {
+ t.Errorf("credential entry not deleted: %v", err)
+ }
+ entries, err := c.List(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Errorf("List: want 0 entries, got %d", len(entries))
+ }
+}
+
+func TestChainChunking(t *testing.T) {
+ kr := keyring.NewArrayKeyring(nil)
+ ctx := context.Background()
+
+ c, priv, certPEM, chainPEM := newTestCredential(t, kr)
+ // Force the chain to split into many chunks.
+ c.maxEntrySize = 64
+
+ if err := c.StoreCert(ctx, priv, certPEM, chainPEM); err != nil {
+ t.Fatal(err)
+ }
+
+ // Sanity check that chunking actually happened.
+ key := cache.CredentialKey(c.Config)
+ if got := chainChunkCount(kr, key); got < 2 {
+ t.Fatalf("expected multiple chain chunks, got %d", got)
+ }
+
+ _, _, gotChain, err := c.GetCredentials(ctx, c.Config)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if diff := cmp.Diff(chainPEM, gotChain); diff != "" {
+ t.Errorf("chain mismatch (-want +got):\n%s", diff)
+ }
+
+ // Deleting removes the chunk entries too.
+ chunks := chainChunkCount(kr, key)
+ if err := c.Delete(ctx); err != nil {
+ t.Fatal(err)
+ }
+ for i := range chunks {
+ if _, err := kr.Get(chainKey(key, i)); !errors.Is(err, keyring.ErrKeyNotFound) {
+ t.Errorf("chain chunk %d not deleted: %v", i, err)
+ }
+ }
+}
+
+func TestValidationFailure(t *testing.T) {
+ kr := keyring.NewArrayKeyring(nil)
+ ctx := context.Background()
+
+ c, priv, certPEM, chainPEM := newTestCredential(t, kr)
+ if err := c.StoreCert(ctx, priv, certPEM, chainPEM); err != nil {
+ t.Fatal(err)
+ }
+
+ // Reads with roots from a different CA must fail and remove the entry.
+ other := fakeca.New(fakeca.IsCA)
+ c.Roots = other.ChainPool()
+ if _, _, _, err := c.GetCredentials(ctx, c.Config); err == nil {
+ t.Fatal("GetCredentials: expected error with wrong roots")
+ }
+ if _, err := kr.Get(cache.CredentialKey(c.Config)); !errors.Is(err, keyring.ErrKeyNotFound) {
+ t.Errorf("credential entry not deleted: %v", err)
+ }
+}
+
+// errKeyring simulates an unavailable/locked keyring.
+type errKeyring struct {
+ err error
+}
+
+func (e errKeyring) Get(string) (keyring.Item, error) { return keyring.Item{}, e.err }
+func (e errKeyring) GetMetadata(string) (keyring.Metadata, error) { return keyring.Metadata{}, e.err }
+func (e errKeyring) Set(keyring.Item) error { return e.err }
+func (e errKeyring) Remove(string) error { return e.err }
+func (e errKeyring) Keys() ([]string, error) { return nil, e.err }
+
+func TestKeyringUnavailable(t *testing.T) {
+ wantErr := errors.New("keyring unavailable")
+ ctx := context.Background()
+
+ c, priv, certPEM, chainPEM := newTestCredential(t, errKeyring{err: wantErr})
+
+ if _, _, _, err := c.GetCredentials(ctx, c.Config); !errors.Is(err, wantErr) {
+ t.Errorf("GetCredentials: want %v, got %v", wantErr, err)
+ }
+ if err := c.StoreCert(ctx, priv, certPEM, chainPEM); !errors.Is(err, wantErr) {
+ t.Errorf("StoreCert: want %v, got %v", wantErr, err)
+ }
+}
+
+func TestDelete(t *testing.T) {
+ kr := keyring.NewArrayKeyring(nil)
+ ctx := context.Background()
+
+ a, privA, certA, chainA := newTestCredential(t, kr)
+ b, privB, certB, chainB := newTestCredential(t, kr)
+ b.Config = testConfig("other@example.com")
+
+ // Deleting a missing entry reports a miss.
+ if err := a.Delete(ctx); !errors.Is(err, cache.ErrNotFound) {
+ t.Fatalf("Delete: want ErrNotFound, got %v", err)
+ }
+
+ if err := a.StoreCert(ctx, privA, certA, chainA); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.StoreCert(ctx, privB, certB, chainB); err != nil {
+ t.Fatal(err)
+ }
+
+ // Delete only removes the current identity.
+ if err := a.Delete(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, _, err := a.GetCredentials(ctx, a.Config); !errors.Is(err, cache.ErrNotFound) {
+ t.Errorf("GetCredentials after delete: want ErrNotFound, got %v", err)
+ }
+ if _, _, _, err := b.GetCredentials(ctx, b.Config); err != nil {
+ t.Errorf("GetCredentials for other identity: %v", err)
+ }
+
+ // DeleteAll removes everything, including chain chunks.
+ if err := b.DeleteAll(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, _, err := b.GetCredentials(ctx, b.Config); !errors.Is(err, cache.ErrNotFound) {
+ t.Errorf("GetCredentials after DeleteAll: want ErrNotFound, got %v", err)
+ }
+ keys, err := kr.Keys()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(keys) != 0 {
+ t.Errorf("Keys after DeleteAll: want 0 entries, got %d: %v", len(keys), keys)
+ }
+ entries, err := b.List(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 0 {
+ t.Errorf("List after DeleteAll: want 0 entries, got %d", len(entries))
+ }
+}
diff --git a/internal/cache/keyring/open_darwin.go b/internal/cache/keyring/open_darwin.go
new file mode 100644
index 000000000..420e16ee6
--- /dev/null
+++ b/internal/cache/keyring/open_darwin.go
@@ -0,0 +1,27 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package keyring
+
+import (
+ "github.com/99designs/keyring"
+)
+
+// openSystemKeyring opens the macOS Keychain via the /usr/bin/security CLI.
+// The native Security.framework backend requires cgo, which gitsign builds
+// don't use; the CLI is fully functional and keeps behavior identical across
+// release binaries and source builds.
+func openSystemKeyring() (keyring.Keyring, error) {
+ return newSecurityCLIKeyring(serviceName), nil
+}
diff --git a/internal/cache/keyring/open_default.go b/internal/cache/keyring/open_default.go
new file mode 100644
index 000000000..c2e3e0292
--- /dev/null
+++ b/internal/cache/keyring/open_default.go
@@ -0,0 +1,43 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !darwin
+
+package keyring
+
+import (
+ "github.com/99designs/keyring"
+)
+
+// allowedBackends restricts storage to native OS credential stores - no
+// file/pass fallbacks that would need their own password prompts.
+var allowedBackends = []keyring.BackendType{
+ keyring.WinCredBackend,
+ keyring.SecretServiceBackend,
+ keyring.KWalletBackend,
+}
+
+// openSystemKeyring opens the native OS credential store.
+func openSystemKeyring() (keyring.Keyring, error) {
+ return keyring.Open(keyring.Config{
+ ServiceName: serviceName,
+ AllowedBackends: allowedBackends,
+ // Linux: use the default collection instead of creating a
+ // gitsign-specific one (which would prompt for a new password).
+ LibSecretCollectionName: "login",
+ WinCredPrefix: serviceName,
+ KWalletAppID: serviceName,
+ KWalletFolder: serviceName,
+ })
+}
diff --git a/internal/cache/keyring/securitycli_darwin.go b/internal/cache/keyring/securitycli_darwin.go
new file mode 100644
index 000000000..fc84cd546
--- /dev/null
+++ b/internal/cache/keyring/securitycli_darwin.go
@@ -0,0 +1,147 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package keyring
+
+import (
+ "encoding/base64"
+ "fmt"
+ "io"
+ "os/exec"
+ "strings"
+
+ "github.com/99designs/keyring"
+)
+
+const securityPath = "/usr/bin/security"
+
+// securityCLIKeyring implements keyring.Keyring on top of the
+// /usr/bin/security CLI, for macOS builds without cgo (which the native
+// Security.framework backend requires).
+//
+// Item data is stored base64-encoded, since the security CLI mangles
+// non-printable/multiline values on read.
+type securityCLIKeyring struct {
+ service string
+}
+
+var _ keyring.Keyring = (*securityCLIKeyring)(nil)
+
+func newSecurityCLIKeyring(service string) *securityCLIKeyring {
+ return &securityCLIKeyring{service: service}
+}
+
+// validToken reports whether s is safe to embed in a `security -i` command
+// without quoting. Keys and payloads used by this package (hex digests,
+// "credential/v1/.../chain/N" paths, base64) all satisfy this.
+func validToken(s string) bool {
+ if s == "" {
+ return false
+ }
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
+ case r == '/', r == '.', r == '_', r == '-', r == '+', r == '=', r == ':':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+func (k *securityCLIKeyring) Get(key string) (keyring.Item, error) {
+ if !validToken(key) {
+ return keyring.Item{}, fmt.Errorf("invalid key %q", key)
+ }
+ out, err := exec.Command(securityPath,
+ "find-generic-password",
+ "-s", k.service,
+ "-wa", key).CombinedOutput()
+ if err != nil {
+ if strings.Contains(string(out), "could not be found") {
+ return keyring.Item{}, keyring.ErrKeyNotFound
+ }
+ return keyring.Item{}, fmt.Errorf("security find-generic-password: %w: %s", err, out)
+ }
+ data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(out)))
+ if err != nil {
+ return keyring.Item{}, fmt.Errorf("error decoding stored value: %w", err)
+ }
+ return keyring.Item{Key: key, Data: data}, nil
+}
+
+func (k *securityCLIKeyring) GetMetadata(_ string) (keyring.Metadata, error) {
+ return keyring.Metadata{}, keyring.ErrMetadataNotSupported
+}
+
+func (k *securityCLIKeyring) Set(item keyring.Item) error {
+ if !validToken(item.Key) {
+ return fmt.Errorf("invalid key %q", item.Key)
+ }
+ encoded := base64.StdEncoding.EncodeToString(item.Data)
+
+ // Run in interactive mode and pass the command via stdin so the secret
+ // doesn't show up in process args.
+ command := fmt.Sprintf("add-generic-password -U -s %s -a %s -w %s\n", k.service, item.Key, encoded)
+ // The security CLI limits interactive commands to 4096 bytes.
+ if len(command) > 4096 {
+ return fmt.Errorf("value for %q too large for the security CLI", item.Key)
+ }
+
+ cmd := exec.Command(securityPath, "-i")
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return err
+ }
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+ if _, err := io.WriteString(stdin, command); err != nil {
+ return err
+ }
+ if err := stdin.Close(); err != nil {
+ return err
+ }
+ if err := cmd.Wait(); err != nil {
+ return fmt.Errorf("security add-generic-password: %w", err)
+ }
+ return nil
+}
+
+func (k *securityCLIKeyring) Remove(key string) error {
+ if !validToken(key) {
+ return fmt.Errorf("invalid key %q", key)
+ }
+ out, err := exec.Command(securityPath,
+ "delete-generic-password",
+ "-s", k.service,
+ "-a", key).CombinedOutput()
+ if err != nil {
+ if strings.Contains(string(out), "could not be found") {
+ return keyring.ErrKeyNotFound
+ }
+ return fmt.Errorf("security delete-generic-password: %w: %s", err, out)
+ }
+ return nil
+}
+
+func (k *securityCLIKeyring) Keys() ([]string, error) {
+ // dump-keychain lists item attributes (not secrets), so it doesn't
+ // require authorization.
+ out, err := exec.Command(securityPath, "dump-keychain").CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("security dump-keychain: %w", err)
+ }
+ return parseKeychainDump(k.service, string(out)), nil
+}
diff --git a/internal/cache/keyring/securitycli_parse.go b/internal/cache/keyring/securitycli_parse.go
new file mode 100644
index 000000000..7f141f13f
--- /dev/null
+++ b/internal/cache/keyring/securitycli_parse.go
@@ -0,0 +1,73 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package keyring
+
+import "strings"
+
+// parseKeychainDump extracts the account names of generic password items
+// belonging to the given service from `security dump-keychain` output.
+//
+// Items are printed as blocks starting with a `keychain:` line, with
+// attribute lines like:
+//
+// "acct"="credential/v1/abc123"
+// "svce"="gitsign"
+//
+// This lives in an untagged file so it can be unit tested on any platform;
+// it is only exercised by the darwin non-cgo security CLI backend.
+func parseKeychainDump(service, dump string) []string {
+ var keys []string
+ var acct string
+ var svce string
+
+ flush := func() {
+ if svce == service && acct != "" {
+ keys = append(keys, acct)
+ }
+ acct, svce = "", ""
+ }
+
+ for line := range strings.SplitSeq(dump, "\n") {
+ trimmed := strings.TrimSpace(line)
+ if strings.HasPrefix(trimmed, "keychain:") {
+ // New item block.
+ flush()
+ continue
+ }
+ if v, ok := attrValue(trimmed, "acct"); ok {
+ acct = v
+ }
+ if v, ok := attrValue(trimmed, "svce"); ok {
+ svce = v
+ }
+ }
+ flush()
+ return keys
+}
+
+// attrValue parses a dump-keychain attribute line of the form
+// `"name"="value"`, returning the value.
+func attrValue(line, name string) (string, bool) {
+ prefix := `"` + name + `"="`
+ if !strings.HasPrefix(line, prefix) {
+ return "", false
+ }
+ rest := strings.TrimPrefix(line, prefix)
+ end := strings.LastIndex(rest, `"`)
+ if end < 0 {
+ return "", false
+ }
+ return rest[:end], true
+}
diff --git a/internal/cache/keyring/securitycli_parse_test.go b/internal/cache/keyring/securitycli_parse_test.go
new file mode 100644
index 000000000..3dea9d58d
--- /dev/null
+++ b/internal/cache/keyring/securitycli_parse_test.go
@@ -0,0 +1,76 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package keyring
+
+import (
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+)
+
+func TestParseKeychainDump(t *testing.T) {
+ dump := `keychain: "/Users/user/Library/Keychains/login.keychain-db"
+version: 512
+class: "genp"
+attributes:
+ 0x00000007 ="gitsign"
+ "acct"="credential/v1/abc123"
+ "cdat"=0x32303236303830343231343233345A00 "20260804214234Z\000"
+ "svce"="gitsign"
+keychain: "/Users/user/Library/Keychains/login.keychain-db"
+version: 512
+class: "genp"
+attributes:
+ "acct"="credential/v1/abc123/chain/0"
+ "svce"="gitsign"
+keychain: "/Users/user/Library/Keychains/login.keychain-db"
+class: "genp"
+attributes:
+ "acct"="some-other-account"
+ "svce"="other-service"
+keychain: "/Users/user/Library/Keychains/login.keychain-db"
+class: "inet"
+attributes:
+ "acct"="no-svce-item"
+`
+
+ got := parseKeychainDump("gitsign", dump)
+ want := []string{
+ "credential/v1/abc123",
+ "credential/v1/abc123/chain/0",
+ }
+ if diff := cmp.Diff(want, got); diff != "" {
+ t.Errorf("parseKeychainDump mismatch (-want +got):\n%s", diff)
+ }
+}
+
+func TestAttrValue(t *testing.T) {
+ for _, tc := range []struct {
+ line string
+ name string
+ want string
+ wantOK bool
+ }{
+ {`"acct"="credential/v1/abc"`, "acct", "credential/v1/abc", true},
+ {`"svce"="gitsign"`, "svce", "gitsign", true},
+ {`"svce"=`, "svce", "", false},
+ {`"desc"="x"`, "acct", "", false},
+ } {
+ got, ok := attrValue(tc.line, tc.name)
+ if got != tc.want || ok != tc.wantOK {
+ t.Errorf("attrValue(%q, %q) = (%q, %v), want (%q, %v)", tc.line, tc.name, got, ok, tc.want, tc.wantOK)
+ }
+ }
+}
diff --git a/internal/cache/service/service.go b/internal/cache/service/service.go
index 2522fa639..a51c65330 100644
--- a/internal/cache/service/service.go
+++ b/internal/cache/service/service.go
@@ -20,14 +20,14 @@ import (
"os"
"time"
- "github.com/patrickmn/go-cache"
+ gocache "github.com/patrickmn/go-cache"
+ "github.com/sigstore/gitsign/internal/cache"
"github.com/sigstore/gitsign/internal/cache/api"
"github.com/sigstore/gitsign/internal/fulcio"
- "github.com/sigstore/sigstore/pkg/cryptoutils"
)
type Service struct {
- store *cache.Cache
+ store *gocache.Cache
}
const (
@@ -35,16 +35,45 @@ const (
cleanupInterval = 1 * time.Minute
)
+// record is what's stored per credential - the credential itself plus
+// metadata for enumeration.
+type record struct {
+ Credential *api.Credential
+ Info api.CredentialInfo
+}
+
func NewService() *Service {
s := &Service{
- store: cache.New(defaultExpiration, cleanupInterval),
+ store: gocache.New(defaultExpiration, cleanupInterval),
}
return s
}
+// store saves the credential with a TTL matching the certificate lifetime,
+// overwriting any existing entry for the ID.
+func (s *Service) storeCredential(id string, cred *api.Credential, meta api.Metadata) error {
+ notAfter, err := cache.NotAfter(cred.Cert)
+ if err != nil {
+ return err
+ }
+ ttl := time.Until(notAfter)
+ if ttl <= 0 {
+ return fmt.Errorf("certificate is already expired (NotAfter: %s)", notAfter)
+ }
+ s.store.Set(id, &record{
+ Credential: cred,
+ Info: api.CredentialInfo{
+ ID: id,
+ NotAfter: notAfter,
+ Meta: meta,
+ },
+ }, ttl)
+ return nil
+}
+
func (s *Service) StoreCredential(req api.StoreCredentialRequest, resp *api.Credential) error {
fmt.Println("Store", req.ID)
- if err := s.store.Add(req.ID, req.Credential, 10*time.Minute); err != nil {
+ if err := s.storeCredential(req.ID, req.Credential, req.Meta); err != nil {
return err
}
*resp = *req.Credential
@@ -57,11 +86,11 @@ func (s *Service) GetCredential(req api.GetCredentialRequest, resp *api.Credenti
i, ok := s.store.Get(req.ID)
if ok {
fmt.Println("gitsign-credential-cache: found credential!")
- cred, ok := i.(*api.Credential)
+ rec, ok := i.(*record)
if !ok {
return fmt.Errorf("unknown credential type %T", i)
}
- *resp = *cred
+ *resp = *rec.Credential
return nil
}
@@ -77,19 +106,43 @@ func (s *Service) GetCredential(req api.GetCredentialRequest, resp *api.Credenti
if err != nil {
return fmt.Errorf("error getting new identity: %w", err)
}
- privPEM, err := cryptoutils.MarshalPrivateKeyToPEM(id.PrivateKey)
+ cred, err := cache.EncodeCredential(id.PrivateKey, id.CertPEM, id.ChainPEM)
if err != nil {
return err
}
- cred := &api.Credential{
- PrivateKey: privPEM,
- Cert: id.CertPEM,
- Chain: id.ChainPEM,
- }
- if err := s.store.Add(req.ID, cred, 10*time.Minute); err != nil {
+ if err := s.storeCredential(req.ID, cred, cache.MetadataFromConfig(req.Config)); err != nil {
// We still generated the credential just fine, so only log the error.
fmt.Printf("error storing credential: %v\n", err)
}
*resp = *cred
return nil
}
+
+func (s *Service) ListCredentials(_ api.ListCredentialsRequest, resp *[]api.CredentialInfo) error {
+ fmt.Println("List")
+ out := []api.CredentialInfo{}
+ for _, item := range s.store.Items() {
+ rec, ok := item.Object.(*record)
+ if !ok {
+ continue
+ }
+ out = append(out, rec.Info)
+ }
+ *resp = out
+ return nil
+}
+
+func (s *Service) DeleteCredential(req api.DeleteCredentialRequest, _ *api.DeleteCredentialsResponse) error {
+ fmt.Println("Delete", req.ID)
+ if _, ok := s.store.Get(req.ID); !ok {
+ return fmt.Errorf("%q not found", req.ID)
+ }
+ s.store.Delete(req.ID)
+ return nil
+}
+
+func (s *Service) DeleteAllCredentials(_ api.DeleteAllCredentialsRequest, _ *api.DeleteCredentialsResponse) error {
+ fmt.Println("DeleteAll")
+ s.store.Flush()
+ return nil
+}
diff --git a/internal/cache/validate.go b/internal/cache/validate.go
new file mode 100644
index 000000000..009603c15
--- /dev/null
+++ b/internal/cache/validate.go
@@ -0,0 +1,59 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cache
+
+import (
+ "crypto/x509"
+ "encoding/asn1"
+ "fmt"
+ "time"
+
+ "github.com/sigstore/sigstore/pkg/cryptoutils"
+)
+
+// ValidateCert checks that the PEM-encoded cert(s) chain to the given roots
+// and intermediates, are valid for code signing, and won't expire in the next
+// 30 seconds.
+func ValidateCert(certPEM []byte, roots, intermediates *x509.CertPool) error {
+ certs, err := cryptoutils.UnmarshalCertificatesFromPEM(certPEM)
+ if err != nil {
+ return fmt.Errorf("error unmarshalling cert: %w", err)
+ }
+ // There should really only be 1 cert, but check them all anyway.
+ for _, cert := range certs {
+ if len(cert.UnhandledCriticalExtensions) > 0 {
+ var unhandledExts []asn1.ObjectIdentifier
+ for _, oid := range cert.UnhandledCriticalExtensions {
+ if !oid.Equal(cryptoutils.SANOID) {
+ unhandledExts = append(unhandledExts, oid)
+ }
+ }
+
+ cert.UnhandledCriticalExtensions = unhandledExts
+ }
+
+ if _, err := cert.Verify(x509.VerifyOptions{
+ Roots: roots,
+ Intermediates: intermediates,
+ KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning},
+ // We're going to be using this key immediately, so we don't need a long window.
+ // Just make sure it's not about to expire.
+ CurrentTime: time.Now().Add(30 * time.Second),
+ }); err != nil {
+ return fmt.Errorf("stored cert no longer valid: %w", err)
+ }
+ }
+ return nil
+}
diff --git a/internal/cache/validate_test.go b/internal/cache/validate_test.go
new file mode 100644
index 000000000..531b6e968
--- /dev/null
+++ b/internal/cache/validate_test.go
@@ -0,0 +1,73 @@
+// Copyright 2026 The Sigstore Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cache
+
+import (
+ "testing"
+ "time"
+
+ "github.com/github/smimesign/fakeca"
+ "github.com/sigstore/sigstore/pkg/cryptoutils"
+)
+
+func TestValidateCert(t *testing.T) {
+ ca := fakeca.New(fakeca.IsCA)
+ leaf := ca.Issue()
+ leafPEM, err := cryptoutils.MarshalCertificateToPEM(leaf.Certificate)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := ValidateCert(leafPEM, ca.ChainPool(), nil); err != nil {
+ t.Errorf("ValidateCert(valid): %v", err)
+ }
+
+ // Wrong roots.
+ other := fakeca.New(fakeca.IsCA)
+ if err := ValidateCert(leafPEM, other.ChainPool(), nil); err == nil {
+ t.Error("ValidateCert(wrong roots): expected error")
+ }
+
+ // Expired cert.
+ expired := ca.Issue(
+ fakeca.NotBefore(time.Now().Add(-2*time.Hour)),
+ fakeca.NotAfter(time.Now().Add(-time.Hour)),
+ )
+ expiredPEM, err := cryptoutils.MarshalCertificateToPEM(expired.Certificate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateCert(expiredPEM, ca.ChainPool(), nil); err == nil {
+ t.Error("ValidateCert(expired): expected error")
+ }
+
+ // Cert expiring within the 30s window is rejected.
+ almostExpired := ca.Issue(
+ fakeca.NotBefore(time.Now().Add(-time.Hour)),
+ fakeca.NotAfter(time.Now().Add(10*time.Second)),
+ )
+ almostExpiredPEM, err := cryptoutils.MarshalCertificateToPEM(almostExpired.Certificate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := ValidateCert(almostExpiredPEM, ca.ChainPool(), nil); err == nil {
+ t.Error("ValidateCert(almost expired): expected error")
+ }
+
+ // Garbage input.
+ if err := ValidateCert([]byte("not a cert"), ca.ChainPool(), nil); err == nil {
+ t.Error("ValidateCert(garbage): expected error")
+ }
+}
diff --git a/internal/commands/credentials/credentials.go b/internal/commands/credentials/credentials.go
new file mode 100644
index 000000000..f9a9f4b62
--- /dev/null
+++ b/internal/commands/credentials/credentials.go
@@ -0,0 +1,153 @@
+//
+// Copyright 2026 The Sigstore Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package credentials
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "text/tabwriter"
+ "time"
+
+ "github.com/sigstore/gitsign/internal/cache"
+ "github.com/sigstore/gitsign/internal/cache/keyring"
+ "github.com/sigstore/gitsign/internal/config"
+ "github.com/spf13/cobra"
+)
+
+// New returns the `gitsign credentials` command group for managing cached
+// signing credentials (see gitsign.credentialCacheMode).
+func New(cfg *config.Config) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "credentials",
+ Short: "Manage cached signing credentials",
+ Long: "Manage cached signing credentials.\n\n" +
+ "The credential cache backend is selected by gitsign.credentialCacheMode:\n" +
+ "the system keyring (`system`), or the gitsign-credential-cache daemon\n" +
+ "(`socket`). When no mode is configured, the system keyring is used.",
+ }
+ cmd.AddCommand(newList(cfg))
+ cmd.AddCommand(newClear(cfg))
+ return cmd
+}
+
+// newManager returns the credential cache backend selected by the config.
+// Unlike the signing path, no certificate roots are loaded - management
+// operations don't validate certs.
+func newManager(cfg *config.Config) (cache.Manager, error) {
+ switch strings.ToLower(cfg.CredentialCacheMode) {
+ case "", "system":
+ // Default to the keyring when no mode is set so that entries are
+ // inspectable even before caching is enabled.
+ if cfg.CredentialCacheMode == "" && cfg.CredentialCache != "" {
+ // A socket path without a mode selects the daemon (matching the
+ // signing path).
+ return cache.NewClient(cfg.CredentialCache, cfg, nil, nil)
+ }
+ return &keyring.Cache{Config: cfg}, nil
+ case "socket":
+ if cfg.CredentialCache == "" {
+ return nil, fmt.Errorf("credential cache mode %q requires a socket path (set GITSIGN_CREDENTIAL_CACHE)", cfg.CredentialCacheMode)
+ }
+ return cache.NewClient(cfg.CredentialCache, cfg, nil, nil)
+ default:
+ return nil, fmt.Errorf("unknown credential cache mode %q (expected one of: system, socket)", cfg.CredentialCacheMode)
+ }
+}
+
+func newList(cfg *config.Config) *cobra.Command {
+ return &cobra.Command{
+ Use: "list",
+ Short: "List cached signing credentials",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ m, err := newManager(cfg)
+ if err != nil {
+ return err
+ }
+ entries, err := m.List(cmd.Context())
+ if err != nil {
+ return err
+ }
+ if len(entries) == 0 {
+ fmt.Fprintln(cmd.OutOrStdout(), "no cached credentials")
+ return nil
+ }
+ w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 8, 2, ' ', 0)
+ fmt.Fprintln(w, "EMAIL\tISSUER\tCLIENTID\tCONNECTOR\tFULCIO\tEXPIRES\tSTATUS")
+ for _, e := range entries {
+ status := "valid"
+ if time.Now().After(e.NotAfter) {
+ status = "expired"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
+ orDash(e.Meta.CommitterEmail),
+ orDash(e.Meta.Issuer),
+ orDash(e.Meta.ClientID),
+ orDash(e.Meta.ConnectorID),
+ orDash(e.Meta.Fulcio),
+ e.NotAfter.Local().Format(time.RFC3339),
+ status,
+ )
+ }
+ return w.Flush()
+ },
+ }
+}
+
+func newClear(cfg *config.Config) *cobra.Command {
+ var all bool
+ cmd := &cobra.Command{
+ Use: "clear",
+ Short: "Remove cached signing credentials",
+ Long: "Remove cached signing credentials.\n\n" +
+ "By default only the credential for the current configuration\n" +
+ "(Fulcio URL, OIDC issuer, client ID, connector ID, and committer email)\n" +
+ "is removed. Use --all to remove all cached credentials.",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ m, err := newManager(cfg)
+ if err != nil {
+ return err
+ }
+ if all {
+ if err := m.DeleteAll(cmd.Context()); err != nil {
+ return err
+ }
+ fmt.Fprintln(cmd.OutOrStdout(), "cleared all cached credentials")
+ return nil
+ }
+ if err := m.Delete(cmd.Context()); err != nil {
+ if errors.Is(err, cache.ErrNotFound) {
+ fmt.Fprintln(cmd.OutOrStdout(), "no cached credential for the current configuration")
+ return nil
+ }
+ return err
+ }
+ fmt.Fprintln(cmd.OutOrStdout(), "cleared cached credential for the current configuration")
+ return nil
+ },
+ }
+ cmd.Flags().BoolVar(&all, "all", false, "remove all cached credentials")
+ return cmd
+}
+
+func orDash(s string) string {
+ if s == "" {
+ return "-"
+ }
+ return s
+}
diff --git a/internal/commands/root/root.go b/internal/commands/root/root.go
index 1e814da96..414cf8e80 100644
--- a/internal/commands/root/root.go
+++ b/internal/commands/root/root.go
@@ -20,6 +20,7 @@ import (
"github.com/spf13/cobra"
"github.com/sigstore/gitsign/internal/commands/attest"
+ "github.com/sigstore/gitsign/internal/commands/credentials"
"github.com/sigstore/gitsign/internal/commands/initialize"
"github.com/sigstore/gitsign/internal/commands/show"
"github.com/sigstore/gitsign/internal/commands/verify"
@@ -101,6 +102,7 @@ func New(cfg *config.Config) *cobra.Command {
rootCmd.AddCommand(verify.New(cfg))
rootCmd.AddCommand(verifytag.New(cfg))
rootCmd.AddCommand(initialize.New())
+ rootCmd.AddCommand(credentials.New(cfg))
o.AddFlags(rootCmd)
return rootCmd
diff --git a/internal/config/config.go b/internal/config/config.go
index a9b1acbc9..a0bcb8784 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -102,6 +102,20 @@ type Config struct {
// Path to log status output. Helpful for debugging when no TTY is available in the environment.
LogPath string
+ // CredentialCacheMode selects how signing credentials (the ephemeral
+ // private key and Fulcio certificate) are cached between invocations:
+ // "" - (default) use the gitsign-credential-cache daemon if
+ // CredentialCache is set, otherwise no caching.
+ // "system" - store credentials in the operating system keyring
+ // (macOS Keychain, Windows Credential Manager, Linux Secret
+ // Service). No daemon required.
+ // "socket" - use the gitsign-credential-cache daemon socket pointed to
+ // by CredentialCache.
+ CredentialCacheMode string
+ // CredentialCache is the path to the gitsign-credential-cache daemon
+ // socket.
+ CredentialCache string
+
// Committer details
CommitterName string
CommitterEmail string
@@ -199,6 +213,8 @@ func Get() (*Config, error) {
}
out.LogPath = envOrValue("GITSIGN_LOG", out.LogPath)
+ out.CredentialCacheMode = envOrValue("GITSIGN_CREDENTIAL_CACHE_MODE", out.CredentialCacheMode)
+ out.CredentialCache = envOrValue("GITSIGN_CREDENTIAL_CACHE", out.CredentialCache)
out.RekorMode = envOrValue("GITSIGN_REKOR_MODE", out.RekorMode)
out.URLOpener = envOrValue("GITSIGN_URL_OPENER", out.URLOpener)
out.EnableSigstoreGo = envOrValue("GITSIGN_ENABLE_SIGSTORE_GO", fmt.Sprintf("%t", out.EnableSigstoreGo)) == "true"
@@ -314,6 +330,10 @@ func applyGitOptions(out *Config, cfg map[string]string) {
out.Issuer = v
case strings.EqualFold(k, "gitsign.logPath"):
out.LogPath = v
+ case strings.EqualFold(k, "gitsign.credentialCacheMode"):
+ out.CredentialCacheMode = v
+ case strings.EqualFold(k, "gitsign.credentialCache"):
+ out.CredentialCache = v
case strings.EqualFold(k, "gitsign.urlOpener"):
out.URLOpener = v
case strings.EqualFold(k, "gitsign.connectorID"):
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index afcb7592b..314ba6ac0 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -133,6 +133,58 @@ func TestEnableSigstoreGo(t *testing.T) {
}
}
+func TestCredentialCache(t *testing.T) {
+ t.Cleanup(func() { execFn = realExec })
+
+ t.Run("from git config", func(t *testing.T) {
+ // git config lowercases key names.
+ execFn = func() (io.Reader, error) {
+ return strings.NewReader("gitsign.credentialcachemode system\ngitsign.credentialcache /tmp/cache.sock\n"), nil
+ }
+ got, err := Get()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.CredentialCacheMode != "system" {
+ t.Errorf("CredentialCacheMode: got %q, want %q", got.CredentialCacheMode, "system")
+ }
+ if got.CredentialCache != "/tmp/cache.sock" {
+ t.Errorf("CredentialCache: got %q, want %q", got.CredentialCache, "/tmp/cache.sock")
+ }
+ })
+
+ t.Run("env takes precedence over git config", func(t *testing.T) {
+ execFn = func() (io.Reader, error) {
+ return strings.NewReader("gitsign.credentialcachemode keyring\ngitsign.credentialcache /tmp/cache.sock\n"), nil
+ }
+ t.Setenv("GITSIGN_CREDENTIAL_CACHE_MODE", "system")
+ t.Setenv("GITSIGN_CREDENTIAL_CACHE", "/other/cache.sock")
+ got, err := Get()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.CredentialCacheMode != "system" {
+ t.Errorf("CredentialCacheMode: got %q, want %q", got.CredentialCacheMode, "system")
+ }
+ if got.CredentialCache != "/other/cache.sock" {
+ t.Errorf("CredentialCache: got %q, want %q", got.CredentialCache, "/other/cache.sock")
+ }
+ })
+
+ t.Run("defaults to empty", func(t *testing.T) {
+ execFn = func() (io.Reader, error) {
+ return strings.NewReader(""), nil
+ }
+ got, err := Get()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.CredentialCacheMode != "" || got.CredentialCache != "" {
+ t.Errorf("expected empty credential cache config, got mode=%q path=%q", got.CredentialCacheMode, got.CredentialCache)
+ }
+ })
+}
+
func TestRekorVersion(t *testing.T) {
t.Cleanup(func() { execFn = realExec })
diff --git a/internal/fulcio/identity.go b/internal/fulcio/identity.go
index 9adfbd843..de821ce6e 100644
--- a/internal/fulcio/identity.go
+++ b/internal/fulcio/identity.go
@@ -23,14 +23,14 @@ import (
"crypto/rand"
"crypto/x509"
"encoding/pem"
+ "errors"
"fmt"
"io"
- "net/rpc"
- "os"
- "path/filepath"
+ "strings"
"github.com/sigstore/cosign/v3/pkg/providers"
"github.com/sigstore/gitsign/internal/cache"
+ "github.com/sigstore/gitsign/internal/cache/keyring"
"github.com/sigstore/gitsign/internal/config"
"github.com/sigstore/gitsign/internal/fulcio/fulcioroots"
"github.com/sigstore/gitsign/internal/signerverifier"
@@ -56,27 +56,12 @@ type Identity struct {
}
func NewIdentity(ctx context.Context, cfg *config.Config, in io.Reader, out io.Writer) (*Identity, error) {
- var cacheClient *cache.Client
+ cacheClient, err := newCacheClient(ctx, cfg)
+ if err != nil {
+ return nil, err
+ }
- cachePath := os.Getenv("GITSIGN_CREDENTIAL_CACHE")
- if cachePath != "" {
- absPath, err := filepath.Abs(cachePath)
- if err != nil {
- return nil, fmt.Errorf("error resolving cache path: %w", err)
- }
- rpcClient, err := rpc.Dial("unix", absPath)
- if err != nil {
- return nil, fmt.Errorf("error creating RPC socket client: %w", err)
- }
- roots, intermediates, err := fulcioroots.NewFromConfig(ctx, cfg)
- if err != nil {
- return nil, fmt.Errorf("error loading certificate roots: %w", err)
- }
- cacheClient = &cache.Client{
- Client: rpcClient,
- Roots: roots,
- Intermediates: intermediates,
- }
+ if cacheClient != nil {
priv, cert, chain, err := cacheClient.GetCredentials(ctx, cfg)
if err == nil {
return &Identity{
@@ -85,9 +70,11 @@ func NewIdentity(ctx context.Context, cfg *config.Config, in io.Reader, out io.W
ChainPEM: chain,
}, nil
}
- // Only print error on failure - if there's a problem fetching
- // from the cache just fall through to normal OIDC.
- fmt.Fprintf(out, "error getting cached creds: %v\n", err) // nolint:errcheck
+ // Only print unexpected errors - a plain cache miss (e.g. first
+ // use) is normal. Either way fall through to normal OIDC.
+ if !errors.Is(err, cache.ErrNotFound) {
+ fmt.Fprintf(out, "error getting cached creds: %v\n", err) // nolint:errcheck
+ }
}
idf := &IdentityFactory{
@@ -108,6 +95,37 @@ func NewIdentity(ctx context.Context, cfg *config.Config, in io.Reader, out io.W
return id, nil
}
+// newCacheClient returns the credential cache backend selected by the config,
+// or nil if credential caching is disabled.
+func newCacheClient(ctx context.Context, cfg *config.Config) (cache.Cache, error) {
+ switch strings.ToLower(cfg.CredentialCacheMode) {
+ case "system":
+ roots, intermediates, err := fulcioroots.NewFromConfig(ctx, cfg)
+ if err != nil {
+ return nil, fmt.Errorf("error loading certificate roots: %w", err)
+ }
+ return &keyring.Cache{
+ Roots: roots,
+ Intermediates: intermediates,
+ Config: cfg,
+ }, nil
+ case "", "socket":
+ if cfg.CredentialCache == "" {
+ if cfg.CredentialCacheMode == "" {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("credential cache mode %q requires a socket path (set GITSIGN_CREDENTIAL_CACHE)", cfg.CredentialCacheMode)
+ }
+ roots, intermediates, err := fulcioroots.NewFromConfig(ctx, cfg)
+ if err != nil {
+ return nil, fmt.Errorf("error loading certificate roots: %w", err)
+ }
+ return cache.NewClient(cfg.CredentialCache, cfg, roots, intermediates)
+ default:
+ return nil, fmt.Errorf("unknown credential cache mode %q (expected one of: system, socket)", cfg.CredentialCacheMode)
+ }
+}
+
// Certificate gets the identity's certificate.
func (i *Identity) Certificate() (*x509.Certificate, error) {
p, _ := pem.Decode(i.CertPEM)
@@ -186,7 +204,7 @@ func (i *Identity) SignerVerifier() (*signerverifier.CertSignerVerifier, error)
}, nil
}
-func (i *Identity) CacheCert(ctx context.Context, cacheClient *cache.Client) error {
+func (i *Identity) CacheCert(ctx context.Context, cacheClient cache.Cache) error {
return cacheClient.StoreCert(ctx, i.PrivateKey, i.CertPEM, i.ChainPEM)
}
diff --git a/internal/fulcio/identity_test.go b/internal/fulcio/identity_test.go
new file mode 100644
index 000000000..e07e376d8
--- /dev/null
+++ b/internal/fulcio/identity_test.go
@@ -0,0 +1,95 @@
+//
+// Copyright 2026 The Sigstore Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package fulcio
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/github/smimesign/fakeca"
+ "github.com/sigstore/gitsign/internal/cache/keyring"
+ "github.com/sigstore/gitsign/internal/config"
+ "github.com/sigstore/sigstore/pkg/cryptoutils"
+)
+
+func TestNewCacheClient(t *testing.T) {
+ ctx := context.Background()
+
+ // Point FulcioRoot at a local PEM so root loading doesn't hit TUF.
+ ca := fakeca.New(fakeca.IsCA)
+ rootPEM, err := cryptoutils.MarshalCertificateToPEM(ca.Certificate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rootPath := filepath.Join(t.TempDir(), "root.pem")
+ if err := os.WriteFile(rootPath, rootPEM, 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("disabled by default", func(t *testing.T) {
+ c, err := newCacheClient(ctx, &config.Config{FulcioRoot: rootPath})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if c != nil {
+ t.Fatalf("expected nil cache, got %T", c)
+ }
+ })
+
+ t.Run("system keyring mode", func(t *testing.T) {
+ for _, mode := range []string{"system", "System"} {
+ c, err := newCacheClient(ctx, &config.Config{
+ FulcioRoot: rootPath,
+ CredentialCacheMode: mode,
+ })
+ if err != nil {
+ t.Fatalf("mode %q: unexpected error: %v", mode, err)
+ }
+ if _, ok := c.(*keyring.Cache); !ok {
+ t.Fatalf("mode %q: expected *keyring.Cache, got %T", mode, c)
+ }
+ }
+ })
+
+ t.Run("socket mode requires a path", func(t *testing.T) {
+ if _, err := newCacheClient(ctx, &config.Config{
+ FulcioRoot: rootPath,
+ CredentialCacheMode: "socket",
+ }); err == nil {
+ t.Fatal("expected error for socket mode without a path")
+ }
+ })
+
+ t.Run("unreachable socket is a hard error", func(t *testing.T) {
+ if _, err := newCacheClient(ctx, &config.Config{
+ FulcioRoot: rootPath,
+ CredentialCache: filepath.Join(t.TempDir(), "missing.sock"),
+ }); err == nil {
+ t.Fatal("expected error for unreachable socket")
+ }
+ })
+
+ t.Run("unknown mode", func(t *testing.T) {
+ if _, err := newCacheClient(ctx, &config.Config{
+ FulcioRoot: rootPath,
+ CredentialCacheMode: "carrier-pigeon",
+ }); err == nil {
+ t.Fatal("expected error for unknown mode")
+ }
+ })
+}