v0.26.0 df1db36

trustedkeys.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"slices"
10	"strings"
11	"unicode/utf8"
12
13	kv "bnema.dev/ermite-kv"
14	"google.golang.org/protobuf/proto"
15
16	storagepb "bnema.dev/ermite/gen/ermite/storage"
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19)
20
21const (
22	trustedKeyPrefix = "signing/trusted/"
23
24	// maxTrustedKeyIDLen bounds a key id: a fingerprint is 50 bytes at most.
25	maxTrustedKeyIDLen = 128
26)
27
28// TrustedKeys is the ermite-kv backed ports.TrustedKeyStore. It shares the
29// database of the Store it comes from.
30type TrustedKeys struct {
31	db *kv.DB
32}
33
34var _ ports.TrustedKeyStore = (*TrustedKeys)(nil)
35
36// TrustedKeys returns the trusted key store over the same database. It is
37// valid until the Store is closed.
38func (s *Store) TrustedKeys() *TrustedKeys { return &TrustedKeys{db: s.db} }
39
40func trustedKeyKey(id string) []byte { return []byte(trustedKeyPrefix + id) }
41
42// ListTrustedKeys returns the stored keys, oldest first, then by id.
43func (s *TrustedKeys) ListTrustedKeys(ctx context.Context) ([]domain.TrustedKey, error) {
44	var out []domain.TrustedKey
45	for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(trustedKeyPrefix)) {
46		k, err := decodeTrustedKey(item.Value)
47		if err != nil {
48			return nil, fmt.Errorf("decode %s: %w", key, err)
49		}
50		out = append(out, k)
51	}
52	slices.SortStableFunc(out, func(a, b domain.TrustedKey) int {
53		if c := a.CreatedAt.Compare(b.CreatedAt); c != 0 {
54			return c
55		}
56		return strings.Compare(a.ID, b.ID)
57	})
58	return out, nil
59}
60
61// CreateTrustedKey stores signing/trusted/<id> with an "absent" precondition:
62// a taken id yields domain.ErrAlreadyExists. The caller bounds the number of
63// keys.
64func (s *TrustedKeys) CreateTrustedKey(ctx context.Context, k domain.TrustedKey) error {
65	val, err := encodeTrustedKey(k)
66	if err != nil {
67		return err
68	}
69	var b kv.Batch
70	b.RequireAbsent(trustedKeyKey(k.ID))
71	b.Put(trustedKeyKey(k.ID), val)
72	if _, err := s.db.Apply(ctx, &b); err != nil {
73		if errors.Is(err, kv.ErrConflict) {
74			return domain.ErrAlreadyExists
75		}
76		return fmt.Errorf("create trusted key: %w", err)
77	}
78	return nil
79}
80
81// DeleteTrustedKey removes signing/trusted/<id>, or returns domain.ErrNotFound.
82func (s *TrustedKeys) DeleteTrustedKey(ctx context.Context, id string) error {
83	if !validTrustedKeyID(id) {
84		return domain.ErrNotFound
85	}
86	for range updateAttempts {
87		item, ok := s.db.Get(ctx, trustedKeyKey(id))
88		if !ok {
89			return domain.ErrNotFound
90		}
91		var b kv.Batch
92		b.RequireVersion(trustedKeyKey(id), item.Version)
93		b.Delete(trustedKeyKey(id))
94		_, err := s.db.Apply(ctx, &b)
95		if errors.Is(err, kv.ErrConflict) {
96			continue // changed or removed meanwhile: read again
97		}
98		if err != nil {
99			return fmt.Errorf("delete trusted key: %w", err)
100		}
101		return nil
102	}
103	return fmt.Errorf("delete trusted key: %w", domain.ErrConflict)
104}
105
106// validTrustedKeyID reports whether id can be a key id: a fingerprint, so
107// non-empty, bounded, valid UTF-8 text.
108func validTrustedKeyID(id string) bool {
109	return id != "" && len(id) <= maxTrustedKeyIDLen && utf8.ValidString(id)
110}
111
112func encodeTrustedKey(k domain.TrustedKey) ([]byte, error) {
113	if !validTrustedKeyID(k.ID) {
114		return nil, fmt.Errorf("%w: trusted key id has %d bytes", domain.ErrInvalid, len(k.ID))
115	}
116	texts := append([]string{string(k.Format), k.Title, k.PublicKey}, k.Emails...)
117	for _, s := range texts {
118		if !utf8.ValidString(s) {
119			return nil, fmt.Errorf("%w: trusted key text is not valid UTF-8", domain.ErrInvalid)
120		}
121	}
122	return mustMarshal(&storagepb.TrustedKey{
123		Id:        k.ID,
124		Format:    string(k.Format),
125		Title:     k.Title,
126		PublicKey: k.PublicKey,
127		Emails:    k.Emails,
128		CreatedAt: encodeTime(k.CreatedAt),
129	}), nil
130}
131
132func decodeTrustedKey(val []byte) (domain.TrustedKey, error) {
133	var pb storagepb.TrustedKey
134	if err := proto.Unmarshal(val, &pb); err != nil {
135		return domain.TrustedKey{}, fmt.Errorf("decode trusted key: %w", err)
136	}
137	if !validTrustedKeyID(pb.GetId()) {
138		return domain.TrustedKey{}, fmt.Errorf("decode trusted key: id has %d bytes", len(pb.GetId()))
139	}
140	return domain.TrustedKey{
141		ID:        pb.GetId(),
142		Format:    domain.SignatureFormat(pb.GetFormat()),
143		Title:     pb.GetTitle(),
144		PublicKey: pb.GetPublicKey(),
145		Emails:    slices.Clone(pb.GetEmails()),
146		CreatedAt: decodeTime(pb.GetCreatedAt()),
147	}, nil
148}

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 2

Tags 45

↑↓ moveenter openesc close