internal/control/sig.go
336 lines · 10751 bytes
1package control
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "io"
8 "strconv"
9 "strings"
10 "time"
11
12 "gitbay.org/gitbay/internal/gitutil"
13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/sig"
16 "gitbay.org/gitbay/internal/store"
17)
18
19func init() {
20 register(Command{Path: []string{"pgp", "add"},
21 Summary: "register an OpenPGP public key (armored)",
22 Usage: "pgp add < key.asc", ReadsStdin: true, Run: runPGPAdd})
23 register(Command{Path: []string{"pgp", "list"},
24 Summary: "list registered OpenPGP keys",
25 Usage: "pgp list", ReadOnly: true, Run: runPGPList})
26 register(Command{Path: []string{"pgp", "remove"},
27 Summary: "remove an OpenPGP key by fingerprint",
28 Usage: "pgp remove <fingerprint>", Run: runPGPRemove})
29 register(Command{Path: []string{"repo", "commit"},
30 Summary: "show one commit with its patch",
31 Usage: "repo commit <owner/name> <sha>",
32 ReadOnly: true, Run: runRepoCommit})
33 register(Command{Path: []string{"repo", "log"},
34 Summary: "commit log with signature states",
35 Usage: "repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog})
36}
37
38func runPGPAdd(c *Ctx, args []string) int {
39 if len(args) != 0 {
40 return c.fail(protocol.ExitUsage, "usage: pgp add < key.asc")
41 }
42 raw, err := io.ReadAll(io.LimitReader(c.Stdin, 1<<20))
43 if err != nil {
44 return c.fail(protocol.ExitFailure, "reading key: %v", err)
45 }
46 meta, err := sig.ParsePGPKey(raw)
47 if err != nil {
48 return c.fail(protocol.ExitUsage, "%v", err)
49 }
50 uids, _ := json.Marshal(meta.Emails)
51 if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil {
52 if errors.Is(err, store.ErrDuplicateKey) {
53 return c.fail(protocol.ExitUsage, "%v", err)
54 }
55 return c.fail(protocol.ExitFailure, "adding key: %v", err)
56 }
57 type out struct {
58 Fingerprint string `json:"fingerprint"`
59 Emails []string `json:"emails"`
60 }
61 d := out{meta.Fingerprint, meta.Emails}
62 return c.emit(d, func(w io.Writer) {
63 fmt.Fprintf(w, "added %s (%v)\n", d.Fingerprint, d.Emails)
64 })
65}
66
67func runPGPList(c *Ctx, args []string) int {
68 keys, err := c.Store.ListPGPKeys(c.User.ID)
69 if err != nil {
70 return c.fail(protocol.ExitFailure, "%v", err)
71 }
72 type out struct {
73 Fingerprint string `json:"fingerprint"`
74 Emails string `json:"emails"`
75 ExpiresAt *time.Time `json:"expires_at,omitempty"`
76 RevokedAt *time.Time `json:"revoked_at,omitempty"`
77 }
78 var ds []out
79 for _, k := range keys {
80 ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt})
81 }
82 return c.emit(ds, func(w io.Writer) {
83 for _, d := range ds {
84 fmt.Fprintf(w, "%s\t%s\n", d.Fingerprint, d.Emails)
85 }
86 })
87}
88
89func runPGPRemove(c *Ctx, args []string) int {
90 if len(args) != 1 {
91 return c.fail(protocol.ExitUsage, "usage: pgp remove <fingerprint>")
92 }
93 if err := c.Store.RemovePGPKey(c.User.ID, args[0]); err != nil {
94 if errors.Is(err, store.ErrNotFound) {
95 return c.fail(protocol.ExitNotFound, "no key %s on your account", args[0])
96 }
97 return c.fail(protocol.ExitFailure, "%v", err)
98 }
99 return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
100 fmt.Fprintf(w, "removed %s\n", args[0])
101 })
102}
103
104// sigParse is a package-local alias so callers avoid importing sig directly.
105func sigParse(raw []byte) (*sig.Commit, error) { return sig.ParseCommit(raw) }
106
107// VerifyCommitCached verifies one commit with the epoch cache. Shared with
108// the web UI.
109func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) {
110 epoch, err := st.KeyEpoch()
111 if err != nil {
112 return sig.Result{}, err
113 }
114 if res, ok, err := st.CachedSignature(repo.ID, sha, epoch); err != nil {
115 return sig.Result{}, err
116 } else if ok {
117 return res, nil
118 }
119 res, err := sig.VerifyCommit(store.SigDB{Store: st}, parsed)
120 if err != nil {
121 return sig.Result{}, err
122 }
123 if err := st.StoreSignature(repo.ID, sha, res, epoch); err != nil {
124 return sig.Result{}, err
125 }
126 return res, nil
127}
128
129func runRepoLog(c *Ctx, args []string) int {
130 limit := 30
131 var path, filePath, ref string
132 for i := 0; i < len(args); i++ {
133 switch args[i] {
134 case "--ref":
135 if i+1 >= len(args) {
136 return c.fail(protocol.ExitUsage, "--ref requires a value")
137 }
138 ref = args[i+1]
139 i++
140 case "--limit":
141 if i+1 >= len(args) {
142 return c.fail(protocol.ExitUsage, "--limit requires a value")
143 }
144 n, err := strconv.Atoi(args[i+1])
145 if err != nil || n < 1 || n > 1000 {
146 return c.fail(protocol.ExitUsage, "--limit must be 1..1000")
147 }
148 limit = n
149 i++
150 case "--path":
151 if i+1 >= len(args) {
152 return c.fail(protocol.ExitUsage, "--path requires a value")
153 }
154 filePath = args[i+1]
155 i++
156 default:
157 if path != "" {
158 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]")
159 }
160 path = args[i]
161 }
162 }
163 if path == "" {
164 return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]")
165 }
166 repo, code := resolveRepo(c, path, policy.CanRead)
167 if code >= 0 {
168 return code
169 }
170 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
171 if ref == "" {
172 ref = repo.DefaultBranch
173 }
174 if _, err := gitutil.ResolveRef(dir, ref); err != nil {
175 return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
176 }
177 var shas []string
178 var err error
179 if filePath != "" {
180 shas, err = gitutil.RevListPath(dir, ref, filePath, limit)
181 } else {
182 shas, err = gitutil.RevList(dir, ref, limit)
183 }
184 if err != nil {
185 return c.fail(protocol.ExitFailure, "reading log: %v", err)
186 }
187
188 type sigOut struct {
189 State string `json:"state"`
190 Signer string `json:"signer,omitempty"`
191 Fingerprint string `json:"key_fingerprint,omitempty"`
192 }
193 type out struct {
194 SHA string `json:"sha"`
195 Subject string `json:"subject"`
196 AuthorName string `json:"author_name"`
197 AuthorEmail string `json:"author_email"`
198 CommitterEmail string `json:"committer_email,omitempty"` // only when it differs
199 Date string `json:"date"`
200 Signature sigOut `json:"signature"`
201 }
202 var ds []out
203 for _, sha := range shas {
204 raw, err := gitutil.ReadCommit(dir, sha)
205 if err != nil {
206 return c.fail(protocol.ExitFailure, "%v", err)
207 }
208 parsed, err := sig.ParseCommit(raw)
209 if err != nil {
210 return c.fail(protocol.ExitFailure, "parsing %s: %v", sha, err)
211 }
212 res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
213 if err != nil {
214 return c.fail(protocol.ExitFailure, "verifying %s: %v", sha, err)
215 }
216 d := out{
217 SHA: sha,
218 Subject: parsed.Subject,
219 AuthorName: parsed.AuthorName,
220 AuthorEmail: parsed.AuthorEmail,
221 Date: time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
222 Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
223 }
224 if parsed.CommitterEmail != parsed.AuthorEmail {
225 d.CommitterEmail = parsed.CommitterEmail
226 }
227 if res.SignerUserID != 0 {
228 if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
229 d.Signature.Signer = u.Username
230 }
231 }
232 ds = append(ds, d)
233 }
234 return c.emit(ds, func(w io.Writer) {
235 for _, d := range ds {
236 fmt.Fprintf(w, "%.10s %-22s %s (%s <%s>)\n", d.SHA, d.Signature.State, d.Subject, d.AuthorName, d.AuthorEmail)
237 }
238 })
239}
240
241// runRepoCommit shows one commit: its metadata, signature verdict, check
242// statuses, and its patch. The web's commit page read these straight from
243// git, which is why no other surface could open a commit.
244func runRepoCommit(c *Ctx, args []string) int {
245 const usage = "repo commit <owner/name> <sha>"
246 if len(args) != 2 {
247 return c.fail(protocol.ExitUsage, "usage: %s", usage)
248 }
249 repo, code := resolveRepo(c, args[0], policy.CanRead)
250 if code >= 0 {
251 return code
252 }
253 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
254 full, err := gitutil.ResolveRef(dir, args[1])
255 if err != nil {
256 return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
257 }
258 raw, err := gitutil.ReadCommit(dir, full)
259 if err != nil {
260 return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
261 }
262 parsed, err := sig.ParseCommit(raw)
263 if err != nil {
264 return c.fail(protocol.ExitFailure, "parsing %s: %v", full, err)
265 }
266 res, err := VerifyCommitCached(c.Store, repo, parsed, full)
267 if err != nil {
268 return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err)
269 }
270 patch, err := gitutil.ShowPatch(dir, full, 4<<20)
271 if err != nil {
272 return c.fail(protocol.ExitFailure, "%v", err)
273 }
274 statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
275 if err != nil {
276 return c.fail(protocol.ExitFailure, "%v", err)
277 }
278
279 // The message body is everything after the subject line.
280 message := ""
281 if i := strings.Index(string(parsed.Payload), "\n\n"); i >= 0 {
282 message = string(parsed.Payload)[i+2:]
283 }
284
285 type checkOut struct {
286 Context string `json:"context"`
287 State string `json:"state"`
288 URL string `json:"url,omitempty"`
289 }
290 type sigOut struct {
291 State string `json:"state"`
292 Signer string `json:"signer,omitempty"`
293 Fingerprint string `json:"key_fingerprint,omitempty"`
294 }
295 type out struct {
296 Path string `json:"path"`
297 SHA string `json:"sha"`
298 Subject string `json:"subject"`
299 Message string `json:"message,omitempty"`
300 AuthorName string `json:"author_name"`
301 AuthorEmail string `json:"author_email"`
302 CommitterEmail string `json:"committer_email,omitempty"`
303 Date string `json:"date"`
304 Signature sigOut `json:"signature"`
305 Checks []checkOut `json:"checks,omitempty"`
306 // Diff is the unified patch, parsed by the client the same way
307 // mr diff is.
308 Diff string `json:"diff"`
309 }
310 d := out{
311 Path: repo.Path(), SHA: full, Subject: parsed.Subject, Message: message,
312 AuthorName: parsed.AuthorName, AuthorEmail: parsed.AuthorEmail,
313 Date: time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
314 Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
315 Diff: patch,
316 }
317 if parsed.CommitterEmail != parsed.AuthorEmail {
318 d.CommitterEmail = parsed.CommitterEmail
319 }
320 if res.SignerUserID != 0 {
321 if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
322 d.Signature.Signer = u.Username
323 }
324 }
325 for _, st := range statuses {
326 d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL})
327 }
328 return c.emit(d, func(w io.Writer) {
329 fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate: %s\n\n %s\n",
330 d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
331 if d.Message != "" {
332 fmt.Fprintf(w, "\n%s\n", d.Message)
333 }
334 fmt.Fprintf(w, "\n%s", d.Diff)
335 })
336}