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") + } + }) +}