internal/control/sig.go

0338e6ace3de199d5fc383852649919b68ef3e42
gitbay/internal/control/sig.go history · blame · raw

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