// 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")