mirror of
https://github.com/hanzoai/cd.git
synced 2026-10-30 14:56:45 +00:00
repository.proto and 11 sibling hand-written .proto files referenced generated.proto's application/v1alpha1 types under the pre-fork package path (github.com.argoproj.argo_cd.v3...); protoc silently kept compiling because generated.proto still carried that same stale package too. Point both at the real, mechanically-derived path (github.com.hanzoai.cd.pkg.apis.application.v1alpha1, no v3 segment) and regenerate everything protoc/go-to-protobuf own: generated.proto/pb.go, every pkg/apiclient and reposerver/cmpserver/commitserver *.pb.go, assets/swagger.json, and the CRD manifests. That regeneration surfaced a second, unrelated bug: hack/gen-crd-spec stamped app.kubernetes.io/part-of: cd on the CRDs, but util/settings.isSettingsObject (and the ConfigMap informer built on it) only ever recognizes part-of=hanzocd. The same wrong "cd" value was baked into cmd/cd/commands/admin/settings.go's setSettingsMeta and the shared test/testdata.go fixtures ~30 files import — util/settings' own test suite was panicking on an empty slice because its fixture was invisible to the informer it was supposedly seeding. Fixed the label everywhere it's produced or asserted in the settings path; left the ~90 unrelated part-of:"cd" literals in other packages' local test fixtures alone since they never touch this selector. Also dropped 25 "TODO: Remove deprecated <dead upstream issue link>" comments that duplicated the real explanation already sitting one line away (a proper `Deprecated:` godoc, a cobra Deprecated field, or a nolint:staticcheck marker) — kept the explanation, dropped the link, per the standing rule for these citations. And repointed the two Grafana dashboards' job selector from argocd-server-metrics, a Service the manifests never create, to hanzocd-server-metrics, the one they do.
831 lines
24 KiB
Go
831 lines
24 KiB
Go
package sourceintegrity
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
|
|
"github.com/hanzoai/cd/util/git"
|
|
|
|
"github.com/hanzoai/cd/common"
|
|
appsv1 "github.com/hanzoai/cd/pkg/apis/application/v1alpha1"
|
|
executil "github.com/hanzoai/cd/util/exec"
|
|
)
|
|
|
|
// Regular expression to match public key beginning
|
|
var subTypeMatch = regexp.MustCompile(`^pub\s+([a-z0-9]+)\s\d+-\d+-\d+\s\[[A-Z]+\].*$`)
|
|
|
|
// Regular expression to match key ID output from gpg
|
|
var keyIdMatch = regexp.MustCompile(`^\s+([0-9A-Za-z]+)\s*$`)
|
|
|
|
// Regular expression to match identity output from gpg
|
|
var uidMatch = regexp.MustCompile(`^uid\s*\[\s*([a-z]+)\s*\]\s+(.*)$`)
|
|
|
|
// Regular expression to match import status
|
|
var importMatch = regexp.MustCompile(`^gpg: key ([A-Z0-9]+): public key "([^"]+)" imported$`)
|
|
|
|
// This is the recipe for automatic key generation, passed to gpg --batch --gen-key
|
|
// for initializing our keyring with a trustdb. A new private key will be generated each
|
|
// time cd-server starts, so it's transient and is not used for anything except for
|
|
// creating the trustdb in a specific cd-repo-server pod.
|
|
var batchKeyCreateRecipe = `%no-protection
|
|
%transient-key
|
|
Key-Type: RSA
|
|
Key-Length: 2048
|
|
Key-Usage: sign
|
|
Name-Real: Anon Ymous
|
|
Name-Comment: Hanzo CD key signing key
|
|
Name-Email: [email protected]
|
|
Expire-Date: 6m
|
|
%commit
|
|
`
|
|
|
|
// Canary marker for GNUPGHOME created by Hanzo CD
|
|
const canaryMarkerFilename = ".cd-generated"
|
|
|
|
type PGPKeyID string
|
|
|
|
func isHexString(s string) bool {
|
|
_, err := hex.DecodeString(s)
|
|
return err == nil
|
|
}
|
|
|
|
// KeyID get the actual correct (short) key ID from either a fingerprint or the key ID. Errors if it is not a valid GnuPG key ID.
|
|
func KeyID(k string) (string, error) {
|
|
if IsLongKeyID(k) {
|
|
return k[24:], nil
|
|
} else if IsShortKeyID(k) {
|
|
return k, nil
|
|
}
|
|
// Invalid key
|
|
return "", fmt.Errorf("'%s' is not a valid GnuPG key ID", k)
|
|
}
|
|
|
|
// IsLongKeyID returns true if the string represents a long key ID (aka fingerprint)
|
|
func IsLongKeyID(k string) bool {
|
|
if len(k) == 40 && isHexString(k) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsShortKeyID returns true if the string represents a short key ID
|
|
func IsShortKeyID(k string) bool {
|
|
if len(k) == 16 && isHexString(k) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Key trust values
|
|
const (
|
|
TrustUnknown = "unknown"
|
|
TrustNone = "never"
|
|
TrustMarginal = "marginal"
|
|
TrustFull = "full"
|
|
TrustUltimate = "ultimate"
|
|
)
|
|
|
|
// Key trust mappings
|
|
var pgpTrustLevels = map[string]int{
|
|
TrustUnknown: 2,
|
|
TrustNone: 3,
|
|
TrustMarginal: 4,
|
|
TrustFull: 5,
|
|
TrustUltimate: 6,
|
|
}
|
|
|
|
// Helper function to append GNUPGHOME for a command execution environment
|
|
func getGPGEnviron() []string {
|
|
return append(os.Environ(), "GNUPGHOME="+common.GetGnuPGHomePath(), "LANG=C.UTF-8")
|
|
}
|
|
|
|
// Helper function to write some data to a temp file and return its path
|
|
func writeKeyToFile(keyData string) (string, error) {
|
|
f, err := os.CreateTemp("", "gpg-public-key")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
err = os.WriteFile(f.Name(), []byte(keyData), 0o600)
|
|
if err != nil {
|
|
os.Remove(f.Name())
|
|
return "", err
|
|
}
|
|
defer func() {
|
|
err = f.Close()
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
common.SecurityField: common.SecurityMedium,
|
|
common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
|
|
}).Errorf("error closing file %q: %v", f.Name(), err)
|
|
}
|
|
}()
|
|
return f.Name(), nil
|
|
}
|
|
|
|
// removeKeyRing removes an already initialized keyring from the file system
|
|
// This must only be called on container startup, when no gpg-agent is running
|
|
// yet, otherwise key generation will fail.
|
|
func removeKeyRing(path string) error {
|
|
_, err := os.Stat(filepath.Join(path, canaryMarkerFilename))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("refusing to remove directory %s: it's not initialized by Hanzo CD", path)
|
|
}
|
|
return err
|
|
}
|
|
rd, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rd.Close()
|
|
dns, err := rd.Readdirnames(-1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, p := range dns {
|
|
if p == "." || p == ".." {
|
|
continue
|
|
}
|
|
err := os.RemoveAll(filepath.Join(path, p))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InitializeGnuPG will initialize a GnuPG working directory and also create a
|
|
// transient private key so that the trust DB will work correctly.
|
|
func InitializeGnuPG() error {
|
|
gnuPgHome := common.GetGnuPGHomePath()
|
|
ctx := context.Background()
|
|
|
|
// We only operate if CD_GNUPGHOME is set
|
|
if gnuPgHome == "" {
|
|
return fmt.Errorf("%s is not set; refusing to initialize", common.EnvGnuPGHome)
|
|
}
|
|
|
|
// Directory set in CD_GNUPGHOME must exist and has to be a directory
|
|
st, err := os.Stat(gnuPgHome)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !st.IsDir() {
|
|
return fmt.Errorf("%s ('%s') does not point to a directory", common.EnvGnuPGHome, gnuPgHome)
|
|
}
|
|
|
|
_, err = os.Stat(path.Join(gnuPgHome, "trustdb.gpg"))
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
} else {
|
|
// This usually happens with emptyDir mount on container crash - we need to
|
|
// re-initialize key ring.
|
|
err = removeKeyRing(gnuPgHome)
|
|
if err != nil {
|
|
return fmt.Errorf("re-initializing keyring at %s failed: %w", gnuPgHome, err)
|
|
}
|
|
}
|
|
|
|
err = os.WriteFile(filepath.Join(gnuPgHome, canaryMarkerFilename), []byte("canary"), 0o644)
|
|
if err != nil {
|
|
return fmt.Errorf("could not create canary: %w", err)
|
|
}
|
|
|
|
f, err := os.CreateTemp("", "gpg-key-recipe")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer os.Remove(f.Name())
|
|
|
|
_, err = f.WriteString(batchKeyCreateRecipe)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer func() {
|
|
err = f.Close()
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
common.SecurityField: common.SecurityMedium,
|
|
common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
|
|
}).Errorf("error closing file %q: %v", f.Name(), err)
|
|
}
|
|
}()
|
|
|
|
cmd := exec.CommandContext(ctx, "gpg", "--no-permission-warning", "--logger-fd", "1", "--batch", "--gen-key", f.Name())
|
|
cmd.Env = getGPGEnviron()
|
|
|
|
_, err = executil.Run(cmd)
|
|
return err
|
|
}
|
|
|
|
func ImportPGPKeysFromString(keyData string) ([]*appsv1.GnuPGPublicKey, error) {
|
|
f, err := os.CreateTemp("", "gpg-key-import")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.Remove(f.Name())
|
|
_, err = f.WriteString(keyData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
err = f.Close()
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
common.SecurityField: common.SecurityMedium,
|
|
common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
|
|
}).Errorf("error closing file %q: %v", f.Name(), err)
|
|
}
|
|
}()
|
|
return ImportPGPKeys(f.Name())
|
|
}
|
|
|
|
// ImportPGPKeys imports one or more keys from a file into the local keyring and optionally
|
|
// signs them with the transient private key for leveraging the trust DB.
|
|
func ImportPGPKeys(keyFile string) ([]*appsv1.GnuPGPublicKey, error) {
|
|
keys := make([]*appsv1.GnuPGPublicKey, 0)
|
|
ctx := context.Background()
|
|
|
|
cmd := exec.CommandContext(ctx, "gpg", "--no-permission-warning", "--logger-fd", "1", "--import", keyFile)
|
|
cmd.Env = getGPGEnviron()
|
|
|
|
out, err := executil.Run(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
for scanner.Scan() {
|
|
if !strings.HasPrefix(scanner.Text(), "gpg: ") {
|
|
continue
|
|
}
|
|
// We ignore lines that are not of interest
|
|
token := importMatch.FindStringSubmatch(scanner.Text())
|
|
if len(token) != 3 {
|
|
continue
|
|
}
|
|
|
|
key := appsv1.GnuPGPublicKey{
|
|
KeyID: token[1],
|
|
Owner: token[2],
|
|
// By default, trust level is unknown
|
|
Trust: TrustUnknown,
|
|
// Subtype is unknown at this point
|
|
SubType: "unknown",
|
|
Fingerprint: "",
|
|
}
|
|
|
|
keys = append(keys, &key)
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
func ValidatePGPKeysFromString(keyData string) (map[string]*appsv1.GnuPGPublicKey, error) {
|
|
f, err := writeKeyToFile(keyData)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.Remove(f)
|
|
|
|
return ValidatePGPKeys(f)
|
|
}
|
|
|
|
// ValidatePGPKeys validates whether the keys in keyFile are valid PGP keys and can be imported
|
|
// It does so by importing them into a temporary keyring. The returned keys are complete, that
|
|
// is, they contain all relevant information
|
|
func ValidatePGPKeys(keyFile string) (map[string]*appsv1.GnuPGPublicKey, error) {
|
|
keys := make(map[string]*appsv1.GnuPGPublicKey)
|
|
tempHome, err := os.MkdirTemp("", "gpg-verify-key")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.RemoveAll(tempHome)
|
|
|
|
// Remember original GNUPGHOME, then set it to temp directory
|
|
oldGPGHome := os.Getenv(common.EnvGnuPGHome)
|
|
defer os.Setenv(common.EnvGnuPGHome, oldGPGHome)
|
|
os.Setenv(common.EnvGnuPGHome, tempHome)
|
|
|
|
// Import they keys to our temporary keyring...
|
|
_, err = ImportPGPKeys(keyFile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// ... and export them again, to get key data and fingerprint
|
|
imported, err := GetInstalledPGPKeys(nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, key := range imported {
|
|
keys[key.KeyID] = key
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// SetPGPTrustLevelById sets the given trust level on keys with specified key IDs
|
|
func SetPGPTrustLevelById(kids []string, trustLevel string) error {
|
|
keys := make([]*appsv1.GnuPGPublicKey, 0)
|
|
for _, kid := range kids {
|
|
keys = append(keys, &appsv1.GnuPGPublicKey{KeyID: kid})
|
|
}
|
|
return SetPGPTrustLevel(keys, trustLevel)
|
|
}
|
|
|
|
// SetPGPTrustLevel sets the given trust level on specified keys
|
|
func SetPGPTrustLevel(pgpKeys []*appsv1.GnuPGPublicKey, trustLevel string) error {
|
|
trust, ok := pgpTrustLevels[trustLevel]
|
|
if !ok {
|
|
return fmt.Errorf("unknown trust level: %s", trustLevel)
|
|
}
|
|
|
|
// We need to store ownertrust specification in a temp file. Format is <fingerprint>:<level>
|
|
f, err := os.CreateTemp("", "gpg-key-fps")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer os.Remove(f.Name())
|
|
ctx := context.Background()
|
|
|
|
for _, k := range pgpKeys {
|
|
_, err := fmt.Fprintf(f, "%s:%d\n", k.KeyID, trust)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
defer func() {
|
|
err = f.Close()
|
|
if err != nil {
|
|
log.WithFields(log.Fields{
|
|
common.SecurityField: common.SecurityMedium,
|
|
common.SecurityCWEField: common.SecurityCWEMissingReleaseOfFileDescriptor,
|
|
}).Errorf("error closing file %q: %v", f.Name(), err)
|
|
}
|
|
}()
|
|
|
|
// Load ownertrust from the file we have constructed and instruct gpg to update the trustdb
|
|
cmd := exec.CommandContext(ctx, "gpg", "--no-permission-warning", "--import-ownertrust", f.Name())
|
|
cmd.Env = getGPGEnviron()
|
|
|
|
_, err = executil.Run(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update the trustdb once we updated the ownertrust, to prevent gpg to do it once we validate a signature
|
|
cmd = exec.CommandContext(ctx, "gpg", "--no-permission-warning", "--update-trustdb")
|
|
cmd.Env = getGPGEnviron()
|
|
_, err = executil.Run(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeletePGPKey deletes a key from our GnuPG key ring
|
|
func DeletePGPKey(keyID string) error {
|
|
args := append([]string{}, "--no-permission-warning", "--yes", "--batch", "--delete-keys", keyID)
|
|
cmd := exec.CommandContext(context.Background(), "gpg", args...)
|
|
cmd.Env = getGPGEnviron()
|
|
|
|
_, err := executil.Run(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsSecretKey returns true if the keyID also has a private key in the keyring
|
|
func IsSecretKey(keyID string) (bool, error) {
|
|
args := append([]string{}, "--no-permission-warning", "--list-secret-keys", keyID)
|
|
cmd := exec.CommandContext(context.Background(), "gpg-wrapper.sh", args...)
|
|
cmd.Env = getGPGEnviron()
|
|
out, err := executil.Run(cmd)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if strings.HasPrefix(out, "gpg: error reading key: No secret key") {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// GetInstalledPGPKeys runs gpg to retrieve public keys from our keyring. If kids is non-empty, limit result to those key IDs
|
|
func GetInstalledPGPKeys(kids []string) ([]*appsv1.GnuPGPublicKey, error) {
|
|
keys := make([]*appsv1.GnuPGPublicKey, 0)
|
|
ctx := context.Background()
|
|
|
|
args := []string{"--no-permission-warning", "--list-public-keys"}
|
|
// kids can contain an arbitrary list of key IDs we want to list. If empty, we list all keys.
|
|
if len(kids) > 0 {
|
|
args = append(args, kids...)
|
|
}
|
|
cmd := exec.CommandContext(ctx, "gpg", args...)
|
|
cmd.Env = getGPGEnviron()
|
|
|
|
out, err := executil.Run(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
var curKey *appsv1.GnuPGPublicKey
|
|
for scanner.Scan() {
|
|
if !strings.HasPrefix(scanner.Text(), "pub ") {
|
|
continue
|
|
}
|
|
// This is the beginning of a new key, time to store the previously parsed one in our list and start fresh.
|
|
if curKey != nil {
|
|
keys = append(keys, curKey)
|
|
curKey = nil
|
|
}
|
|
|
|
key := appsv1.GnuPGPublicKey{}
|
|
|
|
// Second field in pub output denotes key sub type (cipher and length)
|
|
token := subTypeMatch.FindStringSubmatch(scanner.Text())
|
|
if len(token) != 2 {
|
|
return nil, fmt.Errorf("invalid line: %s (len=%d)", scanner.Text(), len(token))
|
|
}
|
|
key.SubType = token[1]
|
|
|
|
// Next line should be the key ID, no prefix
|
|
if !scanner.Scan() {
|
|
return nil, errors.New("invalid output from gpg, end of text after primary key")
|
|
}
|
|
|
|
token = keyIdMatch.FindStringSubmatch(scanner.Text())
|
|
if len(token) != 2 {
|
|
return nil, errors.New("invalid output from gpg, no key ID for primary key")
|
|
}
|
|
|
|
key.Fingerprint = token[1]
|
|
// KeyID is just the last bytes of the fingerprint
|
|
key.KeyID = token[1][24:]
|
|
|
|
if curKey == nil {
|
|
curKey = &key
|
|
}
|
|
|
|
// Next line should be UID
|
|
if !scanner.Scan() {
|
|
return nil, errors.New("invalid output from gpg, end of text after key ID")
|
|
}
|
|
|
|
if !strings.HasPrefix(scanner.Text(), "uid ") {
|
|
return nil, errors.New("invalid output from gpg, no identity for primary key")
|
|
}
|
|
|
|
token = uidMatch.FindStringSubmatch(scanner.Text())
|
|
|
|
if len(token) < 3 {
|
|
return nil, fmt.Errorf("malformed identity line: %s (len=%d)", scanner.Text(), len(token))
|
|
}
|
|
|
|
// Store trust level
|
|
key.Trust = token[1]
|
|
|
|
// Identity - we are only interested in the first uid
|
|
key.Owner = token[2]
|
|
}
|
|
|
|
// Also store the last processed key into our list to be returned
|
|
if curKey != nil {
|
|
keys = append(keys, curKey)
|
|
}
|
|
|
|
// We need to get the final key for each imported key, so we run --export on each key
|
|
for _, key := range keys {
|
|
cmd := exec.CommandContext(ctx, "gpg", "--no-permission-warning", "-a", "--export", key.KeyID)
|
|
cmd.Env = getGPGEnviron()
|
|
|
|
out, err := executil.Run(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
key.KeyData = out
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// SyncKeyRingFromDirectory will sync the GPG keyring with files in a directory. This is a one-way sync,
|
|
// with the configuration being the leading information.
|
|
// Files must have a file name matching their Key ID. Keys that are found in the directory but are not
|
|
// in the keyring will be installed to the keyring, files that exist in the keyring but do not exist in
|
|
// the directory will be deleted.
|
|
func SyncKeyRingFromDirectory(basePath string) ([]string, []string, error) {
|
|
configured := make(map[string]any)
|
|
newKeys := make([]string, 0)
|
|
fingerprints := make([]string, 0)
|
|
removedKeys := make([]string, 0)
|
|
st, err := os.Stat(basePath)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !st.IsDir() {
|
|
return nil, nil, fmt.Errorf("%s is not a directory", basePath)
|
|
}
|
|
|
|
// Collect configuration, i.e. files in basePath
|
|
err = filepath.Walk(basePath, func(_ string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if fi == nil {
|
|
return nil
|
|
}
|
|
if IsShortKeyID(fi.Name()) {
|
|
configured[fi.Name()] = true
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("error walk path: %w", err)
|
|
}
|
|
|
|
// Collect GPG keys installed in the key ring
|
|
installed := make(map[string]*appsv1.GnuPGPublicKey)
|
|
keys, err := GetInstalledPGPKeys(nil)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("error get installed PGP keys: %w", err)
|
|
}
|
|
for _, v := range keys {
|
|
installed[v.KeyID] = v
|
|
}
|
|
|
|
// First, add all keys that are found in the configuration but are not yet in the keyring
|
|
for key := range configured {
|
|
if _, ok := installed[key]; ok {
|
|
continue
|
|
}
|
|
addedKey, err := ImportPGPKeys(path.Join(basePath, key))
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("error import PGP keys: %w", err)
|
|
}
|
|
if len(addedKey) != 1 {
|
|
return nil, nil, fmt.Errorf("invalid key found in %s", path.Join(basePath, key))
|
|
}
|
|
importedKey, err := GetInstalledPGPKeys([]string{addedKey[0].KeyID})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("error get installed PGP keys: %w", err)
|
|
} else if len(importedKey) != 1 {
|
|
return nil, nil, fmt.Errorf("could not get details of imported key ID %s", importedKey)
|
|
}
|
|
newKeys = append(newKeys, key)
|
|
fingerprints = append(fingerprints, importedKey[0].Fingerprint)
|
|
}
|
|
|
|
// Delete all keys from the keyring that are not found in the configuration anymore.
|
|
for key := range installed {
|
|
secret, err := IsSecretKey(key)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("error check secret key: %w", err)
|
|
}
|
|
if _, ok := configured[key]; !ok && !secret {
|
|
err := DeletePGPKey(key)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("error delete PGP keys: %w", err)
|
|
}
|
|
removedKeys = append(removedKeys, key)
|
|
}
|
|
}
|
|
|
|
// Update owner trust for new keys
|
|
if len(fingerprints) > 0 {
|
|
_ = SetPGPTrustLevelById(fingerprints, TrustUltimate)
|
|
}
|
|
|
|
return newKeys, removedKeys, nil
|
|
}
|
|
|
|
// Result of a git commit verification
|
|
type pgpVerifyResult struct {
|
|
// Date the signature was made
|
|
Date string
|
|
// KeyID the signature was made with
|
|
KeyID string
|
|
// Identity
|
|
Identity string
|
|
// Trust level of the key
|
|
Trust string
|
|
// Cipher of the key the signature was made with
|
|
Cipher string
|
|
// Result of verification - "unknown", "good" or "bad"
|
|
Result string
|
|
// Additional informational message
|
|
Message string
|
|
}
|
|
|
|
// Signature verification results
|
|
const (
|
|
verifyResultGood = "Good"
|
|
verifyResultBad = "Bad"
|
|
verifyResultInvalid = "Invalid"
|
|
verifyResultUnknown = "Unknown"
|
|
)
|
|
|
|
// maxVerificationLinesToParse is a maximum number of lines to parse for a gpg verify-commit output
|
|
const maxVerificationLinesToParse = 40
|
|
|
|
var (
|
|
// Regular expression to match the start of a commit signature verification
|
|
verificationStartMatch = regexp.MustCompile(`^gpg: Signature made ([a-zA-Z0-9\ :]+)$`)
|
|
|
|
// Regular expression to match the key ID of a commit signature verification
|
|
verificationKeyIDMatch = regexp.MustCompile(`^gpg:\s+using\s([A-Za-z]+)\skey\s([a-zA-Z0-9]+)$`)
|
|
|
|
// Regular expression to match possible additional fields of a commit signature verification
|
|
verificationAdditionalFields = regexp.MustCompile(`^gpg:\s+issuer\s.+$`)
|
|
|
|
// Regular expression to match the signature status of a commit signature verification
|
|
verificationStatusMatch = regexp.MustCompile(`^gpg: ([a-zA-Z]+) signature from "([^"]+)" \[([a-zA-Z]+)\]$`)
|
|
)
|
|
|
|
func VerifyGnuPGSignature(revision string, validKeys []string, verifyResult string) (condition *appsv1.ApplicationCondition) { // nolint:staticcheck
|
|
now := metav1.Now()
|
|
// We need to have some data in the verification result to parse, otherwise there was no signature
|
|
if verifyResult != "" {
|
|
verifyResult := parseGitCommitVerification(verifyResult)
|
|
switch verifyResult.Result {
|
|
case verifyResultGood:
|
|
// This is the only case we allow to sync to, but we need to make sure signing key is allowed
|
|
validKey := false
|
|
for _, k := range validKeys {
|
|
declared, _ := KeyID(k)
|
|
present, _ := KeyID(verifyResult.KeyID)
|
|
if declared == present && declared != "" {
|
|
validKey = true
|
|
break
|
|
}
|
|
}
|
|
if !validKey {
|
|
msg := fmt.Sprintf("Found good signature made with %s key %s, but this key is not allowed in AppProject",
|
|
verifyResult.Cipher, verifyResult.KeyID)
|
|
condition = &appsv1.ApplicationCondition{Type: appsv1.ApplicationConditionComparisonError, Message: msg, LastTransitionTime: &now}
|
|
}
|
|
case verifyResultInvalid:
|
|
msg := fmt.Sprintf("Found signature made with %s key %s, but verification result was invalid: '%s'",
|
|
verifyResult.Cipher, verifyResult.KeyID, verifyResult.Message)
|
|
condition = &appsv1.ApplicationCondition{Type: appsv1.ApplicationConditionComparisonError, Message: msg, LastTransitionTime: &now}
|
|
default:
|
|
msg := fmt.Sprintf("Could not verify commit signature on revision '%s', check logs for more information.", revision)
|
|
condition = &appsv1.ApplicationCondition{Type: appsv1.ApplicationConditionComparisonError, Message: msg, LastTransitionTime: &now}
|
|
}
|
|
} else {
|
|
msg := fmt.Sprintf("Target revision %s in Git is not signed, but a signature is required", revision)
|
|
condition = &appsv1.ApplicationCondition{Type: appsv1.ApplicationConditionComparisonError, Message: msg, LastTransitionTime: &now}
|
|
}
|
|
|
|
return condition
|
|
}
|
|
|
|
// parseGitCommitVerification parses the output of "git verify-commit" and returns the result
|
|
func parseGitCommitVerification(signature string) pgpVerifyResult {
|
|
result := pgpVerifyResult{Result: verifyResultUnknown}
|
|
parseOk := false
|
|
linesParsed := 0
|
|
|
|
// Shortcut for returning an unknown verification result with a reason
|
|
unknownResult := func(reason string) pgpVerifyResult {
|
|
return pgpVerifyResult{
|
|
Result: verifyResultUnknown,
|
|
Message: reason,
|
|
}
|
|
}
|
|
|
|
scanner := bufio.NewScanner(strings.NewReader(signature))
|
|
for scanner.Scan() && linesParsed < maxVerificationLinesToParse {
|
|
linesParsed++
|
|
|
|
// Indicating the beginning of a signature
|
|
start := verificationStartMatch.FindStringSubmatch(scanner.Text())
|
|
if len(start) == 2 {
|
|
result.Date = start[1]
|
|
if !scanner.Scan() {
|
|
return unknownResult("Unexpected end-of-file while parsing commit verification output.")
|
|
}
|
|
|
|
linesParsed++
|
|
|
|
// What key has made the signature?
|
|
keyID := verificationKeyIDMatch.FindStringSubmatch(scanner.Text())
|
|
if len(keyID) != 3 {
|
|
return unknownResult("Could not parse key ID of commit verification output.")
|
|
}
|
|
|
|
result.Cipher = keyID[1]
|
|
keyId, err := KeyID(keyID[2])
|
|
if err != nil {
|
|
return unknownResult("Invalid PGP key ID found in verification result: " + result.KeyID)
|
|
}
|
|
result.KeyID = keyId
|
|
|
|
// What was the result of signature verification?
|
|
if !scanner.Scan() {
|
|
return unknownResult("Unexpected end-of-file while parsing commit verification output.")
|
|
}
|
|
|
|
linesParsed++
|
|
|
|
// Skip additional fields
|
|
for verificationAdditionalFields.MatchString(scanner.Text()) {
|
|
if !scanner.Scan() {
|
|
return unknownResult("Unexpected end-of-file while parsing commit verification output.")
|
|
}
|
|
|
|
linesParsed++
|
|
}
|
|
|
|
if strings.HasPrefix(scanner.Text(), "gpg: Can't check signature: ") {
|
|
result.Result = verifyResultInvalid
|
|
result.Identity = "unknown"
|
|
result.Trust = TrustUnknown
|
|
result.Message = scanner.Text()
|
|
} else {
|
|
sigState := verificationStatusMatch.FindStringSubmatch(scanner.Text())
|
|
if len(sigState) != 4 {
|
|
return unknownResult("Could not parse result of verify operation, check logs for more information.")
|
|
}
|
|
|
|
switch strings.ToLower(sigState[1]) {
|
|
case "good":
|
|
result.Result = verifyResultGood
|
|
case "bad":
|
|
result.Result = verifyResultBad
|
|
default:
|
|
result.Result = verifyResultInvalid
|
|
}
|
|
result.Identity = sigState[2]
|
|
|
|
// Did we catch a valid trust?
|
|
if _, ok := pgpTrustLevels[sigState[3]]; ok {
|
|
result.Trust = sigState[3]
|
|
} else {
|
|
result.Trust = TrustUnknown
|
|
}
|
|
result.Message = "Success verifying the commit signature."
|
|
}
|
|
|
|
// No more data to parse here
|
|
parseOk = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if parseOk && linesParsed < maxVerificationLinesToParse {
|
|
// Operation successful - return result
|
|
return result
|
|
} else if linesParsed >= maxVerificationLinesToParse {
|
|
// Too many output lines, return error
|
|
return unknownResult("Too many lines of gpg verify-commit output, abort.")
|
|
}
|
|
// No data found, return error
|
|
return unknownResult(fmt.Sprintf("Could not parse output of verify-commit, no verification data found, lines parsed %d.", linesParsed))
|
|
}
|
|
|
|
func CommitSignatureError(ctx context.Context, verifyCommit bool, gitClient git.Client, revision string, repo *appsv1.Repository) error {
|
|
if IsGPGEnabled() && verifyCommit {
|
|
cs, err := gitClient.VerifyCommitSignature(ctx, revision) // nolint:staticcheck
|
|
if err != nil {
|
|
log.Errorf("error verifying signature of commit '%s' in repo '%s': %v", revision, repo.Repo, err)
|
|
return err
|
|
}
|
|
|
|
if cs == "" {
|
|
return fmt.Errorf("revision %s is not signed", revision)
|
|
}
|
|
vr := parseGitCommitVerification(cs)
|
|
if vr.Result == verifyResultUnknown {
|
|
return fmt.Errorf("UNKNOWN signature: %s", vr.Message)
|
|
}
|
|
log.Debugf("%s signature from %s key %s", vr.Result, vr.Cipher, vr.KeyID)
|
|
}
|
|
return nil
|
|
}
|