diff --git a/internal/cli/check.go b/internal/cli/check.go index d10cd51..6514dbd 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "path/filepath" + "strings" "time" "github.com/dustin/go-humanize" @@ -68,6 +69,24 @@ func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error { return fmt.Errorf("failed to load manifest: %w", err) } + // Check signature requirement + requiredSigner := ctx.String("require-signature") + if requiredSigner != "" { + if !chk.IsSigned() { + return fmt.Errorf("manifest is not signed, but signature from %s is required", requiredSigner) + } + signer := chk.Signer() + if signer == nil { + return fmt.Errorf("manifest signature has no signer fingerprint") + } + // Compare signer - the required key ID might be a suffix of the full fingerprint + signerStr := string(signer) + if !strings.EqualFold(signerStr, requiredSigner) && !strings.HasSuffix(strings.ToUpper(signerStr), strings.ToUpper(requiredSigner)) { + return fmt.Errorf("manifest signed by %s, but %s is required", signerStr, requiredSigner) + } + log.Infof("manifest signature verified (signer: %s)", signerStr) + } + log.Infof("manifest contains %d files, %s", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes()))) // Set up results channel diff --git a/internal/cli/mfer.go b/internal/cli/mfer.go index 2be21ba..e99dd7e 100644 --- a/internal/cli/mfer.go +++ b/internal/cli/mfer.go @@ -181,6 +181,12 @@ func (mfa *CLIApp) run(args []string) { Name: "no-extra-files", Usage: "Fail if files exist in base directory that are not in manifest", }, + &cli.StringFlag{ + Name: "require-signature", + Aliases: []string{"S"}, + Usage: "Require manifest to be signed by the specified GPG key ID", + EnvVars: []string{"MFER_REQUIRE_SIGNATURE"}, + }, ), }, { diff --git a/mfer/checker.go b/mfer/checker.go index 147638f..13f0ac2 100644 --- a/mfer/checker.go +++ b/mfer/checker.go @@ -70,6 +70,10 @@ type Checker struct { fs afero.Fs // manifestPaths is a set of paths in the manifest for quick lookup manifestPaths map[RelFilePath]struct{} + // signature info from the manifest + signature []byte + signer []byte + signingPubKey []byte } // NewChecker creates a new Checker for the given manifest, base path, and filesystem. @@ -101,6 +105,9 @@ func NewChecker(manifestPath string, basePath string, fs afero.Fs) (*Checker, er files: files, fs: fs, manifestPaths: manifestPaths, + signature: m.pbOuter.Signature, + signer: m.pbOuter.Signer, + signingPubKey: m.pbOuter.SigningPubKey, }, nil } @@ -118,6 +125,16 @@ func (c *Checker) TotalBytes() FileSize { return total } +// IsSigned returns true if the manifest has a signature. +func (c *Checker) IsSigned() bool { + return len(c.signature) > 0 +} + +// Signer returns the signer fingerprint if the manifest is signed, nil otherwise. +func (c *Checker) Signer() []byte { + return c.signer +} + // Check verifies all files against the manifest. // Results are sent to the results channel as files are checked. // Progress updates are sent to the progress channel approximately once per second. diff --git a/mfer/deserialize.go b/mfer/deserialize.go index 838efc7..76a8655 100644 --- a/mfer/deserialize.go +++ b/mfer/deserialize.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/sha256" "errors" + "fmt" "io" "github.com/google/uuid" @@ -45,10 +46,28 @@ func (m *manifest) deserializeInner() error { if _, err := h.Write(m.pbOuter.InnerMessage); err != nil { return err } - if !bytes.Equal(h.Sum(nil), m.pbOuter.Sha256) { + sha256Hash := h.Sum(nil) + if !bytes.Equal(sha256Hash, m.pbOuter.Sha256) { return errors.New("compressed data hash mismatch") } + // Verify signature if present + if len(m.pbOuter.Signature) > 0 { + if len(m.pbOuter.SigningPubKey) == 0 { + return errors.New("signature present but no public key") + } + + sigString, err := m.signatureString() + if err != nil { + return fmt.Errorf("failed to generate signature string for verification: %w", err) + } + + if err := gpgVerify([]byte(sigString), m.pbOuter.Signature, m.pbOuter.SigningPubKey); err != nil { + return fmt.Errorf("signature verification failed: %w", err) + } + log.Infof("signature verified successfully") + } + bb := bytes.NewBuffer(m.pbOuter.InnerMessage) zr, err := zstd.NewReader(bb) diff --git a/mfer/gpg.go b/mfer/gpg.go index 8c2e02e..94b5bca 100644 --- a/mfer/gpg.go +++ b/mfer/gpg.go @@ -3,7 +3,9 @@ package mfer import ( "bytes" "fmt" + "os" "os/exec" + "path/filepath" "strings" ) @@ -88,3 +90,64 @@ func gpgGetKeyFingerprint(keyID GPGKeyID) ([]byte, error) { return nil, fmt.Errorf("fingerprint not found for key: %s", keyID) } + +// gpgVerify verifies a detached signature against data using the provided public key. +// It creates a temporary keyring to import the public key for verification. +func gpgVerify(data, signature, pubKey []byte) error { + // Create temporary directory for GPG operations + tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*") + if err != nil { + return fmt.Errorf("failed to create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + // Set restrictive permissions + if err := os.Chmod(tmpDir, 0o700); err != nil { + return fmt.Errorf("failed to set temp dir permissions: %w", err) + } + + // Write public key to temp file + pubKeyFile := filepath.Join(tmpDir, "pubkey.asc") + if err := os.WriteFile(pubKeyFile, pubKey, 0o600); err != nil { + return fmt.Errorf("failed to write public key: %w", err) + } + + // Write signature to temp file + sigFile := filepath.Join(tmpDir, "signature.asc") + if err := os.WriteFile(sigFile, signature, 0o600); err != nil { + return fmt.Errorf("failed to write signature: %w", err) + } + + // Write data to temp file + dataFile := filepath.Join(tmpDir, "data") + if err := os.WriteFile(dataFile, data, 0o600); err != nil { + return fmt.Errorf("failed to write data: %w", err) + } + + // Import the public key into the temporary keyring + importCmd := exec.Command("gpg", + "--homedir", tmpDir, + "--import", + pubKeyFile, + ) + var importStderr bytes.Buffer + importCmd.Stderr = &importStderr + if err := importCmd.Run(); err != nil { + return fmt.Errorf("failed to import public key: %w: %s", err, importStderr.String()) + } + + // Verify the signature + verifyCmd := exec.Command("gpg", + "--homedir", tmpDir, + "--verify", + sigFile, + dataFile, + ) + var verifyStderr bytes.Buffer + verifyCmd.Stderr = &verifyStderr + if err := verifyCmd.Run(); err != nil { + return fmt.Errorf("signature verification failed: %w: %s", err, verifyStderr.String()) + } + + return nil +} diff --git a/mfer/gpg_test.go b/mfer/gpg_test.go index 1f448c6..97f72e2 100644 --- a/mfer/gpg_test.go +++ b/mfer/gpg_test.go @@ -210,6 +210,117 @@ func TestScannerWithSigning(t *testing.T) { assert.NotEmpty(t, manifest.pbOuter.SigningPubKey) } +func TestGPGVerify(t *testing.T) { + keyID, cleanup := testGPGEnv(t) + defer cleanup() + + data := []byte("test data to sign and verify") + sig, err := gpgSign(data, keyID) + require.NoError(t, err) + + pubKey, err := gpgExportPublicKey(keyID) + require.NoError(t, err) + + // Verify the signature + err = gpgVerify(data, sig, pubKey) + require.NoError(t, err) +} + +func TestGPGVerifyInvalidSignature(t *testing.T) { + keyID, cleanup := testGPGEnv(t) + defer cleanup() + + data := []byte("test data to sign") + sig, err := gpgSign(data, keyID) + require.NoError(t, err) + + pubKey, err := gpgExportPublicKey(keyID) + require.NoError(t, err) + + // Try to verify with different data - should fail + wrongData := []byte("different data") + err = gpgVerify(wrongData, sig, pubKey) + assert.Error(t, err) +} + +func TestGPGVerifyBadPublicKey(t *testing.T) { + keyID, cleanup := testGPGEnv(t) + defer cleanup() + + data := []byte("test data") + sig, err := gpgSign(data, keyID) + require.NoError(t, err) + + // Try to verify with invalid public key - should fail + badPubKey := []byte("not a valid public key") + err = gpgVerify(data, sig, badPubKey) + assert.Error(t, err) +} + +func TestManifestSignatureVerification(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 for verification") + 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 - signature should be verified during load + manifest, err := NewManifestFromReader(&buf) + require.NoError(t, err) + require.NotNil(t, manifest) + + // Signature should be present and valid + assert.NotEmpty(t, manifest.pbOuter.Signature) +} + +func TestManifestTamperedSignatureFails(t *testing.T) { + keyID, cleanup := testGPGEnv(t) + defer cleanup() + + // Create a signed manifest + b := NewBuilder() + b.SetSigningOptions(&SigningOptions{ + KeyID: keyID, + }) + + content := []byte("test file content") + reader := bytes.NewReader(content) + _, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil) + require.NoError(t, err) + + var buf bytes.Buffer + err = b.Build(&buf) + require.NoError(t, err) + + // Tamper with the signature by replacing some bytes + data := buf.Bytes() + // Find and modify a byte in the signature portion + for i := range data { + if i > 100 && data[i] == 'A' { + data[i] = 'B' + break + } + } + + // Try to load the tampered manifest - should fail + _, err = NewManifestFromReader(bytes.NewReader(data)) + assert.Error(t, err) +} + func TestBuilderWithoutSigning(t *testing.T) { // Create a builder without signing options b := NewBuilder() diff --git a/mfer/manifest.go b/mfer/manifest.go index 9792c13..203c79c 100644 --- a/mfer/manifest.go +++ b/mfer/manifest.go @@ -2,7 +2,11 @@ package mfer import ( "bytes" + "encoding/hex" + "errors" "fmt" + + "github.com/multiformats/go-multihash" ) // manifest holds the internal representation of a manifest file. @@ -30,3 +34,26 @@ func (m *manifest) Files() []*MFFilePath { } return m.pbInner.Files } + +// signatureString generates the canonical string used for signing/verification. +// Format: MAGIC-UUID-MULTIHASH where UUID and multihash are hex-encoded. +// Requires pbOuter to be set with Uuid and Sha256 fields. +func (m *manifest) signatureString() (string, error) { + if m.pbOuter == nil { + return "", errors.New("pbOuter not set") + } + if len(m.pbOuter.Uuid) == 0 { + return "", errors.New("UUID not set") + } + if len(m.pbOuter.Sha256) == 0 { + return "", errors.New("SHA256 hash not set") + } + + mh, err := multihash.Encode(m.pbOuter.Sha256, multihash.SHA2_256) + if err != nil { + return "", fmt.Errorf("failed to encode multihash: %w", err) + } + uuidStr := hex.EncodeToString(m.pbOuter.Uuid) + mhStr := hex.EncodeToString(mh) + return fmt.Sprintf("%s-%s-%s", MAGIC, uuidStr, mhStr), nil +} diff --git a/mfer/serialize.go b/mfer/serialize.go index 8c73f38..0a2898c 100644 --- a/mfer/serialize.go +++ b/mfer/serialize.go @@ -3,14 +3,12 @@ package mfer import ( "bytes" "crypto/sha256" - "encoding/hex" "errors" "fmt" "time" "github.com/google/uuid" "github.com/klauspost/compress/zstd" - "github.com/multiformats/go-multihash" "google.golang.org/protobuf/proto" ) @@ -82,7 +80,7 @@ func (m *manifest) generateOuter() error { } sha256Hash := h.Sum(nil) - o := &MFFileOuter{ + m.pbOuter = &MFFileOuter{ InnerMessage: compressedData, Size: int64(len(innerData)), Sha256: sha256Hash, @@ -93,36 +91,29 @@ func (m *manifest) generateOuter() error { // Sign the manifest if signing options are provided if m.signingOptions != nil && m.signingOptions.KeyID != "" { - // Encode hash as multihash - mh, err := multihash.Encode(sha256Hash, multihash.SHA2_256) + sigString, err := m.signatureString() if err != nil { - return fmt.Errorf("failed to encode multihash: %w", err) + return fmt.Errorf("failed to generate signature string: %w", err) } - // Build signature string: MAGIC-UUID-MULTIHASH - uuidStr := hex.EncodeToString(manifestUUID[:]) - mhStr := hex.EncodeToString(mh) - sigString := fmt.Sprintf("%s-%s-%s", MAGIC, uuidStr, mhStr) - sig, err := gpgSign([]byte(sigString), m.signingOptions.KeyID) if err != nil { return fmt.Errorf("failed to sign manifest: %w", err) } - o.Signature = sig + m.pbOuter.Signature = sig fingerprint, err := gpgGetKeyFingerprint(m.signingOptions.KeyID) if err != nil { return fmt.Errorf("failed to get key fingerprint: %w", err) } - o.Signer = fingerprint + m.pbOuter.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.SigningPubKey = pubKey } - m.pbOuter = o return nil }