v0.36.0 1fb2421

signatures.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"fmt"
8	"slices"
9	"sync"
10
11	"github.com/bnema/zerowrap"
12
13	"bnema.dev/ermite/internal/domain"
14	"bnema.dev/ermite/internal/ports"
15)
16
17// MaxSignatureCacheEntries bounds the verification results kept in memory.
18const MaxSignatureCacheEntries = 10000
19
20// Signatures manages the trusted keys and tells whether commits are signed by
21// them (ADR 0030). The keys are loaded once and kept in memory: this process
22// is their only writer.
23type Signatures struct {
24	store     ports.TrustedKeyStore
25	inspector ports.KeyInspector
26	verifiers map[domain.SignatureFormat]ports.SignatureVerifier
27	objects   ports.CommitObjectReader
28	builtin   domain.TrustedKey
29	clock     ports.Clock
30
31	// mu guards stored and generation. Mutations hold it for writing during
32	// the store call, which serializes them and makes the limit check exact.
33	mu     sync.RWMutex
34	stored []domain.TrustedKey // oldest first
35	// generation changes whenever the set of trusted keys does, so cached
36	// results of an older set are never returned.
37	generation uint64
38
39	cacheMu sync.Mutex
40	cache   map[sigKey]domain.CommitVerification
41}
42
43var (
44	_ ports.TrustedKeyService = (*Signatures)(nil)
45	_ ports.CommitVerifier    = (*Signatures)(nil)
46)
47
48// sigKey identifies a cached verification.
49type sigKey struct {
50	repo       domain.RepoName
51	id         domain.ObjectID
52	generation uint64
53}
54
55// NewSignatures loads the stored trusted keys. builtin is the instance
56// signing key (Builtin, SSH, ID = its fingerprint), built by the caller; a
57// zero ID means there is none.
58func NewSignatures(store ports.TrustedKeyStore, inspector ports.KeyInspector, verifiers map[domain.SignatureFormat]ports.SignatureVerifier,
59	objects ports.CommitObjectReader, builtin domain.TrustedKey, clock ports.Clock) (*Signatures, error) {
60	keys, err := store.ListTrustedKeys(context.Background())
61	if err != nil {
62		return nil, fmt.Errorf("load trusted keys: %w", err)
63	}
64	return &Signatures{
65		store: store, inspector: inspector, verifiers: verifiers, objects: objects, builtin: builtin, clock: clock,
66		stored: keys, cache: map[sigKey]domain.CommitVerification{},
67	}, nil
68}
69
70func cloneKey(k domain.TrustedKey) domain.TrustedKey {
71	k.Emails = slices.Clone(k.Emails)
72	return k
73}
74
75// requireKeyOwner is the access rule of the key changes: the owner.
76func requireKeyOwner(p domain.Principal) error { return requireOwnerFor(p, "managing trusted keys") }
77
78// ListTrustedKeys lists the instance key first, then the stored keys oldest
79// first. Any authenticated caller may.
80func (s *Signatures) ListTrustedKeys(_ context.Context, p domain.Principal) ([]domain.TrustedKey, error) {
81	if p.IsAnonymous() {
82		return nil, domain.ErrUnauthenticated
83	}
84	s.mu.RLock()
85	defer s.mu.RUnlock()
86	out := make([]domain.TrustedKey, 0, len(s.stored)+1)
87	if s.builtin.ID != "" {
88		out = append(out, cloneKey(s.builtin))
89	}
90	for _, k := range s.stored {
91		out = append(out, cloneKey(k))
92	}
93	return out, nil
94}
95
96// AddTrustedKey stores a key. Only the owner may. The instance key cannot be
97// added again (domain.ErrAlreadyExists), and at most domain.MaxTrustedKeys
98// keys are stored (domain.ErrResourceExhausted).
99func (s *Signatures) AddTrustedKey(ctx context.Context, p domain.Principal, format domain.SignatureFormat, title, publicKey string, emails []string) (domain.TrustedKey, error) {
100	if err := requireKeyOwner(p); err != nil {
101		return domain.TrustedKey{}, err
102	}
103	title, err := domain.ParseTrustedKeyTitle(title)
104	if err != nil {
105		return domain.TrustedKey{}, err
106	}
107	emails, err = domain.NormalizeKeyEmails(emails)
108	if err != nil {
109		return domain.TrustedKey{}, err
110	}
111	if format, err = domain.ParseSignatureFormat(string(format)); err != nil {
112		return domain.TrustedKey{}, err
113	}
114	if len(publicKey) > domain.MaxTrustedKeyBytes {
115		return domain.TrustedKey{}, fmt.Errorf("%w: public key over %d bytes", domain.ErrInvalid, domain.MaxTrustedKeyBytes)
116	}
117	id, canonical, err := s.inspector.Inspect(format, publicKey)
118	if err != nil {
119		return domain.TrustedKey{}, err
120	}
121
122	s.mu.Lock()
123	defer s.mu.Unlock()
124	if (s.builtin.ID != "" && id == s.builtin.ID) || slices.ContainsFunc(s.stored, func(k domain.TrustedKey) bool { return k.ID == id }) {
125		return domain.TrustedKey{}, fmt.Errorf("%w: trusted key %s", domain.ErrAlreadyExists, id)
126	}
127	if len(s.stored) >= domain.MaxTrustedKeys {
128		return domain.TrustedKey{}, fmt.Errorf("%w: at most %d trusted keys", domain.ErrResourceExhausted, domain.MaxTrustedKeys)
129	}
130	key := domain.TrustedKey{
131		ID: id, Format: format, Title: title, PublicKey: canonical, Emails: emails, CreatedAt: s.clock.Now().UTC(),
132	}
133	if err := s.store.CreateTrustedKey(ctx, key); err != nil {
134		return domain.TrustedKey{}, err
135	}
136	s.stored = append(s.stored, key)
137	s.generation++
138	log := zerowrap.FromCtx(ctx)
139	log.Info().Str("key_id", id).Msg("trusted key added")
140	return cloneKey(key), nil
141}
142
143// RemoveTrustedKey deletes a key. Only the owner may. The instance key is
144// refused with domain.ErrPreconditionFailed.
145func (s *Signatures) RemoveTrustedKey(ctx context.Context, p domain.Principal, id string) error {
146	if err := requireKeyOwner(p); err != nil {
147		return err
148	}
149	if id == "" {
150		return fmt.Errorf("%w: key id", domain.ErrInvalid)
151	}
152	s.mu.Lock()
153	defer s.mu.Unlock()
154	if s.builtin.ID != "" && id == s.builtin.ID {
155		return fmt.Errorf("%w: the instance signing key cannot be removed", domain.ErrPreconditionFailed)
156	}
157	if err := s.store.DeleteTrustedKey(ctx, id); err != nil {
158		return err
159	}
160	s.stored = slices.DeleteFunc(s.stored, func(k domain.TrustedKey) bool { return k.ID == id })
161	s.generation++
162	log := zerowrap.FromCtx(ctx)
163	log.Info().Str("key_id", id).Msg("trusted key removed")
164	return nil
165}
166
167// snapshot returns the current trust generation and the trusted keys of each
168// format, the instance key included.
169func (s *Signatures) snapshot() (uint64, map[domain.SignatureFormat][]domain.TrustedKey) {
170	s.mu.RLock()
171	defer s.mu.RUnlock()
172	byFormat := map[domain.SignatureFormat][]domain.TrustedKey{}
173	if s.builtin.ID != "" {
174		byFormat[s.builtin.Format] = append(byFormat[s.builtin.Format], s.builtin)
175	}
176	for _, k := range s.stored {
177		byFormat[k.Format] = append(byFormat[k.Format], k)
178	}
179	return s.generation, byFormat
180}
181
182// VerifyCommits checks the signature of the commits against the trusted keys.
183// Results are cached by repository, commit and trust generation. When the raw
184// commits cannot be read, only the cached results are returned.
185func (s *Signatures) VerifyCommits(ctx context.Context, repo domain.RepoName, commits []domain.Commit) map[domain.ObjectID]domain.CommitVerification {
186	out := make(map[domain.ObjectID]domain.CommitVerification, len(commits))
187	if len(commits) == 0 {
188		return out
189	}
190	gen, keys := s.snapshot()
191
192	var missing []domain.ObjectID
193	byID := make(map[domain.ObjectID]domain.Commit, len(commits))
194	s.cacheMu.Lock()
195	for _, c := range commits {
196		if _, dup := byID[c.ID]; dup {
197			continue
198		}
199		byID[c.ID] = c
200		if v, ok := s.cache[sigKey{repo, c.ID, gen}]; ok {
201			out[c.ID] = v
202		} else {
203			missing = append(missing, c.ID)
204		}
205	}
206	s.cacheMu.Unlock()
207	if len(missing) == 0 {
208		return out
209	}
210
211	raws, err := s.objects.ReadCommitObjects(ctx, repo, missing)
212	if err != nil {
213		log := zerowrap.FromCtx(ctx)
214		log.Warn().Err(err).Msg("read commit objects for signature check")
215		return out
216	}
217	fresh := make(map[sigKey]domain.CommitVerification, len(raws))
218	for _, id := range missing {
219		raw, ok := raws[id]
220		if !ok {
221			continue
222		}
223		v := s.verifyOne(byID[id], raw, keys)
224		out[id] = v
225		fresh[sigKey{repo, id, gen}] = v
226	}
227	s.remember(fresh)
228	return out
229}
230
231// verifyOne checks one raw commit.
232func (s *Signatures) verifyOne(c domain.Commit, raw []byte, keys map[domain.SignatureFormat][]domain.TrustedKey) domain.CommitVerification {
233	payload, sig, signed := domain.SplitCommitSignature(raw)
234	if !signed {
235		return domain.CommitVerification{Status: domain.StatusUnsigned}
236	}
237	format := domain.SignatureFormatOf(sig)
238	switch format {
239	case domain.FormatNone:
240		return domain.CommitVerification{Format: domain.FormatNone, Status: domain.StatusBadSignature}
241	case domain.FormatX509:
242		return domain.CommitVerification{Format: domain.FormatX509, Status: domain.StatusUnsupported}
243	}
244	v, ok := s.verifiers[format]
245	if !ok {
246		return domain.CommitVerification{Format: format, Status: domain.StatusUnsupported}
247	}
248	return v.Verify(payload, sig, c.Committer.Email, c.Committer.Time.Unix(), keys[format])
249}
250
251// remember adds results to the cache. The cache is a plain map: when adding
252// would exceed MaxSignatureCacheEntries, an arbitrary half of the entries
253// (the map iteration order) is dropped first. A result is cheap to compute
254// again, so no recency bookkeeping is kept.
255func (s *Signatures) remember(fresh map[sigKey]domain.CommitVerification) {
256	if len(fresh) == 0 {
257		return
258	}
259	s.cacheMu.Lock()
260	defer s.cacheMu.Unlock()
261	if len(fresh) >= MaxSignatureCacheEntries {
262		clear(s.cache)
263	}
264	for len(s.cache)+len(fresh) > MaxSignatureCacheEntries && len(s.cache) > 0 {
265		drop := len(s.cache) / 2
266		if drop == 0 {
267			drop = 1
268		}
269		for k := range s.cache {
270			if drop == 0 {
271				break
272			}
273			delete(s.cache, k)
274			drop--
275		}
276	}
277	// A page larger than the cache keeps only what fits.
278	n := 0
279	for k, v := range fresh {
280		if n >= MaxSignatureCacheEntries {
281			break
282		}
283		s.cache[k] = v
284		n++
285	}
286}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close