internal/control/sig.go

9d8fd17820457f0c60320c9f4a8b0da1db45a6b0
gitbay/internal/control/sig.go history · blame · raw

318 lines · 10505 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.failErr(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.failErr(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	f, perr := parseFlags(args, flagSpec{Values: []string{"--ref", "--limit", "--path"}, MaxPos: 1, Usage: "repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]"})
131	if perr != nil {
132		return c.fail(protocol.ExitUsage, "%v", perr)
133	}
134	limit, path, filePath, ref := 30, f.pos(0), f.Value("--path"), f.Value("--ref")
135	if f.Has("--limit") {
136		n, err := strconv.Atoi(f.Value("--limit"))
137		if err != nil || n < 1 || n > 1000 {
138			return c.fail(protocol.ExitUsage, "--limit must be 1..1000")
139		}
140		limit = n
141	}
142	if path == "" {
143		return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--ref <r>] [--limit n] [--path <file>]")
144	}
145	repo, code := resolveRepo(c, path, policy.CanRead)
146	if code >= 0 {
147		return code
148	}
149	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
150	if ref == "" {
151		ref = repo.DefaultBranch
152	}
153	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
154		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
155	}
156	var shas []string
157	var err error
158	if filePath != "" {
159		shas, err = gitutil.RevListPath(dir, ref, filePath, limit)
160	} else {
161		shas, err = gitutil.RevList(dir, ref, limit)
162	}
163	if err != nil {
164		return c.fail(protocol.ExitFailure, "reading log: %v", err)
165	}
166
167	type sigOut struct {
168		State       string `json:"state"`
169		Signer      string `json:"signer,omitempty"`
170		Fingerprint string `json:"key_fingerprint,omitempty"`
171	}
172	type out struct {
173		SHA            string `json:"sha"`
174		Subject        string `json:"subject"`
175		AuthorName     string `json:"author_name"`
176		AuthorEmail    string `json:"author_email"`
177		CommitterEmail string `json:"committer_email,omitempty"` // only when it differs
178		Date           string `json:"date"`
179		Signature      sigOut `json:"signature"`
180	}
181	var ds []out
182	for _, sha := range shas {
183		raw, err := gitutil.ReadCommit(dir, sha)
184		if err != nil {
185			return c.fail(protocol.ExitFailure, "%v", err)
186		}
187		parsed, err := sig.ParseCommit(raw)
188		if err != nil {
189			return c.fail(protocol.ExitFailure, "parsing %s: %v", sha, err)
190		}
191		res, err := VerifyCommitCached(c.Store, repo, parsed, sha)
192		if err != nil {
193			return c.fail(protocol.ExitFailure, "verifying %s: %v", sha, err)
194		}
195		d := out{
196			SHA:         sha,
197			Subject:     parsed.Subject,
198			AuthorName:  parsed.AuthorName,
199			AuthorEmail: parsed.AuthorEmail,
200			Date:        time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
201			Signature:   sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
202		}
203		if parsed.CommitterEmail != parsed.AuthorEmail {
204			d.CommitterEmail = parsed.CommitterEmail
205		}
206		if res.SignerUserID != 0 {
207			if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
208				d.Signature.Signer = u.Username
209			}
210		}
211		ds = append(ds, d)
212	}
213	return c.emit(ds, func(w io.Writer) {
214		for _, d := range ds {
215			fmt.Fprintf(w, "%.10s  %-22s %s (%s <%s>)\n", d.SHA, d.Signature.State, d.Subject, d.AuthorName, d.AuthorEmail)
216		}
217	})
218}
219
220// runRepoCommit shows one commit: its metadata, signature verdict, check
221// statuses, and its patch. The web's commit page read these straight from
222// git, which is why no other surface could open a commit.
223func runRepoCommit(c *Ctx, args []string) int {
224	const usage = "repo commit <owner/name> <sha>"
225	if len(args) != 2 {
226		return c.fail(protocol.ExitUsage, "usage: %s", usage)
227	}
228	repo, code := resolveRepo(c, args[0], policy.CanRead)
229	if code >= 0 {
230		return code
231	}
232	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
233	full, err := gitutil.ResolveRef(dir, args[1])
234	if err != nil {
235		return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
236	}
237	raw, err := gitutil.ReadCommit(dir, full)
238	if err != nil {
239		return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
240	}
241	parsed, err := sig.ParseCommit(raw)
242	if err != nil {
243		return c.fail(protocol.ExitFailure, "parsing %s: %v", full, err)
244	}
245	res, err := VerifyCommitCached(c.Store, repo, parsed, full)
246	if err != nil {
247		return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err)
248	}
249	patch, truncated, err := gitutil.ShowPatch(dir, full, 4<<20)
250	if err != nil {
251		return c.fail(protocol.ExitFailure, "%v", err)
252	}
253	if truncated {
254		fmt.Fprintln(c.Stderr, "patch truncated at 4 MiB; clone the repository for the rest")
255	}
256	statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
257	if err != nil {
258		return c.fail(protocol.ExitFailure, "%v", err)
259	}
260
261	// The message body is everything after the subject line.
262	message := ""
263	if i := strings.Index(string(parsed.Payload), "\n\n"); i >= 0 {
264		message = string(parsed.Payload)[i+2:]
265	}
266
267	type checkOut struct {
268		Context string `json:"context"`
269		State   string `json:"state"`
270		URL     string `json:"url,omitempty"`
271	}
272	type sigOut struct {
273		State       string `json:"state"`
274		Signer      string `json:"signer,omitempty"`
275		Fingerprint string `json:"key_fingerprint,omitempty"`
276	}
277	type out struct {
278		Path           string     `json:"path"`
279		SHA            string     `json:"sha"`
280		Subject        string     `json:"subject"`
281		Message        string     `json:"message,omitempty"`
282		AuthorName     string     `json:"author_name"`
283		AuthorEmail    string     `json:"author_email"`
284		CommitterEmail string     `json:"committer_email,omitempty"`
285		Date           string     `json:"date"`
286		Signature      sigOut     `json:"signature"`
287		Checks         []checkOut `json:"checks,omitempty"`
288		// Diff is the unified patch, parsed by the client the same way
289		// mr diff is.
290		Diff string `json:"diff"`
291	}
292	d := out{
293		Path: repo.Path(), SHA: full, Subject: parsed.Subject, Message: message,
294		AuthorName: parsed.AuthorName, AuthorEmail: parsed.AuthorEmail,
295		Date:      time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
296		Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
297		Diff:      patch,
298	}
299	if parsed.CommitterEmail != parsed.AuthorEmail {
300		d.CommitterEmail = parsed.CommitterEmail
301	}
302	if res.SignerUserID != 0 {
303		if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
304			d.Signature.Signer = u.Username
305		}
306	}
307	for _, st := range statuses {
308		d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL})
309	}
310	return c.emit(d, func(w io.Writer) {
311		fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate:   %s\n\n    %s\n",
312			d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
313		if d.Message != "" {
314			fmt.Fprintf(w, "\n%s\n", d.Message)
315		}
316		fmt.Fprintf(w, "\n%s", d.Diff)
317	})
318}