Skip to content

Commit

Permalink
GPGME: support passphrase for prompt-less signing
Browse files Browse the repository at this point in the history
To support signing images via gpgme without user prompt, allow for
providing a passphrase via the copy options.  Add a new *WithOptions API
to the `signature` package and extend its interface.

To prevent breaking the API, extend the signature API with an internal
type as has already been done for other types and interfaces.

Signed-off-by: Valentin Rothberg <[email protected]>
  • Loading branch information
vrothberg committed Jan 25, 2022
1 parent beb2d5d commit 2773109
Show file tree
Hide file tree
Showing 16 changed files with 198 additions and 24 deletions.
5 changes: 3 additions & 2 deletions copy/copy.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ type ImageListSelection int
type Options struct {
RemoveSignatures bool // Remove any pre-existing signatures. SignBy will still add a new signature.
SignBy string // If non-empty, asks for a signature to be added during the copy, and specifies a key ID, as accepted by signature.NewGPGSigningMechanism().SignDockerManifest(),
SignPassphrase string // Passphare to use when signing with the key ID from `SignBy`.
ReportWriter io.Writer
SourceCtx *types.SystemContext
DestinationCtx *types.SystemContext
Expand Down Expand Up @@ -569,7 +570,7 @@ func (c *copier) copyMultipleImages(ctx context.Context, policyContext *signatur

// Sign the manifest list.
if options.SignBy != "" {
newSig, err := c.createSignature(manifestList, options.SignBy)
newSig, err := c.createSignature(manifestList, options.SignBy, options.SignPassphrase)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -791,7 +792,7 @@ func (c *copier) copyOneImage(ctx context.Context, policyContext *signature.Poli
}

if options.SignBy != "" {
newSig, err := c.createSignature(manifestBytes, options.SignBy)
newSig, err := c.createSignature(manifestBytes, options.SignBy, options.SignPassphrase)
if err != nil {
return nil, "", "", err
}
Expand Down
4 changes: 2 additions & 2 deletions copy/sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
)

// createSignature creates a new signature of manifest using keyIdentity.
func (c *copier) createSignature(manifest []byte, keyIdentity string) ([]byte, error) {
func (c *copier) createSignature(manifest []byte, keyIdentity string, passphrase string) ([]byte, error) {
mech, err := signature.NewGPGSigningMechanism()
if err != nil {
return nil, errors.Wrap(err, "initializing GPG")
Expand All @@ -23,7 +23,7 @@ func (c *copier) createSignature(manifest []byte, keyIdentity string) ([]byte, e
}

c.Printf("Signing manifest\n")
newSig, err := signature.SignDockerManifest(manifest, dockerReference.String(), mech, keyIdentity)
newSig, err := signature.SignDockerManifestWithOptions(manifest, dockerReference.String(), mech, keyIdentity, &signature.SignOptions{Passphrase: passphrase})
if err != nil {
return nil, errors.Wrap(err, "creating signature")
}
Expand Down
6 changes: 3 additions & 3 deletions copy/sign_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func TestCreateSignature(t *testing.T) {
dest: dirDest,
reportWriter: ioutil.Discard,
}
_, err = c.createSignature(manifestBlob, testKeyFingerprint)
_, err = c.createSignature(manifestBlob, testKeyFingerprint, "")
assert.Error(t, err)

// Set up a docker: reference
Expand All @@ -66,14 +66,14 @@ func TestCreateSignature(t *testing.T) {
}

// Signing with an unknown key fails
_, err = c.createSignature(manifestBlob, "this key does not exist")
_, err = c.createSignature(manifestBlob, "this key does not exist", "")
assert.Error(t, err)

// Success
mech, err = signature.NewGPGSigningMechanism()
require.NoError(t, err)
defer mech.Close()
sig, err := c.createSignature(manifestBlob, testKeyFingerprint)
sig, err := c.createSignature(manifestBlob, testKeyFingerprint, "")
require.NoError(t, err)
verified, err := signature.VerifyDockerManifestSignature(sig, manifestBlob, "docker.io/library/busybox:latest", mech, testKeyFingerprint)
require.NoError(t, err)
Expand Down
36 changes: 36 additions & 0 deletions pkg/cli/passphrase.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package cli

import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/sirupsen/logrus"
)

// ReadPassphraseFile returns the first line of the specified path.
// For convenience, an empty string is returned if the path is empty.
func ReadPassphraseFile(path string) (string, error) {
if path == "" {
return "", nil
}

logrus.Debugf("Reading user-specified passphrase for signing from %s", path)

ppf, err := os.Open(path)
if err != nil {
return "", err
}
defer ppf.Close()

// Read the *first* line in the passphrase file, just as gpg(1) does.
buf, err := bufio.NewReader(ppf).ReadBytes('\n')
if err != nil && !errors.Is(err, io.EOF) {
return "", fmt.Errorf("reading passphrase file: %w", err)
}

return strings.TrimSuffix(string(buf), "\n"), nil
}
30 changes: 27 additions & 3 deletions signature/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,46 @@
package signature

import (
"errors"
"fmt"
"strings"

"github.com/containers/image/v5/docker/reference"
"github.com/containers/image/v5/manifest"
"github.com/opencontainers/go-digest"
)

// SignOptions includes optional parameters for signing container images.
type SignOptions struct {
// Passphare to use when signing with the key identity.
Passphrase string
}

// SignDockerManifest returns a signature for manifest as the specified dockerReference,
// using mech and keyIdentity.
func SignDockerManifest(m []byte, dockerReference string, mech SigningMechanism, keyIdentity string) ([]byte, error) {
// using mech and keyIdentity, and the specified options.
func SignDockerManifestWithOptions(m []byte, dockerReference string, mech SigningMechanism, keyIdentity string, options *SignOptions) ([]byte, error) {
manifestDigest, err := manifest.Digest(m)
if err != nil {
return nil, err
}
sig := newUntrustedSignature(manifestDigest, dockerReference)
return sig.sign(mech, keyIdentity)

var passphrase string
if options != nil {
passphrase = options.Passphrase
// The gpgme implementation can’t use passphrase with \n; reject it here for consistent behavior.
if strings.Contains(passphrase, "\n") {
return nil, errors.New("invalid passphrase: must not contain a line break")
}
}

return sig.sign(mech, keyIdentity, passphrase)
}

// SignDockerManifest returns a signature for manifest as the specified dockerReference,
// using mech and keyIdentity.
func SignDockerManifest(m []byte, dockerReference string, mech SigningMechanism, keyIdentity string) ([]byte, error) {
return SignDockerManifestWithOptions(m, dockerReference, mech, keyIdentity, nil)
}

// VerifyDockerManifestSignature checks that unverifiedSignature uses expectedKeyIdentity to sign unverifiedManifest as expectedDockerReference,
Expand Down
61 changes: 61 additions & 0 deletions signature/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@ package signature

import (
"io/ioutil"
"os"
"os/exec"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// Kill the running gpg-agent to drop unlocked keys. This allows for testing handling of invalid passphrases.
func killGPGAgent(t *testing.T) {
cmd := exec.Command("gpgconf", "--kill", "gpg-agent")
cmd.Env = append(os.Environ(), "GNUPGHOME="+testGPGHomeDirectory)
err := cmd.Run()
assert.NoError(t, err)
}

func TestSignDockerManifest(t *testing.T) {
mech, err := newGPGSigningMechanismInDirectory(testGPGHomeDirectory)
require.NoError(t, err)
Expand Down Expand Up @@ -44,6 +54,57 @@ func TestSignDockerManifest(t *testing.T) {
assert.Error(t, err)
}

func TestSignDockerManifestWithPassphrase(t *testing.T) {
killGPGAgent(t)

mech, err := newGPGSigningMechanismInDirectory(testGPGHomeDirectory)
require.NoError(t, err)
defer mech.Close()

if err := mech.SupportsSigning(); err != nil {
t.Skipf("Signing not supported: %v", err)
}

manifest, err := ioutil.ReadFile("fixtures/image.manifest.json")
require.NoError(t, err)

// Invalid passphrase
_, err = SignDockerManifestWithOptions(manifest, TestImageSignatureReference, mech, TestKeyFingerprintWithPassphrase, &SignOptions{Passphrase: TestPassphrase + "\n"})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid passphrase")

// Wrong passphrase
_, err = SignDockerManifestWithOptions(manifest, TestImageSignatureReference, mech, TestKeyFingerprintWithPassphrase, &SignOptions{Passphrase: "wrong"})
require.Error(t, err)

// No passphrase
_, err = SignDockerManifestWithOptions(manifest, TestImageSignatureReference, mech, TestKeyFingerprintWithPassphrase, nil)
require.Error(t, err)

// Successful signing
signature, err := SignDockerManifestWithOptions(manifest, TestImageSignatureReference, mech, TestKeyFingerprintWithPassphrase, &SignOptions{Passphrase: TestPassphrase})
require.NoError(t, err)

verified, err := VerifyDockerManifestSignature(signature, manifest, TestImageSignatureReference, mech, TestKeyFingerprintWithPassphrase)
assert.NoError(t, err)
assert.Equal(t, TestImageSignatureReference, verified.DockerReference)
assert.Equal(t, TestImageManifestDigest, verified.DockerManifestDigest)

// Error computing Docker manifest
invalidManifest, err := ioutil.ReadFile("fixtures/v2s1-invalid-signatures.manifest.json")
require.NoError(t, err)
_, err = SignDockerManifest(invalidManifest, TestImageSignatureReference, mech, TestKeyFingerprintWithPassphrase)
assert.Error(t, err)

// Error creating blob to sign
_, err = SignDockerManifest(manifest, "", mech, TestKeyFingerprintWithPassphrase)
assert.Error(t, err)

// Error signing
_, err = SignDockerManifest(manifest, TestImageSignatureReference, mech, "this fingerprint doesn't exist")
assert.Error(t, err)
}

func TestVerifyDockerManifestSignature(t *testing.T) {
mech, err := newGPGSigningMechanismInDirectory(testGPGHomeDirectory)
require.NoError(t, err)
Expand Down
Binary file modified signature/fixtures/pubring.gpg
Binary file not shown.
Binary file modified signature/fixtures/secring.gpg
Binary file not shown.
Binary file modified signature/fixtures/trustdb.gpg
Binary file not shown.
4 changes: 4 additions & 0 deletions signature/fixtures_info_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ const (
TestKeyFingerprint = "1D8230F6CDB6A06716E414C1DB72F2188BB46CC8"
// TestKeyShortID is the short ID of the private key in this directory.
TestKeyShortID = "DB72F2188BB46CC8"
// TestKeyFingerprintWithPassphrase is the fingerprint of the private key with passphrase in this directory.
TestKeyFingerprintWithPassphrase = "E3EB7611D815211F141946B5B0CDE60B42557346"
// TestPassphrase is the passphrase for TestKeyFingerprintWithPassphrase.
TestPassphrase = "WithPassphrase123"
)
11 changes: 9 additions & 2 deletions signature/mechanism.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ import (

// SigningMechanism abstracts a way to sign binary blobs and verify their signatures.
// Each mechanism should eventually be closed by calling Close().
// FIXME: Eventually expand on keyIdentity (namespace them between mechanisms to
// eliminate ambiguities, support CA signatures and perhaps other key properties)
type SigningMechanism interface {
// Close removes resources associated with the mechanism, if any.
Close() error
Expand All @@ -38,6 +36,15 @@ type SigningMechanism interface {
UntrustedSignatureContents(untrustedSignature []byte) (untrustedContents []byte, shortKeyIdentifier string, err error)
}

// signingMechanismWithPassphrase is an internal extension of SigningMechanism.
type signingMechanismWithPassphrase interface {
SigningMechanism

// Sign creates a (non-detached) signature of input using keyIdentity and passphrase.
// Fails with a SigningNotSupportedError if the mechanism does not support signing.
SignWithPassphrase(input []byte, keyIdentity string, passphrase string) ([]byte, error)
}

// SigningNotSupportedError is returned when trying to sign using a mechanism which does not support that.
type SigningNotSupportedError string

Expand Down
35 changes: 31 additions & 4 deletions signature/mechanism_gpgme.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package signature

import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"os"
Expand All @@ -20,7 +21,7 @@ type gpgmeSigningMechanism struct {

// newGPGSigningMechanismInDirectory returns a new GPG/OpenPGP signing mechanism, using optionalDir if not empty.
// The caller must call .Close() on the returned SigningMechanism.
func newGPGSigningMechanismInDirectory(optionalDir string) (SigningMechanism, error) {
func newGPGSigningMechanismInDirectory(optionalDir string) (signingMechanismWithPassphrase, error) {
ctx, err := newGPGMEContext(optionalDir)
if err != nil {
return nil, err
Expand All @@ -35,7 +36,7 @@ func newGPGSigningMechanismInDirectory(optionalDir string) (SigningMechanism, er
// recognizes _only_ public keys from the supplied blob, and returns the identities
// of these keys.
// The caller must call .Close() on the returned SigningMechanism.
func newEphemeralGPGSigningMechanism(blob []byte) (SigningMechanism, []string, error) {
func newEphemeralGPGSigningMechanism(blob []byte) (signingMechanismWithPassphrase, []string, error) {
dir, err := ioutil.TempDir("", "containers-ephemeral-gpg-")
if err != nil {
return nil, nil, err
Expand Down Expand Up @@ -117,9 +118,9 @@ func (m *gpgmeSigningMechanism) SupportsSigning() error {
return nil
}

// Sign creates a (non-detached) signature of input using keyIdentity.
// Sign creates a (non-detached) signature of input using keyIdentity and passphrase.
// Fails with a SigningNotSupportedError if the mechanism does not support signing.
func (m *gpgmeSigningMechanism) Sign(input []byte, keyIdentity string) ([]byte, error) {
func (m *gpgmeSigningMechanism) SignWithPassphrase(input []byte, keyIdentity string, passphrase string) ([]byte, error) {
key, err := m.ctx.GetKey(keyIdentity, true)
if err != nil {
return nil, err
Expand All @@ -133,12 +134,38 @@ func (m *gpgmeSigningMechanism) Sign(input []byte, keyIdentity string) ([]byte,
if err != nil {
return nil, err
}

if passphrase != "" {
// Callback to write the passphrase to the specified file descriptor.
callback := func(uidHint string, prevWasBad bool, gpgmeFD *os.File) error {
if prevWasBad {
return errors.New("bad passphrase")
}
_, err := gpgmeFD.WriteString(passphrase + "\n")
return err
}
if err := m.ctx.SetCallback(callback); err != nil {
return nil, fmt.Errorf("setting gpgme passphrase callback: %w", err)
}

// Loopback mode will use the callback instead of prompting the user.
if err := m.ctx.SetPinEntryMode(gpgme.PinEntryLoopback); err != nil {
return nil, fmt.Errorf("setting gpgme pinentry mode: %w", err)
}
}

if err = m.ctx.Sign([]*gpgme.Key{key}, inputData, sigData, gpgme.SigModeNormal); err != nil {
return nil, err
}
return sigBuffer.Bytes(), nil
}

// Sign creates a (non-detached) signature of input using keyIdentity.
// Fails with a SigningNotSupportedError if the mechanism does not support signing.
func (m *gpgmeSigningMechanism) Sign(input []byte, keyIdentity string) ([]byte, error) {
return m.SignWithPassphrase(input, keyIdentity, "")
}

// Verify parses unverifiedSignature and returns the content and the signer's identity
func (m *gpgmeSigningMechanism) Verify(unverifiedSignature []byte) (contents []byte, keyIdentity string, err error) {
signedBuffer := bytes.Buffer{}
Expand Down
12 changes: 9 additions & 3 deletions signature/mechanism_openpgp.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type openpgpSigningMechanism struct {

// newGPGSigningMechanismInDirectory returns a new GPG/OpenPGP signing mechanism, using optionalDir if not empty.
// The caller must call .Close() on the returned SigningMechanism.
func newGPGSigningMechanismInDirectory(optionalDir string) (SigningMechanism, error) {
func newGPGSigningMechanismInDirectory(optionalDir string) (signingMechanismWithPassphrase, error) {
m := &openpgpSigningMechanism{
keyring: openpgp.EntityList{},
}
Expand Down Expand Up @@ -61,7 +61,7 @@ func newGPGSigningMechanismInDirectory(optionalDir string) (SigningMechanism, er
// recognizes _only_ public keys from the supplied blob, and returns the identities
// of these keys.
// The caller must call .Close() on the returned SigningMechanism.
func newEphemeralGPGSigningMechanism(blob []byte) (SigningMechanism, []string, error) {
func newEphemeralGPGSigningMechanism(blob []byte) (signingMechanismWithPassphrase, []string, error) {
m := &openpgpSigningMechanism{
keyring: openpgp.EntityList{},
}
Expand Down Expand Up @@ -110,10 +110,16 @@ func (m *openpgpSigningMechanism) SupportsSigning() error {

// Sign creates a (non-detached) signature of input using keyIdentity.
// Fails with a SigningNotSupportedError if the mechanism does not support signing.
func (m *openpgpSigningMechanism) Sign(input []byte, keyIdentity string) ([]byte, error) {
func (m *openpgpSigningMechanism) SignWithPassphrase(input []byte, keyIdentity string, passphrase string) ([]byte, error) {
return nil, SigningNotSupportedError("signing is not supported in github.com/containers/image built with the containers_image_openpgp build tag")
}

// Sign creates a (non-detached) signature of input using keyIdentity.
// Fails with a SigningNotSupportedError if the mechanism does not support signing.
func (m *openpgpSigningMechanism) Sign(input []byte, keyIdentity string) ([]byte, error) {
return m.SignWithPassphrase(input, keyIdentity, "")
}

// Verify parses unverifiedSignature and returns the content and the signer's identity
func (m *openpgpSigningMechanism) Verify(unverifiedSignature []byte) (contents []byte, keyIdentity string, err error) {
md, err := openpgp.ReadMessage(bytes.NewReader(unverifiedSignature), m.keyring, nil, nil)
Expand Down
2 changes: 1 addition & 1 deletion signature/mechanism_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func TestNewEphemeralGPGSigningMechanism(t *testing.T) {
mech, keyIdentities, err = NewEphemeralGPGSigningMechanism(bytes.Join([][]byte{keyBlob, keyBlob}, nil))
require.NoError(t, err)
defer mech.Close()
assert.Equal(t, []string{TestKeyFingerprint, TestKeyFingerprint}, keyIdentities)
assert.Equal(t, []string{TestKeyFingerprint, TestKeyFingerprintWithPassphrase, TestKeyFingerprint, TestKeyFingerprintWithPassphrase}, keyIdentities)

// Invalid input: This is, sadly, accepted anyway by GPG, just returns no keys.
// For openpgpSigningMechanism we can detect this and fail.
Expand Down
Loading

0 comments on commit 2773109

Please sign in to comment.