diff --git a/Makefile b/Makefile index b1fccef..e27258f 100644 --- a/Makefile +++ b/Makefile @@ -24,7 +24,7 @@ run: ./bin/mfer ci: test test: $(SOURCEFILES) mfer/mf.pb.go - go test -v --timeout 3s ./... + go test -v --timeout 10s ./... $(PROTOC_GEN_GO): test -e $(PROTOC_GEN_GO) || go install -v google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1 diff --git a/internal/cli/freshen.go b/internal/cli/freshen.go index ac4c96b..61f7a86 100644 --- a/internal/cli/freshen.go +++ b/internal/cli/freshen.go @@ -227,6 +227,14 @@ func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error { builder := mfer.NewBuilder() + // Set up signing options if sign-key is provided + if signKey := ctx.String("sign-key"); signKey != "" { + builder.SetSigningOptions(&mfer.SigningOptions{ + KeyID: mfer.GPGKeyID(signKey), + }) + log.Infof("signing manifest with GPG key: %s", signKey) + } + for _, e := range entries { select { case <-ctx.Done(): diff --git a/internal/cli/gen.go b/internal/cli/gen.go index 15d8633..6908c0f 100644 --- a/internal/cli/gen.go +++ b/internal/cli/gen.go @@ -25,6 +25,14 @@ func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error { Fs: mfa.Fs, } + // Set up signing options if sign-key is provided + if signKey := ctx.String("sign-key"); signKey != "" { + opts.SigningOptions = &mfer.SigningOptions{ + KeyID: mfer.GPGKeyID(signKey), + } + log.Infof("signing manifest with GPG key: %s", signKey) + } + s := mfer.NewScannerWithOptions(opts) // Phase 1: Enumeration - collect paths and stat files diff --git a/internal/cli/mfer.go b/internal/cli/mfer.go index d72f55d..2be21ba 100644 --- a/internal/cli/mfer.go +++ b/internal/cli/mfer.go @@ -148,6 +148,12 @@ func (mfa *CLIApp) run(args []string) { Aliases: []string{"P"}, Usage: "Show progress during enumeration and scanning", }, + &cli.StringFlag{ + Name: "sign-key", + Aliases: []string{"s"}, + Usage: "GPG key ID to sign the manifest with", + EnvVars: []string{"MFER_SIGN_KEY"}, + }, ), }, { @@ -208,6 +214,12 @@ func (mfa *CLIApp) run(args []string) { Aliases: []string{"P"}, Usage: "Show progress during scanning and hashing", }, + &cli.StringFlag{ + Name: "sign-key", + Aliases: []string{"s"}, + Usage: "GPG key ID to sign the manifest with", + EnvVars: []string{"MFER_SIGN_KEY"}, + }, ), }, { diff --git a/mfer/builder.go b/mfer/builder.go index df5eca2..22d4d4a 100644 --- a/mfer/builder.go +++ b/mfer/builder.go @@ -50,9 +50,10 @@ type FileHashProgress struct { // Builder constructs a manifest by adding files one at a time. type Builder struct { - mu sync.Mutex - files []*MFFilePath - createdAt time.Time + mu sync.Mutex + files []*MFFilePath + createdAt time.Time + signingOptions *SigningOptions } // NewBuilder creates a new Builder. @@ -165,6 +166,14 @@ func (b *Builder) AddFileWithHash(path RelFilePath, size FileSize, mtime ModTime return nil } +// SetSigningOptions sets the GPG signing options for the manifest. +// If opts is non-nil, the manifest will be signed when Build() is called. +func (b *Builder) SetSigningOptions(opts *SigningOptions) { + b.mu.Lock() + defer b.mu.Unlock() + b.signingOptions = opts +} + // Build finalizes the manifest and writes it to the writer. func (b *Builder) Build(w io.Writer) error { b.mu.Lock() @@ -179,7 +188,8 @@ func (b *Builder) Build(w io.Writer) error { // Create a temporary manifest to use existing serialization m := &manifest{ - pbInner: inner, + pbInner: inner, + signingOptions: b.signingOptions, } // Generate outer wrapper diff --git a/mfer/gpg.go b/mfer/gpg.go new file mode 100644 index 0000000..8c2e02e --- /dev/null +++ b/mfer/gpg.go @@ -0,0 +1,90 @@ +package mfer + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +// GPGKeyID represents a GPG key identifier (fingerprint or key ID). +type GPGKeyID string + +// SigningOptions contains options for GPG signing. +type SigningOptions struct { + KeyID GPGKeyID +} + +// gpgSign creates a detached signature of the data using the specified key. +// Returns the armored detached signature. +func gpgSign(data []byte, keyID GPGKeyID) ([]byte, error) { + cmd := exec.Command("gpg", + "--detach-sign", + "--armor", + "--local-user", string(keyID), + ) + + cmd.Stdin = bytes.NewReader(data) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String()) + } + + return stdout.Bytes(), nil +} + +// gpgExportPublicKey exports the public key for the specified key ID. +// Returns the armored public key. +func gpgExportPublicKey(keyID GPGKeyID) ([]byte, error) { + cmd := exec.Command("gpg", + "--export", + "--armor", + string(keyID), + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("gpg export failed: %w: %s", err, stderr.String()) + } + + if stdout.Len() == 0 { + return nil, fmt.Errorf("gpg key not found: %s", keyID) + } + + return stdout.Bytes(), nil +} + +// gpgGetKeyFingerprint gets the full fingerprint for a key ID. +func gpgGetKeyFingerprint(keyID GPGKeyID) ([]byte, error) { + cmd := exec.Command("gpg", + "--with-colons", + "--fingerprint", + string(keyID), + ) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("gpg fingerprint lookup failed: %w: %s", err, stderr.String()) + } + + // Parse the colon-delimited output to find the fingerprint + lines := strings.Split(stdout.String(), "\n") + for _, line := range lines { + fields := strings.Split(line, ":") + if len(fields) >= 10 && fields[0] == "fpr" { + return []byte(fields[9]), nil + } + } + + return nil, fmt.Errorf("fingerprint not found for key: %s", keyID) +} diff --git a/mfer/gpg_test.go b/mfer/gpg_test.go new file mode 100644 index 0000000..1f448c6 --- /dev/null +++ b/mfer/gpg_test.go @@ -0,0 +1,236 @@ +package mfer + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testGPGEnv sets up a temporary GPG home directory with a test key. +// Returns the key ID and a cleanup function. +func testGPGEnv(t *testing.T) (GPGKeyID, func()) { + t.Helper() + + // Check if gpg is installed + if _, err := exec.LookPath("gpg"); err != nil { + t.Skip("gpg not installed, skipping signing test") + return "", func() {} + } + + // Create temporary GPG home directory + gpgHome, err := os.MkdirTemp("", "mfer-gpg-test-*") + require.NoError(t, err) + + // Set restrictive permissions on GPG home + require.NoError(t, os.Chmod(gpgHome, 0o700)) + + // Save original GNUPGHOME and set new one + origGPGHome := os.Getenv("GNUPGHOME") + os.Setenv("GNUPGHOME", gpgHome) + + cleanup := func() { + if origGPGHome == "" { + os.Unsetenv("GNUPGHOME") + } else { + os.Setenv("GNUPGHOME", origGPGHome) + } + os.RemoveAll(gpgHome) + } + + // Generate a test key with no passphrase + keyParams := `%no-protection +Key-Type: RSA +Key-Length: 2048 +Name-Real: MFER Test Key +Name-Email: test@mfer.test +Expire-Date: 0 +%commit +` + paramsFile := filepath.Join(gpgHome, "key-params") + require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 0o600)) + + cmd := exec.Command("gpg", "--batch", "--gen-key", paramsFile) + cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome) + output, err := cmd.CombinedOutput() + if err != nil { + cleanup() + t.Skipf("failed to generate test GPG key: %v: %s", err, output) + return "", func() {} + } + + // Get the key fingerprint + cmd = exec.Command("gpg", "--list-keys", "--with-colons", "test@mfer.test") + cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome) + output, err = cmd.Output() + if err != nil { + cleanup() + t.Fatalf("failed to list test key: %v", err) + } + + // Parse fingerprint from output + var keyID string + for _, line := range strings.Split(string(output), "\n") { + fields := strings.Split(line, ":") + if len(fields) >= 10 && fields[0] == "fpr" { + keyID = fields[9] + break + } + } + + if keyID == "" { + cleanup() + t.Fatal("failed to find test key fingerprint") + } + + return GPGKeyID(keyID), cleanup +} + +func TestGPGSign(t *testing.T) { + keyID, cleanup := testGPGEnv(t) + defer cleanup() + + data := []byte("test data to sign") + sig, err := gpgSign(data, keyID) + require.NoError(t, err) + assert.NotEmpty(t, sig) + assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----") + assert.Contains(t, string(sig), "-----END PGP SIGNATURE-----") +} + +func TestGPGExportPublicKey(t *testing.T) { + keyID, cleanup := testGPGEnv(t) + defer cleanup() + + pubKey, err := gpgExportPublicKey(keyID) + require.NoError(t, err) + assert.NotEmpty(t, pubKey) + assert.Contains(t, string(pubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----") + assert.Contains(t, string(pubKey), "-----END PGP PUBLIC KEY BLOCK-----") +} + +func TestGPGGetKeyFingerprint(t *testing.T) { + keyID, cleanup := testGPGEnv(t) + defer cleanup() + + fingerprint, err := gpgGetKeyFingerprint(keyID) + require.NoError(t, err) + assert.NotEmpty(t, fingerprint) + // The fingerprint should be 40 hex chars + assert.Len(t, fingerprint, 40, "fingerprint should be 40 hex chars") +} + +func TestGPGSignInvalidKey(t *testing.T) { + // Set up test environment (we need GNUPGHOME set) + _, cleanup := testGPGEnv(t) + defer cleanup() + + data := []byte("test data") + _, err := gpgSign(data, GPGKeyID("NONEXISTENT_KEY_ID_12345")) + assert.Error(t, err) +} + +func TestBuilderWithSigning(t *testing.T) { + keyID, cleanup := testGPGEnv(t) + defer cleanup() + + // Create a builder with signing options + b := NewBuilder() + b.SetSigningOptions(&SigningOptions{ + KeyID: keyID, + }) + + // Add a test file + content := []byte("test file content") + reader := bytes.NewReader(content) + _, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil) + require.NoError(t, err) + + // Build the manifest + var buf bytes.Buffer + err = b.Build(&buf) + require.NoError(t, err) + + // Parse the manifest and verify signature fields are populated + manifest, err := NewManifestFromReader(&buf) + require.NoError(t, err) + require.NotNil(t, manifest.pbOuter) + + assert.NotEmpty(t, manifest.pbOuter.Signature, "signature should be populated") + assert.NotEmpty(t, manifest.pbOuter.Signer, "signer should be populated") + assert.NotEmpty(t, manifest.pbOuter.SigningPubKey, "signing public key should be populated") + + // Verify signature is a valid PGP signature + assert.Contains(t, string(manifest.pbOuter.Signature), "-----BEGIN PGP SIGNATURE-----") + + // Verify public key is a valid PGP public key block + assert.Contains(t, string(manifest.pbOuter.SigningPubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----") +} + +func TestScannerWithSigning(t *testing.T) { + keyID, cleanup := testGPGEnv(t) + defer cleanup() + + // Create in-memory filesystem with test files + fs := afero.NewMemMapFs() + require.NoError(t, fs.MkdirAll("/testdir", 0o755)) + require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644)) + require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644)) + + // Create scanner with signing options + opts := &ScannerOptions{ + Fs: fs, + SigningOptions: &SigningOptions{ + KeyID: keyID, + }, + } + s := NewScannerWithOptions(opts) + + // Enumerate files + require.NoError(t, s.EnumeratePath("/testdir", nil)) + assert.Equal(t, FileCount(2), s.FileCount()) + + // Generate signed manifest + var buf bytes.Buffer + require.NoError(t, s.ToManifest(context.Background(), &buf, nil)) + + // Parse and verify + manifest, err := NewManifestFromReader(&buf) + require.NoError(t, err) + + assert.NotEmpty(t, manifest.pbOuter.Signature) + assert.NotEmpty(t, manifest.pbOuter.Signer) + assert.NotEmpty(t, manifest.pbOuter.SigningPubKey) +} + +func TestBuilderWithoutSigning(t *testing.T) { + // Create a builder without signing options + b := NewBuilder() + + // Add a test file + content := []byte("test file content") + reader := bytes.NewReader(content) + _, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil) + require.NoError(t, err) + + // Build the manifest + var buf bytes.Buffer + err = b.Build(&buf) + require.NoError(t, err) + + // Parse the manifest and verify signature fields are empty + manifest, err := NewManifestFromReader(&buf) + require.NoError(t, err) + require.NotNil(t, manifest.pbOuter) + + assert.Empty(t, manifest.pbOuter.Signature, "signature should be empty when not signing") + assert.Empty(t, manifest.pbOuter.Signer, "signer should be empty when not signing") + assert.Empty(t, manifest.pbOuter.SigningPubKey, "signing public key should be empty when not signing") +} diff --git a/mfer/manifest.go b/mfer/manifest.go index 00f4b9e..9792c13 100644 --- a/mfer/manifest.go +++ b/mfer/manifest.go @@ -9,9 +9,10 @@ import ( // Use NewManifestFromFile or NewManifestFromReader to load an existing manifest, // or use Builder to create a new one. type manifest struct { - pbInner *MFFile - pbOuter *MFFileOuter - output *bytes.Buffer + pbInner *MFFile + pbOuter *MFFileOuter + output *bytes.Buffer + signingOptions *SigningOptions } func (m *manifest) String() string { diff --git a/mfer/scanner.go b/mfer/scanner.go index 645b685..df0df11 100644 --- a/mfer/scanner.go +++ b/mfer/scanner.go @@ -43,9 +43,10 @@ type ScanStatus struct { // ScannerOptions configures scanner behavior. type ScannerOptions struct { - IncludeDotfiles bool // Include files and directories starting with a dot (default: exclude) - FollowSymLinks bool // Resolve symlinks instead of skipping them - Fs afero.Fs // Filesystem to use, defaults to OsFs if nil + IncludeDotfiles bool // Include files and directories starting with a dot (default: exclude) + FollowSymLinks bool // Resolve symlinks instead of skipping them + Fs afero.Fs // Filesystem to use, defaults to OsFs if nil + SigningOptions *SigningOptions // GPG signing options (nil = no signing) } // FileEntry represents a file that has been enumerated. @@ -272,6 +273,9 @@ func (s *Scanner) ToManifest(ctx context.Context, w io.Writer, progress chan<- S s.mu.RUnlock() builder := NewBuilder() + if s.options.SigningOptions != nil { + builder.SetSigningOptions(s.options.SigningOptions) + } var scannedFiles FileCount var scannedBytes FileSize diff --git a/mfer/serialize.go b/mfer/serialize.go index fee0bfe..0a391c1 100644 --- a/mfer/serialize.go +++ b/mfer/serialize.go @@ -4,9 +4,11 @@ import ( "bytes" "crypto/sha256" "errors" + "fmt" "time" "github.com/klauspost/compress/zstd" + "github.com/multiformats/go-multihash" "google.golang.org/protobuf/proto" ) @@ -53,7 +55,10 @@ func (m *manifest) generateOuter() error { } h := sha256.New() - h.Write(innerData) + if _, err := h.Write(innerData); err != nil { + return err + } + sha256Hash := h.Sum(nil) idc := new(bytes.Buffer) zw, err := zstd.NewWriter(idc, zstd.WithEncoderLevel(zstd.SpeedBestCompression)) @@ -70,10 +75,38 @@ func (m *manifest) generateOuter() error { o := &MFFileOuter{ InnerMessage: idc.Bytes(), Size: int64(len(innerData)), - Sha256: h.Sum(nil), + Sha256: sha256Hash, Version: MFFileOuter_VERSION_ONE, CompressionType: MFFileOuter_COMPRESSION_ZSTD, } + + // Sign the manifest if signing options are provided + if m.signingOptions != nil && m.signingOptions.KeyID != "" { + // Encode hash as multihash for signing + mh, err := multihash.Encode(sha256Hash, multihash.SHA2_256) + if err != nil { + return fmt.Errorf("failed to encode multihash: %w", err) + } + + sig, err := gpgSign(mh, m.signingOptions.KeyID) + if err != nil { + return fmt.Errorf("failed to sign manifest: %w", err) + } + o.Signature = sig + + fingerprint, err := gpgGetKeyFingerprint(m.signingOptions.KeyID) + if err != nil { + return fmt.Errorf("failed to get key fingerprint: %w", err) + } + o.Signer = fingerprint + + pubKey, err := gpgExportPublicKey(m.signingOptions.KeyID) + if err != nil { + return fmt.Errorf("failed to export public key: %w", err) + } + o.SigningPubKey = pubKey + } + m.pbOuter = o return nil }