Add GPG signature verification on manifest load

- Implement gpgVerify function that creates a temporary keyring to verify
  detached signatures against embedded public keys
- Signature verification happens during deserialization after hash
  validation but before decompression
- Extract signatureString() as a method on manifest for generating the
  canonical signature string (MAGIC-UUID-MULTIHASH)
- Add --require-signature flag to check command to mandate signature from
  a specific GPG key ID
- Expose IsSigned() and Signer() methods on Checker for signature status
This commit is contained in:
Jeffrey Paul 2025-12-18 05:28:35 -08:00
parent 213364bab5
commit 4a2060087d
8 changed files with 269 additions and 16 deletions

View File

@ -3,6 +3,7 @@ package cli
import ( import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/dustin/go-humanize" "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) 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()))) log.Infof("manifest contains %d files, %s", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())))
// Set up results channel // Set up results channel

View File

@ -181,6 +181,12 @@ func (mfa *CLIApp) run(args []string) {
Name: "no-extra-files", Name: "no-extra-files",
Usage: "Fail if files exist in base directory that are not in manifest", 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"},
},
), ),
}, },
{ {

View File

@ -70,6 +70,10 @@ type Checker struct {
fs afero.Fs fs afero.Fs
// manifestPaths is a set of paths in the manifest for quick lookup // manifestPaths is a set of paths in the manifest for quick lookup
manifestPaths map[RelFilePath]struct{} 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. // 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, files: files,
fs: fs, fs: fs,
manifestPaths: manifestPaths, manifestPaths: manifestPaths,
signature: m.pbOuter.Signature,
signer: m.pbOuter.Signer,
signingPubKey: m.pbOuter.SigningPubKey,
}, nil }, nil
} }
@ -118,6 +125,16 @@ func (c *Checker) TotalBytes() FileSize {
return total 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. // Check verifies all files against the manifest.
// Results are sent to the results channel as files are checked. // Results are sent to the results channel as files are checked.
// Progress updates are sent to the progress channel approximately once per second. // Progress updates are sent to the progress channel approximately once per second.

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"crypto/sha256" "crypto/sha256"
"errors" "errors"
"fmt"
"io" "io"
"github.com/google/uuid" "github.com/google/uuid"
@ -45,10 +46,28 @@ func (m *manifest) deserializeInner() error {
if _, err := h.Write(m.pbOuter.InnerMessage); err != nil { if _, err := h.Write(m.pbOuter.InnerMessage); err != nil {
return err 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") 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) bb := bytes.NewBuffer(m.pbOuter.InnerMessage)
zr, err := zstd.NewReader(bb) zr, err := zstd.NewReader(bb)

View File

@ -3,7 +3,9 @@ package mfer
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
) )
@ -88,3 +90,64 @@ func gpgGetKeyFingerprint(keyID GPGKeyID) ([]byte, error) {
return nil, fmt.Errorf("fingerprint not found for key: %s", keyID) 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
}

View File

@ -210,6 +210,117 @@ func TestScannerWithSigning(t *testing.T) {
assert.NotEmpty(t, manifest.pbOuter.SigningPubKey) 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) { func TestBuilderWithoutSigning(t *testing.T) {
// Create a builder without signing options // Create a builder without signing options
b := NewBuilder() b := NewBuilder()

View File

@ -2,7 +2,11 @@ package mfer
import ( import (
"bytes" "bytes"
"encoding/hex"
"errors"
"fmt" "fmt"
"github.com/multiformats/go-multihash"
) )
// manifest holds the internal representation of a manifest file. // manifest holds the internal representation of a manifest file.
@ -30,3 +34,26 @@ func (m *manifest) Files() []*MFFilePath {
} }
return m.pbInner.Files 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
}

View File

@ -3,14 +3,12 @@ package mfer
import ( import (
"bytes" "bytes"
"crypto/sha256" "crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
"github.com/multiformats/go-multihash"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
) )
@ -82,7 +80,7 @@ func (m *manifest) generateOuter() error {
} }
sha256Hash := h.Sum(nil) sha256Hash := h.Sum(nil)
o := &MFFileOuter{ m.pbOuter = &MFFileOuter{
InnerMessage: compressedData, InnerMessage: compressedData,
Size: int64(len(innerData)), Size: int64(len(innerData)),
Sha256: sha256Hash, Sha256: sha256Hash,
@ -93,36 +91,29 @@ func (m *manifest) generateOuter() error {
// Sign the manifest if signing options are provided // Sign the manifest if signing options are provided
if m.signingOptions != nil && m.signingOptions.KeyID != "" { if m.signingOptions != nil && m.signingOptions.KeyID != "" {
// Encode hash as multihash sigString, err := m.signatureString()
mh, err := multihash.Encode(sha256Hash, multihash.SHA2_256)
if err != nil { 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) sig, err := gpgSign([]byte(sigString), m.signingOptions.KeyID)
if err != nil { if err != nil {
return fmt.Errorf("failed to sign manifest: %w", err) return fmt.Errorf("failed to sign manifest: %w", err)
} }
o.Signature = sig m.pbOuter.Signature = sig
fingerprint, err := gpgGetKeyFingerprint(m.signingOptions.KeyID) fingerprint, err := gpgGetKeyFingerprint(m.signingOptions.KeyID)
if err != nil { if err != nil {
return fmt.Errorf("failed to get key fingerprint: %w", err) return fmt.Errorf("failed to get key fingerprint: %w", err)
} }
o.Signer = fingerprint m.pbOuter.Signer = fingerprint
pubKey, err := gpgExportPublicKey(m.signingOptions.KeyID) pubKey, err := gpgExportPublicKey(m.signingOptions.KeyID)
if err != nil { if err != nil {
return fmt.Errorf("failed to export public key: %w", err) return fmt.Errorf("failed to export public key: %w", err)
} }
o.SigningPubKey = pubKey m.pbOuter.SigningPubKey = pubKey
} }
m.pbOuter = o
return nil return nil
} }