From 347014354ef1632fd0bcf69189a99ba6295f0f25 Mon Sep 17 00:00:00 2001 From: Billy Lynch Date: Tue, 4 Aug 2026 18:36:39 -0400 Subject: [PATCH 1/3] Add system keyring credential caching Add a credential cache backed by the OS keyring (macOS Keychain, Windows Credential Manager, Linux Secret Service) via zalando/go-keyring, so signing credentials (ephemeral private key + Fulcio cert/chain) can be reused for the lifetime of the certificate without running the gitsign-credential-cache daemon. Enable with gitsign.credentialCacheMode=keyring (or GITSIGN_CREDENTIAL_CACHE_MODE=keyring). Credentials are cached per identity, keyed by a hash of the configuration used to obtain them (Fulcio URL, OIDC issuer, client ID, connector ID, and committer email), so multiple identities can be stored concurrently. Expired or invalid entries are deleted lazily on read, and keyring failures (locked keychain, headless hosts) fall through to the normal OIDC flow. Chain data is chunked across entries to stay under the Windows credential blob size limit. The existing daemon cache is consolidated onto the same building blocks: - A shared cache.Cache interface, credential key derivation, cert validation, and credential encode/decode helpers are used by both backends. The daemon client now uses the config-derived identity key instead of hostname@cwd (the key is opaque to the daemon, so mixed client/daemon versions interoperate), giving the daemon per-identity, multi-identity caching. - The daemon stores entries with a TTL matching the certificate lifetime instead of a fixed 10 minutes, overwrites on re-store instead of erroring, and rejects already-expired certs. - Plain cache misses are reported as a sentinel error so first use no longer prints "error getting cached creds". A new `gitsign credentials list` / `gitsign credentials clear [--all]` subcommand inspects and removes cached credentials for whichever backend is configured (keyring directly, or the daemon via new List/Delete RPCs; old daemons get a clear upgrade error). Co-Authored-By: Claude Fable 5 Signed-off-by: Billy Lynch --- README.md | 17 +- cmd/gitsign-credential-cache/README.md | 17 +- docs/cli/gitsign.md | 1 + docs/cli/gitsign_credentials.md | 24 ++ docs/cli/gitsign_credentials_clear.md | 27 ++ docs/cli/gitsign_credentials_list.md | 18 + docs/keyring-cache.md | 113 ++++++ go.mod | 4 + go.sum | 2 + internal/cache/api/api.go | 36 +- internal/cache/cache.go | 55 +++ internal/cache/cache_test.go | 116 +++++- internal/cache/client.go | 145 ++++---- internal/cache/credential.go | 59 ++++ internal/cache/key.go | 67 ++++ internal/cache/key_test.go | 64 ++++ internal/cache/keyring/keyring.go | 353 +++++++++++++++++++ internal/cache/keyring/keyring_test.go | 312 ++++++++++++++++ internal/cache/service/service.go | 81 ++++- internal/cache/validate.go | 59 ++++ internal/cache/validate_test.go | 73 ++++ internal/commands/credentials/credentials.go | 153 ++++++++ internal/commands/root/root.go | 2 + internal/config/config.go | 20 ++ internal/config/config_test.go | 52 +++ internal/fulcio/identity.go | 72 ++-- internal/fulcio/identity_test.go | 95 +++++ 27 files changed, 1912 insertions(+), 125 deletions(-) create mode 100644 docs/cli/gitsign_credentials.md create mode 100644 docs/cli/gitsign_credentials_clear.md create mode 100644 docs/cli/gitsign_credentials_list.md create mode 100644 docs/keyring-cache.md create mode 100644 internal/cache/cache.go create mode 100644 internal/cache/credential.go create mode 100644 internal/cache/key.go create mode 100644 internal/cache/key_test.go create mode 100644 internal/cache/keyring/keyring.go create mode 100644 internal/cache/keyring/keyring_test.go create mode 100644 internal/cache/validate.go create mode 100644 internal/cache/validate_test.go create mode 100644 internal/commands/credentials/credentials.go create mode 100644 internal/fulcio/identity_test.go diff --git a/README.md b/README.md index ab4c02903..830b24c3a 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 `keyring` (or `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 `keyring` (or `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 keyring`). 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..49a6d1ea2 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 keyring`) 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..00db11d8c --- /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 (`keyring`), 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..14cf8ca21 --- /dev/null +++ b/docs/keyring-cache.md @@ -0,0 +1,113 @@ +# 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, KWallet, etc.) + +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 keyring +``` + +or via environment variable: + +```sh +export GITSIGN_CREDENTIAL_CACHE_MODE=keyring +``` + +(`system` is accepted as an alias for `keyring`.) + +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. + +## 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..416e8264f 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/sigstore/sigstore-go v1.2.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 + github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.54.0 golang.org/x/oauth2 v0.36.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da @@ -33,6 +34,7 @@ require ( ) require ( + al.essio.dev/pkg/shellescape v1.6.0 // indirect buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.20.0 // 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 @@ -149,6 +152,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/v5 v5.2.2 // 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 diff --git a/go.sum b/go.sum index 4770977c4..ed10cdac3 100644 --- a/go.sum +++ b/go.sum @@ -416,6 +416,8 @@ github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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..9bfbaa5ee --- /dev/null +++ b/internal/cache/keyring/keyring.go @@ -0,0 +1,353 @@ +// 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). 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. +package keyring + +import ( + "context" + "crypto" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/sigstore/gitsign/internal/cache" + "github.com/sigstore/gitsign/internal/cache/api" + "github.com/sigstore/gitsign/internal/config" + "github.com/zalando/go-keyring" +) + +const ( + // defaultService is the keyring service name all gitsign entries are + // stored under. + defaultService = "gitsign" + // indexKey holds a JSON list of stored credentials, since the keyring + // API has no enumeration support. + indexKey = "index/v1" + // 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 + + // service overrides the keyring service name. For testing. + service string + // maxEntrySize overrides the per-entry size limit. For testing. + maxEntrySize int +} + +var _ cache.Manager = (*Cache)(nil) + +func (c *Cache) serviceName() string { + if c.service != "" { + return c.service + } + return defaultService +} + +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 + } + key := cache.CredentialKey(cfg) + raw, err := keyring.Get(c.serviceName(), key) + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + 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([]byte(raw), env); err != nil { + c.deleteEntry(key, 0) + return nil, nil, nil, fmt.Errorf("error unmarshalling stored credential (entry deleted): %w", err) + } + if env.Version != envelopeVersion { + c.deleteEntry(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(key, env.ChainChunks) + return nil, nil, nil, fmt.Errorf("%w: stored cert expired", cache.ErrNotFound) + } + + chain, err := c.readChain(key, env.ChainChunks) + if err != nil { + c.deleteEntry(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(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(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 { + 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()) + env := &envelope{ + Version: envelopeVersion, + NotAfter: notAfter, + PrivateKey: string(cred.PrivateKey), + Cert: string(cert), + ChainChunks: len(chunks), + Meta: cache.MetadataFromConfig(cfg), + } + raw, err := json.Marshal(env) + if err != nil { + return fmt.Errorf("error marshalling credential: %w", err) + } + + // 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 := keyring.Set(c.serviceName(), chainKey(key, i), string(ch)); err != nil { + return fmt.Errorf("error storing chain in keyring: %w", err) + } + } + if err := keyring.Set(c.serviceName(), key, string(raw)); err != nil { + return fmt.Errorf("error storing credential in keyring: %w", err) + } + + // Index maintenance is best-effort - it only powers enumeration + // (e.g. `gitsign credentials list`). + c.updateIndex(func(entries []cache.CredentialInfo) []cache.CredentialInfo { + out := entries[:0] + for _, e := range entries { + if e.ID != key { + out = append(out, e) + } + } + return append(out, cache.CredentialInfo{ID: key, NotAfter: notAfter, Meta: env.Meta}) + }) + + return nil +} + +// List returns the index of stored credentials. The index is advisory - it is +// maintained best-effort on store/delete. +func (c *Cache) List(_ context.Context) ([]cache.CredentialInfo, error) { + entries, err := c.readIndex() + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return nil, nil + } + return nil, err + } + return entries, 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 { + key := cache.CredentialKey(c.Config) + chunks := c.chainChunkCount(key) + if err := keyring.Delete(c.serviceName(), key); err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("%w: no entry for identity", cache.ErrNotFound) + } + return fmt.Errorf("error deleting credential from keyring: %w", err) + } + c.deleteChain(key, chunks) + c.updateIndex(func(entries []cache.CredentialInfo) []cache.CredentialInfo { + out := entries[:0] + for _, e := range entries { + if e.ID != key { + out = append(out, e) + } + } + return out + }) + return nil +} + +// DeleteAll removes every indexed credential and the index itself. +func (c *Cache) DeleteAll(_ context.Context) error { + entries, err := c.readIndex() + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return err + } + for _, e := range entries { + c.deleteEntry(e.ID, c.chainChunkCount(e.ID)) + } + if err := keyring.Delete(c.serviceName(), indexKey); err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("error deleting credential index from keyring: %w", err) + } + return nil +} + +// 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 (c *Cache) chainChunkCount(key string) int { + raw, err := keyring.Get(c.serviceName(), key) + if err != nil { + return 0 + } + env := new(envelope) + if err := json.Unmarshal([]byte(raw), env); err != nil { + return 0 + } + return env.ChainChunks +} + +func (c *Cache) readChain(key string, chunks int) ([]byte, error) { + if chunks == 0 { + return nil, nil + } + var chain []byte + for i := range chunks { + part, err := keyring.Get(c.serviceName(), chainKey(key, i)) + if err != nil { + return nil, fmt.Errorf("error reading chain chunk %d: %w", i, err) + } + chain = append(chain, part...) + } + return chain, nil +} + +// deleteEntry removes the credential entry, its chain chunks, and its index +// row. All deletions are best-effort. +func (c *Cache) deleteEntry(key string, chunks int) { + _ = keyring.Delete(c.serviceName(), key) + c.deleteChain(key, chunks) + c.updateIndex(func(entries []cache.CredentialInfo) []cache.CredentialInfo { + out := entries[:0] + for _, e := range entries { + if e.ID != key { + out = append(out, e) + } + } + return out + }) +} + +func (c *Cache) deleteChain(key string, chunks int) { + for i := range chunks { + _ = keyring.Delete(c.serviceName(), chainKey(key, i)) + } +} + +func (c *Cache) readIndex() ([]cache.CredentialInfo, error) { + raw, err := keyring.Get(c.serviceName(), indexKey) + if err != nil { + return nil, err + } + var entries []cache.CredentialInfo + if err := json.Unmarshal([]byte(raw), &entries); err != nil { + return nil, fmt.Errorf("error unmarshalling credential index: %w", err) + } + return entries, nil +} + +// updateIndex applies fn to the current index entries and writes the result +// back. Failures are ignored - the index is advisory only. +func (c *Cache) updateIndex(fn func([]cache.CredentialInfo) []cache.CredentialInfo) { + entries, err := c.readIndex() + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return + } + entries = fn(entries) + raw, err := json.Marshal(entries) + if err != nil { + return + } + _ = keyring.Set(c.serviceName(), indexKey, string(raw)) +} + +func chainKey(key string, i int) string { + return fmt.Sprintf("%s/chain/%d", key, 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..82f239a9a --- /dev/null +++ b/internal/cache/keyring/keyring_test.go @@ -0,0 +1,312 @@ +// 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/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" + "github.com/zalando/go-keyring" +) + +// Note: keyring.MockInit and MockInitWithError mutate package-global state in +// go-keyring, so these tests must not run in parallel. + +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 the +// cache under test along with the credential parts. +func newTestCredential(t *testing.T, 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"), + } + return c, priv, certPEM, chainPEM +} + +func TestRoundtrip(t *testing.T) { + keyring.MockInit() + ctx := context.Background() + + c, priv, certPEM, chainPEM := newTestCredential(t) + + // 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) + } + + // The index should have exactly one row 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) { + keyring.MockInit() + ctx := context.Background() + + a, privA, certA, chainA := newTestCredential(t) + b, privB, certB, chainB := newTestCredential(t) + 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) { + keyring.MockInit() + ctx := context.Background() + + c, priv, certPEM, chainPEM := newTestCredential(t, + 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 := keyring.Get(defaultService, cache.CredentialKey(c.Config)); !errors.Is(err, keyring.ErrNotFound) { + 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) { + keyring.MockInit() + ctx := context.Background() + + c, priv, certPEM, chainPEM := newTestCredential(t) + // 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 := c.chainChunkCount(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 := c.chainChunkCount(key) + if err := c.Delete(ctx); err != nil { + t.Fatal(err) + } + for i := range chunks { + if _, err := keyring.Get(defaultService, chainKey(key, i)); !errors.Is(err, keyring.ErrNotFound) { + t.Errorf("chain chunk %d not deleted: %v", i, err) + } + } +} + +func TestValidationFailure(t *testing.T) { + keyring.MockInit() + ctx := context.Background() + + c, priv, certPEM, chainPEM := newTestCredential(t) + 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 := keyring.Get(defaultService, cache.CredentialKey(c.Config)); !errors.Is(err, keyring.ErrNotFound) { + t.Errorf("credential entry not deleted: %v", err) + } +} + +func TestKeyringUnavailable(t *testing.T) { + wantErr := errors.New("keyring unavailable") + keyring.MockInitWithError(wantErr) + t.Cleanup(keyring.MockInit) + ctx := context.Background() + + c, priv, certPEM, chainPEM := newTestCredential(t) + + 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) { + keyring.MockInit() + ctx := context.Background() + + a, privA, certA, chainA := newTestCredential(t) + b, privB, certB, chainB := newTestCredential(t) + 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 the index. + 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) + } + 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/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..f3530bec1 --- /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 (`keyring`), 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 "", "keyring", "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: keyring, 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..7dbd175ee 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. + // "keyring", "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..3a1476d8d 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 keyring\ngitsign.credentialcache /tmp/cache.sock\n"), nil + } + got, err := Get() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.CredentialCacheMode != "keyring" { + t.Errorf("CredentialCacheMode: got %q, want %q", got.CredentialCacheMode, "keyring") + } + 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..83728983c 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 "keyring", "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: keyring, 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..1dca81649 --- /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("keyring mode", func(t *testing.T) { + for _, mode := range []string{"keyring", "system", "KeyRing"} { + 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") + } + }) +} From a283a6f3a15bb0f4544c36f1a6329c78f1403d45 Mon Sep 17 00:00:00 2001 From: Billy Lynch Date: Tue, 4 Aug 2026 18:42:54 -0400 Subject: [PATCH 2/3] Drop "keyring" as a credential cache mode value, use "system" only gitsign.credentialCacheMode now accepts "system" (or "socket") - the "keyring" alias is removed before the option ships. Co-Authored-By: Claude Fable 5 Signed-off-by: Billy Lynch --- README.md | 6 +++--- cmd/gitsign-credential-cache/README.md | 2 +- docs/cli/gitsign_credentials.md | 2 +- docs/keyring-cache.md | 6 ++---- internal/commands/credentials/credentials.go | 6 +++--- internal/config/config.go | 14 +++++++------- internal/config/config_test.go | 6 +++--- internal/fulcio/identity.go | 4 ++-- internal/fulcio/identity_test.go | 4 ++-- 9 files changed, 24 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 830b24c3a..d1a6b4572 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ The following config options are supported: | Option | Default | Description | | ------------------ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| credentialCacheMode | | Optional credential caching mode. If set to `keyring` (or `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. | +| 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. | @@ -96,7 +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 `keyring` (or `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_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 | @@ -215,7 +215,7 @@ things you can do to make this process a bit easier! directly to the auth success screen! (and you can clean up the browser tabs later) 2. Use the built-in [system keyring cache](docs/keyring-cache.md) - (`git config --global gitsign.credentialCacheMode keyring`). This stores + (`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 diff --git a/cmd/gitsign-credential-cache/README.md b/cmd/gitsign-credential-cache/README.md index 49a6d1ea2..6944a7e9d 100644 --- a/cmd/gitsign-credential-cache/README.md +++ b/cmd/gitsign-credential-cache/README.md @@ -5,7 +5,7 @@ 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 keyring`) that needs no +(`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. diff --git a/docs/cli/gitsign_credentials.md b/docs/cli/gitsign_credentials.md index 00db11d8c..fc9bf5c1d 100644 --- a/docs/cli/gitsign_credentials.md +++ b/docs/cli/gitsign_credentials.md @@ -7,7 +7,7 @@ Manage cached signing credentials Manage cached signing credentials. The credential cache backend is selected by gitsign.credentialCacheMode: -the system keyring (`keyring`), or the gitsign-credential-cache daemon +the system keyring (`system`), or the gitsign-credential-cache daemon (`socket`). When no mode is configured, the system keyring is used. ### Options diff --git a/docs/keyring-cache.md b/docs/keyring-cache.md index 14cf8ca21..e6b2ecd73 100644 --- a/docs/keyring-cache.md +++ b/docs/keyring-cache.md @@ -17,17 +17,15 @@ lifetime instead of once per signature. ## Setup ```sh -git config --global gitsign.credentialCacheMode keyring +git config --global gitsign.credentialCacheMode system ``` or via environment variable: ```sh -export GITSIGN_CREDENTIAL_CACHE_MODE=keyring +export GITSIGN_CREDENTIAL_CACHE_MODE=system ``` -(`system` is accepted as an alias for `keyring`.) - 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 diff --git a/internal/commands/credentials/credentials.go b/internal/commands/credentials/credentials.go index f3530bec1..f9a9f4b62 100644 --- a/internal/commands/credentials/credentials.go +++ b/internal/commands/credentials/credentials.go @@ -36,7 +36,7 @@ func New(cfg *config.Config) *cobra.Command { 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 (`keyring`), or the gitsign-credential-cache daemon\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)) @@ -49,7 +49,7 @@ func New(cfg *config.Config) *cobra.Command { // operations don't validate certs. func newManager(cfg *config.Config) (cache.Manager, error) { switch strings.ToLower(cfg.CredentialCacheMode) { - case "", "keyring", "system": + 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 != "" { @@ -64,7 +64,7 @@ func newManager(cfg *config.Config) (cache.Manager, error) { } return cache.NewClient(cfg.CredentialCache, cfg, nil, nil) default: - return nil, fmt.Errorf("unknown credential cache mode %q (expected one of: keyring, system, socket)", cfg.CredentialCacheMode) + return nil, fmt.Errorf("unknown credential cache mode %q (expected one of: system, socket)", cfg.CredentialCacheMode) } } diff --git a/internal/config/config.go b/internal/config/config.go index 7dbd175ee..a0bcb8784 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -104,13 +104,13 @@ type Config struct { // 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. - // "keyring", "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. + // "" - (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. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3a1476d8d..314ba6ac0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -139,14 +139,14 @@ func TestCredentialCache(t *testing.T) { t.Run("from git config", func(t *testing.T) { // git config lowercases key names. execFn = func() (io.Reader, error) { - return strings.NewReader("gitsign.credentialcachemode keyring\ngitsign.credentialcache /tmp/cache.sock\n"), nil + 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 != "keyring" { - t.Errorf("CredentialCacheMode: got %q, want %q", got.CredentialCacheMode, "keyring") + 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") diff --git a/internal/fulcio/identity.go b/internal/fulcio/identity.go index 83728983c..de821ce6e 100644 --- a/internal/fulcio/identity.go +++ b/internal/fulcio/identity.go @@ -99,7 +99,7 @@ func NewIdentity(ctx context.Context, cfg *config.Config, in io.Reader, out io.W // or nil if credential caching is disabled. func newCacheClient(ctx context.Context, cfg *config.Config) (cache.Cache, error) { switch strings.ToLower(cfg.CredentialCacheMode) { - case "keyring", "system": + case "system": roots, intermediates, err := fulcioroots.NewFromConfig(ctx, cfg) if err != nil { return nil, fmt.Errorf("error loading certificate roots: %w", err) @@ -122,7 +122,7 @@ func newCacheClient(ctx context.Context, cfg *config.Config) (cache.Cache, error } return cache.NewClient(cfg.CredentialCache, cfg, roots, intermediates) default: - return nil, fmt.Errorf("unknown credential cache mode %q (expected one of: keyring, system, socket)", cfg.CredentialCacheMode) + return nil, fmt.Errorf("unknown credential cache mode %q (expected one of: system, socket)", cfg.CredentialCacheMode) } } diff --git a/internal/fulcio/identity_test.go b/internal/fulcio/identity_test.go index 1dca81649..e07e376d8 100644 --- a/internal/fulcio/identity_test.go +++ b/internal/fulcio/identity_test.go @@ -51,8 +51,8 @@ func TestNewCacheClient(t *testing.T) { } }) - t.Run("keyring mode", func(t *testing.T) { - for _, mode := range []string{"keyring", "system", "KeyRing"} { + 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, From dc68e8ce155fe12ab8fd7c55de6ef336ce716ee8 Mon Sep 17 00:00:00 2001 From: Billy Lynch Date: Wed, 5 Aug 2026 10:17:15 -0400 Subject: [PATCH 3/3] Switch keyring backend to 99designs/keyring Replace zalando/go-keyring with 99designs/keyring for the system keyring credential cache: - Keys() enumeration removes the need for the best-effort index entry that powered `gitsign credentials list` - entries are now enumerated directly from the keyring. - Storage is restricted to native OS credential stores (Windows Credential Manager, Secret Service, KWallet) - no file/pass fallbacks that would need their own password prompts. - 99designs' macOS Keychain backend requires cgo, but gitsign is built with CGO_ENABLED=0 everywhere (releases cross-compile darwin on Linux runners). On macOS, use a small backend implementing the keyring.Keyring interface on top of the /usr/bin/security CLI instead (the same approach zalando/go-keyring uses). This keeps behavior identical across release binaries and source builds regardless of CGO settings. - Tests inject keyring.NewArrayKeyring instead of relying on process-global mock state; a live unavailable-keyring stub covers soft-fail behavior. Co-Authored-By: Claude Fable 5 Signed-off-by: Billy Lynch --- docs/keyring-cache.md | 15 +- go.mod | 9 +- go.sum | 15 +- internal/cache/keyring/keyring.go | 273 ++++++++++-------- internal/cache/keyring/keyring_test.go | 81 +++--- internal/cache/keyring/open_darwin.go | 27 ++ internal/cache/keyring/open_default.go | 43 +++ internal/cache/keyring/securitycli_darwin.go | 147 ++++++++++ internal/cache/keyring/securitycli_parse.go | 73 +++++ .../cache/keyring/securitycli_parse_test.go | 76 +++++ 10 files changed, 602 insertions(+), 157 deletions(-) create mode 100644 internal/cache/keyring/open_darwin.go create mode 100644 internal/cache/keyring/open_default.go create mode 100644 internal/cache/keyring/securitycli_darwin.go create mode 100644 internal/cache/keyring/securitycli_parse.go create mode 100644 internal/cache/keyring/securitycli_parse_test.go diff --git a/docs/keyring-cache.md b/docs/keyring-cache.md index e6b2ecd73..fd69c327b 100644 --- a/docs/keyring-cache.md +++ b/docs/keyring-cache.md @@ -6,7 +6,7 @@ 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, KWallet, etc.) + (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 @@ -32,6 +32,19 @@ 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 diff --git a/go.mod b/go.mod index 416e8264f..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 @@ -26,7 +27,6 @@ require ( github.com/sigstore/sigstore-go v1.2.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 - github.com/zalando/go-keyring v0.2.6 golang.org/x/crypto v0.54.0 golang.org/x/oauth2 v0.36.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da @@ -34,7 +34,6 @@ require ( ) require ( - al.essio.dev/pkg/shellescape v1.6.0 // indirect buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.20.0 // indirect @@ -46,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 @@ -121,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 @@ -152,7 +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/v5 v5.2.2 // 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 @@ -169,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 @@ -197,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 ed10cdac3..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= @@ -416,8 +424,6 @@ github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= -github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro= github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg= github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -435,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= @@ -527,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= @@ -946,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/keyring/keyring.go b/internal/cache/keyring/keyring.go index 9bfbaa5ee..57f0827e0 100644 --- a/internal/cache/keyring/keyring.go +++ b/internal/cache/keyring/keyring.go @@ -14,13 +14,17 @@ // Package keyring implements a credential cache backed by the operating // system keyring (macOS Keychain, Windows Credential Manager, Linux Secret -// Service). Unlike the gitsign-credential-cache daemon, no long-running -// process is required. +// 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 ( @@ -30,21 +34,25 @@ import ( "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" - "github.com/zalando/go-keyring" ) const ( - // defaultService is the keyring service name all gitsign entries are - // stored under. - defaultService = "gitsign" - // indexKey holds a JSON list of stored credentials, since the keyring - // API has no enumeration support. - indexKey = "index/v1" + // 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. @@ -72,19 +80,33 @@ type Cache struct { // Config is used to derive the identity key when storing credentials. Config *config.Config - // service overrides the keyring service name. For testing. - service string + // 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) -func (c *Cache) serviceName() string { - if c.service != "" { - return c.service +// 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 } - return defaultService + 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 { @@ -100,41 +122,45 @@ func (c *Cache) GetCredentials(_ context.Context, cfg *config.Config) (crypto.Pr if cfg == nil { cfg = c.Config } + kr, err := c.keyring() + if err != nil { + return nil, nil, nil, err + } key := cache.CredentialKey(cfg) - raw, err := keyring.Get(c.serviceName(), key) + item, err := kr.Get(key) if err != nil { - if errors.Is(err, keyring.ErrNotFound) { + 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([]byte(raw), env); err != nil { - c.deleteEntry(key, 0) + 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(key, env.ChainChunks) + 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(key, env.ChainChunks) + c.deleteEntry(kr, key, env.ChainChunks) return nil, nil, nil, fmt.Errorf("%w: stored cert expired", cache.ErrNotFound) } - chain, err := c.readChain(key, env.ChainChunks) + chain, err := readChain(kr, key, env.ChainChunks) if err != nil { - c.deleteEntry(key, env.ChainChunks) + 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(key, env.ChainChunks) + c.deleteEntry(kr, key, env.ChainChunks) return nil, nil, nil, err } @@ -144,7 +170,7 @@ func (c *Cache) GetCredentials(_ context.Context, cfg *config.Config) (crypto.Pr Chain: chain, }) if err != nil { - c.deleteEntry(key, env.ChainChunks) + c.deleteEntry(kr, key, env.ChainChunks) return nil, nil, nil, fmt.Errorf("error unmarshalling private key (entry deleted): %w", err) } @@ -154,6 +180,10 @@ func (c *Cache) GetCredentials(_ context.Context, cfg *config.Config) (crypto.Pr // 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) @@ -168,178 +198,185 @@ func (c *Cache) StoreCert(_ context.Context, priv crypto.PrivateKey, cert, chain } 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: cache.MetadataFromConfig(cfg), + 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 := keyring.Set(c.serviceName(), chainKey(key, i), string(ch)); err != nil { + 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 := keyring.Set(c.serviceName(), key, string(raw)); err != nil { + 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) } - // Index maintenance is best-effort - it only powers enumeration - // (e.g. `gitsign credentials list`). - c.updateIndex(func(entries []cache.CredentialInfo) []cache.CredentialInfo { - out := entries[:0] - for _, e := range entries { - if e.ID != key { - out = append(out, e) - } - } - return append(out, cache.CredentialInfo{ID: key, NotAfter: notAfter, Meta: env.Meta}) - }) - return nil } -// List returns the index of stored credentials. The index is advisory - it is -// maintained best-effort on store/delete. +// List enumerates stored credentials. func (c *Cache) List(_ context.Context) ([]cache.CredentialInfo, error) { - entries, err := c.readIndex() + kr, err := c.keyring() if err != nil { - if errors.Is(err, keyring.ErrNotFound) { - return nil, nil - } return nil, err } - return entries, nil + 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) - chunks := c.chainChunkCount(key) - if err := keyring.Delete(c.serviceName(), key); err != nil { - if errors.Is(err, keyring.ErrNotFound) { + // 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) } - c.deleteChain(key, chunks) - c.updateIndex(func(entries []cache.CredentialInfo) []cache.CredentialInfo { - out := entries[:0] - for _, e := range entries { - if e.ID != key { - out = append(out, e) - } - } - return out - }) + deleteChain(kr, key, chunks) return nil } -// DeleteAll removes every indexed credential and the index itself. +// DeleteAll removes every gitsign credential entry (including chain chunks). func (c *Cache) DeleteAll(_ context.Context) error { - entries, err := c.readIndex() - if err != nil && !errors.Is(err, keyring.ErrNotFound) { + kr, err := c.keyring() + if err != nil { return err } - for _, e := range entries { - c.deleteEntry(e.ID, c.chainChunkCount(e.ID)) + keys, err := kr.Keys() + if err != nil { + return fmt.Errorf("error listing keyring entries: %w", err) } - if err := keyring.Delete(c.serviceName(), indexKey); err != nil && !errors.Is(err, keyring.ErrNotFound) { - return fmt.Errorf("error deleting credential index from keyring: %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 nil + 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 (c *Cache) chainChunkCount(key string) int { - raw, err := keyring.Get(c.serviceName(), key) +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([]byte(raw), env); err != nil { + if err := json.Unmarshal(item.Data, env); err != nil { return 0 } return env.ChainChunks } -func (c *Cache) readChain(key string, chunks int) ([]byte, error) { +func readChain(kr keyring.Keyring, key string, chunks int) ([]byte, error) { if chunks == 0 { return nil, nil } var chain []byte for i := range chunks { - part, err := keyring.Get(c.serviceName(), chainKey(key, i)) + 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, part...) + chain = append(chain, item.Data...) } return chain, nil } -// deleteEntry removes the credential entry, its chain chunks, and its index -// row. All deletions are best-effort. -func (c *Cache) deleteEntry(key string, chunks int) { - _ = keyring.Delete(c.serviceName(), key) - c.deleteChain(key, chunks) - c.updateIndex(func(entries []cache.CredentialInfo) []cache.CredentialInfo { - out := entries[:0] - for _, e := range entries { - if e.ID != key { - out = append(out, e) - } - } - return out - }) +// 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 (c *Cache) deleteChain(key string, chunks int) { +func deleteChain(kr keyring.Keyring, key string, chunks int) { for i := range chunks { - _ = keyring.Delete(c.serviceName(), chainKey(key, i)) - } -} - -func (c *Cache) readIndex() ([]cache.CredentialInfo, error) { - raw, err := keyring.Get(c.serviceName(), indexKey) - if err != nil { - return nil, err - } - var entries []cache.CredentialInfo - if err := json.Unmarshal([]byte(raw), &entries); err != nil { - return nil, fmt.Errorf("error unmarshalling credential index: %w", err) - } - return entries, nil -} - -// updateIndex applies fn to the current index entries and writes the result -// back. Failures are ignored - the index is advisory only. -func (c *Cache) updateIndex(fn func([]cache.CredentialInfo) []cache.CredentialInfo) { - entries, err := c.readIndex() - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - return - } - entries = fn(entries) - raw, err := json.Marshal(entries) - if err != nil { - return + _ = kr.Remove(chainKey(key, i)) } - _ = keyring.Set(c.serviceName(), indexKey, string(raw)) } func chainKey(key string, i int) string { - return fmt.Sprintf("%s/chain/%d", key, i) + return fmt.Sprintf("%s%s%d", key, chainKeyMarker, i) } func chunk(b []byte, size int) [][]byte { diff --git a/internal/cache/keyring/keyring_test.go b/internal/cache/keyring/keyring_test.go index 82f239a9a..fc490963b 100644 --- a/internal/cache/keyring/keyring_test.go +++ b/internal/cache/keyring/keyring_test.go @@ -23,17 +23,14 @@ import ( "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" - "github.com/zalando/go-keyring" ) -// Note: keyring.MockInit and MockInitWithError mutate package-global state in -// go-keyring, so these tests must not run in parallel. - func testConfig(email string) *config.Config { return &config.Config{ Fulcio: "https://fulcio.example.com", @@ -44,9 +41,10 @@ func testConfig(email string) *config.Config { } } -// newTestCredential issues a leaf cert from a fresh fake CA and returns the -// cache under test along with the credential parts. -func newTestCredential(t *testing.T, caOpts ...fakeca.Option) (*Cache, *ecdsa.PrivateKey, []byte, []byte) { +// 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) @@ -66,17 +64,18 @@ func newTestCredential(t *testing.T, caOpts ...fakeca.Option) (*Cache, *ecdsa.Pr } c := &Cache{ - Roots: ca.ChainPool(), - Config: testConfig("user@example.com"), + Roots: ca.ChainPool(), + Config: testConfig("user@example.com"), + Keyring: kr, } return c, priv, certPEM, chainPEM } func TestRoundtrip(t *testing.T) { - keyring.MockInit() + kr := keyring.NewArrayKeyring(nil) ctx := context.Background() - c, priv, certPEM, chainPEM := newTestCredential(t) + c, priv, certPEM, chainPEM := newTestCredential(t, kr) // Miss before store. if _, _, _, err := c.GetCredentials(ctx, c.Config); !errors.Is(err, cache.ErrNotFound) { @@ -107,7 +106,7 @@ func TestRoundtrip(t *testing.T) { t.Fatalf("StoreCert (second): %v", err) } - // The index should have exactly one row for this identity. + // Enumeration should show exactly one credential for this identity. entries, err := c.List(ctx) if err != nil { t.Fatalf("List: %v", err) @@ -121,11 +120,11 @@ func TestRoundtrip(t *testing.T) { } func TestMultipleIdentities(t *testing.T) { - keyring.MockInit() + kr := keyring.NewArrayKeyring(nil) ctx := context.Background() - a, privA, certA, chainA := newTestCredential(t) - b, privB, certB, chainB := newTestCredential(t) + 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 { @@ -160,10 +159,10 @@ func TestMultipleIdentities(t *testing.T) { } func TestExpiredCert(t *testing.T) { - keyring.MockInit() + kr := keyring.NewArrayKeyring(nil) ctx := context.Background() - c, priv, certPEM, chainPEM := newTestCredential(t, + c, priv, certPEM, chainPEM := newTestCredential(t, kr, fakeca.NotBefore(time.Now().Add(-2*time.Hour)), fakeca.NotAfter(time.Now().Add(-time.Hour)), ) @@ -178,7 +177,7 @@ func TestExpiredCert(t *testing.T) { } // ...and the entries are removed. - if _, err := keyring.Get(defaultService, cache.CredentialKey(c.Config)); !errors.Is(err, keyring.ErrNotFound) { + 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) @@ -191,10 +190,10 @@ func TestExpiredCert(t *testing.T) { } func TestChainChunking(t *testing.T) { - keyring.MockInit() + kr := keyring.NewArrayKeyring(nil) ctx := context.Background() - c, priv, certPEM, chainPEM := newTestCredential(t) + c, priv, certPEM, chainPEM := newTestCredential(t, kr) // Force the chain to split into many chunks. c.maxEntrySize = 64 @@ -204,7 +203,7 @@ func TestChainChunking(t *testing.T) { // Sanity check that chunking actually happened. key := cache.CredentialKey(c.Config) - if got := c.chainChunkCount(key); got < 2 { + if got := chainChunkCount(kr, key); got < 2 { t.Fatalf("expected multiple chain chunks, got %d", got) } @@ -217,22 +216,22 @@ func TestChainChunking(t *testing.T) { } // Deleting removes the chunk entries too. - chunks := c.chainChunkCount(key) + chunks := chainChunkCount(kr, key) if err := c.Delete(ctx); err != nil { t.Fatal(err) } for i := range chunks { - if _, err := keyring.Get(defaultService, chainKey(key, i)); !errors.Is(err, keyring.ErrNotFound) { + 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) { - keyring.MockInit() + kr := keyring.NewArrayKeyring(nil) ctx := context.Background() - c, priv, certPEM, chainPEM := newTestCredential(t) + c, priv, certPEM, chainPEM := newTestCredential(t, kr) if err := c.StoreCert(ctx, priv, certPEM, chainPEM); err != nil { t.Fatal(err) } @@ -243,18 +242,27 @@ func TestValidationFailure(t *testing.T) { if _, _, _, err := c.GetCredentials(ctx, c.Config); err == nil { t.Fatal("GetCredentials: expected error with wrong roots") } - if _, err := keyring.Get(defaultService, cache.CredentialKey(c.Config)); !errors.Is(err, keyring.ErrNotFound) { + 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") - keyring.MockInitWithError(wantErr) - t.Cleanup(keyring.MockInit) ctx := context.Background() - c, priv, certPEM, chainPEM := newTestCredential(t) + 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) @@ -265,11 +273,11 @@ func TestKeyringUnavailable(t *testing.T) { } func TestDelete(t *testing.T) { - keyring.MockInit() + kr := keyring.NewArrayKeyring(nil) ctx := context.Background() - a, privA, certA, chainA := newTestCredential(t) - b, privB, certB, chainB := newTestCredential(t) + 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. @@ -295,13 +303,20 @@ func TestDelete(t *testing.T) { t.Errorf("GetCredentials for other identity: %v", err) } - // DeleteAll removes everything, including the index. + // 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) 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) + } + } +}