1package replica
2
3import (
4 "bytes"
5 "crypto/sha512"
6 "encoding/base64"
7 "errors"
8 "fmt"
9 "io"
10 "slices"
11 "strings"
12
13 "github.com/go-git/go-git/v5/plumbing"
14 "github.com/go-git/go-git/v5/plumbing/object"
15 "github.com/go-git/go-git/v5/plumbing/storer"
16 "golang.org/x/crypto/ssh"
17)
18
19var (
20 ErrUnsigned = errors.New("replica: commit is not signed")
21 ErrNotSSHSignature = errors.New("replica: signature is not an SSH signature")
22 ErrMalformed = errors.New("replica: malformed SSH signature")
23 ErrNamespace = errors.New("replica: signature namespace is not git")
24 ErrWrongKey = errors.New("replica: commit signed by another key")
25 ErrBadSignature = errors.New("replica: signature does not match the commit")
26)
27
28const (
29 sigHeader = "gpgsig "
30 sigHeaderSHA256 = "gpgsig-sha256 "
31)
32
33func VerifyCommit(objects storer.EncodedObjectStorer, id plumbing.Hash, key ssh.PublicKey) (*object.Commit, error) {
34 if key == nil || key.Type() != ssh.KeyAlgoED25519 {
35 return nil, ErrUnsupportedKey
36 }
37 return VerifySSHCommit(objects, id, key, ssh.KeyAlgoED25519)
38}
39
40func VerifySSHCommit(objects storer.EncodedObjectStorer, id plumbing.Hash, key ssh.PublicKey, formats ...string) (*object.Commit, error) {
41 if key == nil || len(formats) == 0 {
42 return nil, ErrUnsupportedKey
43 }
44 obj, err := objects.EncodedObject(plumbing.CommitObject, id)
45 if err != nil {
46 return nil, err
47 }
48 rd, err := obj.Reader()
49 if err != nil {
50 return nil, err
51 }
52 raw, err := io.ReadAll(rd)
53 rd.Close()
54 if err != nil {
55 return nil, err
56 }
57 payload, armored, err := splitSignature(raw)
58 if err != nil {
59 return nil, fmt.Errorf("%w: %s", err, id)
60 }
61 blob, err := dearmor(armored)
62 if err != nil {
63 return nil, fmt.Errorf("%w: %s", err, id)
64 }
65 if err := verifySSHBlob(blob, payload, key, formats); err != nil {
66 return nil, fmt.Errorf("%w: %s", err, id)
67 }
68 return object.DecodeCommit(objects, obj)
69}
70
71func splitSignature(raw []byte) ([]byte, []byte, error) {
72 end := bytes.Index(raw, []byte("\n\n"))
73 if end < 0 {
74 return nil, nil, ErrUnsigned
75 }
76 headers, rest := raw[:end+1], raw[end+1:]
77 var payload, sig bytes.Buffer
78 found, inSig := 0, false
79 for len(headers) > 0 {
80 i := bytes.IndexByte(headers, '\n')
81 line := headers[:i+1]
82 headers = headers[i+1:]
83 if inSig && line[0] == ' ' {
84 sig.Write(line[1:])
85 continue
86 }
87 inSig = false
88 switch {
89 case bytes.HasPrefix(line, []byte(sigHeader)):
90 found++
91 inSig = true
92 sig.Write(line[len(sigHeader):])
93 case bytes.HasPrefix(line, []byte(sigHeaderSHA256)):
94 return nil, nil, ErrMalformed
95 default:
96 payload.Write(line)
97 }
98 }
99 switch {
100 case found == 0:
101 return nil, nil, ErrUnsigned
102 case found > 1:
103 return nil, nil, ErrMalformed
104 }
105 payload.Write(rest)
106 return payload.Bytes(), sig.Bytes(), nil
107}
108
109func dearmor(armored []byte) ([]byte, error) {
110 text := string(armored)
111 if !strings.HasPrefix(text, armorBegin+"\n") {
112 return nil, ErrNotSSHSignature
113 }
114 body := strings.TrimSuffix(strings.TrimPrefix(text, armorBegin+"\n"), "\n")
115 if !strings.HasSuffix(body, "\n"+armorEnd) {
116 return nil, ErrMalformed
117 }
118 body = strings.TrimSuffix(body, "\n"+armorEnd)
119 blob, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(body, "\n", ""))
120 if err != nil {
121 return nil, ErrMalformed
122 }
123 return blob, nil
124}
125
126func verifyBlob(blob, payload []byte, key ssh.PublicKey) error {
127 return verifySSHBlob(blob, payload, key, []string{ssh.KeyAlgoED25519})
128}
129
130func verifySSHBlob(blob, payload []byte, key ssh.PublicKey, formats []string) error {
131 if !bytes.HasPrefix(blob, []byte(sigMagic)) {
132 return ErrMalformed
133 }
134 var w sigBlob
135 if err := ssh.Unmarshal(blob[len(sigMagic):], &w); err != nil {
136 return ErrMalformed
137 }
138 if w.Version != sigVersion || w.Reserved != "" || w.HashAlg != sigHashAlg {
139 return ErrMalformed
140 }
141 if w.Namespace != Namespace {
142 return ErrNamespace
143 }
144 signer, err := ssh.ParsePublicKey(w.PublicKey)
145 if err != nil {
146 return ErrMalformed
147 }
148 if !bytes.Equal(signer.Marshal(), key.Marshal()) {
149 return ErrWrongKey
150 }
151 var sig ssh.Signature
152 if err := ssh.Unmarshal(w.Signature, &sig); err != nil || len(sig.Rest) != 0 || !slices.Contains(formats, sig.Format) {
153 return ErrMalformed
154 }
155 sum := sha512.Sum512(payload)
156 signed := append([]byte(sigMagic), ssh.Marshal(signedData{
157 Namespace: w.Namespace,
158 HashAlg: w.HashAlg,
159 Hash: sum[:],
160 })...)
161 if err := key.Verify(signed, &sig); err != nil {
162 return ErrBadSignature
163 }
164 return nil
165}