1
0
mirror of https://github.com/getsops/sops.git synced 2026-02-05 12:45:21 +01:00
Files
sops/pgp/keysource.go

636 lines
20 KiB
Go

/*
Package pgp contains an implementation of the go.mozilla.org/sops/v3.MasterKey
interface that encrypts and decrypts the data key by first trying with the
github.com/ProtonMail/go-crypto/openpgp package and if that fails, by calling
the "gpg" binary.
*/
package pgp //import "go.mozilla.org/sops/v3/pgp"
import (
"bytes"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"time"
"github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/howeyc/gopass"
"github.com/sirupsen/logrus"
gpgagent "go.mozilla.org/gopgagent"
"go.mozilla.org/sops/v3/logging"
)
const (
// SopsGpgExecEnv can be set as an environment variable to overwrite the
// GnuPG binary used.
SopsGpgExecEnv = "SOPS_GPG_EXEC"
)
var (
// pgpTTL is the duration after which a MasterKey requires rotation.
pgpTTL = time.Hour * 24 * 30 * 6
// defaultPubRing is the relative path to the pubring in the GnuPG
// home.
// NB: This format is no longer in use since GnuPG >=2.1, which switched
// to .kbx for new installations, and merged secring.gpg into pubring.gpg.
defaultPubRing = "pubring.gpg"
// defaultSecRing is the relative path to the secring in the GnuPG
// home.
// NB: GnuPG >= 2.1 merged this together with pubring.gpg, see
// defaultPubRing.
defaultSecRing = "secring.gpg"
)
// log is the global logger for any PGP MasterKey.
// TODO(hidde): this is not-so-nice for any implementation other than the CLI,
// as it becomes difficult to sugar the logger with data for e.g. individual
// processes.
var log *logrus.Logger
func init() {
log = logging.NewLogger("PGP")
}
// MasterKey is a PGP key used to securely store SOPS' data key by
// encrypting it and decrypting it.
type MasterKey struct {
// Fingerprint contains the fingerprint of the PGP key used to Encrypt
// or Decrypt the data key with.
Fingerprint string
// EncryptedKey contains the SOPS data key encrypted with PGP.
EncryptedKey string
// CreationDate of the MasterKey, used to determine if the EncryptedKey
// needs rotation.
CreationDate time.Time
// gnuPGHomeDir contains the absolute path to a GnuPG home directory.
// It can be injected by a (local) keyservice.KeyServiceServer using
// GnuPGHome.ApplyToMasterKey().
gnuPGHomeDir string
// disableAgent instructs the MasterKey to not use the GnuPG agent during
// decryption operations.
disableAgent bool
// disableOpenPGP instructs the MasterKey to skip attempting to open any
// pubRing or secRing using OpenPGP.
disableOpenPGP bool
// pubRing contains the absolute path to a public keyring used by OpenPGP.
// When empty, defaultPubRing relative to GnuPG home is assumed.
pubRing string
// secRing contains the absolute path to a sec keyring used by OpenPGP.
// When empty, defaultSecRing relative to GnuPG home is assumed.
secRing string
}
// NewMasterKeyFromFingerprint takes a PGP fingerprint and returns a new
// MasterKey with that fingerprint.
func NewMasterKeyFromFingerprint(fingerprint string) *MasterKey {
return &MasterKey{
Fingerprint: strings.Replace(fingerprint, " ", "", -1),
CreationDate: time.Now().UTC(),
}
}
// MasterKeysFromFingerprintString takes a comma separated list of PGP
// fingerprints and returns a slice of new MasterKeys with those fingerprints.
func MasterKeysFromFingerprintString(fingerprint string) []*MasterKey {
var keys []*MasterKey
if fingerprint == "" {
return keys
}
for _, s := range strings.Split(fingerprint, ",") {
keys = append(keys, NewMasterKeyFromFingerprint(s))
}
return keys
}
// 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)
}
// Cleanup deletes the GnuPGHome if it passes Validate.
// It returns an error if the GnuPGHome does not pass Validate, or if the
// removal failed.
func (d GnuPGHome) Cleanup() error {
if err := d.Validate(); err != nil {
return err
}
return os.RemoveAll(d.String())
}
// 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()
}
}
// DisableAgent disables the GnuPG agent for a MasterKey.
type DisableAgent struct{}
// ApplyToMasterKey configures the provided key to not use the GnuPG agent.
func (d DisableAgent) ApplyToMasterKey(key *MasterKey) {
key.disableAgent = true
}
// DisableOpenPGP disables encrypt and decrypt operations using OpenPGP.
type DisableOpenPGP struct{}
// ApplyToMasterKey configures the provided key to not use OpenPGP.
func (d DisableOpenPGP) ApplyToMasterKey(key *MasterKey) {
key.disableOpenPGP = true
}
// PubRing can be used to configure the absolute path to a public keyring
// used by OpenPGP.
type PubRing string
// ApplyToMasterKey configures the provided key to not use the GnuPG agent.
func (r PubRing) ApplyToMasterKey(key *MasterKey) {
key.pubRing = string(r)
}
// SecRing can be used to configure the absolute path to a sec keyring
// used by OpenPGP.
type SecRing string
// ApplyToMasterKey configures the provided key to not use the GnuPG agent.
func (r SecRing) ApplyToMasterKey(key *MasterKey) {
key.secRing = string(r)
}
// errSet is a collection of captured errors.
type errSet []error
// Error joins the errors into a "; " seperated string.
func (e errSet) Error() string {
str := make([]string, len(e))
for i, err := range e {
str[i] = err.Error()
}
return strings.Join(str, "; ")
}
// Encrypt encrypts the data key with the PGP key with the same
// fingerprint as the MasterKey.
func (key *MasterKey) Encrypt(dataKey []byte) error {
var errs errSet
if !key.disableOpenPGP {
openpgpErr := key.encryptWithOpenPGP(dataKey)
if openpgpErr == nil {
log.WithField("fingerprint", key.Fingerprint).Info("Encryption succeeded")
return nil
}
errs = append(errs, fmt.Errorf("github.com/ProtonMail/go-crypto/openpgp error: %w", openpgpErr))
}
binaryErr := key.encryptWithGnuPG(dataKey)
if binaryErr == nil {
log.WithField("fingerprint", key.Fingerprint).Info("Encryption succeeded")
return nil
}
errs = append(errs, fmt.Errorf("GnuPG binary error: %w", binaryErr))
log.WithError(errs).WithField("fingerprint", key.Fingerprint).Error("Encryption failed")
return fmt.Errorf("could not encrypt data key with PGP key: %w", errs)
}
// encryptWithOpenPGP attempts to encrypt the data key using OpenPGP with the
// PGP key that belongs to Fingerprint. It sets EncryptedDataKey, or returns
// an error.
func (key *MasterKey) encryptWithOpenPGP(dataKey []byte) error {
entity, err := key.retrievePubKey()
if err != nil {
return err
}
encBuf := new(bytes.Buffer)
armorBuf, err := armor.Encode(encBuf, "PGP MESSAGE", nil)
if err != nil {
return err
}
plainBuf, err := openpgp.Encrypt(armorBuf, []*openpgp.Entity{&entity}, nil, &openpgp.FileHints{IsBinary: true}, nil)
if err != nil {
return err
}
_, err = plainBuf.Write(dataKey)
if err != nil {
return err
}
err = plainBuf.Close()
if err != nil {
return err
}
err = armorBuf.Close()
if err != nil {
return err
}
b, err := io.ReadAll(encBuf)
if err != nil {
return err
}
key.SetEncryptedDataKey(b)
return nil
}
// encryptWithOpenPGP attempts to encrypt the data key using GnuPG with the
// PGP key that belongs to Fingerprint. It sets EncryptedDataKey, or returns
// an error.
func (key *MasterKey) encryptWithGnuPG(dataKey []byte) error {
fingerprint := shortenFingerprint(key.Fingerprint)
args := []string{
"--no-default-recipient",
"--yes",
"--encrypt",
"-a",
"-r",
key.Fingerprint,
"--trusted-key",
fingerprint,
"--no-encrypt-to",
}
err, stdout, stderr := gpgExec(key.gnuPGHome(), args, bytes.NewReader(dataKey))
if err != nil {
return fmt.Errorf("failed to encrypt sops data key with pgp: %s", strings.TrimSpace(stderr.String()))
}
key.SetEncryptedDataKey(bytes.TrimSpace(stdout.Bytes()))
return nil
}
// EncryptIfNeeded encrypts the data key with PGP only if it's needed,
// that is, if it hasn't been encrypted already.
func (key *MasterKey) EncryptIfNeeded(dataKey []byte) error {
if key.EncryptedKey == "" {
return key.Encrypt(dataKey)
}
return nil
}
// EncryptedDataKey returns the encrypted data key this master key holds.
func (key *MasterKey) EncryptedDataKey() []byte {
return []byte(key.EncryptedKey)
}
// SetEncryptedDataKey sets the encrypted data key for this master key.
func (key *MasterKey) SetEncryptedDataKey(enc []byte) {
key.EncryptedKey = string(enc)
}
// Decrypt first attempts to obtain the data key from the EncryptedKey
// stored in the MasterKey using OpenPGP, before falling back to GnuPG.
// When both attempts fail, an error is returned.
func (key *MasterKey) Decrypt() ([]byte, error) {
var errs errSet
if !key.disableOpenPGP {
dataKey, openpgpErr := key.decryptWithOpenPGP()
if openpgpErr == nil {
log.WithField("fingerprint", key.Fingerprint).Info("Decryption succeeded")
return dataKey, nil
}
errs = append(errs, fmt.Errorf("github.com/ProtonMail/go-crypto/openpgp error: %w", openpgpErr))
}
dataKey, binaryErr := key.decryptWithGnuPG()
if binaryErr == nil {
log.WithField("fingerprint", key.Fingerprint).Info("Decryption succeeded")
return dataKey, nil
}
errs = append(errs, fmt.Errorf("GnuPG binary error: %w", binaryErr))
// TODO: Should only display an error to the user if all keys have been attempted (when there are multiple keys in the pgp key group)
log.WithError(errs).WithField("fingerprint", key.Fingerprint).Info("Decryption failed")
return nil, fmt.Errorf("could not decrypt data key with PGP key: %w", errs)
}
// decryptWithOpenPGP attempts to obtain the data key from the EncryptedKey
// using OpenPGP and returns the result.
//
// Note: the current development of OpenPGP vs GnuPG has moved in separate
// directions. This means that e.g. GnuPG >=2.1 works with a .kbx format which
// can not be read by OpenPGP. Given the further assumptions around the
// placement of the files, and the generic fallback Decrypt uses, this raises
// the question of how widely utilized this method still is.
func (key *MasterKey) decryptWithOpenPGP() ([]byte, error) {
ring, err := key.getSecRing()
if err != nil {
return nil, fmt.Errorf("could not load secring: %s", err)
}
block, err := armor.Decode(strings.NewReader(key.EncryptedKey))
if err != nil {
return nil, fmt.Errorf("armor decoding failed: %s", err)
}
md, err := openpgp.ReadMessage(block.Body, ring, key.passphrasePrompt(), nil)
if err != nil {
return nil, fmt.Errorf("reading PGP message failed: %s", err)
}
if b, err := io.ReadAll(md.UnverifiedBody); err == nil {
return b, nil
}
return nil, fmt.Errorf("the key could not be decrypted with any of the PGP entries")
}
// decryptWithGnuPG attempts to obtain the data key from the EncryptedKey using
// GnuPG and returns the result. If DisableAgent is configured on the MasterKey,
// the GnuPG agent is not enabled. When the decryption command fails, it returns
// the error from stdout.
func (key *MasterKey) decryptWithGnuPG() ([]byte, error) {
args := []string{
"-d",
}
if !key.disableAgent {
args = append([]string{"--use-agent"}, args...)
}
err, stdout, stderr := gpgExec(key.gnuPGHome(), args, strings.NewReader(key.EncryptedKey))
if err != nil {
return nil, fmt.Errorf("failed to decrypt sops data key with pgp: %s",
strings.TrimSpace(stderr.String()))
}
return stdout.Bytes(), nil
}
// NeedsRotation returns whether the data key needs to be rotated
// or not.
func (key *MasterKey) NeedsRotation() bool {
return time.Since(key.CreationDate) > (pgpTTL)
}
// ToString returns the string representation of the key, i.e. its
// fingerprint.
func (key *MasterKey) ToString() string {
return key.Fingerprint
}
// ToMap converts the MasterKey into a map for serialization purposes.
func (key MasterKey) ToMap() map[string]interface{} {
out := make(map[string]interface{})
out["fp"] = key.Fingerprint
out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339)
out["enc"] = key.EncryptedKey
return out
}
// gnuPGHome determines the GnuPG home directory for the MasterKey, and returns
// its path. In order of preference:
// 1. MasterKey.gnuPGHomeDir
// 2. $GNUPGHOME
// 3. user.Current().HomeDir/.gnupg
// 4. $HOME/.gnupg
func (key *MasterKey) gnuPGHome() string {
if key.gnuPGHomeDir == "" {
dir := os.Getenv("GNUPGHOME")
if dir == "" {
usr, err := user.Current()
if err != nil {
return filepath.Join(os.Getenv("HOME"), ".gnupg")
}
return filepath.Join(usr.HomeDir, ".gnupg")
}
return dir
}
return key.gnuPGHomeDir
}
// retrievePubKey attempts to retrieve the public key from the public keyring
// by Fingerprint.
func (key *MasterKey) retrievePubKey() (openpgp.Entity, error) {
ring, err := key.getPubRing()
if err == nil {
fingerprints := fingerprintIndex(ring)
entity, ok := fingerprints[key.Fingerprint]
if ok {
return entity, nil
}
}
return openpgp.Entity{},
fmt.Errorf("key with fingerprint '%s' is not available "+
"in keyring", key.Fingerprint)
}
// getPubRing loads the public keyring from the configured path, or falls back
// to defaultPubRing relative to the GnuPG home. It returns an openpgp.EntityList
// read from the keyring, or an error.
func (key *MasterKey) getPubRing() (openpgp.EntityList, error) {
path := key.pubRing
if path == "" {
path = filepath.Join(key.gnuPGHome(), defaultPubRing)
}
return loadRing(path)
}
// getSecRing loads the sec keyring from the configured path, or falls back
// to defaultSecRing relative to the GnuPG home. It returns an openpgp.EntityList
// read from the keyring, or an error.
func (key *MasterKey) getSecRing() (openpgp.EntityList, error) {
path := key.secRing
if path == "" {
path = filepath.Join(key.gnuPGHome(), defaultSecRing)
}
if _, err := os.Lstat(path); err != nil {
if !os.IsNotExist(err) {
return nil, err
}
return key.getPubRing()
}
return loadRing(path)
}
// passphrasePrompt prompts the user for a passphrase when this is required for
// encryption or decryption.
func (key *MasterKey) passphrasePrompt() func(keys []openpgp.Key, symmetric bool) ([]byte, error) {
callCounter := 0
maxCalls := 3
return func(keys []openpgp.Key, symmetric bool) ([]byte, error) {
if callCounter >= maxCalls {
return nil, fmt.Errorf("function passphrasePrompt called too many times")
}
callCounter++
conn, err := gpgagent.NewConn()
if err == gpgagent.ErrNoAgent {
log.Infof("gpg-agent not found, continuing with manual passphrase " +
"input...")
fmt.Print("Enter PGP key passphrase: ")
pass, err := gopass.GetPasswd()
if err != nil {
return nil, err
}
for _, k := range keys {
k.PrivateKey.Decrypt(pass)
}
return pass, err
}
if err != nil {
return nil, fmt.Errorf("could not establish connection with gpg-agent: %s", err)
}
defer func(conn *gpgagent.Conn) {
if err := conn.Close(); err != nil {
log.Errorf("failed to close connection with gpg-agent: %s", err)
}
}(conn)
for _, k := range keys {
req := gpgagent.PassphraseRequest{
CacheKey: k.PublicKey.KeyIdShortString(),
Prompt: "Passphrase",
Desc: fmt.Sprintf("Unlock key %s to decrypt sops's key", k.PublicKey.KeyIdShortString()),
}
pass, err := conn.GetPassphrase(&req)
if err != nil {
return nil, fmt.Errorf("gpg-agent passphrase request errored: %s", err)
}
k.PrivateKey.Decrypt([]byte(pass))
return []byte(pass), nil
}
return nil, fmt.Errorf("no key to unlock")
}
}
// loadRing attempts to load the keyring from the provided path.
// Unsupported keys are ignored as long as at least a single valid key is
// found.
func loadRing(path string) (openpgp.EntityList, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
keyring, err := openpgp.ReadKeyRing(f)
if err != nil {
return nil, err
}
return keyring, nil
}
// fingerprintIndex indexes the openpgp.Entity objects from the given ring
// by their fingerprint, and returns the result.
func fingerprintIndex(ring openpgp.EntityList) map[string]openpgp.Entity {
fps := make(map[string]openpgp.Entity)
for _, entity := range ring {
fp := strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint[:]))
if entity != nil {
fps[fp] = *entity
}
}
return fps
}
// gpgExec runs the provided args with the gpgBinary, while restricting it to
// gnuPGHome. Stdout and stderr can be read from the returned buffers.
// When the command fails, an error is returned.
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...)
cmd.Stdin = stdin
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err = cmd.Run()
return
}
// 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 {
binary := "gpg"
if envBinary := os.Getenv(SopsGpgExecEnv); envBinary != "" && filepath.IsAbs(envBinary) {
binary = envBinary
}
return binary
}
// shortenFingerprint returns the short ID of the given fingerprint.
// This is mostly used for compatability reasons, as older versions of GnuPG
// do not always like long IDs.
func shortenFingerprint(fingerprint string) string {
if offset := len(fingerprint) - 16; offset > 0 {
fingerprint = fingerprint[offset:]
}
return fingerprint
}