Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Credential Caching #75

Merged
merged 11 commits into from
Jun 23, 2022
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ git config --global gpg.format x509 # gitsign expects x509 args

| Environment Variable | Default | Description |
| ------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| GITSIGN_CREDENTIAL_CACHE | | Optional path to [gitsign-credential-cache](cmd/gitsign-credential-cache/README.md) socket. |
| GITSIGN_FULCIO_URL | https://fulcio.sigstore.dev | Address of Fulcio server |
| GITSIGN_LOG | | Path to log status output. Helpful for debugging, since Git will not forward stderr output to user terminals. |
| GITSIGN_OIDC_CLIENT_ID | sigstore | OIDC client ID for application |
Expand Down
38 changes: 38 additions & 0 deletions cmd/gitsign-credential-cache/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# gitsign-credential-cache

`gitsign-credential-cache` is a **optional** helper binary that allows users to
cache signing credentials. This can be helpful in situations where you need to
perform multiple signing operations back to back.

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
data.

⚠️ When in doubt, we recommend **not** using the cache. In particular:

- If you're running on a shared system
- if other admins have access to the cache socket they can access your keys.
- If you're running in an environment that has ambient OIDC credentials (e.g.
GCE/GKE, AWS, GitHub Actions, etc.), Gitsign will automatically use the
environment's OIDC credentials. You don't need caching.

If you understand the risks, read on!

## What's stored in the cache

- 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)

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.

## Usage

```
$ gitsign-credential-cache &
$ export GITSIGN_CREDENTIAL_CACHE="$HOME/.sigstore/gitsign/cache.sock"
$ git commit ...
```
61 changes: 61 additions & 0 deletions cmd/gitsign-credential-cache/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2022 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 main

import (
"fmt"
"log"
"net"
"net/rpc"
"os"
"path/filepath"
"syscall"

"github.com/sigstore/gitsign/internal/cache"
)

func main() {
// Override default umask so created files are always scoped to the
// current user.
syscall.Umask(0077)

user, err := os.UserCacheDir()
if err != nil {
log.Fatalf("error getting user cache directory: %v", err)
}

dir := filepath.Join(user, ".sigstore", "gitsign")
if err := os.MkdirAll(dir, 0700); err != nil {
wlynch marked this conversation as resolved.
Show resolved Hide resolved
log.Fatalf("error creating %s: %v", dir, err)
}

path := filepath.Join(dir, "cache.sock")
if _, err := os.Stat(path); err == nil {
os.Remove(path)
}
fmt.Println(path)

l, err := net.Listen("unix", path)
if err != nil {
log.Fatalf("error opening socket: %v", err)
}
srv := rpc.NewServer()
if err := srv.Register(cache.NewService()); err != nil {
log.Fatalf("error registering RPC service: %v", err)
}
for {
srv.Accept(l)
}
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/go-openapi/strfmt v0.21.2
github.com/go-openapi/swag v0.21.1
github.com/google/go-cmp v0.5.8
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b
github.com/sigstore/cosign v1.8.1-0.20220601172726-ae90c7495df6
github.com/sigstore/fulcio v0.1.2-0.20220114150912-86a2036f9bc7
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1827,6 +1827,8 @@ github.com/otiai10/mint v1.3.1/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH
github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b h1:K1wa7ads2Bu1PavI6LfBRMYSy6Zi+Rky0OhWBfrmkmY=
github.com/pborman/getopt v0.0.0-20180811024354-2b5b3bfb099b/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
Expand Down
94 changes: 94 additions & 0 deletions internal/cache/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright 2022 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/ecdsa"
"crypto/elliptic"
"crypto/rand"
"net"
"net/rpc"
"os"
"path/filepath"
"testing"

"github.com/github/smimesign/fakeca"
"github.com/google/go-cmp/cmp"
"github.com/sigstore/sigstore/pkg/cryptoutils"
)

