Skip to content

Commit 23bd9dc

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

27 files changed

+431
-8
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+
sqOpts := []simplesequoia.Option{
431+
simplesequoia.WithKeyFingerprint(opts.signBySequoiaFingerprint),
432+
}
433+
if passphrase != "" {
434+
sqOpts = append(sqOpts, simplesequoia.WithPassphrase(passphrase))
435+
}
436+
signer, err := simplesequoia.NewSigner(sqOpts...)
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: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"errors"
66
"os"
7+
"slices"
78
"testing"
89

910
"github.com/containers/image/v5/copy"
@@ -378,6 +379,7 @@ func TestSharedCopyOptionsCopyOptions(t *testing.T) {
378379
// Set most flags to non-default values
379380
// This should also test --sign-by-sigstore and --sign-by-sigstore-private-key; we would have
380381
// to create test keys for that.
382+
// This does not test --sign-by-sq-fingerprint, because that needs to be conditional based on buildWithSequoia.
381383
opts = fakeSharedCopyOptions(t, []string{
382384
"--remove-signatures",
383385
"--sign-by", "gpgFingerprint",
@@ -395,12 +397,13 @@ func TestSharedCopyOptionsCopyOptions(t *testing.T) {
395397
ForceManifestMIMEType: imgspecv1.MediaTypeImageManifest,
396398
}, res)
397399

398-
// --sign-passphrase-file + --sign-by work
400+
// --sign-passphrase-file:
399401
passphraseFile, err := os.CreateTemp("", "passphrase") // Eventually we could refer to a passphrase fixture instead
400402
require.NoError(t, err)
401403
defer os.Remove(passphraseFile.Name())
402404
_, err = passphraseFile.WriteString("test-passphrase")
403405
require.NoError(t, err)
406+
// --sign-passphrase-file + --sign-by work
404407
opts = fakeSharedCopyOptions(t, []string{
405408
"--sign-by", "gpgFingerprint",
406409
"--sign-passphrase-file", passphraseFile.Name(),
@@ -414,14 +417,42 @@ func TestSharedCopyOptionsCopyOptions(t *testing.T) {
414417
SignSigstorePrivateKeyPassphrase: []byte("test-passphrase"),
415418
ReportWriter: &someStdout,
416419
}, res)
417-
// --sign-passphrase-file + --sign-by-sigstore-private-key should be tested here.
420+
// If Sequoia is supported, --sign-passphrase-file + --sign-by-sq-fingerprint work
421+
if buildWithSequoia {
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

424-
// More --sign-passphrase-file, --sign-by-sigstore-private-key, --sign-by-sigstore failure cases should be tested here.
443+
// More --sign-by-sigstore-private-key, --sign-by-sigstore failure cases should be tested here.
444+
// --sign-passphrase-file + more than one key option
445+
for _, opts := range [][]string{
446+
{"--sign-by", "gpgFingerprint", "--sign-by-sq-fingerprint", "sqFingerprint"},
447+
{"--sign-by", "gpgFingerprint", "--sign-by-sigstore-private-key", "sigstorePrivateKey"},
448+
{"--sign-by-sq-fingerprint", "sqFingerprint", "--sign-by-sigstore-private-key", "sigstorePrivateKey"},
449+
} {
450+
opts := fakeSharedCopyOptions(t, slices.Concat(opts, []string{
451+
"--sign-passphrase-file", passphraseFile.Name(),
452+
}))
453+
_, _, err = opts.copyOptions(&someStdout)
454+
assert.Error(t, err)
455+
}
425456

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

docs/skopeo-copy.1.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,14 @@ 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** _fingerprint_
111+
112+
Add a “simple signing” signature using a Sequoia-PGP key with the specified _fingerprint_.
113+
110114
**--sign-passphrase-file** _path_
111115

112-
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.
116+
The passphrase to use when signing with `--sign-by`, `--sign-by-sigstore-private-key` or `--sign-by-sq-fingerprint`.
117+
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.
113118

114119
**--sign-identity** _reference_
115120

docs/skopeo-sync.1.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,14 @@ 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** _fingerprint_
107+
108+
Add a “simple signing” signature using a Sequoia-PGP key with the specified _fingerprint_.
109+
106110
**--sign-passphrase-file** _path_
107111

108-
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.
112+
The passphrase to use when signing with `--sign-by`, `--sign-by-sigstore-private-key` or `--sign-by-sq-fingerprint`.
113+
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.
109114

110115
**--src-creds** _username[:password]_ for accessing the source registry.
111116

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.
2.12 KB
Binary file not shown.

0 commit comments

Comments
 (0)