Skip to content

Commit

Permalink
sops/pgp: add GnuPGHome utility type
Browse files Browse the repository at this point in the history
This adds a new GnuPGHome type which can be used to create a new
contained GnuPG home directory. The type is self-validating, ensuring
it adheres to e.g. permission rules set out by GnuPG, and allows for
importing keys from armored bytes and files.

Because of this introduction, the decryptor service no longer has to
write data from a Secret to a temporary file, but is instead able to
directly import them into the keyring from the Secret entry's bytes.

Signed-off-by: Hidde Beydals <hello@hidde.co>
  • Loading branch information
hiddeco committed Apr 4, 2022
1 parent a6e6ce2 commit 61c2761
Show file tree
Hide file tree
Showing 5 changed files with 313 additions and 123 deletions.
43 changes: 11 additions & 32 deletions controllers/kustomization_decryptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,9 @@ import (
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"

securejoin "github.com/cyphar/filepath-securejoin"
"go.mozilla.org/sops/v3"
"go.mozilla.org/sops/v3/aes"
"go.mozilla.org/sops/v3/cmd/sops/common"
Expand All @@ -44,6 +42,7 @@ import (
kustomizev1 "github.com/fluxcd/kustomize-controller/api/v1beta2"
"github.com/fluxcd/kustomize-controller/internal/sops/azkv"
intkeyservice "github.com/fluxcd/kustomize-controller/internal/sops/keyservice"
"github.com/fluxcd/kustomize-controller/internal/sops/pgp"
)

const (
Expand All @@ -59,29 +58,29 @@ type KustomizeDecryptor struct {
client.Client

kustomization kustomizev1.Kustomization
homeDir string
gnuPGHome pgp.GnuPGHome
ageIdentities []string
vaultToken string
azureAADConfig *azkv.AADConfig
}

func NewDecryptor(kubeClient client.Client,
kustomization kustomizev1.Kustomization, homeDir string) *KustomizeDecryptor {
kustomization kustomizev1.Kustomization, gnuPGHome string) *KustomizeDecryptor {
return &KustomizeDecryptor{
Client: kubeClient,
kustomization: kustomization,
homeDir: homeDir,
gnuPGHome: pgp.GnuPGHome(gnuPGHome),
}
}

func NewTempDecryptor(kubeClient client.Client,
kustomization kustomizev1.Kustomization) (*KustomizeDecryptor, func(), error) {
tmpDir, err := os.MkdirTemp("", fmt.Sprintf("decryptor-%s-", kustomization.Name))
gnuPGHome, err := pgp.NewGnuPGHome()
if err != nil {
return nil, nil, fmt.Errorf("tmp dir error: %w", err)
return nil, nil, fmt.Errorf("cannot create decryptor: %w", err)
}
cleanup := func() { os.RemoveAll(tmpDir) }
return NewDecryptor(kubeClient, kustomization, tmpDir), cleanup, nil
cleanup := func() { os.RemoveAll(gnuPGHome.String()) }
return NewDecryptor(kubeClient, kustomization, gnuPGHome.String()), cleanup, nil
}

func (kd *KustomizeDecryptor) Decrypt(res *resource.Resource) (*resource.Resource, error) {
Expand Down Expand Up @@ -162,15 +161,8 @@ func (kd *KustomizeDecryptor) ImportKeys(ctx context.Context) error {
for name, value := range secret.Data {
switch filepath.Ext(name) {
case ".asc":
keyPath, err := securejoin.SecureJoin(tmpDir, name)
if err != nil {
return err
}
if err := os.WriteFile(keyPath, value, os.ModePerm); err != nil {
return fmt.Errorf("unable to write key to storage: %w", err)
}
if err := kd.gpgImport(keyPath); err != nil {
return err
if err := kd.gnuPGHome.Import(value); err != nil {
return fmt.Errorf("failed to import '%s' Secret data: %w", name, err)
}
case ".agekey":
ageIdentities = append(ageIdentities, string(value))
Expand Down Expand Up @@ -200,19 +192,6 @@ func (kd *KustomizeDecryptor) ImportKeys(ctx context.Context) error {
return nil
}

func (kd *KustomizeDecryptor) gpgImport(path string) error {
args := []string{"--batch", "--import", path}
if kd.homeDir != "" {
args = append([]string{"--homedir", kd.homeDir}, args...)
}
cmd := exec.Command("gpg", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("gpg import error: %s", string(out))
}
return nil
}

func (kd *KustomizeDecryptor) decryptDotEnvFiles(dirpath string) error {
kustomizePath := filepath.Join(dirpath, konfig.DefaultKustomizationFileName())
ksData, err := os.ReadFile(kustomizePath)
Expand Down Expand Up @@ -285,7 +264,7 @@ func (kd KustomizeDecryptor) DataWithFormat(data []byte, inputFormat, outputForm
}

serverOpts := []intkeyservice.ServerOption{
intkeyservice.WithHomeDir(kd.homeDir),
intkeyservice.WithGnuPGHome(kd.gnuPGHome),
intkeyservice.WithVaultToken(kd.vaultToken),
intkeyservice.WithAgePrivateKeys(kd.ageIdentities),
}
Expand Down
9 changes: 5 additions & 4 deletions internal/sops/keyservice/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package keyservice
import (
"github.com/fluxcd/kustomize-controller/internal/sops/azkv"
"github.com/fluxcd/kustomize-controller/internal/sops/hcvault"
"github.com/fluxcd/kustomize-controller/internal/sops/pgp"

"go.mozilla.org/sops/v3/keyservice"
)
Expand All @@ -29,12 +30,12 @@ type ServerOption interface {
ApplyToServer(s *Server)
}

// WithHomeDir configures the contained "home directory" on the Server.
type WithHomeDir string
// WithGnuPGHome configures the GnuPG home directory on the Server.
type WithGnuPGHome string

// ApplyToServer applies this configuration to the given Server.
func (o WithHomeDir) ApplyToServer(s *Server) {
s.homeDir = string(o)
func (o WithGnuPGHome) ApplyToServer(s *Server) {
s.gnuPGHome = pgp.GnuPGHome(o)
}

// WithVaultToken configures the Hashicorp Vault token on the Server.
Expand Down
24 changes: 12 additions & 12 deletions internal/sops/keyservice/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ import (
// environment. Any request not handled by the Server is forwarded to the
// embedded default server.
type Server struct {
// homeDir is the contained "home directory" used for the Encrypt and
// Decrypt operations for certain key types, e.g. PGP.
homeDir string
// gnuPGHome is the GnuPG home directory used for the Encrypt and Decrypt
// operations for PGP key types.
// When empty, the requests will be handled using the systems' runtime
// keyring.
gnuPGHome pgp.GnuPGHome

// agePrivateKeys holds the private keys used for Encrypt and Decrypt
// operations of age requests.
Expand All @@ -50,7 +52,7 @@ type Server struct {

// NewServer constructs a new Server, configuring it with the provided options
// before returning the result.
// When WithDefaultServer is not provided as an option, the SOPS server
// When WithDefaultServer() is not provided as an option, the SOPS server
// implementation is configured as default.
func NewServer(options ...ServerOption) keyservice.KeyServiceServer {
s := &Server{}
Expand Down Expand Up @@ -152,11 +154,10 @@ func (ks Server) Decrypt(ctx context.Context, req *keyservice.DecryptRequest) (*
}

func (ks *Server) encryptWithPgp(key *keyservice.PgpKey, plaintext []byte) ([]byte, error) {
if ks.homeDir == "" {
return nil, status.Errorf(codes.Unimplemented, "PGP encrypt service unavailable: missing home dir configuration")
pgpKey := pgp.MasterKeyFromFingerprint(key.Fingerprint)
if ks.gnuPGHome != "" {
ks.gnuPGHome.ApplyToMasterKey(pgpKey)
}

pgpKey := pgp.MasterKeyFromFingerprint(key.Fingerprint, ks.homeDir)
err := pgpKey.Encrypt(plaintext)
if err != nil {
return nil, err
Expand All @@ -165,11 +166,10 @@ func (ks *Server) encryptWithPgp(key *keyservice.PgpKey, plaintext []byte) ([]by
}

func (ks *Server) decryptWithPgp(key *keyservice.PgpKey, ciphertext []byte) ([]byte, error) {
if ks.homeDir == "" {
return nil, status.Errorf(codes.Unimplemented, "PGP decrypt service unavailable: missing home dir configuration")
pgpKey := pgp.MasterKeyFromFingerprint(key.Fingerprint)
if ks.gnuPGHome != "" {
ks.gnuPGHome.ApplyToMasterKey(pgpKey)
}

pgpKey := pgp.MasterKeyFromFingerprint(key.Fingerprint, ks.homeDir)
pgpKey.EncryptedKey = string(ciphertext)
plaintext, err := pgpKey.Decrypt()
return plaintext, err
Expand Down
114 changes: 99 additions & 15 deletions internal/sops/pgp/keysource.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,100 @@ type MasterKey struct {
// needs rotation.
CreationDate time.Time

homeDir string
// gnuPGHomeDir contains the absolute path to a GnuPG home directory.
// It can be injected by a (local) keyservice.KeyServiceServer using
// GnuPGHome.ApplyToMasterKey().
gnuPGHomeDir string
}

// MasterKeyFromFingerprint takes a PGP fingerprint and returns a
// new MasterKey with that fingerprint.
func MasterKeyFromFingerprint(fingerprint, homeDir string) *MasterKey {
func MasterKeyFromFingerprint(fingerprint string) *MasterKey {
return &MasterKey{
Fingerprint: strings.Replace(fingerprint, " ", "", -1),
CreationDate: time.Now().UTC(),
homeDir: homeDir,
}
}

// GnuPGHome is the absolute path to a GnuPG home directory.
// A new keyring can be constructed by combining the use of NewGnuPGHome() and
// Import() or ImportFile().
type GnuPGHome string

// NewGnuPGHome initializes a new GnuPGHome in a temporary directory.
// The caller is expected to handle the garbage collection of the created
// directory.
func NewGnuPGHome() (GnuPGHome, error) {
tmpDir, err := os.MkdirTemp("", "sops-gnupghome-")
if err != nil {
return "", fmt.Errorf("failed to create new GnuPG home: %w", err)
}
return GnuPGHome(tmpDir), nil
}

// Import attempts to import the armored key bytes into the GnuPGHome keyring.
// It returns an error if the GnuPGHome does not pass Validate, or if the
// import failed.
func (d GnuPGHome) Import(armoredKey []byte) error {
if err := d.Validate(); err != nil {
return fmt.Errorf("cannot import armored key data into GnuPG keyring: %w", err)
}

args := []string{"--batch", "--import"}
err, _, stderr := gpgExec(d.String(), args, bytes.NewReader(armoredKey))
if err != nil {
return fmt.Errorf("failed to import armored key data into GnuPG keyring: %s", strings.TrimSpace(stderr.String()))
}
return nil
}

// ImportFile attempts to import the armored key file into the GnuPGHome
// keyring.
// It returns an error if the GnuPGHome does not pass Validate, or if the
// import failed.
func (d GnuPGHome) ImportFile(path string) error {
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("cannot read armored key data from file: %w", err)
}
return d.Import(b)
}

// Validate ensures the GnuPGHome is a valid GnuPG home directory path.
// When validation fails, it returns a descriptive reason as error.
func (d GnuPGHome) Validate() error {
if d == "" {
return fmt.Errorf("empty GNUPGHOME path")
}
if !filepath.IsAbs(d.String()) {
return fmt.Errorf("GNUPGHOME must be an absolute path")
}
fi, err := os.Lstat(d.String())
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("GNUPGHOME does not exist")
}
return fmt.Errorf("cannot stat GNUPGHOME: %w", err)
}
if !fi.IsDir() {
return fmt.Errorf("GNUGPHOME is not a directory")
}
if perm := fi.Mode().Perm(); perm != 0o700 {
return fmt.Errorf("GNUPGHOME has invalid permissions: got %#o wanted %#o", perm, 0o700)
}
return nil
}

// String returns the GnuPGHome as a string. It does not Validate.
func (d GnuPGHome) String() string {
return string(d)
}

// ApplyToMasterKey configures the GnuPGHome on the provided key if it passes
// Validate.
func (d GnuPGHome) ApplyToMasterKey(key *MasterKey) {
if err := d.Validate(); err == nil {
key.gnuPGHomeDir = d.String()
}
}

Expand All @@ -78,7 +162,7 @@ func (key *MasterKey) Encrypt(dataKey []byte) error {
fingerprint,
"--no-encrypt-to",
}
err, stdout, stderr := gpgExec(key.gpgHome(), args, bytes.NewReader(dataKey))
err, stdout, stderr := gpgExec(key.gnuPGHome(), args, bytes.NewReader(dataKey))
if err != nil {
return fmt.Errorf("%s", strings.TrimSpace(stderr.String()))
}
Expand Down Expand Up @@ -112,7 +196,7 @@ func (key *MasterKey) Decrypt() ([]byte, error) {
args := []string{
"-d",
}
err, stdout, stderr := gpgExec(key.gpgHome(), args, strings.NewReader(key.EncryptedKey))
err, stdout, stderr := gpgExec(key.gnuPGHome(), args, strings.NewReader(key.EncryptedKey))
if err != nil {
return nil, fmt.Errorf("%s", strings.TrimSpace(stderr.String()))
}
Expand Down Expand Up @@ -140,14 +224,14 @@ func (key MasterKey) ToMap() map[string]interface{} {
return out
}

// gpgHome determines the GnuPG home directory for the MasterKey, and returns
// gnuPGHome determines the GnuPG home directory for the MasterKey, and returns
// its path. In order of preference:
// 1. MasterKey.homeDir
// 1. MasterKey.gnuPGHomeDir
// 2. $GNUPGHOME
// 3. user.Current().HomeDir/.gnupg
// 4. $HOME/.gnupg
func (key *MasterKey) gpgHome() string {
if key.homeDir == "" {
func (key *MasterKey) gnuPGHome() string {
if key.gnuPGHomeDir == "" {
dir := os.Getenv("GNUPGHOME")
if dir == "" {
usr, err := user.Current()
Expand All @@ -158,15 +242,15 @@ func (key *MasterKey) gpgHome() string {
}
return dir
}
return key.homeDir
return key.gnuPGHomeDir
}

// gpgExec runs the provided args with the gpgBinary, while restricting it to
// gpgHome. Stdout and stderr can be read from the returned buffers.
// gnuPGHome. Stdout and stderr can be read from the returned buffers.
// When the command fails, an error is returned.
func gpgExec(gpgHome string, args []string, stdin io.Reader) (err error, stdout bytes.Buffer, stderr bytes.Buffer) {
if gpgHome != "" {
args = append([]string{"--no-default-keyring", "--homedir", gpgHome}, args...)
func gpgExec(gnuPGHome string, args []string, stdin io.Reader) (err error, stdout bytes.Buffer, stderr bytes.Buffer) {
if gnuPGHome != "" {
args = append([]string{"--no-default-keyring", "--homedir", gnuPGHome}, args...)
}

cmd := exec.Command(gpgBinary(), args...)
Expand All @@ -177,7 +261,7 @@ func gpgExec(gpgHome string, args []string, stdin io.Reader) (err error, stdout
return
}

// gpgBinary returns the GNuPG binary which must be used.
// gpgBinary returns the GnuPG binary which must be used.
// It allows for runtime modifications by setting the environment variable
// SopsGpgExecEnv to the absolute path of the replacement binary.
func gpgBinary() string {
Expand Down
Loading

0 comments on commit 61c2761

Please sign in to comment.