internal/control/sig.go

8e6fc2062ca26f7a848bdf6ad6345fd345351231
gitbay/internal/control/sig.go history · blame · raw

317 lines · 10236 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.usage()
 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.failInput(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.usage()
 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.usage()
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	if len(args) != 2 {
225		return c.usage()
226	}
227	repo, code := resolveRepo(c, args[0], policy.CanRead)
228	if code >= 0 {
229		return code
230	}
231	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
232	full, err := gitutil.ResolveRef(dir, args[1])
233	if err != nil {
234		return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
235	}
236	raw, err := gitutil.ReadCommit(dir, full)
237	if err != nil {
238		return c.fail(protocol.ExitNotFound, "no commit %q in %s", args[1], repo.Path())
239	}
240	parsed, err := sig.ParseCommit(raw)
241	if err != nil {
242		return c.fail(protocol.ExitFailure, "parsing %s: %v", full, err)
243	}
244	res, err := VerifyCommitCached(c.Store, repo, parsed, full)
245	if err != nil {
246		return c.fail(protocol.ExitFailure, "verifying %s: %v", full, err)
247	}
248	patch, truncated, err := gitutil.ShowPatch(dir, full, 4<<20)
249	if err != nil {
250		return c.fail(protocol.ExitFailure, "%v", err)
251	}
252	if truncated {
253		fmt.Fprintln(c.Stderr, "patch truncated at 4 MiB; clone the repository for the rest")
254	}
255	statuses, err := c.Store.ListCommitStatuses(repo.ID, full)
256	if err != nil {
257		return c.fail(protocol.ExitFailure, "%v", err)
258	}
259
260	// The message body is everything after the subject line.
261	message := ""
262	if i := strings.Index(string(parsed.Payload), "\n\n"); i >= 0 {
263		message = string(parsed.Payload)[i+2:]
264	}
265
266	type checkOut struct {
267		Context string `json:"context"`
268		State   string `json:"state"`
269		URL     string `json:"url,omitempty"`
270	}
271	type sigOut struct {
272		State       string `json:"state"`
273		Signer      string `json:"signer,omitempty"`
274		Fingerprint string `json:"key_fingerprint,omitempty"`
275	}
276	type out struct {
277		Path           string     `json:"path"`
278		SHA            string     `json:"sha"`
279		Subject        string     `json:"subject"`
280		Message        string     `json:"message,omitempty"`
281		AuthorName     string     `json:"author_name"`
282		AuthorEmail    string     `json:"author_email"`
283		CommitterEmail string     `json:"committer_email,omitempty"`
284		Date           string     `json:"date"`
285		Signature      sigOut     `json:"signature"`
286		Checks         []checkOut `json:"checks,omitempty"`
287		// Diff is the unified patch, parsed by the client the same way
288		// mr diff is.
289		Diff string `json:"diff"`
290	}
291	d := out{
292		Path: repo.Path(), SHA: full, Subject: parsed.Subject, Message: message,
293		AuthorName: parsed.AuthorName, AuthorEmail: parsed.AuthorEmail,
294		Date:      time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339),
295		Signature: sigOut{State: string(res.State), Fingerprint: res.KeyFingerprint},
296		Diff:      patch,
297	}
298	if parsed.CommitterEmail != parsed.AuthorEmail {
299		d.CommitterEmail = parsed.CommitterEmail
300	}
301	if res.SignerUserID != 0 {
302		if u, err := c.Store.UserByID(res.SignerUserID); err == nil {
303			d.Signature.Signer = u.Username
304		}
305	}
306	for _, st := range statuses {
307		d.Checks = append(d.Checks, checkOut{st.Context, st.State, st.TargetURL})
308	}
309	return c.emit(d, func(w io.Writer) {
310		fmt.Fprintf(w, "commit %s\nAuthor: %s <%s>\nDate:   %s\n\n    %s\n",
311			d.SHA, d.AuthorName, d.AuthorEmail, d.Date, d.Subject)
312		if d.Message != "" {
313			fmt.Fprintf(w, "\n%s\n", d.Message)
314		}
315		fmt.Fprintf(w, "\n%s", d.Diff)
316	})
317}