sorcery-go/pkg/warding/pgp.go

90 lines
3.8 KiB
Go
Executable File

// PGP attestation for the Grimoire.
//
// Every DETAILS file may be accompanied by a detached PGP signature
// (DETAILS.asc). When the engine is configured with a keyring
// (/etc/sorcery-go/keyring.gpg), the Warding calls VerifySignature before
// the Cauldron sources the DETAILS — refusing to cast spells signed by
// unknown keys.
//
// We shell out to the system `gpg` binary because:
// 1. Every Source Mage box already has GnuPG installed.
// 2. x/crypto/openpgp is deprecated upstream.
// 3. ProtonMail/go-crypto would be a new dependency for a feature most
// admins disable during the bootstrap phase anyway.
package warding
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// VerifySignature checks that `signaturePath` is a valid detached PGP
// signature for `filePath`, signed by a key in `keyring`. Returns nil on
// success, an error otherwise.
//
// If keyring is empty, GnuPG's default keyring is used.
// If signaturePath does not exist, returns ErrNoSignature (non-blocking —
// the caller may allow unsigned spells via policy).
func VerifySignature(filePath, signaturePath, keyring string) error {
if _, err := os.Stat(signaturePath); err != nil {
return ErrNoSignature
}
args := []string{"--verify", signaturePath, filePath}
if keyring != "" {
args = append([]string{"--no-default-keyring", "--keyring", keyring}, args...)
}
args = append([]string{"--batch", "--status-fd", "1"}, args...)
cmd := exec.Command("gpg", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("pgp: gpg --verify failed: %w (output: %s)", err, string(out))
}
// GnuPG emits GOODSIG / BADSIG / ERRSIG / NO_PUBKEY status lines.
if !strings.Contains(string(out), "GOODSIG") {
return fmt.Errorf("pgp: no GOODSIG in gpg output: %s", string(out))
}
return nil
}
// VerifySpell checks for a DETAILS.asc next to the spell's DETAILS file
// and verifies it if present. Returns (signerFingerprint, nil) on success,
// ("", ErrNoSignature) when no signature file exists.
func VerifySpell(spellDir, keyring string) (string, error) {
details := filepath.Join(spellDir, "DETAILS")
sig := details + ".asc"
if _, err := os.Stat(sig); err != nil {
return "", ErrNoSignature
}
if err := VerifySignature(details, sig, keyring); err != nil {
return "", err
}
// Extract the signer fingerprint from gpg's status output.
// This requires a second gpg invocation because VerifySignature only
// checks for GOODSIG presence (success/fail). To get the actual
// fingerprint we need the full status-fd output, which VerifySignature
// doesn't expose. Combining both into one call would require
// refactoring VerifySignature to return structured status data.
cmd := exec.Command("gpg", "--batch", "--status-fd", "1",
"--verify", sig, details)
if keyring != "" {
cmd.Args = append([]string{"--no-default-keyring", "--keyring", keyring},
cmd.Args[1:]...)
}
out, _ := cmd.CombinedOutput()
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, "[GNUPG:] GOODSIG") {
fields := strings.Fields(line)
if len(fields) >= 3 {
return fields[2], nil
}
}
}
return "unknown", nil
}
// ErrNoSignature is returned when a spell has no .asc sidecar. Non-fatal.
var ErrNoSignature = fmt.Errorf("pgp: no detached signature found")