Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GPGME: support passphrase for prompt-less signing #1446

Merged
merged 2 commits into from
Jan 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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")
mtrmac marked this conversation as resolved.
Show resolved Hide resolved
}
}

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"})
vrothberg marked this conversation as resolved.
Show resolved Hide resolved
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)
mtrmac marked this conversation as resolved.
Show resolved Hide resolved

// 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 {
vrothberg marked this conversation as resolved.
Show resolved Hide resolved
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