internal/control/sig.go

235ad4973643a1d36743915dea455c6695399e50
gitbay/internal/control/sig.go history · blame · raw

218 lines · 6734 bytes

  1package control
  2
  3import (
  4	"encoding/json"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"strconv"
  9	"time"
 10
 11	"gitbay.org/gitbay/internal/gitutil"
 12	"gitbay.org/gitbay/internal/policy"
 13	"gitbay.org/gitbay/internal/protocol"
 14	"gitbay.org/gitbay/internal/sig"
 15	"gitbay.org/gitbay/internal/store"
 16)
 17
 18func init() {
 19	register(Command{Path: []string{"pgp", "add"},
 20		Summary: "register an OpenPGP public key (armored, on stdin)", ReadsStdin: true, Run: runPGPAdd})
 21	register(Command{Path: []string{"pgp", "list"},
 22		Summary: "list registered OpenPGP keys", ReadOnly: true, Run: runPGPList})
 23	register(Command{Path: []string{"pgp", "remove"},
 24		Summary: "remove an OpenPGP key by fingerprint", Run: runPGPRemove})
 25	register(Command{Path: []string{"repo", "log"},
 26		Summary: "commit log with signature states: repo log <owner/name> [--limit n] [--path <file>]", ReadOnly: true, Run: runRepoLog})
 27}
 28
 29func runPGPAdd(c *Ctx, args []string) int {
 30	if len(args) != 0 {
 31		return c.fail(protocol.ExitUsage, "usage: pgp add < key.asc")
 32	}
 33	raw, err := io.ReadAll(io.LimitReader(c.Stdin, 1<<20))
 34	if err != nil {
 35		return c.fail(protocol.ExitFailure, "reading key: %v", err)
 36	}
 37	meta, err := sig.ParsePGPKey(raw)
 38	if err != nil {
 39		return c.fail(protocol.ExitUsage, "%v", err)
 40	}
 41	uids, _ := json.Marshal(meta.Emails)
 42	if err := c.Store.AddPGPKey(c.User.ID, meta.Fingerprint, string(raw), string(uids), meta.ExpiresAt, meta.RevokedAt); err != nil {
 43		if errors.Is(err, store.ErrDuplicateKey) {
 44			return c.fail(protocol.ExitUsage, "%v", err)
 45		}
 46		return c.fail(protocol.ExitFailure, "adding key: %v", err)
 47	}
 48	type out struct {
 49		Fingerprint string   `json:"fingerprint"`
 50		Emails      []string `json:"emails"`
 51	}
 52	d := out{meta.Fingerprint, meta.Emails}
 53	return c.emit(d, func(w io.Writer) {
 54		fmt.Fprintf(w, "added %s (%v)\n", d.Fingerprint, d.Emails)
 55	})
 56}
 57
 58func runPGPList(c *Ctx, args []string) int {
 59	keys, err := c.Store.ListPGPKeys(c.User.ID)
 60	if err != nil {
 61		return c.fail(protocol.ExitFailure, "%v", err)
 62	}
 63	type out struct {
 64		Fingerprint string     `json:"fingerprint"`
 65		Emails      string     `json:"emails"`
 66		ExpiresAt   *time.Time `json:"expires_at,omitempty"`
 67		RevokedAt   *time.Time `json:"revoked_at,omitempty"`
 68	}
 69	var ds []out
 70	for _, k := range keys {
 71		ds = append(ds, out{k.Fingerprint, k.UIDsJSON, k.ExpiresAt, k.RevokedAt})
 72	}
 73	return c.emit(ds, func(w io.Writer) {
 74		for _, d := range ds {
 75			fmt.Fprintf(w, "%s\t%s\n", d.Fingerprint, d.Emails)
 76		}
 77	})
 78}
 79
 80func runPGPRemove(c *Ctx, args []string) int {
 81	if len(args) != 1 {
 82		return c.fail(protocol.ExitUsage, "usage: pgp remove <fingerprint>")
 83	}
 84	if err := c.Store.RemovePGPKey(c.User.ID, args[0]); err != nil {
 85		if errors.Is(err, store.ErrNotFound) {
 86			return c.fail(protocol.ExitNotFound, "no key %s on your account", args[0])
 87		}
 88		return c.fail(protocol.ExitFailure, "%v", err)
 89	}
 90	return c.emit(map[string]string{"removed": args[0]}, func(w io.Writer) {
 91		fmt.Fprintf(w, "removed %s\n", args[0])
 92	})
 93}
 94
 95// sigParse is a package-local alias so callers avoid importing sig directly.
 96func sigParse(raw []byte) (*sig.Commit, error) { return sig.ParseCommit(raw) }
 97
 98// VerifyCommitCached verifies one commit with the epoch cache. Shared with
 99// the web UI.
100func VerifyCommitCached(st *store.Store, repo store.Repo, parsed *sig.Commit, sha string) (sig.Result, error) {
101	epoch, err := st.KeyEpoch()
102	if err != nil {
103		return sig.Result{}, err
104	}
105	if res, ok, err := st.CachedSignature(repo.ID, sha, epoch); err != nil {
106		return sig.Result{}, err
107	} else if ok {
108		return res, nil
109	}
110	res, err := sig.VerifyCommit(store.SigDB{Store: st}, parsed)
111	if err != nil {
112		return sig.Result{}, err
113	}
114	if err := st.StoreSignature(repo.ID, sha, res, epoch); err != nil {
115		return sig.Result{}, err
116	}
117	return res, nil
118}
119
120func runRepoLog(c *Ctx, args []string) int {
121	limit := 30
122	var path, filePath string
123	for i := 0; i < len(args); i++ {
124		switch args[i] {
125		case "--limit":
126			if i+1 >= len(args) {
127				return c.fail(protocol.ExitUsage, "--limit requires a value")
128			}
129			n, err := strconv.Atoi(args[i+1])
130			if err != nil || n < 1 || n > 1000 {
131				return c.fail(protocol.ExitUsage, "--limit must be 1..1000")
132			}
133			limit = n
134			i++
135		case "--path":
136			if i+1 >= len(args) {
137				return c.fail(protocol.ExitUsage, "--path requires a value")
138			}
139			filePath = args[i+1]
140			i++
141		default:
142			if path != "" {
143				return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n] [--path <file>]")
144			}
145			path = args[i]
146		}
147	}
148	if path == "" {
149		return c.fail(protocol.ExitUsage, "usage: repo log <owner/name> [--limit n] [--path <file>]")
150	}
151	repo, code := resolveRepo(c, path, policy.CanRead)
152	if code >= 0 {
153		return code
154	}
155	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
156	var shas []string
157	var err error
158	if filePath != "" {
159		shas, err = gitutil.RevListPath(dir, repo.DefaultBranch, filePath, limit)
160	} else {
161		shas, err = gitutil.RevList(dir, repo.DefaultBranch, 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}