Skip to content

Commit

Permalink
Merge pull request #1446 from vrothberg/passphrase
Browse files Browse the repository at this point in the history
GPGME: support passphrase for prompt-less signing
  • Loading branch information
mtrmac authored Jan 25, 2022
2 parents 1485258 + 2773109 commit bd97b58
Show file tree
Hide file tree
Showing 18 changed files with 202 additions and 28 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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ require (
github.com/klauspost/compress v1.14.1
github.com/klauspost/pgzip v1.2.5
github.com/manifoldco/promptui v0.9.0
github.com/mtrmac/gpgme v0.1.2
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.0.3-0.20211202193544-a5463b7f9c84
github.com/opencontainers/selinux v1.10.0
github.com/ostreedev/ostree-go v0.0.0-20190702140239-759a8c1ac913
github.com/pkg/errors v0.9.1
github.com/proglottis/gpgme v0.1.1
github.com/sirupsen/logrus v1.8.1
github.com/stretchr/testify v1.7.0
github.com/sylabs/sif/v2 v2.3.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -670,8 +670,6 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
github.com/mtrmac/gpgme v0.1.2 h1:dNOmvYmsrakgW7LcgiprD0yfRuQQe8/C8F6Z+zogO3s=
github.com/mtrmac/gpgme v0.1.2/go.mod h1:GYYHnGSuS7HK3zVS2n3y73y0okK/BeKzwnn5jgiVFNI=
github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
Expand Down Expand Up @@ -748,6 +746,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/pquerna/cachecontrol v0.0.0-20171018203845-0dec1b30a021/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
github.com/proglottis/gpgme v0.1.1 h1:72xI0pt/hy7pqsRxk32KExITkXp+RZErRizsA+up/lQ=
github.com/proglottis/gpgme v0.1.1/go.mod h1:fPbW/EZ0LvwQtH8Hy7eixhp1eF3G39dtx7GUN+0Gmy0=
github.com/prometheus/client_golang v0.0.0-20180209125602-c332b6f63c06/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
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
37 changes: 32 additions & 5 deletions signature/mechanism_gpgme.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ package signature

import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"os"

"github.com/mtrmac/gpgme"
"github.com/proglottis/gpgme"
)

// A GPG/OpenPGP signing mechanism, implemented using gpgme.
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
Loading

0 comments on commit bd97b58

Please sign in to comment.