1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package sigverify
4
5import (
6 "bytes"
7 "crypto/sha256"
8 "crypto/sha512"
9 "encoding/base64"
10 "strings"
11
12 "golang.org/x/crypto/ssh"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// SSHSIG constants (PROTOCOL.sshsig); the namespace is the one Git signs with.
19const (
20 sshMagic = "SSHSIG"
21 sshVersion = 1
22 sshNamespace = "git"
23 sshArmorHead = "-----BEGIN SSH SIGNATURE-----"
24 sshArmorTail = "-----END SSH SIGNATURE-----"
25)
26
27// SSH verifies SSH signatures (gpg.format=ssh). It ignores the trusted keys
28// that are not SSH keys.
29type SSH struct{}
30
31var _ ports.SignatureVerifier = SSH{}
32
33// sshSignedData is what the signature covers after the magic.
34type sshSignedData struct {
35 Namespace string
36 Reserved string
37 HashAlgorithm string
38 Hash string
39}
40
41// sshSigBlob is the signature file after the magic.
42type sshSigBlob struct {
43 Version uint32
44 PublicKey string
45 Namespace string
46 Reserved string
47 HashAlgorithm string
48 Signature string
49}
50
51// Verify implements ports.SignatureVerifier.
52func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
53 v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature}
54 defer func() { // a parser bug must not take the server down
55 if recover() != nil {
56 v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature, Fingerprint: v.Fingerprint}
57 }
58 }()
59 if len(signature) > maxSignatureBytes {
60 return v
61 }
62 blob, ok := decodeSSHArmor(signature)
63 if !ok || !bytes.HasPrefix(blob, []byte(sshMagic)) {
64 return v
65 }
66 var sb sshSigBlob
67 if ssh.Unmarshal(blob[len(sshMagic):], &sb) != nil {
68 return v
69 }
70 pk, err := ssh.ParsePublicKey([]byte(sb.PublicKey))
71 if err != nil {
72 return v
73 }
74 v.Fingerprint = ssh.FingerprintSHA256(pk)
75 if sb.Version != sshVersion || sb.Namespace != sshNamespace {
76 return v
77 }
78 var sum []byte
79 switch sb.HashAlgorithm {
80 case "sha256":
81 h := sha256.Sum256(payload)
82 sum = h[:]
83 case "sha512":
84 h := sha512.Sum512(payload)
85 sum = h[:]
86 default:
87 return v
88 }
89 var sf struct {
90 Format string
91 Blob string
92 Rest []byte `ssh:"rest"`
93 }
94 if ssh.Unmarshal([]byte(sb.Signature), &sf) != nil {
95 return v
96 }
97 if pk.Type() == ssh.KeyAlgoRSA && sf.Format != ssh.KeyAlgoRSASHA256 && sf.Format != ssh.KeyAlgoRSASHA512 {
98 return v // ssh-rsa is SHA-1
99 }
100 msg := append([]byte(sshMagic), ssh.Marshal(sshSignedData{
101 Namespace: sb.Namespace, Reserved: sb.Reserved, HashAlgorithm: sb.HashAlgorithm, Hash: string(sum),
102 })...)
103 if pk.Verify(msg, &ssh.Signature{Format: sf.Format, Blob: []byte(sf.Blob), Rest: sf.Rest}) != nil {
104 return v
105 }
106
107 want := pk.Marshal()
108 for _, k := range keys {
109 if k.Format != domain.FormatSSH {
110 continue
111 }
112 tk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.PublicKey))
113 if err != nil || !bytes.Equal(tk.Marshal(), want) {
114 continue
115 }
116 return trusted(v, k, committerEmail)
117 }
118 v.Status = domain.StatusUnknownKey
119 return v
120}
121
122// decodeSSHArmor returns the binary signature file of an armored SSH
123// signature.
124func decodeSSHArmor(armored []byte) ([]byte, bool) {
125 lines := strings.Split(strings.TrimSpace(string(armored)), "\n")
126 if len(lines) < 3 || strings.TrimSpace(lines[0]) != sshArmorHead || strings.TrimSpace(lines[len(lines)-1]) != sshArmorTail {
127 return nil, false
128 }
129 var b strings.Builder
130 for _, l := range lines[1 : len(lines)-1] {
131 b.WriteString(strings.TrimSpace(l))
132 }
133 raw, err := base64.StdEncoding.DecodeString(b.String())
134 return raw, err == nil
135}