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}