Skip to content

Commit f0d6d0c

Browse files
committed
Add --sign-by-sq-fingerprint and an integration test
Signed-off-by: Miloslav Trmač <[email protected]>
1 parent 75b3602 commit f0d6d0c

27 files changed

+420
-5
lines changed

cmd/skopeo/utils.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/containers/image/v5/pkg/cli/sigstore"
2121
"github.com/containers/image/v5/pkg/compression"
2222
"github.com/containers/image/v5/signature/signer"
23+
"github.com/containers/image/v5/signature/simplesequoia"
2324
"github.com/containers/image/v5/storage"
2425
"github.com/containers/image/v5/transports/alltransports"
2526
"github.com/containers/image/v5/types"
@@ -327,6 +328,7 @@ func (opts *imageDestOptions) warnAboutIneffectiveOptions(destTransport types.Im
327328
type sharedCopyOptions struct {
328329
removeSignatures bool // Do not copy signatures from the source image
329330
signByFingerprint string // Sign the image using a GPG key with the specified fingerprint
331+
signBySequoiaFingerprint string // Sign the image using a Sequoia-PGP key with the specified fingerprint
330332
signBySigstoreParamFile string // Sign the image using a sigstore signature per configuration in a param file
331333
signBySigstorePrivateKey string // Sign the image using a sigstore private key
332334
signPassphraseFile string // Path pointing to a passphrase file when signing
@@ -340,6 +342,7 @@ func sharedCopyFlags() (pflag.FlagSet, *sharedCopyOptions) {
340342
fs := pflag.FlagSet{}
341343
fs.BoolVar(&opts.removeSignatures, "remove-signatures", false, "Do not copy signatures from source")
342344
fs.StringVar(&opts.signByFingerprint, "sign-by", "", "Sign the image using a GPG key with the specified `FINGERPRINT`")
345+
fs.StringVar(&opts.signBySequoiaFingerprint, "sign-by-sq-fingerprint", "", "Sign the image using a Sequoia-PGP key with the specified `FINGERPRINT`")
343346
fs.StringVar(&opts.signBySigstoreParamFile, "sign-by-sigstore", "", "Sign the image using a sigstore parameter file at `PATH`")
344347
fs.StringVar(&opts.signBySigstorePrivateKey, "sign-by-sigstore-private-key", "", "Sign the image using a sigstore private key at `PATH`")
345348
fs.StringVar(&opts.signPassphraseFile, "sign-passphrase-file", "", "Read a passphrase for signing an image from `PATH`")
@@ -363,8 +366,20 @@ func (opts *sharedCopyOptions) copyOptions(stdout io.Writer) (*copy.Options, fun
363366
// c/image/copy.Image does allow creating both simple signing and sigstore signatures simultaneously,
364367
// with independent passphrases, but that would make the CLI probably too confusing.
365368
// For now, use the passphrase with either, but only one of them.
366-
if opts.signPassphraseFile != "" && opts.signByFingerprint != "" && opts.signBySigstorePrivateKey != "" {
367-
return nil, nil, fmt.Errorf("Only one of --sign-by and sign-by-sigstore-private-key can be used with sign-passphrase-file")
369+
if opts.signPassphraseFile != "" {
370+
count := 0
371+
if opts.signByFingerprint != "" {
372+
count++
373+
}
374+
if opts.signBySequoiaFingerprint != "" {
375+
count++
376+
}
377+
if opts.signBySigstorePrivateKey != "" {
378+
count++
379+
}
380+
if count > 1 {
381+
return nil, nil, fmt.Errorf("Only one of --sign-by, --sign-by-sq-fingerprint and --sign-by-sigstore-private-key can be used with --sign-passphrase-file")
382+
}
368383
}
369384
var passphrase string
370385
if opts.signPassphraseFile != "" {
@@ -380,6 +395,7 @@ func (opts *sharedCopyOptions) copyOptions(stdout io.Writer) (*copy.Options, fun
380395
}
381396
passphrase = p
382397
} // opts.signByFingerprint triggers a GPG-agent passphrase prompt, possibly using a more secure channel, so we usually shouldn’t prompt ourselves if no passphrase was explicitly provided.
398+
// With opts.signBySequoiaFingerprint, we don’t prompt for a passphrase (for now??): We don’t know whether the key requires a passphrase.
383399
var passphraseBytes []byte
384400
if passphrase != "" {
385401
passphraseBytes = []byte(passphrase)
@@ -410,6 +426,19 @@ func (opts *sharedCopyOptions) copyOptions(stdout io.Writer) (*copy.Options, fun
410426
}
411427
signers = append(signers, signer)
412428
}
429+
if opts.signBySequoiaFingerprint != "" {
430+
opts := []simplesequoia.Option{
431+
simplesequoia.WithKeyFingerprint(opts.signBySequoiaFingerprint),
432+
}
433+
if passphrase != "" {
434+
opts = append(opts, simplesequoia.WithPassphrase(passphrase))
435+
}
436+
signer, err := simplesequoia.NewSigner(opts...)
437+
if err != nil {
438+
return nil, nil, fmt.Errorf("Error using --sign-by-sq-fingerprint: %w", err)
439+
}
440+
signers = append(signers, signer)
441+
}
413442

414443
succeeded = true
415444
return &copy.Options{

cmd/skopeo/utils_nosequoia_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//go:build !containers_image_sequoia
2+
3+
package main
4+
5+
const buildWithSequoia = false

cmd/skopeo/utils_sequoia_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
//go:build containers_image_sequoia
2+
3+
package main
4+
5+
const buildWithSequoia = true

cmd/skopeo/utils_test.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,7 @@ func TestSharedCopyOptionsCopyOptions(t *testing.T) {
378378
// Set most flags to non-default values
379379
// This should also test --sign-by-sigstore and --sign-by-sigstore-private-key; we would have
380380
// to create test keys for that.
381+
// This does not test --sign-by-sq-fingerprint, because that needs to be conditional based on buildWithSequoia.
381382
opts = fakeSharedCopyOptions(t, []string{
382383
"--remove-signatures",
383384
"--sign-by", "gpgFingerprint",
@@ -395,12 +396,13 @@ func TestSharedCopyOptionsCopyOptions(t *testing.T) {
395396
ForceManifestMIMEType: imgspecv1.MediaTypeImageManifest,
396397
}, res)
397398

398-
// --sign-passphrase-file + --sign-by work
399+
// --sign-passphrase-file:
399400
passphraseFile, err := os.CreateTemp("", "passphrase") // Eventually we could refer to a passphrase fixture instead
400401
require.NoError(t, err)
401402
defer os.Remove(passphraseFile.Name())
402403
_, err = passphraseFile.WriteString("test-passphrase")
403404
require.NoError(t, err)
405+
// --sign-passphrase-file + --sign-by work
404406
opts = fakeSharedCopyOptions(t, []string{
405407
"--sign-by", "gpgFingerprint",
406408
"--sign-passphrase-file", passphraseFile.Name(),
@@ -414,14 +416,39 @@ func TestSharedCopyOptionsCopyOptions(t *testing.T) {
414416
SignSigstorePrivateKeyPassphrase: []byte("test-passphrase"),
415417
ReportWriter: &someStdout,
416418
}, res)
417-
// --sign-passphrase-file + --sign-by-sigstore-private-key should be tested here.
419+
// If Sequoia is supported, --sign-passphrase-file + --sign-by-sq-fingerprint work
420+
if buildWithSequoia {
421+
// --sign-passphrase-file + --sign-by-sq-fingerprint work
422+
opts = fakeSharedCopyOptions(t, []string{
423+
"--sign-by-sq-fingerprint", "sqFingerprint",
424+
"--sign-passphrase-file", passphraseFile.Name(),
425+
})
426+
res, cleanup, err = opts.copyOptions(&someStdout)
427+
require.NoError(t, err)
428+
defer cleanup()
429+
assert.NotNil(t, res.Signers) // Contains a Sequoia signer
430+
res.Signers = nil // To allow the comparison below
431+
assert.Equal(t, &copy.Options{
432+
SignPassphrase: "test-passphrase",
433+
SignSigstorePrivateKeyPassphrase: []byte("test-passphrase"),
434+
ReportWriter: &someStdout,
435+
}, res)
436+
}
418437

419438
// Invalid --format
420439
opts = fakeSharedCopyOptions(t, []string{"--format", "invalid"})
421440
_, _, err = opts.copyOptions(&someStdout)
422441
assert.Error(t, err)
423442

424443
// More --sign-passphrase-file, --sign-by-sigstore-private-key, --sign-by-sigstore failure cases should be tested here.
444+
// --sign-passphrase-file + several key options (other combinations should be tested here)
445+
opts = fakeSharedCopyOptions(t, []string{
446+
"--sign-by", "gpgFingerprint",
447+
"--sign-by-sq-fingerprint", "sqFingerprint",
448+
"--sign-passphrase-file", passphraseFile.Name(),
449+
})
450+
_, _, err = opts.copyOptions(&someStdout)
451+
assert.Error(t, err)
425452

426453
// --sign-passphrase-file not found
427454
opts = fakeSharedCopyOptions(t, []string{

docs/skopeo-copy.1.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ See containers-sigstore-signing-params.yaml(5) for details about the file format
107107

108108
Add a sigstore signature using a private key at _path_ for an image name corresponding to _destination-image_
109109

110+
**--sign-by-sq-fingerprint** _path_
111+
112+
Add a “simple signing” signature using a Sequoia-PGP key with the specified _fingerprint_.
113+
110114
**--sign-passphrase-file** _path_
111115

112116
The passphare to use when signing with `--sign-by` or `--sign-by-sigstore-private-key`. Only the first line will be read. A passphrase stored in a file is of questionable security if other users can read this file. Do not use this option if at all avoidable.

docs/skopeo-sync.1.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ See containers-sigstore-signing-params.yaml(5) for details about the file format
103103

104104
Add a sigstore signature using a private key at _path_ for an image name corresponding to _destination-image_
105105

106+
**--sign-by-sq-fingerprint** _path_
107+
108+
Add a “simple signing” signature using a Sequoia-PGP key with the specified _fingerprint_.
109+
106110
**--sign-passphrase-file** _path_
107111

108112
The passphare to use when signing with `--sign-by` or `--sign-by-sigstore-private-key`. Only the first line will be read. A passphrase stored in a file is of questionable security if other users can read this file. Do not use this option if at all avoidable.

integration/copy_test.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
"github.com/containers/image/v5/manifest"
2020
"github.com/containers/image/v5/signature"
21+
"github.com/containers/image/v5/signature/simplesequoia"
2122
"github.com/containers/image/v5/types"
2223
digest "github.com/opencontainers/go-digest"
2324
imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
@@ -106,7 +107,9 @@ func (s *copySuite) TearDownSuite() {
106107
// and returns a path to a policy, which will be automatically removed when the test completes.
107108
func (s *copySuite) policyFixture(extraSubstitutions map[string]string) string {
108109
t := s.T()
109-
edits := map[string]string{"@keydir@": s.gpgHome}
110+
fixtureDir, err := filepath.Abs("fixtures")
111+
require.NoError(t, err)
112+
edits := map[string]string{"@keydir@": s.gpgHome, "@fixturedir@": fixtureDir}
110113
maps.Copy(edits, extraSubstitutions)
111114
policyPath := fileFromFixture(t, "fixtures/policy.json", edits)
112115
return policyPath
@@ -846,6 +849,39 @@ func (s *copySuite) TestCopyDirSignatures() {
846849
"--policy", policy, "copy", topDirDest+"/restricted/badidentity", topDirDest+"/dest")
847850
}
848851

852+
func (s *copySuite) TestCopySequoiaSignatures() {
853+
t := s.T()
854+
signer, err := simplesequoia.NewSigner(simplesequoia.WithSequoiaHome(testSequoiaHome), simplesequoia.WithKeyFingerprint(testSequoiaKeyFingerprint))
855+
if err != nil {
856+
t.Skipf("Sequoia not supported: %v", err)
857+
}
858+
signer.Close()
859+
860+
const ourRegistry = "docker://" + v2DockerRegistryURL + "/"
861+
862+
dirDest := "dir:" + t.TempDir()
863+
864+
policy := s.policyFixture(nil)
865+
registriesDir := t.TempDir()
866+
registriesFile := fileFromFixture(t, "fixtures/registries.yaml",
867+
map[string]string{"@lookaside@": t.TempDir(), "@split-staging@": "/var/empty", "@split-read@": "file://var/empty"})
868+
err = os.Symlink(registriesFile, filepath.Join(registriesDir, "registries.yaml"))
869+
require.NoError(t, err)
870+
871+
// Sign the images
872+
absSequoiaHome, err := filepath.Abs(testSequoiaHome)
873+
require.NoError(t, err)
874+
t.Setenv("SEQUOIA_HOME", absSequoiaHome)
875+
assertSkopeoSucceeds(t, "", "copy", "--retry-times", "3", "--dest-tls-verify=false", "--sign-by-sq-fingerprint", testSequoiaKeyFingerprint,
876+
testFQIN+":1.26", ourRegistry+"sequoia-no-passphrase")
877+
assertSkopeoSucceeds(t, "", "copy", "--retry-times", "3", "--dest-tls-verify=false", "--sign-by-sq-fingerprint", testSequoiaKeyFingerprintWithPassphrase,
878+
"--sign-passphrase-file", filepath.Join(absSequoiaHome, "with-passphrase.passphrase"),
879+
testFQIN+":1.26.1", ourRegistry+"sequoia-with-passphrase")
880+
// Verify that we can pull them
881+
assertSkopeoSucceeds(t, "", "--policy", policy, "copy", "--src-tls-verify=false", ourRegistry+"sequoia-no-passphrase", dirDest)
882+
assertSkopeoSucceeds(t, "", "--policy", policy, "copy", "--src-tls-verify=false", ourRegistry+"sequoia-with-passphrase", dirDest)
883+
}
884+
849885
// Compression during copy
850886
func (s *copySuite) TestCopyCompression() {
851887
t := s.T()

integration/fixtures/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/data/pgp.cert.d/_sequoia*

integration/fixtures/data/keystore/keystore.cookie

Whitespace-only changes.

0 commit comments

Comments
 (0)