Raw
  1package config
  2
  3import (
  4	"bytes"
  5	"crypto/rsa"
  6	"fmt"
  7	"strings"
  8
  9	"golang.org/x/crypto/ssh"
 10)
 11
 12const (
 13	maxTrustSize        = 64 * 1024
 14	keyAllowedSigners   = "config.git.allowed_signers"
 15	keyConfigKnownHosts = "config.git.known_hosts"
 16	gitNamespace        = "git"
 17)
 18
 19const (
 20	reasonTrustTooLarge        = "file is larger than 64 KiB"
 21	reasonSignersLine          = "line is not an allowed-signers entry (principals, optional options, a public key)"
 22	reasonSignersKeyType       = "key must be ed25519, RSA with at least 2048 bits, or ECDSA P-256, P-384 or P-521"
 23	reasonSignersCertAuthority = "cert-authority entries are not supported (list each signing key)"
 24	reasonSignersNoGitKey      = "no key is allowed for the git namespace"
 25	reasonKnownHostsLine       = "line is not a known_hosts entry"
 26	reasonKnownHostsEmpty      = "file holds no host key"
 27)
 28
 29func readTrustFile(path, key string) ([]byte, []Problem) {
 30	data, reason := readRegular(path, maxTrustSize, reasonTrustTooLarge, nil)
 31	if reason != "" {
 32		return nil, []Problem{{File: path, Key: key, Reason: reason}}
 33	}
 34	return data, nil
 35}
 36
 37func readTrust(cfg *Config) []Problem {
 38	g := cfg.ConfigGit
 39	if cfg.Source != sourceGit || g == nil {
 40		return nil
 41	}
 42	data, problems := readTrustFile(g.AllowedSigners, keyAllowedSigners)
 43	if problems == nil {
 44		g.Signers, problems = parseAllowedSigners(g.AllowedSigners, data)
 45	}
 46	if g.SSH() {
 47		hosts, hostProblems := readTrustFile(g.KnownHosts, keyConfigKnownHosts)
 48		if hostProblems == nil {
 49			hostProblems = checkKnownHosts(g.KnownHosts, hosts)
 50		}
 51		if hostProblems == nil {
 52			g.KnownHostsData = hosts
 53		}
 54		problems = append(problems, hostProblems...)
 55	}
 56	return problems
 57}
 58
 59func trustLines(data []byte, each func(n int, line string)) {
 60	for i, line := range strings.Split(string(data), "\n") {
 61		line = strings.TrimSpace(line)
 62		if line != "" && line[0] != '#' {
 63			each(i+1, line)
 64		}
 65	}
 66}
 67
 68func parseAllowedSigners(file string, data []byte) ([]ssh.PublicKey, []Problem) {
 69	var keys []ssh.PublicKey
 70	var problems []Problem
 71	trustLines(data, func(n int, line string) {
 72		problem := func(reason string) {
 73			problems = append(problems, Problem{File: file, Line: n, Key: keyAllowedSigners, Reason: reason})
 74		}
 75		i := strings.IndexAny(line, " \t")
 76		if i < 0 {
 77			problem(reasonSignersLine)
 78			return
 79		}
 80		key, _, options, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(line[i:])))
 81		switch {
 82		case err != nil:
 83			problem(reasonSignersLine)
 84		case len(SignatureAlgorithms(key)) == 0:
 85			problem(reasonSignersKeyType)
 86		case hasOption(options, "cert-authority"):
 87			problem(reasonSignersCertAuthority)
 88		case signsGit(options):
 89			keys = append(keys, key)
 90		}
 91	})
 92	if len(problems) == 0 && len(keys) == 0 {
 93		problems = append(problems, Problem{File: file, Key: keyAllowedSigners, Reason: reasonSignersNoGitKey})
 94	}
 95	return keys, problems
 96}
 97
 98func SignatureAlgorithms(key ssh.PublicKey) []string {
 99	if key == nil {
100		return nil
101	}
102	switch key.Type() {
103	case ssh.KeyAlgoED25519, ssh.KeyAlgoECDSA256, ssh.KeyAlgoECDSA384, ssh.KeyAlgoECDSA521:
104		return []string{key.Type()}
105	case ssh.KeyAlgoRSA:
106		if crypto, ok := key.(ssh.CryptoPublicKey); ok {
107			if rsaKey, ok := crypto.CryptoPublicKey().(*rsa.PublicKey); ok && rsaKey.N.BitLen() >= 2048 {
108				return []string{ssh.KeyAlgoRSASHA256, ssh.KeyAlgoRSASHA512}
109			}
110		}
111	}
112	return nil
113}
114
115func hasOption(options []string, name string) bool {
116	for _, o := range options {
117		if strings.EqualFold(o, name) {
118			return true
119		}
120	}
121	return false
122}
123
124func signsGit(options []string) bool {
125	for _, o := range options {
126		name, value, ok := strings.Cut(o, "=")
127		if !ok || !strings.EqualFold(name, "namespaces") {
128			continue
129		}
130		for _, ns := range strings.Split(strings.Trim(value, `"`), ",") {
131			if ns == gitNamespace {
132				return true
133			}
134		}
135		return false
136	}
137	return true
138}
139
140func checkKnownHosts(file string, data []byte) []Problem {
141	var problems []Problem
142	entries := 0
143	trustLines(data, func(n int, line string) {
144		if _, _, _, _, _, err := ssh.ParseKnownHosts([]byte(line)); err != nil {
145			problems = append(problems, Problem{File: file, Line: n, Key: keyConfigKnownHosts, Reason: reasonKnownHostsLine})
146			return
147		}
148		entries++
149	})
150	if len(problems) == 0 && entries == 0 {
151		problems = append(problems, Problem{File: file, Key: keyConfigKnownHosts, Reason: reasonKnownHostsEmpty})
152	}
153	return problems
154}
155
156func SignerPrincipal(file string, key ssh.PublicKey) (string, error) {
157	data, problems := readTrustFile(file, keyAllowedSigners)
158	if len(problems) != 0 {
159		return "", fmt.Errorf("cannot read allowed signers")
160	}
161	principal := ""
162	trustLines(data, func(_ int, line string) {
163		i := strings.IndexAny(line, " \t")
164		if i < 0 {
165			return
166		}
167		listed, _, options, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(line[i:])))
168		if err == nil && !hasOption(options, "cert-authority") && signsGit(options) && bytes.Equal(listed.Marshal(), key.Marshal()) && principal == "" {
169			principal = line[:i]
170		}
171	})
172	if principal == "" {
173		return "", fmt.Errorf("verified signer is no longer listed")
174	}
175	return principal, nil
176}