krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: internal/sig/sshsig.go · raw
1package sig
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "crypto/sha512"
7 "encoding/base64"
8 "encoding/binary"
9 "fmt"
10 "slices"
11 "strings"
12
13 "golang.org/x/crypto/ssh"
14)
15
16// SSHSIG armored signature format, per openssh PROTOCOL.sshsig.
17const sshsigMagic = "SSHSIG"
18
19type sshsigBlob struct {
20 Version uint32
21 PublicKey []byte
22 Namespace string
23 Reserved string
24 HashAlgorithm string
25 Signature []byte
26}
27
28func verifySSH(db DB, c *Commit) (Result, error) {
29 blob, err := decodeArmor(c.Signature, "SSH SIGNATURE")
30 if err != nil {
31 return Result{State: BadSignature}, nil
32 }
33 sb, err := parseSSHSig(blob)
34 if err != nil {
35 return Result{State: BadSignature}, nil
36 }
37 pub, err := ssh.ParsePublicKey(sb.PublicKey)
38 if err != nil {
39 return Result{State: BadSignature}, nil
40 }
41
42 fp := ssh.FingerprintSHA256(pub)
43 key, found, err := db.SSHSignerByFingerprint(fp)
44 if err != nil {
45 return Result{}, err
46 }
47 if !found {
48 return Result{State: SignedUnknownKey, KeyFingerprint: fp}, nil
49 }
50 res := Result{SignerUserID: key.UserID, KeyFingerprint: fp}
51
52 // Reconstruct the signed blob: MAGIC || namespace || reserved ||
53 // hash_algorithm || H(payload).
54 var h []byte
55 switch sb.HashAlgorithm {
56 case "sha512":
57 d := sha512.Sum512(c.Payload)
58 h = d[:]
59 case "sha256":
60 d := sha256.Sum256(c.Payload)
61 h = d[:]
62 default:
63 res.State = BadSignature
64 return res, nil
65 }
66 if sb.Namespace != "git" {
67 res.State = BadSignature
68 return res, nil
69 }
70 signed := buildSSHSignedData(sb.Namespace, sb.Reserved, sb.HashAlgorithm, h)
71
72 var sshSig ssh.Signature
73 if err := ssh.Unmarshal(sb.Signature, &sshSig); err != nil {
74 res.State = BadSignature
75 return res, nil
76 }
77 if err := pub.Verify(signed, &sshSig); err != nil {
78 res.State = BadSignature
79 return res, nil
80 }
81
82 // SSH keys carry no identities: the principal set is the owning
83 // account's verified emails.
84 verified, err := db.VerifiedEmails(key.UserID)
85 if err != nil {
86 return Result{}, err
87 }
88 if !slices.Contains(verified, c.AuthorEmail) {
89 res.State = SignedEmailMismatch
90 return res, nil
91 }
92 res.State = Verified
93 return res, nil
94}
95
96func parseSSHSig(blob []byte) (*sshsigBlob, error) {
97 if !bytes.HasPrefix(blob, []byte(sshsigMagic)) {
98 return nil, fmt.Errorf("missing SSHSIG magic")
99 }
100 var sb sshsigBlob
101 if err := ssh.Unmarshal(blob[len(sshsigMagic):], &sb); err != nil {
102 return nil, err
103 }
104 if sb.Version != 1 {
105 return nil, fmt.Errorf("unsupported sshsig version %d", sb.Version)
106 }
107 return &sb, nil
108}
109
110func buildSSHSignedData(namespace, reserved, hashAlg string, hash []byte) []byte {
111 var b bytes.Buffer
112 b.WriteString(sshsigMagic)
113 writeSSHString(&b, []byte(namespace))
114 writeSSHString(&b, []byte(reserved))
115 writeSSHString(&b, []byte(hashAlg))
116 writeSSHString(&b, hash)
117 return b.Bytes()
118}
119
120func writeSSHString(b *bytes.Buffer, s []byte) {
121 var l [4]byte
122 binary.BigEndian.PutUint32(l[:], uint32(len(s)))
123 b.Write(l[:])
124 b.Write(s)
125}
126
127// MarshalSSHSig builds an armored SSHSIG over payload with the given signer.
128// Used by fixture generation and (later) forge-side tooling.
129func MarshalSSHSig(signer ssh.Signer, payload []byte) ([]byte, error) {
130 d := sha512.Sum512(payload)
131 signed := buildSSHSignedData("git", "", "sha512", d[:])
132 sshSig, err := signer.Sign(nil, signed)
133 if err != nil {
134 return nil, err
135 }
136 var body bytes.Buffer
137 body.WriteString(sshsigMagic)
138 blob := sshsigBlob{
139 Version: 1,
140 PublicKey: signer.PublicKey().Marshal(),
141 Namespace: "git",
142 Reserved: "",
143 HashAlgorithm: "sha512",
144 Signature: ssh.Marshal(sshSig),
145 }
146 body.Write(ssh.Marshal(blob))
147
148 b64 := base64.StdEncoding.EncodeToString(body.Bytes())
149 var out strings.Builder
150 out.WriteString("-----BEGIN SSH SIGNATURE-----\n")
151 for len(b64) > 70 {
152 out.WriteString(b64[:70] + "\n")
153 b64 = b64[70:]
154 }
155 out.WriteString(b64 + "\n-----END SSH SIGNATURE-----\n")
156 return []byte(out.String()), nil
157}
158
159// decodeArmor extracts the base64 body between BEGIN/END markers for the
160// given label. Works for both SSHSIG and OpenPGP armor (checksum lines and
161// armor headers are skipped).
162func decodeArmor(armored []byte, label string) ([]byte, error) {
163 begin := "-----BEGIN " + label + "-----"
164 end := "-----END " + label + "-----"
165 s := string(armored)
166 i := strings.Index(s, begin)
167 j := strings.Index(s, end)
168 if i < 0 || j < i {
169 return nil, fmt.Errorf("no %s armor", label)
170 }
171 var b64 strings.Builder
172 for _, line := range strings.Split(s[i+len(begin):j], "\n") {
173 line = strings.TrimSpace(line)
174 if line == "" || strings.HasPrefix(line, "=") || strings.Contains(line, ":") {
175 continue // armor checksum or header line
176 }
177 b64.WriteString(line)
178 }
179 return base64.StdEncoding.DecodeString(b64.String())
180}