Compare --require-signature with the key that signed (closes #167)
check / check (push) Canceled after 0s

check and fetch --require-signature compared the required fingerprint
with the first key in the manifest's embedded public key block, while
gpg accepted a good signature by any key in that block.

Loading a signed manifest now refuses one whose embedded block holds
more than one primary key, counted as gpg reads the block, or whose
signer field is not the primary key fingerprint gpg reports for the
signature. --require-signature compares with the signer field, which
loading has checked. Signing names and embeds the key gpg reports it
signed with, so a key ID matching several keys still writes a manifest
that loads. docs/FORMAT.md states what a verifier checks.

Model: opus-5-5
This commit was merged in pull request #171.
This commit is contained in:
2026-10-07 13:59:17 +02:00
parent 2a174e3ba2
commit 0762a728d4
11 changed files with 479 additions and 181 deletions
+122 -83
View File
@@ -41,16 +41,26 @@ const (
// fields in a gpg fingerprint record (the fingerprint is field 10).
gpgFingerprintMinFields = 10
// gpgStatusPrefix starts each status line gpg writes to the file
// descriptor named by --status-fd.
gpgStatusPrefix = "[GNUPG:]"
// gpg option names used from more than one call site.
gpgOptArmor = "--armor"
gpgOptHomedir = "--homedir"
gpgOptVerify = "--verify"
gpgOptArmor = "--armor"
gpgOptHomedir = "--homedir"
gpgOptStatusFD = "--status-fd"
gpgOptVerify = "--verify"
)
var (
errGPGKeyNotFound = errors.New("gpg key not found")
errFingerprintNotFound = errors.New("fingerprint not found for key")
errImportedFPRNotFound = errors.New("fingerprint not found in imported key")
errSigningKeyCount = errors.New(
"embedded public key block must hold exactly one key")
errNotOneGoodSignature = errors.New(
"gpg did not report exactly one good signature")
errSigningKeyNotReported = errors.New(
"gpg did not report the key that made the signature")
)
// GPGKeyID represents a GPG key identifier (fingerprint or key ID).
@@ -136,19 +146,67 @@ func parseFingerprint(colonOutput string) (string, bool) {
return "", false
}
// gpgSign creates a detached signature of the data using the specified key.
// Returns the armored detached signature.
func gpgSign(ctx context.Context, data []byte, keyID GPGKeyID) ([]byte, error) {
// parseStatusLine returns the arguments of the status line for keyword in
// gpg --status-fd output, or ok=false unless there is exactly one such line
// and it has arguments.
func parseStatusLine(statusOutput, keyword string) ([]string, bool) {
var found [][]string
for line := range strings.SplitSeq(statusOutput, "\n") {
fields := strings.Fields(line)
if len(fields) > 2 && fields[0] == gpgStatusPrefix && fields[1] == keyword {
found = append(found, fields[2:])
}
}
if len(found) != 1 {
return nil, false
}
return found[0], true
}
// gpgSign creates an armored detached signature of data with the key gpg
// picks for keyID, and returns it with the fingerprint of the key that made
// it, which is a subkey's when gpg signed with a subkey.
func gpgSign(
ctx context.Context, data []byte, keyID GPGKeyID,
) ([]byte, string, error) {
tmpDir, err := os.MkdirTemp("", "mfer-gpg-sign-*")
if err != nil {
return nil, "", fmt.Errorf("failed to create temp dir: %w", err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
sigFile := filepath.Join(tmpDir, "signature.asc")
// The signature goes to sigFile, so --status-fd 1 can send gpg's status
// lines to stdout; its messages go to stderr.
stdout, stderr, err := runGPG(ctx, bytes.NewReader(data),
"--detach-sign",
gpgOptArmor,
"--output", sigFile,
gpgOptStatusFD, "1",
"--local-user", string(keyID),
)
if err != nil {
return nil, fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
return nil, "", fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
}
return stdout.Bytes(), nil
// The last argument of SIG_CREATED is the fingerprint of the key that
// made the signature.
created, ok := parseStatusLine(stdout.String(), "SIG_CREATED")
if !ok {
return nil, "", fmt.Errorf("%w: %s", errSigningKeyNotReported, stderr.String())
}
sig, err := os.ReadFile(sigFile) //nolint:gosec // G304: inside tmpDir, made above
if err != nil {
return nil, "", fmt.Errorf("failed to read signature: %w", err)
}
return sig, created[len(created)-1], nil
}
// gpgExportPublicKey exports the public key for the specified key ID.
@@ -187,12 +245,44 @@ func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
return []byte(fpr), nil
}
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
// and extracts its fingerprint. This verifies the key is valid and returns
// the actual fingerprint from the key material.
func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, error) {
// gpgImportOneKey imports the public key block in pubKeyFile into the
// keyring in gpgHome. The block must hold exactly one primary key.
func gpgImportOneKey(ctx context.Context, gpgHome, pubKeyFile string) error {
// --status-fd 1 sends gpg's status lines to stdout, which importing
// otherwise leaves empty; its messages go to stderr.
importStdout, importStderr, err := runGPG(ctx, nil,
gpgArgs([]string{gpgOptHomedir, gpgHome, gpgOptStatusFD, "1", "--import"},
pubKeyFile)...,
)
if err != nil {
return fmt.Errorf(
"failed to import public key: %w: %s", err, importStderr.String(),
)
}
// The first argument of IMPORT_RES counts the primary keys gpg read
// from the block, those it then skipped (one with no user ID, for
// example) included.
result, ok := parseStatusLine(importStdout.String(), "IMPORT_RES")
if !ok {
return fmt.Errorf("%w, gpg reported no count", errSigningKeyCount)
}
if result[0] != "1" {
return fmt.Errorf("%w, found %s", errSigningKeyCount, result[0])
}
return nil
}
// gpgVerify verifies a detached signature against data using the provided
// public key, imported into a temporary keyring, and returns the
// fingerprint of the primary key that made the signature. The public key
// must hold exactly one primary key, so that a good signature can come
// from no other key.
func gpgVerify(ctx context.Context, data, signature, pubKey []byte) (string, error) {
// Create temporary directory for GPG operations
tmpDir, err := os.MkdirTemp("", "mfer-gpg-fingerprint-*")
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
if err != nil {
return "", fmt.Errorf("failed to create temp dir: %w", err)
}
@@ -213,67 +303,12 @@ func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, er
return "", fmt.Errorf("failed to write public key: %w", err)
}
// Import the public key into the temporary keyring
_, importStderr, err := runGPG(ctx, nil,
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
)
if err != nil {
return "", fmt.Errorf(
"failed to import public key: %w: %s", err, importStderr.String(),
)
}
// List keys to get fingerprint
listStdout, listStderr, err := runGPG(ctx, nil,
"--homedir", tmpDir,
"--with-colons",
"--fingerprint",
)
if err != nil {
return "", fmt.Errorf(
"failed to list keys: %w: %s", err, listStderr.String(),
)
}
fpr, ok := parseFingerprint(listStdout.String())
if !ok {
return "", errImportedFPRNotFound
}
return fpr, nil
}
// 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(ctx context.Context, 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 func() { _ = os.RemoveAll(tmpDir) }()
// Set restrictive permissions
err = os.Chmod(tmpDir, privateDirPerms)
if 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")
err = os.WriteFile(pubKeyFile, pubKey, privateFilePerms)
if err != nil {
return fmt.Errorf("failed to write public key: %w", err)
}
// Write signature to temp file
sigFile := filepath.Join(tmpDir, "signature.asc")
err = os.WriteFile(sigFile, signature, privateFilePerms)
if err != nil {
return fmt.Errorf("failed to write signature: %w", err)
return "", fmt.Errorf("failed to write signature: %w", err)
}
// Write data to temp file
@@ -281,29 +316,33 @@ func gpgVerify(ctx context.Context, data, signature, pubKey []byte) error {
err = os.WriteFile(dataFile, data, privateFilePerms)
if err != nil {
return fmt.Errorf("failed to write data: %w", err)
return "", fmt.Errorf("failed to write data: %w", err)
}
// Import the public key into the temporary keyring
_, importStderr, err := runGPG(ctx, nil,
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
)
err = gpgImportOneKey(ctx, tmpDir, pubKeyFile)
if err != nil {
return fmt.Errorf(
"failed to import public key: %w: %s", err, importStderr.String(),
)
return "", err
}
// Verify the signature
_, verifyStderr, err := runGPG(ctx, nil,
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptVerify},
// --status-fd 1 sends gpg's status lines to stdout, which verifying a
// detached signature otherwise leaves empty; its messages go to stderr.
verifyStdout, verifyStderr, err := runGPG(ctx, nil,
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptStatusFD, "1", gpgOptVerify},
sigFile, dataFile)...,
)
if err != nil {
return fmt.Errorf(
return "", fmt.Errorf(
"signature verification failed: %w: %s", err, verifyStderr.String(),
)
}
return nil
// gpg writes a VALIDSIG line for each good signature. Its first
// argument is the fingerprint of the key that made the signature,
// which may be a subkey; its last is that of the primary key.
valid, ok := parseStatusLine(verifyStdout.String(), "VALIDSIG")
if !ok {
return "", errNotOneGoodSignature
}
return valid[len(valid)-1], nil
}