diff --git a/go.mod b/go.mod index 69d38d3..e5ec562 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.19 require ( github.com/ProtonMail/go-crypto v0.0.0-20220930113650-c6815a8c17ad github.com/ProtonMail/gopenpgp/v2 v2.4.10 + github.com/adrg/xdg v0.4.0 github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.3 github.com/hashicorp/go-multierror v1.1.1 github.com/stretchr/testify v1.8.0 diff --git a/go.sum b/go.sum index 8c7dbd5..6254863 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/ProtonMail/go-mime v0.0.0-20220302105931-303f85f7fe0f h1:CGq7OieOz3wy github.com/ProtonMail/go-mime v0.0.0-20220302105931-303f85f7fe0f/go.mod h1:NYt+V3/4rEeDuaev/zw1zCq8uqVEuPHzDPo3OZrlGJ4= github.com/ProtonMail/gopenpgp/v2 v2.4.10 h1:EYgkxzwmQvsa6kxxkgP1AwzkFqKHscF2UINxaSn6rdI= github.com/ProtonMail/gopenpgp/v2 v2.4.10/go.mod h1:CTRA7/toc/4DxDy5Du4hPDnIZnJvXSeQ8LsRTOUJoyc= +github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls= +github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E= github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= @@ -41,6 +43,7 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -70,6 +73,7 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/pkg/pgp/client/client.go b/pkg/pgp/client/client.go new file mode 100644 index 0000000..ed77f82 --- /dev/null +++ b/pkg/pgp/client/client.go @@ -0,0 +1,6 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// Package client provides utilities for handling client-side PGP keys. +package client diff --git a/pkg/pgp/client/key.go b/pkg/pgp/client/key.go new file mode 100644 index 0000000..5afe8cc --- /dev/null +++ b/pkg/pgp/client/key.go @@ -0,0 +1,17 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package client + +import ( + "github.com/siderolabs/go-api-signature/pkg/pgp" +) + +// Key represents an OpenPGP client key pair associated with a context and an identity. +// It is stored on the filesystem. +type Key struct { + *pgp.Key + context string + identity string +} diff --git a/pkg/pgp/client/provider.go b/pkg/pgp/client/provider.go new file mode 100644 index 0000000..e4b628e --- /dev/null +++ b/pkg/pgp/client/provider.go @@ -0,0 +1,136 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package client + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "time" + + pgpcrypto "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/adrg/xdg" + + "github.com/siderolabs/go-api-signature/pkg/pgp" +) + +const ( + keyLifetime = 4 * time.Hour +) + +// KeyProvider handles loading/saving client keys. +type KeyProvider struct { + dataFileDirectory string + keyLifetime time.Duration +} + +// NewKeyProvider creates a new KeyProvider. +func NewKeyProvider(dataFileDirectory string) *KeyProvider { + return &KeyProvider{ + dataFileDirectory: dataFileDirectory, + keyLifetime: keyLifetime, + } +} + +// ReadValidKey reads a PGP key from the filesystem. +// +// If the key is missing or invalid (e.g. expired, revoked), an error will be returned. +func (provider *KeyProvider) ReadValidKey(context, email string) (*Key, error) { + keyPath, err := provider.getKeyFilePath(context, email) + if err != nil { + return nil, err + } + + keyF, err := os.Open(keyPath) + if err != nil { + return nil, err + } + + defer keyF.Close() //nolint:errcheck + + key, err := pgpcrypto.NewKeyFromArmoredReader(keyF) + if err != nil { + return nil, err + } + + pgpKey, err := pgp.NewKey(key) + if err != nil { + return nil, err + } + + err = pgpKey.Validate() + if err != nil { + return nil, err + } + + unlocked, err := pgpKey.IsUnlocked() + if err != nil { + return nil, err + } + + if !unlocked { + return nil, fmt.Errorf("private key is locked") + } + + return &Key{ + Key: pgpKey, + context: context, + identity: email, + }, nil +} + +// GenerateKey generates a new PGP key pair. +func (provider *KeyProvider) GenerateKey(context, email, clientNameWithVersion string) (*Key, error) { + name := clientNameWithVersion + comment := fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH) + + key, err := pgp.GenerateKey(name, comment, email, provider.keyLifetime) + if err != nil { + return nil, err + } + + return &Key{ + Key: key, + context: context, + identity: email, + }, nil +} + +// DeleteKey deletes the key pair from disk. +func (provider *KeyProvider) DeleteKey(context, email string) error { + keyPath, err := provider.getKeyFilePath(context, email) + if err != nil { + return err + } + + return os.Remove(keyPath) +} + +// WriteKey saves the key pair to disk and returns the save path. +func (provider *KeyProvider) WriteKey(c *Key) (string, error) { + armored, err := c.Armor() + if err != nil { + return "", err + } + + keyPath, err := provider.getKeyFilePath(c.context, c.identity) + if err != nil { + return "", err + } + + err = os.WriteFile(keyPath, []byte(armored), 0o600) + if err != nil { + return "", err + } + + return keyPath, err +} + +func (provider *KeyProvider) getKeyFilePath(context, identity string) (string, error) { + keyName := fmt.Sprintf("%s-%s.pgp", context, identity) + + return xdg.DataFile(filepath.Join(provider.dataFileDirectory, keyName)) +} diff --git a/pkg/pgp/client/provider_test.go b/pkg/pgp/client/provider_test.go new file mode 100644 index 0000000..d5a6f9e --- /dev/null +++ b/pkg/pgp/client/provider_test.go @@ -0,0 +1,46 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package client_test + +import ( + "testing" + + "github.com/adrg/xdg" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/siderolabs/go-api-signature/pkg/pgp/client" +) + +func TestKeyProvider(t *testing.T) { + t.Cleanup(xdg.Reload) + + // fake XDG paths + t.Setenv("HOME", t.TempDir()) + xdg.Reload() + + provider := client.NewKeyProvider("test/keys") + + key, err := provider.GenerateKey("testapp", "john@example.com", "Linux") + require.NoError(t, err) + + assert.True(t, key.IsPrivate()) + + path, err := provider.WriteKey(key) + require.NoError(t, err) + + t.Logf("saved key to %s", path) + + k, err := provider.ReadValidKey("testapp", "john@example.com") + require.NoError(t, err) + + assert.True(t, k.IsPrivate()) + + err = provider.DeleteKey("testapp", "john@example.com") + require.NoError(t, err) + + _, err = provider.ReadValidKey("testapp", "john@example.com") + require.Error(t, err) +} diff --git a/pkg/pgp/key.go b/pkg/pgp/key.go index 03c65cc..fc94527 100644 --- a/pkg/pgp/key.go +++ b/pkg/pgp/key.go @@ -85,6 +85,11 @@ func (p *Key) IsPrivate() bool { return p.key.IsPrivate() } +// IsUnlocked returns true if the private key is unlocked. +func (p *Key) IsUnlocked() (bool, error) { + return p.key.IsUnlocked() +} + // Armor returns the key in the armored format. func (p *Key) Armor() (string, error) { return p.key.Armor()