internal/control/sig.go

3af6fd1aaf69413d258a4131a627d048d2f1d290
gitbay/internal/control/sig.go history · blame · raw

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}