90 lines
3.8 KiB
Go
Executable File
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")
|