func TestCache(t *testing.T) {
ctx := context.Background()

path := filepath.Join(t.TempDir(), "cache.sock")
l, err := net.Listen("unix", path)
if err != nil {
t.Fatal(err)
}
srv := rpc.NewServer()
srv.Register(NewService())
go func() {
for {
srv.Accept(l)
}
}()

rpcClient, _ := rpc.Dial("unix", path)
defer rpcClient.Close()
ca := fakeca.New()
client := &Client{
Client: rpcClient,
Roots: ca.ChainPool(),
}

if _, err := client.GetSignerVerifier(ctx); err == nil {
t.Fatal("GetSignerVerifier: expected err, got not")
}

priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
certPEM, _ := cryptoutils.MarshalCertificateToPEM(ca.Certificate)

if err := client.StoreCert(ctx, priv, certPEM, nil); err != nil {
t.Fatalf("StoreCert: %v", err)
}

id, _ := os.Getwd()
cred := new(Credential)
if err := client.Client.Call("Service.GetCredential", &GetCredentialRequest{ID: id}, cred); err != nil {
t.Fatal(err)
}

privPEM, _ := cryptoutils.MarshalPrivateKeyToPEM(priv)
want := &Credential{
PrivateKey: privPEM,
Cert: certPEM,
}

if diff := cmp.Diff(want, cred); diff != "" {
t.Error(diff)
}

got, err := client.GetSignerVerifier(ctx)
if err != nil {
t.Fatal(err)
}
if got == nil {
t.Fatal("SignerVerifier was nil")
}
if ok := cmp.Equal(certPEM, got.Cert); !ok {
t.Error("stored cert does not match")
}
}
113 changes: 113 additions & 0 deletions internal/cache/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright 2022 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"
"crypto/x509"
"fmt"
"net/rpc"
"os"
"time"

"github.com/sigstore/gitsign/internal/signerverifier"
"github.com/sigstore/sigstore/pkg/cryptoutils"
"github.com/sigstore/sigstore/pkg/signature"
)

type Client struct {
Client *rpc.Client
Roots *x509.CertPool
Intermediates *x509.CertPool
}

func (c *Client) GetSignerVerifier(ctx context.Context) (*signerverifier.CertSignerVerifier, error) {
id, err := os.Getwd()
if err != nil {
return nil, err
}

resp := new(Credential)
if err := c.Client.Call("Service.GetCredential", GetCredentialRequest{
ID: id,
}, resp); err != nil {
return nil, err
}

privateKey, err := cryptoutils.UnmarshalPEMToPrivateKey(resp.PrivateKey, cryptoutils.SkipPassword)
if err != nil {
return nil, fmt.Errorf("error unmarshalling private key: %w", err)
}

sv, err := signature.LoadSignerVerifier(privateKey, crypto.SHA256)
if err != nil {
return nil, fmt.Errorf("error creating SignerVerifier: %w", err)
}

// Check that the cert is in fact still valid.
certs, err := cryptoutils.UnmarshalCertificatesFromPEM(resp.Cert)
if err != nil {
return 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 _, 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, fmt.Errorf("stored cert no longer valid: %w", err)
}
}

return &signerverifier.CertSignerVerifier{
SignerVerifier: sv,
Cert: resp.Cert,
Chain: resp.Chain,
}, nil
}

type PrivateKey interface {
crypto.PrivateKey
Public() crypto.PublicKey
}

func (c *Client) StoreCert(ctx context.Context, priv PrivateKey, cert, chain []byte) error {
id, err := os.Getwd()
if err != nil {
return err
}
privPEM, err := cryptoutils.MarshalPrivateKeyToPEM(priv)
if err != nil {
return err
}

if err := c.Client.Call("Service.StoreCredential", StoreCredentialRequest{
ID: id,
Credential: &Credential{
PrivateKey: privPEM,
Cert: cert,
Chain: chain,
},
}, new(Credential)); err != nil {
return err
}

return err
}
Loading