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.
52//
53//hexcheck:ignore suspicious-business-logic-in-adapter this is the SSHSIG protocol check the port delegates to the adapter; the trust and email rules stay in domain
54func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
55 v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature}
56 defer func() { // a parser bug must not take the server down
57 if recover() != nil {
58 v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature, Fingerprint: v.Fingerprint}
59 }
60 }()
61 if len(signature) > maxSignatureBytes {
62 return v
63 }
64 blob, ok := decodeSSHArmor(signature)
65 if !ok || !bytes.HasPrefix(blob, []byte(sshMagic)) {
66 return v
67 }
68 var sb sshSigBlob
69 if ssh.Unmarshal(blob[len(sshMagic):], &sb) != nil {
70 return v
71 }
72 pk, err := ssh.ParsePublicKey([]byte(sb.PublicKey))
73 if err != nil {
74 return v
75 }
76 v.Fingerprint = ssh.FingerprintSHA256(pk)
77 if sb.Version != sshVersion || sb.Namespace != sshNamespace {
78 return v
79 }
80 var sum []byte
81 switch sb.HashAlgorithm {
82 case "sha256":
83 h := sha256.Sum256(payload)
84 sum = h[:]
85 case "sha512":
86 h := sha512.Sum512(payload)
87 sum = h[:]
88 default:
89 return v
90 }
91 var sf struct {
92 Format string
93 Blob string
94 Rest []byte `ssh:"rest"`
95 }
96 if ssh.Unmarshal([]byte(sb.Signature), &sf) != nil {
97 return v
98 }
99 if pk.Type() == ssh.KeyAlgoRSA && sf.Format != ssh.KeyAlgoRSASHA256 && sf.Format != ssh.KeyAlgoRSASHA512 {
100 return v // ssh-rsa is SHA-1
101 }
102 msg := append([]byte(sshMagic), ssh.Marshal(sshSignedData{
103 Namespace: sb.Namespace, Reserved: sb.Reserved, HashAlgorithm: sb.HashAlgorithm, Hash: string(sum),
104 })...)
105 if pk.Verify(msg, &ssh.Signature{Format: sf.Format, Blob: []byte(sf.Blob), Rest: sf.Rest}) != nil {
106 return v
107 }
108
109 want := pk.Marshal()
110 for _, k := range keys {
111 if k.Format != domain.FormatSSH {
112 continue
113 }
114 tk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.PublicKey))
115 if err != nil || !bytes.Equal(tk.Marshal(), want) {
116 continue
117 }
118 return trusted(v, k, committerEmail)
119 }
120 v.Status = domain.StatusUnknownKey
121 return v
122}
123
124// decodeSSHArmor returns the binary signature file of an armored SSH
125// signature.
126func decodeSSHArmor(armored []byte) ([]byte, bool) {
127 lines := strings.Split(strings.TrimSpace(string(armored)), "\n")
128 if len(lines) < 3 || strings.TrimSpace(lines[0]) != sshArmorHead || strings.TrimSpace(lines[len(lines)-1]) != sshArmorTail {
129 return nil, false
130 }
131 var b strings.Builder
132 for _, l := range lines[1 : len(lines)-1] {
133 b.WriteString(strings.TrimSpace(l))
134 }
135 raw, err := base64.StdEncoding.DecodeString(b.String())
136 return raw, err == nil
137}