detached 94ae608

feat: verified commit signatures (SSH and OpenPGP) with protocol badges (#72)

commit
94ae608fcea4e13f07ac015bb4d3955005fb3eda
author
brice · · 2026-10-18 18:14 UTC
committer
bnema · · 2026-10-18 18:14 UTC
parent
f347d8a
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
74 files · +6406 -525
M README.md +1 -0
@@ -130,6 +130,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t
130130 - [Git over HTTP](docs/git-http.md): URLs and authentication
131131 - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens
132132 - [Merge requests](docs/merge-requests.md)
133+- [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys
133134 - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links
134135 - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows
135136 - [CI events](docs/ci-events.md)
A docs/commit-signatures.md +40 -0
@@ -0,0 +1,40 @@
1+# Commit signatures
2+
3+ermite checks commit signatures on the commit list and the commit page and shows a badge with the protocol and the result. The API returns the same result in `Commit.verification` from `ListCommits` and `GetCommit`.
4+
5+## Formats
6+
7+| Format | Git setting | Checked |
8+|---|---|---|
9+| SSH | `gpg.format=ssh` | yes |
10+| OpenPGP | `gpg.format=openpgp` (default) | yes |
11+| X.509 | `gpg.format=x509` | no, shown as unverified |
12+
13+Signatures are checked in the server, without `gpg` or `ssh-keygen`.
14+
15+## States
16+
17+| Badge | Meaning |
18+|---|---|
19+| `SSH Verified` / `GPG Verified` | valid signature, trusted key, committer email listed for that key |
20+| `Unverified` | unknown key, email not listed for the key, expired or revoked OpenPGP key, or X.509; the tooltip says which |
21+| `Bad signature` | the signature is malformed or does not match the commit |
22+| none (list) / `unsigned` (commit page) | no signature |
23+
24+An OpenPGP key's expiry is judged when the signature was made; a revoked key is unverified for all its commits. OpenPGP signatures over SHA-1 count as bad signatures.
25+
26+## Trusted keys
27+
28+Only the owner (the admin token, or a token with every scope, no repository list and no branch prefix) changes the list. Any authenticated caller can read it.
29+
30+```sh
31+ermite signing-key trust add --format ssh --title laptop --email me@example.com ~/.ssh/id_ed25519.pub
32+gpg --armor --export me@example.com | ermite signing-key trust add --format gpg --title gpg --email me@example.com -
33+ermite signing-key trust list
34+ermite signing-key trust remove <id>
35+```
36+
37+- A key may sign for 1 to 10 emails; up to 100 keys.
38+- The id is the fingerprint: `SHA256:…` for SSH, the primary key fingerprint for OpenPGP.
39+- The server's own key (`ermite signing-key show`), which signs squash merges, is always trusted for the committer email and cannot be removed.
40+- Private keys are refused.
M gen/ermite/storage/signing.pb.go +113 -11 generated file: collapsed
M go.mod +3 -1
@@ -13,10 +13,11 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.5.0
16+ bnema.dev/ermite-api v0.6.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
20+ github.com/ProtonMail/go-crypto v1.4.1
2021 github.com/a-h/templ v0.3.1020
2122 github.com/alecthomas/chroma/v2 v2.27.0
2223 github.com/bnema/kvconf v0.1.2
@@ -71,6 +72,7 @@ require (
7172 github.com/cespare/xxhash/v2 v2.3.0 // indirect
7273 github.com/cli/browser v1.3.0 // indirect
7374 github.com/clipperhouse/uax29/v2 v2.6.0 // indirect
75+ github.com/cloudflare/circl v1.6.2 // indirect
7476 github.com/containerd/errdefs v1.0.0 // indirect
7577 github.com/containerd/errdefs/pkg v0.3.0 // indirect
7678 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
M go.sum +6 -2 generated file: collapsed
M internal/adapters/client/client.go +5 -0
@@ -110,6 +110,11 @@ func (c *Client) Environments() ermiteconnect.EnvironmentServiceClient {
110110 return ermiteconnect.NewEnvironmentServiceClient(c.http, c.base, c.opts...)
111111 }
112112
113+// TrustedKeys returns the TrustedKeyService client.
114+func (c *Client) TrustedKeys() ermiteconnect.TrustedKeyServiceClient {
115+ return ermiteconnect.NewTrustedKeyServiceClient(c.http, c.base, c.opts...)
116+}
117+
113118 // Server returns the ServerService client.
114119 func (c *Client) Server() ermiteconnect.ServerServiceClient {
115120 return ermiteconnect.NewServerServiceClient(c.http, c.base, c.opts...)
M internal/adapters/client/client_test.go +3 -3
@@ -139,16 +139,16 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146146 client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch,
147- client.FeatureCIArtifacts, client.FeaturePasskeys,
147+ client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures,
148148 }))
149149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
150150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
151- client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts,
151+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, client.FeatureCIArtifacts, client.FeatureCommitSignatures,
152152 }))
153153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
154154 }
M internal/adapters/client/features.go +4 -1
@@ -45,11 +45,14 @@ const (
4545 // FeatureCIArtifacts is ArtifactService (`ermite run artifacts`,
4646 // `run download`).
4747 FeatureCIArtifacts = "ci.artifacts"
48+ // FeatureCommitSignatures is TrustedKeyService and Commit.verification
49+ // (`ermite signing-key trust`).
50+ FeatureCommitSignatures = "commit_signatures"
4851 )
4952
5053 // UsedFeatures lists the features the CLI uses, in the order they are reported.
5154 func UsedFeatures() []string {
52- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIArtifacts, FeaturePasskeys}
55+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeatureCISecrets, FeatureCIDispatch, FeatureCIArtifacts, FeaturePasskeys, FeatureCommitSignatures}
5356 }
5457
5558 // MissingFeatures returns the features the CLI uses that have is lacking.
A internal/adapters/ermitegit/commitobjects.go +78 -0
@@ -0,0 +1,78 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ git "bnema.dev/ermite-git"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+var _ ports.CommitObjectReader = (*Adapter)(nil)
17+
18+// ReadCommitObjects reads the raw content of commits with ermite-git. Ids that
19+// are missing, are not commits, or are larger than
20+// domain.MaxCommitObjectBytes are left out. The read ends at the first commit
21+// that would take the total over domain.MaxCommitObjectsBytes. It is not routable: the library
22+// always serves it, and the wrapped reader only the repositories the library
23+// cannot read.
24+//
25+//hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain
26+func (a *Adapter) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
27+ if len(ids) > ports.MaxCommitLimit {
28+ return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit)
29+ }
30+ cli := func() (map[domain.ObjectID][]byte, error) {
31+ r, ok := a.cli.(ports.CommitObjectReader)
32+ if !ok {
33+ return nil, fmt.Errorf("read commit objects of %q: the wrapped reader cannot read commit objects", repo)
34+ }
35+ return r.ReadCommitObjects(ctx, repo, ids)
36+ }
37+ return serve(ctx, a, repo, func(r *git.Repo) (map[domain.ObjectID][]byte, error) {
38+ out := make(map[domain.ObjectID][]byte, len(ids))
39+ var total int64
40+ for _, id := range ids {
41+ if err := ctx.Err(); err != nil {
42+ return nil, err
43+ }
44+ if _, done := out[id]; done {
45+ continue
46+ }
47+ gid, err := git.ParseID(id.String())
48+ if err != nil {
49+ return nil, err
50+ }
51+ typ, size, err := r.ObjectHeader(gid)
52+ if errors.Is(err, git.ErrNotFound) {
53+ continue
54+ }
55+ if err != nil {
56+ return nil, err
57+ }
58+ if typ != git.TypeCommit || size > domain.MaxCommitObjectBytes {
59+ continue
60+ }
61+ if total+size > domain.MaxCommitObjectsBytes {
62+ break
63+ }
64+ typ, data, err := r.ReadObject(gid, nil)
65+ if errors.Is(err, git.ErrNotFound) {
66+ continue // removed meanwhile
67+ }
68+ if err != nil {
69+ return nil, err
70+ }
71+ if typ == git.TypeCommit && len(data) <= domain.MaxCommitObjectBytes && total+int64(len(data)) <= domain.MaxCommitObjectsBytes {
72+ out[id] = data
73+ total += int64(len(data))
74+ }
75+ }
76+ return out, nil
77+ }, cli)
78+}
A internal/adapters/ermitegit/commitobjects_test.go +54 -0
@@ -0,0 +1,54 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ermitegit_test
4+
5+import (
6+ "context"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/adapters/ermitegit"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/readertest"
18+)
19+
20+// A repository ermite-git cannot read (alternates) is served by the wrapped
21+// reader, with the same bytes.
22+func TestReadCommitObjectsUnsupportedRepositoryFallsBack(t *testing.T) {
23+ ctx := context.Background()
24+ a, g, data := newAdapter(t, ermitegit.DefaultRoutes(), nil)
25+ h := readertest.NewHistory(t, data)
26+
27+ tip := readertest.ObjectID(t, h.Git("rev-parse", "HEAD"))
28+ ids := []domain.ObjectID{tip, readertest.ObjectID(t, h.Git("rev-parse", "HEAD^{tree}"))}
29+ require.NoError(t, os.WriteFile(filepath.Join(h.Bare, "objects", "info", "alternates"), []byte(filepath.Join(t.TempDir(), "objects")+"\n"), 0o644))
30+ want, err := g.ReadCommitObjects(ctx, h.Name, ids)
31+ require.NoError(t, err)
32+ require.Len(t, want, 1)
33+ got, err := a.ReadCommitObjects(ctx, h.Name, ids)
34+ require.NoError(t, err)
35+ assert.Equal(t, want, got)
36+}
37+
38+// The library and git read the same bytes for every commit of a history.
39+func TestReadCommitObjectsMatchesCLI(t *testing.T) {
40+ ctx := context.Background()
41+ a, g, data := newAdapter(t, ermitegit.DefaultRoutes(), nil)
42+ h := readertest.NewHistory(t, data)
43+ var ids []domain.ObjectID
44+ for _, line := range strings.Fields(h.Git("rev-list", "--all")) {
45+ ids = append(ids, readertest.ObjectID(t, line))
46+ }
47+ require.NotEmpty(t, ids)
48+ want, err := g.ReadCommitObjects(ctx, h.Name, ids)
49+ require.NoError(t, err)
50+ got, err := a.ReadCommitObjects(ctx, h.Name, ids)
51+ require.NoError(t, err)
52+ assert.Len(t, got, len(ids))
53+ assert.Equal(t, want, got)
54+}
M internal/adapters/ermitegit/conformance_test.go +9 -0
@@ -28,3 +28,12 @@ func TestConformanceDefaultRoutes(t *testing.T) {
2828 return a, data
2929 })
3030 }
31+
32+// TestCommitObjects runs the shared CommitObjectReader table against the
33+// adapter.
34+func TestCommitObjects(t *testing.T) {
35+ readertest.CommitObjects(t, func(t *testing.T) (ports.CommitObjectReader, string) {
36+ a, _, data := newAdapter(t, ermitegit.DefaultRoutes(), nil)
37+ return a, data
38+ })
39+}
A internal/adapters/gitcli/commitobjects.go +73 -0
@@ -0,0 +1,73 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "context"
7+ "fmt"
8+
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports"
11+)
12+
13+var _ ports.CommitObjectReader = (*Git)(nil)
14+
15+// ReadCommitObjects reads the raw content of commits with the cat-file pool:
16+// one `info` per id, then `contents` for the commits of acceptable size. Ids
17+// that are missing, are not commits, or are larger than
18+// domain.MaxCommitObjectBytes are left out. The read ends at the first commit
19+// that would take the total over domain.MaxCommitObjectsBytes (known from
20+// `info`, before any content is read, so the stream stays consistent).
21+//
22+//hexcheck:ignore suspicious-business-logic-in-adapter applies the limits of the port and of the domain
23+func (g *Git) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
24+ if len(ids) > ports.MaxCommitLimit {
25+ return nil, fmt.Errorf("%w: %d commit objects asked, at most %d", domain.ErrInvalid, len(ids), ports.MaxCommitLimit)
26+ }
27+ for _, id := range ids {
28+ if id.IsZero() {
29+ return nil, fmt.Errorf("%w: empty commit id", domain.ErrInvalidObjectID)
30+ }
31+ }
32+ dir, err := g.repoDir(repo)
33+ if err != nil {
34+ return nil, err
35+ }
36+ out := make(map[domain.ObjectID][]byte, len(ids))
37+ err = g.cat.do(ctx, dir, func(c *catConn) error {
38+ clear(out) // a retry on a fresh process starts over
39+ var total int64
40+ for _, id := range ids {
41+ if err := ctx.Err(); err != nil {
42+ return err
43+ }
44+ if _, done := out[id]; done {
45+ continue
46+ }
47+ h, err := c.info(id.String())
48+ if notFound(err) {
49+ continue
50+ }
51+ if err != nil {
52+ return err
53+ }
54+ if h.Type != "commit" || h.Size > domain.MaxCommitObjectBytes {
55+ continue
56+ }
57+ if total+h.Size > domain.MaxCommitObjectsBytes {
58+ break
59+ }
60+ _, data, err := c.contents(id.String(), domain.MaxCommitObjectBytes)
61+ if err != nil {
62+ return err
63+ }
64+ out[id] = data
65+ total += int64(len(data))
66+ }
67+ return nil
68+ })
69+ if err != nil {
70+ return nil, fmt.Errorf("read commit objects of %q: %w", repo, err)
71+ }
72+ return out, nil
73+}
A internal/adapters/gitcli/commitobjects_test.go +19 -0
@@ -0,0 +1,19 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "bnema.dev/ermite/internal/ports"
10+ "bnema.dev/ermite/internal/readertest"
11+)
12+
13+func TestReadCommitObjects(t *testing.T) {
14+ readertest.CommitObjects(t, func(t *testing.T) (ports.CommitObjectReader, string) {
15+ g, data := newGit(t)
16+ t.Cleanup(func() { _ = g.Close(context.Background()) })
17+ return g, data
18+ })
19+}
A internal/adapters/kvstore/trustedkeys.go +148 -0
@@ -0,0 +1,148 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
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+
21+const (
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.
30+type TrustedKeys struct {
31+ db *kv.DB
32+}
33+
34+var _ 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.
38+func (s *Store) TrustedKeys() *TrustedKeys { return &TrustedKeys{db: s.db} }
39+
40+func trustedKeyKey(id string) []byte { return []byte(trustedKeyPrefix + id) }
41+
42+// ListTrustedKeys returns the stored keys, oldest first, then by id.
43+func (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.
64+func (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.
82+func (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.
108+func validTrustedKeyID(id string) bool {
109+ return id != "" && len(id) <= maxTrustedKeyIDLen && utf8.ValidString(id)
110+}
111+
112+func 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+
132+func 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+}
A internal/adapters/kvstore/trustedkeys_test.go +126 -0
@@ -0,0 +1,126 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+func newTrustedKey(id string, at time.Time) domain.TrustedKey {
18+ return domain.TrustedKey{
19+ ID: id,
20+ Format: domain.FormatSSH,
21+ Title: "key " + id,
22+ PublicKey: "ssh-ed25519 AAAA" + id,
23+ Emails: []string{"ada@example.org", "ada@work.example"},
24+ CreatedAt: at,
25+ }
26+}
27+
28+func TestTrustedKeysRoundTrip(t *testing.T) {
29+ ctx := context.Background()
30+ dir := t.TempDir()
31+ s := openStore(t, dir)
32+ ks := s.TrustedKeys()
33+
34+ k := newTrustedKey("SHA256:abc/def+ghi", created)
35+ require.NoError(t, ks.CreateTrustedKey(ctx, k))
36+ gpg := domain.TrustedKey{
37+ ID: strings.Repeat("AB", 20), Format: domain.FormatGPG, Title: "gpg é",
38+ PublicKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxyz\n-----END PGP PUBLIC KEY BLOCK-----\n",
39+ Emails: []string{"grace@example.org"}, CreatedAt: created.Add(time.Hour),
40+ }
41+ require.NoError(t, ks.CreateTrustedKey(ctx, gpg))
42+
43+ list, err := ks.ListTrustedKeys(ctx)
44+ require.NoError(t, err)
45+ assert.Equal(t, []domain.TrustedKey{k, gpg}, list)
46+ assert.False(t, list[0].Builtin)
47+
48+ // The keys survive a restart.
49+ require.NoError(t, s.Close(ctx))
50+ list, err = openStore(t, dir).TrustedKeys().ListTrustedKeys(ctx)
51+ require.NoError(t, err)
52+ assert.Equal(t, []domain.TrustedKey{k, gpg}, list)
53+}
54+
55+func TestTrustedKeysEmptyAndInvalid(t *testing.T) {
56+ ctx := context.Background()
57+ ks := openStore(t, t.TempDir()).TrustedKeys()
58+
59+ list, err := ks.ListTrustedKeys(ctx)
60+ require.NoError(t, err)
61+ assert.Empty(t, list)
62+
63+ assert.ErrorIs(t, ks.CreateTrustedKey(ctx, domain.TrustedKey{Title: "no id"}), domain.ErrInvalid)
64+ assert.ErrorIs(t, ks.CreateTrustedKey(ctx, newTrustedKey(strings.Repeat("x", 129), created)), domain.ErrInvalid)
65+ bad := newTrustedKey("k", created)
66+ bad.Title = "\xff"
67+ assert.ErrorIs(t, ks.CreateTrustedKey(ctx, bad), domain.ErrInvalid)
68+ list, err = ks.ListTrustedKeys(ctx)
69+ require.NoError(t, err)
70+ assert.Empty(t, list)
71+}
72+
73+func TestTrustedKeysDuplicateAndUnknown(t *testing.T) {
74+ ctx := context.Background()
75+ ks := openStore(t, t.TempDir()).TrustedKeys()
76+ k := newTrustedKey("SHA256:one", created)
77+ require.NoError(t, ks.CreateTrustedKey(ctx, k))
78+
79+ dup := newTrustedKey("SHA256:one", created.Add(time.Hour))
80+ dup.Title = "other"
81+ assert.ErrorIs(t, ks.CreateTrustedKey(ctx, dup), domain.ErrAlreadyExists)
82+ list, err := ks.ListTrustedKeys(ctx)
83+ require.NoError(t, err)
84+ assert.Equal(t, []domain.TrustedKey{k}, list, "the first key is kept")
85+
86+ assert.ErrorIs(t, ks.DeleteTrustedKey(ctx, "SHA256:nope"), domain.ErrNotFound)
87+ assert.ErrorIs(t, ks.DeleteTrustedKey(ctx, ""), domain.ErrNotFound)
88+}
89+
90+func TestTrustedKeysDelete(t *testing.T) {
91+ ctx := context.Background()
92+ ks := openStore(t, t.TempDir()).TrustedKeys()
93+ a, b := newTrustedKey("a", created), newTrustedKey("b", created.Add(time.Hour))
94+ require.NoError(t, ks.CreateTrustedKey(ctx, a))
95+ require.NoError(t, ks.CreateTrustedKey(ctx, b))
96+
97+ require.NoError(t, ks.DeleteTrustedKey(ctx, "a"))
98+ assert.ErrorIs(t, ks.DeleteTrustedKey(ctx, "a"), domain.ErrNotFound)
99+ list, err := ks.ListTrustedKeys(ctx)
100+ require.NoError(t, err)
101+ assert.Equal(t, []domain.TrustedKey{b}, list)
102+
103+ // A deleted id can be trusted again.
104+ require.NoError(t, ks.CreateTrustedKey(ctx, a))
105+}
106+
107+// List orders by creation time, then by id.
108+func TestTrustedKeysListOrder(t *testing.T) {
109+ ctx := context.Background()
110+ ks := openStore(t, t.TempDir()).TrustedKeys()
111+ for _, k := range []domain.TrustedKey{
112+ newTrustedKey("c", created.Add(2*time.Hour)),
113+ newTrustedKey("b", created),
114+ newTrustedKey("a", created),
115+ newTrustedKey("d", created.Add(time.Hour)),
116+ } {
117+ require.NoError(t, ks.CreateTrustedKey(ctx, k))
118+ }
119+ list, err := ks.ListTrustedKeys(ctx)
120+ require.NoError(t, err)
121+ var ids []string
122+ for _, k := range list {
123+ ids = append(ids, k.ID)
124+ }
125+ assert.Equal(t, []string{"a", "b", "d", "c"}, ids)
126+}
M internal/adapters/rpc/api.go +11 -1
@@ -34,7 +34,7 @@ const RunnerMaxRequestBytes = MaxRequestBytes + 64<<10
3434 // Features are the optional capabilities of the contract that this server
3535 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3636 // when Config.Passkeys is set, and FeatureCISecrets when the settings services
37-// are.
37+// are, and FeatureCommitSignatures when Config.TrustedKeys is set.
3838 var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache"}
3939
4040 // FeaturePasskeys is listed by GetVersion when passkeys are available.
@@ -92,6 +92,10 @@ type Config struct {
9292 // Same rule as Sessions about typed nil pointers.
9393 JobArtifacts ports.JobArtifactService
9494 Artifacts ports.ArtifactService
95+ // TrustedKeys serves TrustedKeyService, and GetVersion lists
96+ // "commit_signatures", when set (ADR 0030). Same rule as Sessions about
97+ // typed nil pointers.
98+ TrustedKeys ports.TrustedKeyService
9599 }
96100
97101 // API is the set of Connect handlers of the contract.
@@ -133,11 +137,17 @@ func New(cfg Config) *API {
133137 if cfg.OIDCKeys != nil {
134138 features = append(features, FeatureCIOIDC)
135139 }
140+ if cfg.TrustedKeys != nil {
141+ features = append(features, FeatureCommitSignatures)
142+ }
136143 mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...))
137144 mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...))
138145 if cfg.Sessions != nil {
139146 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
140147 }
148+ if cfg.TrustedKeys != nil {
149+ mux.Handle(ermiteconnect.NewTrustedKeyServiceHandler(&trustedKeyService{cfg.TrustedKeys}, opts...))
150+ }
141151 if settings {
142152 mux.Handle(ermiteconnect.NewSecretServiceHandler(&secretService{cfg.Secrets}, opts...))
143153 mux.Handle(ermiteconnect.NewVariableServiceHandler(&variableService{cfg.Variables}, opts...))
M internal/adapters/rpc/convert.go +76 -0
@@ -227,9 +227,85 @@ func commitToPB(c domain.Commit) *pb.Commit {
227227 for _, p := range c.Parents {
228228 out.ParentIds = append(out.ParentIds, p.String())
229229 }
230+ if c.Verification.Status != domain.StatusUnchecked {
231+ out.Verification = verificationToPB(c.Verification)
232+ }
230233 return out
231234 }
232235
236+func verificationToPB(v domain.CommitVerification) *pb.CommitVerification {
237+ return &pb.CommitVerification{
238+ Format: signatureFormatToPB(v.Format),
239+ Status: signatureStatusToPB(v.Status),
240+ Fingerprint: v.Fingerprint,
241+ KeyId: v.KeyID,
242+ KeyTitle: v.KeyTitle,
243+ }
244+}
245+
246+func signatureFormatToPB(f domain.SignatureFormat) pb.SignatureFormat {
247+ switch f {
248+ case domain.FormatSSH:
249+ return pb.SignatureFormat_SIGNATURE_FORMAT_SSH
250+ case domain.FormatGPG:
251+ return pb.SignatureFormat_SIGNATURE_FORMAT_GPG
252+ case domain.FormatX509:
253+ return pb.SignatureFormat_SIGNATURE_FORMAT_X509
254+ case domain.FormatNone:
255+ return pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED
256+ }
257+ return pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED
258+}
259+
260+// signatureFormatFromPB maps the format of a key to add: only SSH and GPG keys
261+// can be trusted.
262+func signatureFormatFromPB(f pb.SignatureFormat) (domain.SignatureFormat, error) {
263+ switch f {
264+ case pb.SignatureFormat_SIGNATURE_FORMAT_SSH:
265+ return domain.FormatSSH, nil
266+ case pb.SignatureFormat_SIGNATURE_FORMAT_GPG:
267+ return domain.FormatGPG, nil
268+ case pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, pb.SignatureFormat_SIGNATURE_FORMAT_X509:
269+ }
270+ return domain.FormatNone, fmt.Errorf("%w: key format must be ssh or gpg", domain.ErrInvalid)
271+}
272+
273+func signatureStatusToPB(s domain.SignatureStatus) pb.SignatureStatus {
274+ switch s {
275+ case domain.StatusUnsigned:
276+ return pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED
277+ case domain.StatusVerified:
278+ return pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED
279+ case domain.StatusUnknownKey:
280+ return pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY
281+ case domain.StatusBadSignature:
282+ return pb.SignatureStatus_SIGNATURE_STATUS_BAD_SIGNATURE
283+ case domain.StatusEmailMismatch:
284+ return pb.SignatureStatus_SIGNATURE_STATUS_EMAIL_MISMATCH
285+ case domain.StatusExpired:
286+ return pb.SignatureStatus_SIGNATURE_STATUS_EXPIRED
287+ case domain.StatusRevoked:
288+ return pb.SignatureStatus_SIGNATURE_STATUS_REVOKED
289+ case domain.StatusUnsupported:
290+ return pb.SignatureStatus_SIGNATURE_STATUS_UNSUPPORTED
291+ case domain.StatusUnchecked:
292+ return pb.SignatureStatus_SIGNATURE_STATUS_UNSPECIFIED
293+ }
294+ return pb.SignatureStatus_SIGNATURE_STATUS_UNSPECIFIED
295+}
296+
297+func trustedKeyToPB(k domain.TrustedKey) *pb.TrustedKey {
298+ return &pb.TrustedKey{
299+ Id: k.ID,
300+ Format: signatureFormatToPB(k.Format),
301+ Title: k.Title,
302+ PublicKey: k.PublicKey,
303+ Emails: slices.Clone(k.Emails),
304+ CreatedAt: timestamp(k.CreatedAt),
305+ Builtin: k.Builtin,
306+ }
307+}
308+
233309 func treeEntryToPB(e domain.TreeEntry) *pb.TreeEntry {
234310 out := &pb.TreeEntry{
235311 Name: e.Name,
A internal/adapters/rpc/trusted_keys.go +53 -0
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+
8+ "connectrpc.com/connect"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// FeatureCommitSignatures is listed by GetVersion when the server verifies
15+// commit signatures: Commit.verification and TrustedKeyService (ADR 0030).
16+const FeatureCommitSignatures = "commit_signatures"
17+
18+// trustedKeyService implements ermiteconnect.TrustedKeyServiceHandler. The
19+// owner check of add and remove belongs to the use case.
20+type trustedKeyService struct {
21+ keys ports.TrustedKeyService
22+}
23+
24+func (s *trustedKeyService) ListTrustedKeys(ctx context.Context, _ *connect.Request[pb.ListTrustedKeysRequest]) (*connect.Response[pb.ListTrustedKeysResponse], error) {
25+ list, err := s.keys.ListTrustedKeys(ctx, caller(ctx))
26+ if err != nil {
27+ return nil, connectError(ctx, err)
28+ }
29+ out := make([]*pb.TrustedKey, 0, len(list))
30+ for _, k := range list {
31+ out = append(out, trustedKeyToPB(k))
32+ }
33+ return connect.NewResponse(&pb.ListTrustedKeysResponse{Keys: out}), nil
34+}
35+
36+func (s *trustedKeyService) AddTrustedKey(ctx context.Context, req *connect.Request[pb.AddTrustedKeyRequest]) (*connect.Response[pb.AddTrustedKeyResponse], error) {
37+ format, err := signatureFormatFromPB(req.Msg.GetFormat())
38+ if err != nil {
39+ return nil, connectError(ctx, err)
40+ }
41+ k, err := s.keys.AddTrustedKey(ctx, caller(ctx), format, req.Msg.GetTitle(), req.Msg.GetPublicKey(), req.Msg.GetEmails())
42+ if err != nil {
43+ return nil, connectError(ctx, err)
44+ }
45+ return connect.NewResponse(&pb.AddTrustedKeyResponse{Key: trustedKeyToPB(k)}), nil
46+}
47+
48+func (s *trustedKeyService) RemoveTrustedKey(ctx context.Context, req *connect.Request[pb.RemoveTrustedKeyRequest]) (*connect.Response[pb.RemoveTrustedKeyResponse], error) {
49+ if err := s.keys.RemoveTrustedKey(ctx, caller(ctx), req.Msg.GetId()); err != nil {
50+ return nil, connectError(ctx, err)
51+ }
52+ return connect.NewResponse(&pb.RemoveTrustedKeyResponse{}), nil
53+}
A internal/adapters/rpc/trusted_keys_test.go +273 -0
@@ -0,0 +1,273 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http/httptest"
9+ "slices"
10+ "testing"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ pb "bnema.dev/ermite-api/gen/ermite"
19+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports/mocks"
22+)
23+
24+type statusCase struct {
25+ domain domain.SignatureStatus
26+ pb pb.SignatureStatus
27+}
28+
29+type formatCase struct {
30+ domain domain.SignatureFormat
31+ pb pb.SignatureFormat
32+}
33+
34+var (
35+ statusCases = []statusCase{
36+ {domain.StatusUnsigned, pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED},
37+ {domain.StatusVerified, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED},
38+ {domain.StatusUnknownKey, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY},
39+ {domain.StatusBadSignature, pb.SignatureStatus_SIGNATURE_STATUS_BAD_SIGNATURE},
40+ {domain.StatusEmailMismatch, pb.SignatureStatus_SIGNATURE_STATUS_EMAIL_MISMATCH},
41+ {domain.StatusExpired, pb.SignatureStatus_SIGNATURE_STATUS_EXPIRED},
42+ {domain.StatusRevoked, pb.SignatureStatus_SIGNATURE_STATUS_REVOKED},
43+ {domain.StatusUnsupported, pb.SignatureStatus_SIGNATURE_STATUS_UNSUPPORTED},
44+ }
45+ formatCases = []formatCase{
46+ {domain.FormatSSH, pb.SignatureFormat_SIGNATURE_FORMAT_SSH},
47+ {domain.FormatGPG, pb.SignatureFormat_SIGNATURE_FORMAT_GPG},
48+ {domain.FormatX509, pb.SignatureFormat_SIGNATURE_FORMAT_X509},
49+ }
50+)
51+
52+func TestSignatureEnumMappers(t *testing.T) {
53+ t.Parallel()
54+ for _, c := range statusCases {
55+ assert.Equal(t, c.pb, signatureStatusToPB(c.domain), c.domain)
56+ }
57+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNSPECIFIED, signatureStatusToPB(domain.StatusUnchecked))
58+ for _, c := range formatCases {
59+ assert.Equal(t, c.pb, signatureFormatToPB(c.domain), c.domain)
60+ }
61+ assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, signatureFormatToPB(domain.FormatNone))
62+
63+ // Nothing in the contract is unreachable.
64+ for v, n := range pb.SignatureStatus_name {
65+ if v != 0 {
66+ assert.True(t, slices.ContainsFunc(statusCases, func(c statusCase) bool {
67+ return c.pb == pb.SignatureStatus(v)
68+ }), "%s is never produced", n)
69+ }
70+ }
71+ for v, n := range pb.SignatureFormat_name {
72+ if v != 0 {
73+ assert.True(t, slices.ContainsFunc(formatCases, func(c formatCase) bool {
74+ return c.pb == pb.SignatureFormat(v)
75+ }), "%s is never produced", n)
76+ }
77+ }
78+}
79+
80+func TestSignatureFormatFromPB(t *testing.T) {
81+ t.Parallel()
82+ f, err := signatureFormatFromPB(pb.SignatureFormat_SIGNATURE_FORMAT_SSH)
83+ require.NoError(t, err)
84+ assert.Equal(t, domain.FormatSSH, f)
85+ f, err = signatureFormatFromPB(pb.SignatureFormat_SIGNATURE_FORMAT_GPG)
86+ require.NoError(t, err)
87+ assert.Equal(t, domain.FormatGPG, f)
88+ for _, bad := range []pb.SignatureFormat{pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, pb.SignatureFormat_SIGNATURE_FORMAT_X509, pb.SignatureFormat(99)} {
89+ _, err = signatureFormatFromPB(bad)
90+ assert.ErrorIs(t, err, domain.ErrInvalid, bad)
91+ }
92+}
93+
94+func TestCommitToPBVerification(t *testing.T) {
95+ t.Parallel()
96+ t.Run("unchecked sets nothing", func(t *testing.T) {
97+ t.Parallel()
98+ assert.Nil(t, commitToPB(domain.Commit{}).GetVerification())
99+ })
100+ t.Run("unsigned", func(t *testing.T) {
101+ t.Parallel()
102+ v := commitToPB(domain.Commit{Verification: domain.CommitVerification{Status: domain.StatusUnsigned}}).GetVerification()
103+ require.NotNil(t, v)
104+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED, v.GetStatus())
105+ assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, v.GetFormat())
106+ })
107+ t.Run("verified", func(t *testing.T) {
108+ t.Parallel()
109+ v := commitToPB(domain.Commit{Verification: domain.CommitVerification{
110+ Format: domain.FormatSSH, Status: domain.StatusVerified, Fingerprint: "SHA256:abc", KeyID: "SHA256:abc", KeyTitle: "laptop",
111+ }}).GetVerification()
112+ require.NotNil(t, v)
113+ assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_SSH, v.GetFormat())
114+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, v.GetStatus())
115+ assert.Equal(t, "SHA256:abc", v.GetFingerprint())
116+ assert.Equal(t, "SHA256:abc", v.GetKeyId())
117+ assert.Equal(t, "laptop", v.GetKeyTitle())
118+ })
119+ t.Run("every status", func(t *testing.T) {
120+ t.Parallel()
121+ for _, c := range statusCases {
122+ v := commitToPB(domain.Commit{Verification: domain.CommitVerification{Format: domain.FormatGPG, Status: c.domain}}).GetVerification()
123+ require.NotNil(t, v, c.domain)
124+ assert.Equal(t, c.pb, v.GetStatus())
125+ }
126+ })
127+}
128+
129+func TestTrustedKeyServiceList(t *testing.T) {
130+ t.Parallel()
131+ created := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
132+ keys := []domain.TrustedKey{
133+ {ID: "SHA256:builtin", Format: domain.FormatSSH, Title: "instance", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"ermite@example.com"}, Builtin: true},
134+ {ID: "ABCD", Format: domain.FormatGPG, Title: "me", PublicKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----", Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: created},
135+ }
136+ tests := []struct {
137+ name string
138+ list []domain.TrustedKey
139+ usecase error
140+ wantCode connect.Code
141+ }{
142+ {name: "ok", list: keys},
143+ {name: "empty"},
144+ {name: "anonymous", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
145+ {name: "unknown error", usecase: errors.New("disk on fire"), wantCode: connect.CodeInternal},
146+ }
147+ for _, tt := range tests {
148+ t.Run(tt.name, func(t *testing.T) {
149+ t.Parallel()
150+ svc := mocks.NewTrustedKeyService(t)
151+ svc.EXPECT().ListTrustedKeys(mock.Anything, domain.Owner()).Return(tt.list, tt.usecase).Once()
152+ resp, err := (&trustedKeyService{svc}).ListTrustedKeys(ownerCtx(), connect.NewRequest(&pb.ListTrustedKeysRequest{}))
153+ if tt.wantCode != 0 {
154+ requireCode(t, tt.wantCode, err)
155+ return
156+ }
157+ require.NoError(t, err)
158+ require.Len(t, resp.Msg.GetKeys(), len(tt.list))
159+ for i, want := range tt.list {
160+ got := resp.Msg.GetKeys()[i]
161+ assert.Equal(t, want.ID, got.GetId())
162+ assert.Equal(t, signatureFormatToPB(want.Format), got.GetFormat())
163+ assert.Equal(t, want.Title, got.GetTitle())
164+ assert.Equal(t, want.PublicKey, got.GetPublicKey())
165+ assert.Equal(t, want.Emails, got.GetEmails())
166+ assert.Equal(t, want.Builtin, got.GetBuiltin())
167+ assert.Equal(t, want.CreatedAt.IsZero(), got.GetCreatedAt() == nil)
168+ if !want.CreatedAt.IsZero() {
169+ assert.True(t, created.Equal(got.GetCreatedAt().AsTime()))
170+ }
171+ }
172+ })
173+ }
174+}
175+
176+func TestTrustedKeyServiceAdd(t *testing.T) {
177+ t.Parallel()
178+ stored := domain.TrustedKey{ID: "SHA256:k", Format: domain.FormatSSH, Title: "laptop", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"a@b.c"}}
179+ tests := []struct {
180+ name string
181+ format pb.SignatureFormat
182+ usecase error
183+ callsUC bool
184+ wantCode connect.Code
185+ }{
186+ {name: "ssh", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true},
187+ {name: "unspecified format", format: pb.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED, wantCode: connect.CodeInvalidArgument},
188+ {name: "x509 format", format: pb.SignatureFormat_SIGNATURE_FORMAT_X509, wantCode: connect.CodeInvalidArgument},
189+ {name: "not the owner", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
190+ {name: "invalid key", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrInvalid, wantCode: connect.CodeInvalidArgument},
191+ {name: "already listed", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists},
192+ {name: "too many", format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, callsUC: true, usecase: domain.ErrResourceExhausted, wantCode: connect.CodeResourceExhausted},
193+ }
194+ for _, tt := range tests {
195+ t.Run(tt.name, func(t *testing.T) {
196+ t.Parallel()
197+ svc := mocks.NewTrustedKeyService(t)
198+ if tt.callsUC {
199+ ret := stored
200+ if tt.usecase != nil {
201+ ret = domain.TrustedKey{}
202+ }
203+ svc.EXPECT().AddTrustedKey(mock.Anything, domain.Owner(), domain.FormatSSH, "laptop", "ssh-ed25519 AAAA", []string{"a@b.c"}).Return(ret, tt.usecase).Once()
204+ }
205+ resp, err := (&trustedKeyService{svc}).AddTrustedKey(ownerCtx(), connect.NewRequest(&pb.AddTrustedKeyRequest{
206+ Format: tt.format, Title: "laptop", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"a@b.c"},
207+ }))
208+ if tt.wantCode != 0 {
209+ requireCode(t, tt.wantCode, err)
210+ return
211+ }
212+ require.NoError(t, err)
213+ assert.Equal(t, "SHA256:k", resp.Msg.GetKey().GetId())
214+ assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_SSH, resp.Msg.GetKey().GetFormat())
215+ })
216+ }
217+}
218+
219+func TestTrustedKeyServiceRemove(t *testing.T) {
220+ t.Parallel()
221+ tests := []struct {
222+ name string
223+ usecase error
224+ wantCode connect.Code
225+ }{
226+ {name: "ok"},
227+ {name: "not the owner", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied},
228+ {name: "unknown id", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
229+ {name: "builtin", usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition},
230+ }
231+ for _, tt := range tests {
232+ t.Run(tt.name, func(t *testing.T) {
233+ t.Parallel()
234+ svc := mocks.NewTrustedKeyService(t)
235+ svc.EXPECT().RemoveTrustedKey(mock.Anything, domain.Owner(), "SHA256:k").Return(tt.usecase).Once()
236+ _, err := (&trustedKeyService{svc}).RemoveTrustedKey(ownerCtx(), connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: "SHA256:k"}))
237+ if tt.wantCode != 0 {
238+ requireCode(t, tt.wantCode, err)
239+ return
240+ }
241+ require.NoError(t, err)
242+ })
243+ }
244+}
245+
246+// GetVersion lists "commit_signatures", and the service is mounted, only when
247+// the API is built with trusted keys.
248+func TestAPICommitSignaturesFeature(t *testing.T) {
249+ t.Parallel()
250+ serve := func(cfg Config) (features []string, keys ermiteconnect.TrustedKeyServiceClient) {
251+ cfg.SigningKey = mocks.NewSigningKeyService(t)
252+ srv := httptest.NewServer(New(cfg).Handler())
253+ t.Cleanup(srv.Close)
254+ c := ermiteconnect.NewServerServiceClient(srv.Client(), srv.URL)
255+ resp, err := c.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{}))
256+ require.NoError(t, err)
257+ return resp.Msg.GetFeatures(), ermiteconnect.NewTrustedKeyServiceClient(srv.Client(), srv.URL)
258+ }
259+
260+ features, keys := serve(Config{})
261+ assert.NotContains(t, features, "commit_signatures")
262+ assert.NotContains(t, Features, "commit_signatures", "the shared list is not modified")
263+ _, err := keys.ListTrustedKeys(context.Background(), connect.NewRequest(&pb.ListTrustedKeysRequest{}))
264+ assert.Equal(t, connect.CodeUnimplemented, connect.CodeOf(err))
265+
266+ svc := mocks.NewTrustedKeyService(t)
267+ features, keys = serve(Config{TrustedKeys: svc, Auth: mocks.NewAuthService(t)})
268+ assert.Contains(t, features, "commit_signatures")
269+ // An anonymous caller reaches the use case, which refuses it.
270+ svc.EXPECT().ListTrustedKeys(mock.Anything, domain.Anonymous()).Return(nil, domain.ErrUnauthenticated).Once()
271+ _, err = keys.ListTrustedKeys(context.Background(), connect.NewRequest(&pb.ListTrustedKeysRequest{}))
272+ assert.Equal(t, connect.CodeUnauthenticated, connect.CodeOf(err))
273+}
A internal/adapters/sigverify/gpg.go +177 -0
@@ -0,0 +1,177 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package sigverify
4+
5+import (
6+ "bytes"
7+ "crypto"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "strings"
12+ "time"
13+
14+ "github.com/ProtonMail/go-crypto/openpgp"
15+ "github.com/ProtonMail/go-crypto/openpgp/armor"
16+ pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors"
17+ "github.com/ProtonMail/go-crypto/openpgp/packet"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// acceptedHashes are the digest algorithms of the signatures GPG accepts. The
24+// library's own reject lists (packet.Config.RejectMessageHashAlgorithms) are
25+// not applied to detached signatures, so the allowlist is enforced explicitly:
26+// a signature over MD5, SHA-1, RIPEMD-160 or SHA-224 is a bad signature.
27+var acceptedHashes = []crypto.Hash{crypto.SHA256, crypto.SHA384, crypto.SHA512, crypto.SHA3_256, crypto.SHA3_512}
28+
29+// GPG verifies OpenPGP detached signatures. It ignores the trusted keys that
30+// are not GPG keys.
31+//
32+// Time policy: the committer date is not used. Key, subkey and signature
33+// expiry are judged at the creation time of the signature, which the
34+// signature authenticates, so a key that has expired since still verifies
35+// what it signed while it was valid. Revocation is judged now: a signature by
36+// a key that is revoked today (key, signing subkey or primary user ID) is
37+// reported as revoked, whenever it was made.
38+type GPG struct{}
39+
40+var _ ports.SignatureVerifier = GPG{}
41+
42+// Verify implements ports.SignatureVerifier. signedAt is ignored: see the time
43+// policy of GPG.
44+//
45+//hexcheck:ignore suspicious-business-logic-in-adapter this is the OpenPGP protocol check the port delegates to the adapter; the trust and email rules stay in domain
46+func (GPG) Verify(payload, signature []byte, committerEmail string, _ int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
47+ v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature}
48+ defer func() { // a parser bug must not take the server down
49+ if recover() != nil {
50+ v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature, Fingerprint: v.Fingerprint}
51+ }
52+ }()
53+ if len(signature) > maxSignatureBytes {
54+ return v
55+ }
56+ block, err := armor.Decode(bytes.NewReader(signature))
57+ if err != nil || block.Type != openpgp.SignatureType {
58+ return v
59+ }
60+ body, err := io.ReadAll(io.LimitReader(block.Body, maxSignatureBytes))
61+ if err != nil {
62+ return v
63+ }
64+ sig, ok := onlySignature(body)
65+ if !ok {
66+ return v
67+ }
68+ issuer := *sig.IssuerKeyId
69+ v.Fingerprint = fmt.Sprintf("%016X", issuer)
70+
71+ ring, byFingerprint := gpgKeyring(keys)
72+ created := sig.CreationTime
73+ cfg := &packet.Config{Time: func() time.Time { return created }}
74+ _, signer, err := openpgp.VerifyDetachedSignatureAndHash(ring, bytes.NewReader(payload), bytes.NewReader(body), acceptedHashes, cfg)
75+ if signer == nil && !errors.Is(err, pgperrors.ErrUnknownIssuer) {
76+ // The check failed before naming the signer: look it up for the fingerprint.
77+ if known := ring.KeysById(issuer); len(known) > 0 {
78+ signer = known[0].Entity
79+ }
80+ }
81+ if signer != nil {
82+ v.Fingerprint = fingerprintHex(signer)
83+ }
84+
85+ if signer != nil && (err == nil || errors.Is(err, pgperrors.ErrKeyExpired) || errors.Is(err, pgperrors.ErrSignatureExpired)) &&
86+ revokedNow(ring.KeysById(issuer), signer) {
87+ // The library judged revocation at the signature date: a revocation
88+ // published since is only seen with the current time.
89+ v.Status = domain.StatusRevoked
90+ return v
91+ }
92+
93+ switch {
94+ case err == nil:
95+ case errors.Is(err, pgperrors.ErrUnknownIssuer):
96+ v.Status = domain.StatusUnknownKey
97+ return v
98+ case errors.Is(err, pgperrors.ErrKeyExpired), errors.Is(err, pgperrors.ErrSignatureExpired):
99+ v.Status = domain.StatusExpired
100+ return v
101+ case errors.Is(err, pgperrors.ErrKeyRevoked):
102+ v.Status = domain.StatusRevoked
103+ return v
104+ default:
105+ return v
106+ }
107+ if key, ok := byFingerprint[v.Fingerprint]; ok {
108+ return trusted(v, key, committerEmail)
109+ }
110+ v.Status = domain.StatusUnknownKey
111+ return v
112+}
113+
114+// onlySignature returns the signature packet of body when body holds exactly
115+// one packet, a signature with an issuer key id and a creation time. One
116+// packet only: the library verifies the first packet whose issuer it knows,
117+// so with several the clock and the revocation check could be taken from a
118+// packet other than the one that verified.
119+func onlySignature(body []byte) (*packet.Signature, bool) {
120+ r := bytes.NewReader(body)
121+ p, err := packet.Read(r)
122+ if err != nil {
123+ return nil, false
124+ }
125+ if _, err := packet.Read(r); !errors.Is(err, io.EOF) {
126+ return nil, false
127+ }
128+ sig, ok := p.(*packet.Signature)
129+ if !ok || sig.IssuerKeyId == nil || sig.CreationTime.IsZero() {
130+ return nil, false
131+ }
132+ return sig, true
133+}
134+
135+// revokedNow reports whether the entity, the signing key among keys (the
136+// primary key or a subkey) or the primary user ID is revoked at the current
137+// time.
138+func revokedNow(keys []openpgp.Key, signer *openpgp.Entity) bool {
139+ now := time.Now()
140+ if signer.Revoked(now) {
141+ return true
142+ }
143+ if _, id := signer.PrimarySelfSignature(); id != nil && id.Revoked(now) {
144+ return true
145+ }
146+ for _, k := range keys {
147+ if k.Entity == signer && k.Revoked(now) {
148+ return true
149+ }
150+ }
151+ return false
152+}
153+
154+// gpgKeyring builds the keyring of the trusted GPG keys, and indexes the
155+// trusted keys by the primary fingerprint. Unreadable keys and private keys
156+// are skipped.
157+func gpgKeyring(keys []domain.TrustedKey) (openpgp.EntityList, map[string]domain.TrustedKey) {
158+ var ring openpgp.EntityList
159+ byFingerprint := make(map[string]domain.TrustedKey, len(keys))
160+ for _, k := range keys {
161+ if k.Format != domain.FormatGPG || len(k.PublicKey) > domain.MaxTrustedKeyBytes {
162+ continue
163+ }
164+ entities, err := openpgp.ReadArmoredKeyRing(strings.NewReader(k.PublicKey))
165+ if err != nil {
166+ continue
167+ }
168+ for _, e := range entities {
169+ if hasPrivateKey(e) {
170+ continue
171+ }
172+ ring = append(ring, e)
173+ byFingerprint[fingerprintHex(e)] = k
174+ }
175+ }
176+ return ring, byFingerprint
177+}
A internal/adapters/sigverify/gpg_test.go +277 -0
@@ -0,0 +1,277 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package sigverify_test
4+
5+import (
6+ "bytes"
7+ "io"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/ProtonMail/go-crypto/openpgp"
13+ "github.com/ProtonMail/go-crypto/openpgp/armor"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/adapters/sigverify"
18+ "bnema.dev/ermite/internal/domain"
19+)
20+
21+func TestGPGVerify(t *testing.T) {
22+ const email = "dev@example.org"
23+ g := newGPGHome(t)
24+ for name, algo := range map[string]string{"ed25519": "ed25519", "rsa": "rsa2048"} {
25+ fpr := g.generate(name+" <"+email+">", algo, "never")
26+ pub := g.public(fpr)
27+ otherFpr := g.generate("other-"+name+" <other@example.org>", "ed25519", "never")
28+ otherPub := g.public(otherFpr)
29+ c := g.commit(fpr, email)
30+
31+ t.Run(name, func(t *testing.T) {
32+ tamperedPayload := append(bytes.Clone(c.payload), '!')
33+ tests := []struct {
34+ name string
35+ payload []byte
36+ signature []byte
37+ keys []domain.TrustedKey
38+ email string
39+ want domain.SignatureStatus
40+ wantFpr string
41+ wantKey bool
42+ }{
43+ {"verified", c.payload, c.signature, []domain.TrustedKey{trustedGPG(pub, email)}, email, domain.StatusVerified, fpr, true},
44+ {"email case is ignored", c.payload, c.signature, []domain.TrustedKey{trustedGPG(pub, email)}, "Dev@EXAMPLE.org", domain.StatusVerified, fpr, true},
45+ {"verified among other keys", c.payload, c.signature, []domain.TrustedKey{trustedGPG("garbage", email), {ID: "s", Format: domain.FormatSSH, PublicKey: "x"}, trustedGPG(otherPub, email), trustedGPG(pub, email)}, email, domain.StatusVerified, fpr, true},
46+ {"email mismatch", c.payload, c.signature, []domain.TrustedKey{trustedGPG(pub, "other@example.org")}, email, domain.StatusEmailMismatch, fpr, true},
47+ {"unknown key", c.payload, c.signature, []domain.TrustedKey{trustedGPG(otherPub, email)}, email, domain.StatusUnknownKey, strings.ToUpper(fpr[len(fpr)-16:]), false},
48+ {"no trusted key", c.payload, c.signature, nil, email, domain.StatusUnknownKey, strings.ToUpper(fpr[len(fpr)-16:]), false},
49+ {"ssh keys are ignored", c.payload, c.signature, []domain.TrustedKey{{ID: "x", Format: domain.FormatSSH, PublicKey: pub, Emails: []string{email}}}, email, domain.StatusUnknownKey, strings.ToUpper(fpr[len(fpr)-16:]), false},
50+ {"tampered payload", tamperedPayload, c.signature, []domain.TrustedKey{trustedGPG(pub, email)}, email, domain.StatusBadSignature, fpr, false},
51+ {"empty signature", c.payload, nil, []domain.TrustedKey{trustedGPG(pub, email)}, email, domain.StatusBadSignature, "", false},
52+ }
53+ for _, tt := range tests {
54+ t.Run(tt.name, func(t *testing.T) {
55+ v := sigverify.GPG{}.Verify(tt.payload, tt.signature, tt.email, c.signedAt, tt.keys)
56+ assert.Equal(t, domain.FormatGPG, v.Format)
57+ assert.Equal(t, tt.want, v.Status)
58+ assert.Equal(t, tt.wantFpr, v.Fingerprint)
59+ if tt.wantKey {
60+ assert.Equal(t, "gpg-id", v.KeyID)
61+ assert.Equal(t, "gpg key", v.KeyTitle)
62+ } else {
63+ assert.Empty(t, v.KeyID)
64+ assert.Empty(t, v.KeyTitle)
65+ }
66+ })
67+ }
68+ })
69+ }
70+}
71+
72+// TestGPGVerifyExpiredKey checks that expiry is judged at the creation time of
73+// the signature, not at the committer date nor now.
74+func TestGPGVerifyExpiredKey(t *testing.T) {
75+ const email = "dev@example.org"
76+ g := newGPGHome(t)
77+ day := 24 * time.Hour
78+ now := time.Now()
79+ // The key is created 10 days ago, signs 8 and 2 days ago, then gets an
80+ // expiry 3 days after its creation (published 9 days ago): it expired
81+ // 7 days ago, between the two signatures.
82+ fakedGPG := func(at time.Time, args ...string) {
83+ g.gpg("", append([]string{"--faked-system-time", fakedTime(at), "--passphrase", ""}, args...)...)
84+ }
85+ fakedGPG(now.Add(-10*day), "--quick-gen-key", "expiring <"+email+">", "ed25519", "sign", "never")
86+ fpr := g.fingerprint(email)
87+ payload := []byte("payload")
88+ validThen := g.detachSign(fpr, payload, "--faked-system-time", fakedTime(now.Add(-8*day)))
89+ expiredThen := g.detachSign(fpr, payload, "--faked-system-time", fakedTime(now.Add(-2*day)))
90+ fakedGPG(now.Add(-9*day), "--quick-set-expire", fpr, "3d")
91+ keys := []domain.TrustedKey{trustedGPG(g.public(fpr), email)}
92+
93+ tests := []struct {
94+ name string
95+ sig []byte
96+ want domain.SignatureStatus
97+ }{
98+ {"signed while the key was valid, key expired since", validThen, domain.StatusVerified},
99+ {"signed after the key expired", expiredThen, domain.StatusExpired},
100+ }
101+ for _, tt := range tests {
102+ t.Run(tt.name, func(t *testing.T) {
103+ // The committer date is irrelevant: any value gives the same status.
104+ for _, committed := range []int64{0, now.Add(-9 * day).Unix(), now.Unix(), now.Add(30 * day).Unix()} {
105+ v := sigverify.GPG{}.Verify(payload, tt.sig, email, committed, keys)
106+ assert.Equal(t, tt.want, v.Status, "committer date %d", committed)
107+ assert.Equal(t, fpr, v.Fingerprint)
108+ }
109+ })
110+ }
111+}
112+
113+// TestGPGVerifyIgnoresCommitterDate: a signature that is valid and a key that
114+// never expires verify whatever the committer date says, including a date
115+// before the signature was made.
116+func TestGPGVerifyIgnoresCommitterDate(t *testing.T) {
117+ const email = "dev@example.org"
118+ g := newGPGHome(t)
119+ fpr := g.generate("dates <"+email+">", "ed25519", "never")
120+ keys := []domain.TrustedKey{trustedGPG(g.public(fpr), email)}
121+ c := g.commit(fpr, email)
122+ for _, delta := range []int64{-365 * 24 * 3600, -3600, 0, 3600} {
123+ v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt+delta, keys)
124+ assert.Equal(t, domain.StatusVerified, v.Status, "committer date %+d s", delta)
125+ }
126+}
127+
128+// TestGPGVerifyWeakHash: the digest algorithm allowlist is enforced; the
129+// OpenPGP library does not reject a SHA-1 detached signature by itself.
130+func TestGPGVerifyWeakHash(t *testing.T) {
131+ const email = "dev@example.org"
132+ g := newGPGHome(t)
133+ payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\nmessage\n")
134+ for name, algo := range map[string]string{"rsa": "rsa2048", "ed25519": "ed25519"} {
135+ fpr := g.generate("weak-"+name+" <"+email+">", algo, "never")
136+ keys := []domain.TrustedKey{trustedGPG(g.public(fpr), email)}
137+ now := time.Now().Unix()
138+ tests := []struct {
139+ digest string
140+ want domain.SignatureStatus
141+ }{
142+ {"SHA1", domain.StatusBadSignature},
143+ {"RIPEMD160", domain.StatusBadSignature},
144+ {"SHA224", domain.StatusBadSignature},
145+ {"SHA256", domain.StatusVerified},
146+ {"SHA384", domain.StatusVerified},
147+ {"SHA512", domain.StatusVerified},
148+ }
149+ for _, tt := range tests {
150+ t.Run(name+"/"+tt.digest, func(t *testing.T) {
151+ sig := g.detachSign(fpr, payload, "--digest-algo", tt.digest)
152+ v := sigverify.GPG{}.Verify(payload, sig, email, now, keys)
153+ assert.Equal(t, tt.want, v.Status)
154+ if tt.want != domain.StatusVerified {
155+ assert.Empty(t, v.KeyID, "a weak signature never names a trusted key")
156+ }
157+ })
158+ }
159+ }
160+}
161+
162+// Several signature packets are refused: the library would verify the first
163+// one whose issuer it knows, while the clock and the revocation check would
164+// come from another one.
165+func TestGPGVerifyRefusesSeveralPackets(t *testing.T) {
166+ const email = "dev@example.org"
167+ g := newGPGHome(t)
168+ payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\nmessage\n")
169+ unknown := g.generate("unknown <other@example.org>", "ed25519", "never")
170+ trusted := g.generate("trusted <"+email+">", "ed25519", "never")
171+ keys := []domain.TrustedKey{trustedGPG(g.public(trusted), email)}
172+ binary := func(fpr string) []byte {
173+ block, err := armor.Decode(bytes.NewReader(g.detachSign(fpr, payload)))
174+ require.NoError(t, err)
175+ b, err := io.ReadAll(block.Body)
176+ require.NoError(t, err)
177+ return b
178+ }
179+ var both bytes.Buffer
180+ w, err := armor.Encode(&both, openpgp.SignatureType, nil)
181+ require.NoError(t, err)
182+ _, _ = w.Write(binary(unknown))
183+ _, _ = w.Write(binary(trusted))
184+ require.NoError(t, w.Close())
185+
186+ single := sigverify.GPG{}.Verify(payload, g.detachSign(trusted, payload), email, 0, keys)
187+ require.Equal(t, domain.StatusVerified, single.Status, "control: one packet verifies")
188+ v := sigverify.GPG{}.Verify(payload, both.Bytes(), email, 0, keys)
189+ assert.Equal(t, domain.StatusBadSignature, v.Status)
190+ assert.Empty(t, v.KeyID)
191+}
192+
193+func TestGPGVerifyRevokedKey(t *testing.T) {
194+ const email = "dev@example.org"
195+ g := newGPGHome(t)
196+ fpr := g.generate("revoked <"+email+">", "ed25519", "never")
197+ c := g.commit(fpr, email)
198+ g.revoke(fpr)
199+ pub := g.public(fpr) // carries the revocation
200+
201+ v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt, []domain.TrustedKey{trustedGPG(pub, email)})
202+ assert.Equal(t, domain.StatusRevoked, v.Status)
203+ assert.Equal(t, fpr, v.Fingerprint)
204+}
205+
206+func TestGPGVerifySubkey(t *testing.T) {
207+ const email = "dev@example.org"
208+ g := newGPGHome(t)
209+ fpr := g.generate("subkey <"+email+">", "ed25519", "never")
210+ // A signing subkey; the primary key is certify-only for gpg when asked so.
211+ g.gpg("", "--passphrase", "", "--quick-add-key", fpr, "ed25519", "sign", "never")
212+ out := g.gpg("", "--with-colons", "--list-keys", "--with-subkey-fingerprints", fpr)
213+ var sub string
214+ var next bool
215+ for _, l := range strings.Split(out, "\n") {
216+ if strings.HasPrefix(l, "sub:") {
217+ next = true
218+ } else if next && strings.HasPrefix(l, "fpr:") {
219+ sub = strings.Split(l, ":")[9]
220+ break
221+ }
222+ }
223+ require.NotEmpty(t, sub)
224+ c := g.commit(sub+"!", email)
225+
226+ v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt, []domain.TrustedKey{trustedGPG(g.public(fpr), email)})
227+ assert.Equal(t, domain.StatusVerified, v.Status)
228+ assert.Equal(t, fpr, v.Fingerprint, "the fingerprint is the one of the primary key")
229+ assert.Equal(t, "gpg-id", v.KeyID)
230+}
231+
232+func TestGPGVerifyHostileInput(t *testing.T) {
233+ const email = "dev@example.org"
234+ g := newGPGHome(t)
235+ fpr := g.generate("hostile <"+email+">", "ed25519", "never")
236+ pub := g.public(fpr)
237+ c := g.commit(fpr, email)
238+ keys := []domain.TrustedKey{trustedGPG(pub, email)}
239+
240+ huge := []byte("-----BEGIN PGP SIGNATURE-----\n\n" + strings.Repeat("AAAA\n", 20<<10) + "-----END PGP SIGNATURE-----\n")
241+ tests := []struct {
242+ name string
243+ sig []byte
244+ }{
245+ {"not armored", []byte("hello")},
246+ {"wrong armor type", []byte(strings.ReplaceAll(string(c.signature), "PGP SIGNATURE", "PGP MESSAGE"))},
247+ {"empty armor", []byte("-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\n")},
248+ {"a public key instead", []byte(pub)},
249+ {"larger than 64 KiB", huge},
250+ }
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ var v domain.CommitVerification
254+ require.NotPanics(t, func() { v = sigverify.GPG{}.Verify(c.payload, tt.sig, email, c.signedAt, keys) })
255+ assert.Equal(t, domain.StatusBadSignature, v.Status)
256+ assert.Equal(t, domain.FormatGPG, v.Format)
257+ })
258+ }
259+
260+ t.Run("every truncation of a valid signature", func(t *testing.T) {
261+ for i := range c.signature {
262+ require.NotPanics(t, func() { sigverify.GPG{}.Verify(c.payload, c.signature[:i], email, c.signedAt, keys) })
263+ }
264+ })
265+}
266+
267+// TestGPGVerifyIgnoresPrivateKeys: a private key stored as trusted (it cannot
268+// be added through the inspector) does not take part in verification.
269+func TestGPGVerifyIgnoresPrivateKeys(t *testing.T) {
270+ const email = "dev@example.org"
271+ g := newGPGHome(t)
272+ fpr := g.generate("private <"+email+">", "ed25519", "never")
273+ c := g.commit(fpr, email)
274+
275+ v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt, []domain.TrustedKey{trustedGPG(g.private(fpr), email)})
276+ assert.Equal(t, domain.StatusUnknownKey, v.Status)
277+}
A internal/adapters/sigverify/helpers_test.go +192 -0
@@ -0,0 +1,192 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package sigverify_test
4+
5+import (
6+ "bytes"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strconv"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// need skips the test when a tool is missing, except in CI where it must exist.
21+func need(t *testing.T, tool string) string {
22+ t.Helper()
23+ path, err := exec.LookPath(tool)
24+ if err != nil {
25+ if os.Getenv("CI") != "" {
26+ t.Fatalf("%s not found: CI must have it", tool)
27+ }
28+ t.Skipf("%s not found: install it to check signatures against real tools", tool)
29+ }
30+ return path
31+}
32+
33+// run runs a command and returns its stdout; a failure fails the test with
34+// its output.
35+func run(t *testing.T, dir string, env []string, stdin string, name string, args ...string) string {
36+ t.Helper()
37+ cmd := exec.Command(name, args...)
38+ cmd.Dir = dir
39+ cmd.Env = append(os.Environ(), env...)
40+ cmd.Stdin = strings.NewReader(stdin)
41+ var stdout, stderr bytes.Buffer
42+ cmd.Stdout, cmd.Stderr = &stdout, &stderr
43+ require.NoError(t, cmd.Run(), "%s %v: %s", name, args, stderr.String())
44+ return stdout.String()
45+}
46+
47+// signedCommit is a commit as the verifier sees it.
48+type signedCommit struct {
49+ payload, signature []byte
50+ signedAt int64
51+ email string
52+}
53+
54+// newRepo makes an empty Git repository with a committer identity.
55+func newRepo(t *testing.T) string {
56+ t.Helper()
57+ need(t, "git")
58+ dir := t.TempDir()
59+ run(t, dir, nil, "", "git", "init", "-q")
60+ return dir
61+}
62+
63+// commit makes a signed empty commit in dir, with extra git config, and
64+// returns it split the way ermite reads it.
65+func commit(t *testing.T, dir, email string, env []string, config ...string) signedCommit {
66+ t.Helper()
67+ now := time.Now().Unix()
68+ args := []string{"-c", "user.name=Test", "-c", "user.email=" + email}
69+ for _, c := range config {
70+ args = append(args, "-c", c)
71+ }
72+ args = append(args, "commit", "-q", "--allow-empty", "-S", "-m", "signed commit")
73+ env = append(env,
74+ "GIT_COMMITTER_DATE="+strconv.FormatInt(now, 10)+" +0000",
75+ "GIT_AUTHOR_DATE="+strconv.FormatInt(now, 10)+" +0000",
76+ )
77+ run(t, dir, env, "", "git", args...)
78+ raw := run(t, dir, nil, "", "git", "cat-file", "commit", "HEAD")
79+ payload, signature, ok := domain.SplitCommitSignature([]byte(raw))
80+ require.True(t, ok, "the commit must be signed:\n%s", raw)
81+ return signedCommit{payload: payload, signature: signature, signedAt: now, email: email}
82+}
83+
84+// sshKey makes an SSH key pair and returns the private key path and the
85+// public key line.
86+func sshKey(t *testing.T, typ string, extra ...string) (private, public string) {
87+ t.Helper()
88+ need(t, "ssh-keygen")
89+ private = filepath.Join(t.TempDir(), "key")
90+ args := append([]string{"-q", "-t", typ, "-N", "", "-C", "test comment", "-f", private}, extra...)
91+ run(t, "", nil, "", "ssh-keygen", args...)
92+ pub, err := os.ReadFile(private + ".pub")
93+ require.NoError(t, err)
94+ return private, strings.TrimSpace(string(pub))
95+}
96+
97+// sshCommit makes a commit signed by an SSH private key.
98+func sshCommit(t *testing.T, private, email string) signedCommit {
99+ t.Helper()
100+ return commit(t, newRepo(t), email, nil, "gpg.format=ssh", "user.signingkey="+private)
101+}
102+
103+// gpgHome is a throw-away GnuPG home directory. It lives in /tmp: the agent
104+// socket path is limited to 108 bytes.
105+type gpgHome struct {
106+ t *testing.T
107+ home string
108+}
109+
110+func newGPGHome(t *testing.T) gpgHome {
111+ t.Helper()
112+ need(t, "gpg")
113+ home, err := os.MkdirTemp("", "sigverify-gpg")
114+ require.NoError(t, err)
115+ require.NoError(t, os.Chmod(home, 0o700))
116+ t.Cleanup(func() {
117+ _ = exec.Command("gpgconf", "--homedir", home, "--kill", "all").Run()
118+ _ = os.RemoveAll(home)
119+ })
120+ return gpgHome{t: t, home: home}
121+}
122+
123+func (g gpgHome) gpg(stdin string, args ...string) string {
124+ g.t.Helper()
125+ return run(g.t, "", nil, stdin, "gpg", append([]string{"--homedir", g.home, "--batch", "--yes", "--no-tty"}, args...)...)
126+}
127+
128+// generate makes a key without passphrase and returns its fingerprint. expire
129+// is a gpg expiration like "never" or "2d".
130+func (g gpgHome) generate(uid, algo, expire string) string {
131+ g.t.Helper()
132+ g.gpg("", "--passphrase", "", "--quick-gen-key", uid, algo, "sign", expire)
133+ return g.fingerprint(uid)
134+}
135+
136+// fingerprint returns the primary key fingerprint of the key of uid.
137+func (g gpgHome) fingerprint(uid string) string {
138+ g.t.Helper()
139+ out := g.gpg("", "--with-colons", "--fingerprint", "--list-keys", uid)
140+ for _, l := range strings.Split(out, "\n") {
141+ if strings.HasPrefix(l, "fpr:") {
142+ return strings.Split(l, ":")[9]
143+ }
144+ }
145+ g.t.Fatalf("no fingerprint in %q", out)
146+ return ""
147+}
148+
149+// public exports the public keys, in one armor block.
150+func (g gpgHome) public(fpr ...string) string {
151+ g.t.Helper()
152+ return g.gpg("", append([]string{"--armor", "--export"}, fpr...)...)
153+}
154+
155+func (g gpgHome) private(fpr string) string {
156+ g.t.Helper()
157+ return g.gpg("", "--armor", "--export-secret-keys", fpr)
158+}
159+
160+// revoke publishes a revocation certificate of the key in the keyring.
161+func (g gpgHome) revoke(fpr string) {
162+ g.t.Helper()
163+ cert := filepath.Join(g.home, "revoke.asc")
164+ // --gen-revoke asks questions: it cannot run in batch mode.
165+ run(g.t, "", nil, "y\n0\n\ny\n", "gpg", "--homedir", g.home, "--no-tty", "--yes", "--command-fd", "0", "--output", cert, "--gen-revoke", fpr)
166+ g.gpg("", "--import", cert)
167+}
168+
169+// detachSign signs data with the key (armored detached signature). extra are
170+// gpg options such as "--digest-algo", "SHA1" or "--faked-system-time", "<t>!".
171+func (g gpgHome) detachSign(fpr string, data []byte, extra ...string) []byte {
172+ g.t.Helper()
173+ args := append(append([]string{}, extra...), "--armor", "--detach-sign", "-u", fpr)
174+ return []byte(run(g.t, "", nil, string(data), "gpg", append([]string{"--homedir", g.home, "--batch", "--yes", "--no-tty"}, args...)...))
175+}
176+
177+// fakedTime formats a time for gpg's --faked-system-time.
178+func fakedTime(t time.Time) string { return t.UTC().Format("20060102T150405") + "!" }
179+
180+// commit makes a commit signed by the key.
181+func (g gpgHome) commit(fpr, email string) signedCommit {
182+ g.t.Helper()
183+ return commit(g.t, newRepo(g.t), email, []string{"GNUPGHOME=" + g.home}, "gpg.format=openpgp", "user.signingkey="+fpr)
184+}
185+
186+func trustedSSH(public string, emails ...string) domain.TrustedKey {
187+ return domain.TrustedKey{ID: "ssh-id", Format: domain.FormatSSH, Title: "ssh key", PublicKey: public, Emails: emails}
188+}
189+
190+func trustedGPG(public string, emails ...string) domain.TrustedKey {
191+ return domain.TrustedKey{ID: "gpg-id", Format: domain.FormatGPG, Title: "gpg key", PublicKey: public, Emails: emails}
192+}
A internal/adapters/sigverify/inspector.go +102 -0
@@ -0,0 +1,102 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package sigverify
4+
5+import (
6+ "bytes"
7+ "encoding/hex"
8+ "errors"
9+ "fmt"
10+ "strings"
11+
12+ "github.com/ProtonMail/go-crypto/openpgp"
13+ "github.com/ProtonMail/go-crypto/openpgp/armor"
14+ "golang.org/x/crypto/ssh"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// Inspector parses the public keys to trust. The zero value is ready.
21+type Inspector struct{}
22+
23+var _ ports.KeyInspector = Inspector{}
24+
25+// Inspect implements ports.KeyInspector.
26+func (Inspector) Inspect(format domain.SignatureFormat, publicKey string) (id, canonical string, err error) {
27+ if len(publicKey) > domain.MaxTrustedKeyBytes {
28+ return "", "", fmt.Errorf("%w: public key larger than %d bytes", domain.ErrInvalid, domain.MaxTrustedKeyBytes)
29+ }
30+ defer func() { // a parser bug must not take the server down
31+ if recover() != nil {
32+ id, canonical, err = "", "", fmt.Errorf("%w: unreadable public key", domain.ErrInvalid)
33+ }
34+ }()
35+ switch format {
36+ case domain.FormatSSH:
37+ return inspectSSH(publicKey)
38+ case domain.FormatGPG:
39+ return inspectGPG(publicKey)
40+ }
41+ return "", "", fmt.Errorf("%w: key format %q, want ssh or gpg", domain.ErrInvalid, format)
42+}
43+
44+func inspectSSH(publicKey string) (id, canonical string, err error) {
45+ pk, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(publicKey))
46+ if err != nil {
47+ return "", "", fmt.Errorf("%w: not an SSH public key (want one authorized_keys line)", domain.ErrInvalid)
48+ }
49+ if len(bytes.TrimSpace(rest)) != 0 {
50+ return "", "", fmt.Errorf("%w: give exactly one SSH public key", domain.ErrInvalid)
51+ }
52+ if _, isCert := pk.(*ssh.Certificate); isCert {
53+ return "", "", fmt.Errorf("%w: SSH certificates cannot be trusted, give the public key", domain.ErrInvalid)
54+ }
55+ return ssh.FingerprintSHA256(pk), strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pk))), nil
56+}
57+
58+func inspectGPG(publicKey string) (id, canonical string, err error) {
59+ if strings.Count(publicKey, "-----BEGIN PGP") != 1 {
60+ return "", "", fmt.Errorf("%w: give exactly one armored OpenPGP public key block", domain.ErrInvalid)
61+ }
62+ ring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(publicKey))
63+ if err != nil {
64+ return "", "", fmt.Errorf("%w: not an armored OpenPGP public key", domain.ErrInvalid)
65+ }
66+ if len(ring) != 1 {
67+ return "", "", fmt.Errorf("%w: give exactly one OpenPGP key, got %d", domain.ErrInvalid, len(ring))
68+ }
69+ e := ring[0]
70+ if hasPrivateKey(e) {
71+ return "", "", fmt.Errorf("%w: this is a private key; export the public key instead", domain.ErrInvalid)
72+ }
73+ var out bytes.Buffer
74+ w, err := armor.Encode(&out, openpgp.PublicKeyType, nil)
75+ if err != nil {
76+ return "", "", errors.New("armor the public key")
77+ }
78+ if err := e.Serialize(w); err != nil {
79+ return "", "", fmt.Errorf("%w: unreadable OpenPGP key", domain.ErrInvalid)
80+ }
81+ if err := w.Close(); err != nil {
82+ return "", "", errors.New("armor the public key")
83+ }
84+ return fingerprintHex(e), out.String(), nil
85+}
86+
87+func hasPrivateKey(e *openpgp.Entity) bool {
88+ if e.PrivateKey != nil {
89+ return true
90+ }
91+ for _, s := range e.Subkeys {
92+ if s.PrivateKey != nil {
93+ return true
94+ }
95+ }
96+ return false
97+}
98+
99+// fingerprintHex is the primary key fingerprint of e in upper-case hex.
100+func fingerprintHex(e *openpgp.Entity) string {
101+ return strings.ToUpper(hex.EncodeToString(e.PrimaryKey.Fingerprint))
102+}
A internal/adapters/sigverify/inspector_test.go +103 -0
@@ -0,0 +1,103 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package sigverify_test
4+
5+import (
6+ "os"
7+ "strings"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/adapters/sigverify"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+func TestInspectSSH(t *testing.T) {
18+ priv, pub := sshKey(t, "ed25519")
19+ _, rsaPub := sshKey(t, "rsa", "-b", "2048")
20+ privText, err := os.ReadFile(priv)
21+ require.NoError(t, err)
22+ canonical := strings.Join(strings.Fields(pub)[:2], " ")
23+
24+ id, got, err := sigverify.Inspector{}.Inspect(domain.FormatSSH, pub)
25+ require.NoError(t, err)
26+ assert.Equal(t, fingerprint(t, pub), id)
27+ assert.Equal(t, canonical, got, "the comment is dropped")
28+
29+ id2, got2, err := sigverify.Inspector{}.Inspect(domain.FormatSSH, " "+canonical+"\n\n")
30+ require.NoError(t, err)
31+ assert.Equal(t, id, id2)
32+ assert.Equal(t, canonical, got2)
33+
34+ _, got, err = sigverify.Inspector{}.Inspect(domain.FormatSSH, rsaPub)
35+ require.NoError(t, err)
36+ assert.True(t, strings.HasPrefix(got, "ssh-rsa "))
37+
38+ tests := []struct {
39+ name, key string
40+ }{
41+ {"empty", ""},
42+ {"garbage", "hello world"},
43+ {"private key", string(privText)},
44+ {"two keys", pub + "\n" + rsaPub + "\n"},
45+ {"truncated", canonical[:len(canonical)-10]},
46+ {"larger than the limit", strings.Repeat("a", domain.MaxTrustedKeyBytes+1)},
47+ }
48+ for _, tt := range tests {
49+ t.Run(tt.name, func(t *testing.T) {
50+ _, _, err := sigverify.Inspector{}.Inspect(domain.FormatSSH, tt.key)
51+ require.ErrorIs(t, err, domain.ErrInvalid)
52+ assert.NotContains(t, err.Error(), "PRIVATE KEY")
53+ })
54+ }
55+}
56+
57+func TestInspectGPG(t *testing.T) {
58+ g := newGPGHome(t)
59+ fpr := g.generate("inspect <dev@example.org>", "ed25519", "never")
60+ pub := g.public(fpr)
61+ other := g.generate("two <two@example.org>", "ed25519", "never")
62+
63+ id, canonical, err := sigverify.Inspector{}.Inspect(domain.FormatGPG, pub)
64+ require.NoError(t, err)
65+ assert.Equal(t, strings.ToUpper(fpr), id)
66+ assert.True(t, strings.HasPrefix(canonical, "-----BEGIN PGP PUBLIC KEY BLOCK-----"))
67+
68+ // The canonical form is stable and verifies signatures like the original.
69+ id2, canonical2, err := sigverify.Inspector{}.Inspect(domain.FormatGPG, canonical)
70+ require.NoError(t, err)
71+ assert.Equal(t, id, id2)
72+ assert.Equal(t, canonical, canonical2)
73+ c := g.commit(fpr, "dev@example.org")
74+ v := sigverify.GPG{}.Verify(c.payload, c.signature, "dev@example.org", c.signedAt, []domain.TrustedKey{trustedGPG(canonical, "dev@example.org")})
75+ assert.Equal(t, domain.StatusVerified, v.Status)
76+
77+ tests := []struct {
78+ name, key string
79+ }{
80+ {"empty", ""},
81+ {"garbage", "hello world"},
82+ {"private key", g.private(fpr)},
83+ {"two keys in one block", g.public(fpr, other)},
84+ {"two blocks", pub + "\n" + g.public(other)},
85+ {"a signature", string(c.signature)},
86+ {"an ssh key", "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA"},
87+ {"larger than the limit", strings.Repeat("a", domain.MaxTrustedKeyBytes+1)},
88+ }
89+ for _, tt := range tests {
90+ t.Run(tt.name, func(t *testing.T) {
91+ _, _, err := sigverify.Inspector{}.Inspect(domain.FormatGPG, tt.key)
92+ require.ErrorIs(t, err, domain.ErrInvalid)
93+ assert.NotContains(t, err.Error(), "PRIVATE KEY")
94+ })
95+ }
96+}
97+
98+func TestInspectUnknownFormat(t *testing.T) {
99+ for _, f := range []domain.SignatureFormat{domain.FormatNone, domain.FormatX509, "pgp"} {
100+ _, _, err := sigverify.Inspector{}.Inspect(f, "x")
101+ require.ErrorIs(t, err, domain.ErrInvalid)
102+ }
103+}
A internal/adapters/sigverify/ssh.go +137 -0
@@ -0,0 +1,137 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package sigverify
4+
5+import (
6+ "bytes"
7+ "crypto/sha256"
8+ "crypto/sha512"
9+ "encoding/base64"
10+ "strings"
11+
12+ "golang.org/x/crypto/ssh"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// SSHSIG constants (PROTOCOL.sshsig); the namespace is the one Git signs with.
19+const (
20+ sshMagic = "SSHSIG"
21+ sshVersion = 1
22+ sshNamespace = "git"
23+ sshArmorHead = "-----BEGIN SSH SIGNATURE-----"
24+ sshArmorTail = "-----END SSH SIGNATURE-----"
25+)
26+
27+// SSH verifies SSH signatures (gpg.format=ssh). It ignores the trusted keys
28+// that are not SSH keys.
29+type SSH struct{}
30+
31+var _ ports.SignatureVerifier = SSH{}
32+
33+// sshSignedData is what the signature covers after the magic.
34+type sshSignedData struct {
35+ Namespace string
36+ Reserved string
37+ HashAlgorithm string
38+ Hash string
39+}
40+
41+// sshSigBlob is the signature file after the magic.
42+type sshSigBlob struct {
43+ Version uint32
44+ PublicKey string
45+ Namespace string
46+ Reserved string
47+ HashAlgorithm string
48+ Signature string
49+}
50+
51+// Verify implements ports.SignatureVerifier.
52+//
53+//hexcheck:ignore suspicious-business-logic-in-adapter this is the SSHSIG protocol check the port delegates to the adapter; the trust and email rules stay in domain
54+func (SSH) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
55+ v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature}
56+ defer func() { // a parser bug must not take the server down
57+ if recover() != nil {
58+ v = domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusBadSignature, Fingerprint: v.Fingerprint}
59+ }
60+ }()
61+ if len(signature) > maxSignatureBytes {
62+ return v
63+ }
64+ blob, ok := decodeSSHArmor(signature)
65+ if !ok || !bytes.HasPrefix(blob, []byte(sshMagic)) {
66+ return v
67+ }
68+ var sb sshSigBlob
69+ if ssh.Unmarshal(blob[len(sshMagic):], &sb) != nil {
70+ return v
71+ }
72+ pk, err := ssh.ParsePublicKey([]byte(sb.PublicKey))
73+ if err != nil {
74+ return v
75+ }
76+ v.Fingerprint = ssh.FingerprintSHA256(pk)
77+ if sb.Version != sshVersion || sb.Namespace != sshNamespace {
78+ return v
79+ }
80+ var sum []byte
81+ switch sb.HashAlgorithm {
82+ case "sha256":
83+ h := sha256.Sum256(payload)
84+ sum = h[:]
85+ case "sha512":
86+ h := sha512.Sum512(payload)
87+ sum = h[:]
88+ default:
89+ return v
90+ }
91+ var sf struct {
92+ Format string
93+ Blob string
94+ Rest []byte `ssh:"rest"`
95+ }
96+ if ssh.Unmarshal([]byte(sb.Signature), &sf) != nil {
97+ return v
98+ }
99+ if pk.Type() == ssh.KeyAlgoRSA && sf.Format != ssh.KeyAlgoRSASHA256 && sf.Format != ssh.KeyAlgoRSASHA512 {
100+ return v // ssh-rsa is SHA-1
101+ }
102+ msg := append([]byte(sshMagic), ssh.Marshal(sshSignedData{
103+ Namespace: sb.Namespace, Reserved: sb.Reserved, HashAlgorithm: sb.HashAlgorithm, Hash: string(sum),
104+ })...)
105+ if pk.Verify(msg, &ssh.Signature{Format: sf.Format, Blob: []byte(sf.Blob), Rest: sf.Rest}) != nil {
106+ return v
107+ }
108+
109+ want := pk.Marshal()
110+ for _, k := range keys {
111+ if k.Format != domain.FormatSSH {
112+ continue
113+ }
114+ tk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.PublicKey))
115+ if err != nil || !bytes.Equal(tk.Marshal(), want) {
116+ continue
117+ }
118+ return trusted(v, k, committerEmail)
119+ }
120+ v.Status = domain.StatusUnknownKey
121+ return v
122+}
123+
124+// decodeSSHArmor returns the binary signature file of an armored SSH
125+// signature.
126+func decodeSSHArmor(armored []byte) ([]byte, bool) {
127+ lines := strings.Split(strings.TrimSpace(string(armored)), "\n")
128+ if len(lines) < 3 || strings.TrimSpace(lines[0]) != sshArmorHead || strings.TrimSpace(lines[len(lines)-1]) != sshArmorTail {
129+ return nil, false
130+ }
131+ var b strings.Builder
132+ for _, l := range lines[1 : len(lines)-1] {
133+ b.WriteString(strings.TrimSpace(l))
134+ }
135+ raw, err := base64.StdEncoding.DecodeString(b.String())
136+ return raw, err == nil
137+}
A internal/adapters/sigverify/ssh_test.go +181 -0
@@ -0,0 +1,181 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package sigverify_test
4+
5+import (
6+ "bytes"
7+ "crypto/ed25519"
8+ "strings"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+ "golang.org/x/crypto/ssh"
14+
15+ "bnema.dev/ermite/internal/adapters/sigverify"
16+ "bnema.dev/ermite/internal/adapters/sshsig"
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+func fingerprint(t *testing.T, authorizedKey string) string {
21+ t.Helper()
22+ pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(authorizedKey))
23+ require.NoError(t, err)
24+ return ssh.FingerprintSHA256(pk)
25+}
26+
27+func TestSSHVerify(t *testing.T) {
28+ const email = "dev@example.org"
29+ keys := map[string]struct{ private, public string }{}
30+ for name, args := range map[string][]string{
31+ "ed25519": {"ed25519"},
32+ "rsa": {"rsa", "-b", "2048"},
33+ "ecdsa": {"ecdsa", "-b", "256"},
34+ } {
35+ priv, pub := sshKey(t, args[0], args[1:]...)
36+ keys[name] = struct{ private, public string }{priv, pub}
37+ }
38+
39+ for name, key := range keys {
40+ t.Run(name, func(t *testing.T) {
41+ c := sshCommit(t, key.private, email)
42+ fp := fingerprint(t, key.public)
43+ other := keys["ed25519"]
44+ if name == "ed25519" {
45+ other = keys["rsa"]
46+ }
47+ tamperedPayload := append(bytes.Clone(c.payload), '!')
48+ tamperedSig := bytes.Replace(c.signature, []byte("\n"), []byte("\nAAAA"), 2)
49+
50+ tests := []struct {
51+ name string
52+ payload []byte
53+ signature []byte
54+ keys []domain.TrustedKey
55+ email string
56+ want domain.SignatureStatus
57+ wantKey bool
58+ }{
59+ {"verified", c.payload, c.signature, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusVerified, true},
60+ {"verified with the canonical key", c.payload, c.signature, []domain.TrustedKey{trustedSSH(strings.Join(strings.Fields(key.public)[:2], " "), email)}, email, domain.StatusVerified, true},
61+ {"email case is ignored", c.payload, c.signature, []domain.TrustedKey{trustedSSH(key.public, email)}, "DEV@Example.ORG", domain.StatusVerified, true},
62+ {"verified among other keys", c.payload, c.signature, []domain.TrustedKey{trustedSSH(other.public, email), trustedGPG("not a key", email), trustedSSH(key.public, email)}, email, domain.StatusVerified, true},
63+ {"email mismatch", c.payload, c.signature, []domain.TrustedKey{trustedSSH(key.public, "other@example.org")}, email, domain.StatusEmailMismatch, true},
64+ {"unknown key", c.payload, c.signature, []domain.TrustedKey{trustedSSH(other.public, email)}, email, domain.StatusUnknownKey, false},
65+ {"no trusted key", c.payload, c.signature, nil, email, domain.StatusUnknownKey, false},
66+ {"gpg keys are ignored", c.payload, c.signature, []domain.TrustedKey{{ID: "x", Format: domain.FormatGPG, PublicKey: key.public, Emails: []string{email}}}, email, domain.StatusUnknownKey, false},
67+ {"tampered payload", tamperedPayload, c.signature, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusBadSignature, false},
68+ {"tampered signature", c.payload, tamperedSig, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusBadSignature, false},
69+ {"empty signature", c.payload, nil, []domain.TrustedKey{trustedSSH(key.public, email)}, email, domain.StatusBadSignature, false},
70+ }
71+ for _, tt := range tests {
72+ t.Run(tt.name, func(t *testing.T) {
73+ v := sigverify.SSH{}.Verify(tt.payload, tt.signature, tt.email, c.signedAt, tt.keys)
74+ assert.Equal(t, domain.FormatSSH, v.Format)
75+ assert.Equal(t, tt.want, v.Status)
76+ if tt.wantKey {
77+ assert.Equal(t, "ssh-id", v.KeyID)
78+ assert.Equal(t, "ssh key", v.KeyTitle)
79+ } else {
80+ assert.Empty(t, v.KeyID)
81+ assert.Empty(t, v.KeyTitle)
82+ }
83+ if tt.want == domain.StatusVerified || tt.want == domain.StatusEmailMismatch || tt.want == domain.StatusUnknownKey {
84+ assert.Equal(t, fp, v.Fingerprint)
85+ }
86+ })
87+ }
88+ })
89+ }
90+}
91+
92+func TestSSHVerifyWrongNamespace(t *testing.T) {
93+ const email = "dev@example.org"
94+ priv, pub := sshKey(t, "ed25519")
95+ payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@b> 1 +0000\n\nmsg\n")
96+ // Reading stdin, ssh-keygen -Y sign writes the armored signature to stdout.
97+ sig := run(t, "", nil, string(payload), "ssh-keygen", "-q", "-Y", "sign", "-n", "file", "-f", priv)
98+ require.Contains(t, sig, "BEGIN SSH SIGNATURE")
99+
100+ v := sigverify.SSH{}.Verify(payload, []byte(sig), email, 0, []domain.TrustedKey{trustedSSH(pub, email)})
101+ assert.Equal(t, domain.StatusBadSignature, v.Status)
102+ assert.Equal(t, fingerprint(t, pub), v.Fingerprint, "the key is still named")
103+
104+ // The same signing in namespace git verifies: the namespace is the only change.
105+ sig = run(t, "", nil, string(payload), "ssh-keygen", "-q", "-Y", "sign", "-n", "git", "-f", priv)
106+ v = sigverify.SSH{}.Verify(payload, []byte(sig), email, 0, []domain.TrustedKey{trustedSSH(pub, email)})
107+ assert.Equal(t, domain.StatusVerified, v.Status)
108+}
109+
110+func TestSSHVerifyHostileInput(t *testing.T) {
111+ const email = "dev@example.org"
112+ _, pub := sshKey(t, "ed25519")
113+ keys := []domain.TrustedKey{trustedSSH(pub, email)}
114+ valid := func() []byte {
115+ s, err := sshsig.New(bytes.Repeat([]byte{9}, ed25519.SeedSize))
116+ require.NoError(t, err)
117+ sig, err := s.Sign([]byte("payload"))
118+ require.NoError(t, err)
119+ return []byte(sig)
120+ }()
121+
122+ huge := []byte("-----BEGIN SSH SIGNATURE-----\n" + strings.Repeat("A", 70<<10) + "\n-----END SSH SIGNATURE-----\n")
123+ tests := []struct {
124+ name string
125+ sig []byte
126+ }{
127+ {"not armored", []byte("hello")},
128+ {"no end line", bytes.TrimSuffix(valid, []byte("-----END SSH SIGNATURE-----\n"))},
129+ {"bad base64", []byte("-----BEGIN SSH SIGNATURE-----\n!!!!\n-----END SSH SIGNATURE-----\n")},
130+ {"wrong magic", []byte("-----BEGIN SSH SIGNATURE-----\nQUJDREVGR0g=\n-----END SSH SIGNATURE-----\n")},
131+ {"magic only", []byte("-----BEGIN SSH SIGNATURE-----\nU1NIU0lH\n-----END SSH SIGNATURE-----\n")},
132+ {"larger than 64 KiB", huge},
133+ }
134+ for _, tt := range tests {
135+ t.Run(tt.name, func(t *testing.T) {
136+ var v domain.CommitVerification
137+ require.NotPanics(t, func() { v = sigverify.SSH{}.Verify([]byte("payload"), tt.sig, email, 0, keys) })
138+ assert.Equal(t, domain.StatusBadSignature, v.Status)
139+ assert.Equal(t, domain.FormatSSH, v.Format)
140+ })
141+ }
142+
143+ t.Run("every truncation of a valid signature", func(t *testing.T) {
144+ for i := range valid {
145+ require.NotPanics(t, func() { sigverify.SSH{}.Verify([]byte("payload"), valid[:i], email, 0, keys) })
146+ }
147+ })
148+}
149+
150+// TestSSHVerifyInstanceKey checks the signature of the commits ermite itself
151+// creates (ADR 0022) against the public key it publishes.
152+func TestSSHVerifyInstanceKey(t *testing.T) {
153+ const email = "ermite@example.org"
154+ signer, err := sshsig.New(bytes.Repeat([]byte{5}, ed25519.SeedSize))
155+ require.NoError(t, err)
156+ payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor E <ermite@example.org> 1 +0000\ncommitter E <ermite@example.org> 1 +0000\n\nmsg\n")
157+ sig, err := signer.Sign(payload)
158+ require.NoError(t, err)
159+
160+ key := domain.TrustedKey{ID: signer.PublicKey().Fingerprint, Format: domain.FormatSSH, Title: "instance", PublicKey: signer.PublicKey().AuthorizedKey, Emails: []string{email}, Builtin: true}
161+ v := sigverify.Verifier{}.Verify(payload, []byte(sig), email, 1, []domain.TrustedKey{key})
162+ assert.Equal(t, domain.CommitVerification{
163+ Format: domain.FormatSSH, Status: domain.StatusVerified,
164+ Fingerprint: signer.PublicKey().Fingerprint, KeyID: key.ID, KeyTitle: "instance",
165+ }, v)
166+
167+ v = sigverify.Verifier{}.Verify(append(payload, 'x'), []byte(sig), email, 1, []domain.TrustedKey{key})
168+ assert.Equal(t, domain.StatusBadSignature, v.Status)
169+}
170+
171+// TestSSHVerifyRSASignatureFormat checks that the RSA signature ssh-keygen
172+// makes (rsa-sha2-512) verifies; the legacy SHA-1 format is refused by the
173+// verifier.
174+func TestSSHVerifyRSASignatureFormat(t *testing.T) {
175+ const email = "dev@example.org"
176+ priv, pub := sshKey(t, "rsa", "-b", "2048")
177+ payload := []byte("payload\n")
178+ sig := run(t, "", nil, string(payload), "ssh-keygen", "-q", "-Y", "sign", "-n", "git", "-f", priv)
179+ v := sigverify.SSH{}.Verify(payload, []byte(sig), email, 0, []domain.TrustedKey{trustedSSH(pub, email)})
180+ assert.Equal(t, domain.StatusVerified, v.Status)
181+}
A internal/adapters/sigverify/verifier.go +55 -0
@@ -0,0 +1,55 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package sigverify checks commit signatures against the trusted keys
4+// (ADR 0030). It is pure Go: no gpg or ssh-keygen binary is run. SSH
5+// signatures (SSHSIG, gpg.format=ssh) and OpenPGP detached signatures are
6+// verified; X.509 signatures are recognized and reported as unsupported.
7+//
8+// Every verifier is a stateless value, safe for concurrent use, and never
9+// panics on hostile input: a malformed signature is a bad_signature status.
10+package sigverify
11+
12+import (
13+ "bytes"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// maxSignatureBytes bounds the signature a verifier parses; a larger one is
20+// reported as bad_signature without being read.
21+const maxSignatureBytes = 64 << 10
22+
23+// Verifier dispatches a signature to the verifier of its format.
24+type Verifier struct{}
25+
26+var _ ports.SignatureVerifier = Verifier{}
27+
28+// Verify implements ports.SignatureVerifier. An x509 signature is
29+// unsupported; an unrecognized armor is a bad signature without format.
30+func (Verifier) Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) domain.CommitVerification {
31+ switch domain.SignatureFormatOf(bytes.TrimLeft(signature, " \t\r\n")) {
32+ case domain.FormatSSH:
33+ return SSH{}.Verify(payload, signature, committerEmail, signedAt, keys)
34+ case domain.FormatGPG:
35+ return GPG{}.Verify(payload, signature, committerEmail, signedAt, keys)
36+ case domain.FormatX509:
37+ return domain.CommitVerification{Format: domain.FormatX509, Status: domain.StatusUnsupported}
38+ }
39+ return domain.CommitVerification{Format: domain.FormatNone, Status: domain.StatusBadSignature}
40+}
41+
42+// trusted completes the verification of a signature whose cryptography is
43+// valid and whose key is trusted: verified when the key may sign for the
44+// committer email, email_mismatch otherwise.
45+//
46+//hexcheck:ignore suspicious-business-logic-in-adapter the email rule is domain.TrustedKey.TrustsEmail; this only maps it to the port's status
47+func trusted(v domain.CommitVerification, key domain.TrustedKey, committerEmail string) domain.CommitVerification {
48+ v.KeyID, v.KeyTitle = key.ID, key.Title
49+ if key.TrustsEmail(committerEmail) {
50+ v.Status = domain.StatusVerified
51+ } else {
52+ v.Status = domain.StatusEmailMismatch
53+ }
54+ return v
55+}
A internal/adapters/sigverify/verifier_test.go +44 -0
@@ -0,0 +1,44 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package sigverify_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "bnema.dev/ermite/internal/adapters/sigverify"
11+ "bnema.dev/ermite/internal/domain"
12+)
13+
14+func TestVerifierDispatch(t *testing.T) {
15+ const email = "dev@example.org"
16+ priv, sshPub := sshKey(t, "ed25519")
17+ sshC := sshCommit(t, priv, email)
18+ g := newGPGHome(t)
19+ fpr := g.generate("dispatch <"+email+">", "ed25519", "never")
20+ gpgC := g.commit(fpr, email)
21+ keys := []domain.TrustedKey{trustedSSH(sshPub, email), trustedGPG(g.public(fpr), email)}
22+
23+ tests := []struct {
24+ name string
25+ payload []byte
26+ signature []byte
27+ wantFormat domain.SignatureFormat
28+ wantStatus domain.SignatureStatus
29+ }{
30+ {"ssh", sshC.payload, sshC.signature, domain.FormatSSH, domain.StatusVerified},
31+ {"gpg", gpgC.payload, gpgC.signature, domain.FormatGPG, domain.StatusVerified},
32+ {"ssh signature of another commit", append(sshC.payload[:len(sshC.payload):len(sshC.payload)], 0), sshC.signature, domain.FormatSSH, domain.StatusBadSignature},
33+ {"x509", sshC.payload, []byte("-----BEGIN SIGNED MESSAGE-----\nMIIB\n-----END SIGNED MESSAGE-----\n"), domain.FormatX509, domain.StatusUnsupported},
34+ {"unknown armor", sshC.payload, []byte("-----BEGIN SOMETHING-----\nAAAA\n-----END SOMETHING-----\n"), domain.FormatNone, domain.StatusBadSignature},
35+ {"empty", sshC.payload, nil, domain.FormatNone, domain.StatusBadSignature},
36+ }
37+ for _, tt := range tests {
38+ t.Run(tt.name, func(t *testing.T) {
39+ v := sigverify.Verifier{}.Verify(tt.payload, tt.signature, email, sshC.signedAt, keys)
40+ assert.Equal(t, tt.wantFormat, v.Format)
41+ assert.Equal(t, tt.wantStatus, v.Status)
42+ })
43+ }
44+}
M internal/adapters/web/commit.go +7 -0
@@ -46,6 +46,10 @@ type CommitPage struct {
4646 Invisible bool
4747 // Checks is the combined state of the CI checks of the commit.
4848 Checks ChecksSummary
49+ // Sig is the signature badge, nil when the commit was not checked.
50+ Sig *SignatureBadge
51+ // Verification is what is known of the signing key, for the details.
52+ Verification domain.CommitVerification
4953 }
5054
5155 // DiffView is how a page lays its diff out: the layouts in the page and the
@@ -121,6 +125,9 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
121125 Adds: res.Stats.Additions,
122126 Dels: res.Stats.Deletions,
123127 Files: s.hl.files(ctx, res.Diff.Files),
128+
129+ Sig: signatureBadge(c.Verification, true),
130+ Verification: c.Verification,
124131 }
125132 contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid))
126133 if cm := person(c.Committer, now); cm != p.Author || c.Committer.Email != c.Author.Email {
M internal/adapters/web/components.templ +14 -0
@@ -193,6 +193,9 @@ templ CommitRows(rows []CommitRow) {
193193 if c.Merge {
194194 <span class="pill">merge</span>
195195 }
196+ if c.Sig != nil {
197+ @signaturePill(c.Sig)
198+ }
196199 for _, b := range c.Branches {
197200 <span class="pill">{ b }</span>
198201 }
@@ -208,3 +211,14 @@ templ CommitRows(rows []CommitRow) {
208211 </tbody>
209212 </table>
210213 }
214+
215+// signaturePill is the badge of a signed commit: the protocol and the verdict
216+// as text, the reason in the tooltip.
217+templ signaturePill(b *SignatureBadge) {
218+ <span class={ "pill sig", b.Class } title={ b.Title }>
219+ if b.Proto != "" {
220+ <span class="sig-proto">{ b.Proto }</span>
221+ }
222+ { b.Label }
223+ </span>
224+}
M internal/adapters/web/components_templ.go +106 -6 generated file: collapsed
M internal/adapters/web/diff.templ +15 -0
@@ -40,6 +40,21 @@ templ Commit(l Layout, p CommitPage) {
4040 { " · diff against the first parent" }
4141 }
4242 </dd>
43+ if p.Sig != nil {
44+ <dt>signature</dt>
45+ <dd>
46+ @signaturePill(p.Sig)
47+ if p.Verification.Fingerprint != "" {
48+ <code>{ p.Verification.Fingerprint }</code>
49+ }
50+ if p.Verification.KeyTitle != "" {
51+ <span class="dim">{ "· " + visibleText(p.Verification.KeyTitle) }</span>
52+ }
53+ if p.Sig != nil && p.Sig.Class != "sig-none" {
54+ <span class="sig-why dim">{ p.Sig.Title }</span>
55+ }
56+ </dd>
57+ }
4358 if p.Checks.Total > 0 {
4459 <dt>checks</dt>
4560 <dd>@CommitChecks(p.Checks)</dd>
M internal/adapters/web/diff_templ.go +555 -484 generated file: collapsed
M internal/adapters/web/repo.go +1 -0
@@ -272,6 +272,7 @@ func commitRow(repo string, c domain.Commit, now time.Time) CommitRow {
272272 Short: short(c.ID.String()), Subject: visibleText(c.Summary),
273273 CHref: repoHref(repo, "commit", c.ID.String()),
274274 Author: visibleText(c.Author.Name), Merge: len(c.Parents) > 1,
275+ Sig: signatureBadge(c.Verification, false),
275276 }
276277 row.When, row.WhenAt = whenOf(now, c.Committer.Time)
277278 return row
A internal/adapters/web/signature.go +102 -0
@@ -0,0 +1,102 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bnema.dev/ermite/internal/domain"
7+)
8+
9+// SignatureBadge is the pill of a signed commit. Label is the verdict, Proto
10+// the protocol ("SSH", "GPG", "X.509", empty when unknown), Class the style
11+// (sig-ok, sig-warn, sig-bad, sig-none) and Title the tooltip that says why.
12+type SignatureBadge struct {
13+ Label, Proto, Class, Title string
14+}
15+
16+// signatureProto is the display name of a signature format.
17+func signatureProto(f domain.SignatureFormat) string {
18+ switch f {
19+ case domain.FormatSSH:
20+ return "SSH"
21+ case domain.FormatGPG:
22+ return "GPG"
23+ case domain.FormatX509:
24+ return "X.509"
25+ }
26+ return ""
27+}
28+
29+// signatureBadge is the badge of a verification, nil when there is nothing to
30+// show: the commit was not checked, or (unless long, the commit page) it is
31+// not signed.
32+func signatureBadge(v domain.CommitVerification, long bool) *SignatureBadge {
33+ switch v.Status {
34+ case domain.StatusUnchecked:
35+ return nil
36+ case domain.StatusUnsigned:
37+ if !long {
38+ return nil
39+ }
40+ return &SignatureBadge{Label: "unsigned", Class: "sig-none", Title: "This commit is not signed"}
41+ }
42+ b := &SignatureBadge{Proto: signatureProto(v.Format)}
43+ key := keyPhrase(v)
44+ switch v.Status {
45+ case domain.StatusVerified:
46+ b.Label, b.Class = "Verified", "sig-ok"
47+ b.Title = "Signed with " + key + " — verified for the committer email"
48+ case domain.StatusUnknownKey:
49+ b.Label, b.Class = "Unverified", "sig-warn"
50+ b.Title = unknownKeyTitle(v)
51+ case domain.StatusEmailMismatch:
52+ b.Label, b.Class = "Unverified", "sig-warn"
53+ b.Title = "Signed with " + key + " — this key is not trusted for the committer email"
54+ case domain.StatusExpired:
55+ b.Label, b.Class = "Unverified", "sig-warn"
56+ b.Title = "Signed with " + key + " — the key or the signature expired"
57+ case domain.StatusRevoked:
58+ b.Label, b.Class = "Unverified", "sig-warn"
59+ b.Title = "Signed with " + key + " — the key is revoked"
60+ case domain.StatusBadSignature:
61+ b.Label, b.Class = "Bad signature", "sig-bad"
62+ b.Title = "The signature does not match the commit"
63+ case domain.StatusUnsupported:
64+ b.Label, b.Class = "Unverified", "sig-warn"
65+ if proto := signatureProto(v.Format); proto != "" {
66+ b.Title = proto + " signatures are not checked"
67+ } else {
68+ b.Title = "This kind of signature is not checked"
69+ }
70+ default:
71+ return nil
72+ }
73+ return b
74+}
75+
76+// keyPhrase names the signing key for a tooltip: "SSH key SHA256:… (title)".
77+func keyPhrase(v domain.CommitVerification) string {
78+ s := "a key"
79+ if p := signatureProto(v.Format); p != "" {
80+ s = p + " key"
81+ }
82+ if v.Fingerprint != "" {
83+ s += " " + v.Fingerprint
84+ }
85+ if v.KeyTitle != "" {
86+ s += " (" + visibleText(v.KeyTitle) + ")"
87+ }
88+ return s
89+}
90+
91+// unknownKeyTitle is the tooltip of a signature by a key that is not trusted.
92+func unknownKeyTitle(v domain.CommitVerification) string {
93+ s := "Signed with an unknown "
94+ if p := signatureProto(v.Format); p != "" {
95+ s += p + " "
96+ }
97+ s += "key"
98+ if v.Fingerprint != "" {
99+ s += " " + v.Fingerprint
100+ }
101+ return s
102+}
A internal/adapters/web/signature_test.go +141 -0
@@ -0,0 +1,141 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+const (
16+ sshFP = "SHA256:abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"
17+ gpgFP = "0123456789ABCDEF0123456789ABCDEF01234567"
18+)
19+
20+func verification(f domain.SignatureFormat, s domain.SignatureStatus, fp, title string) domain.CommitVerification {
21+ return domain.CommitVerification{Format: f, Status: s, Fingerprint: fp, KeyTitle: title}
22+}
23+
24+func TestSignatureBadge(t *testing.T) {
25+ t.Parallel()
26+ for _, tt := range []struct {
27+ name string
28+ v domain.CommitVerification
29+ long bool
30+ nilBadge bool
31+ proto, label string
32+ class, title string
33+ }{
34+ {name: "unchecked", v: domain.CommitVerification{}, nilBadge: true},
35+ {name: "unchecked long", v: domain.CommitVerification{}, long: true, nilBadge: true},
36+ {name: "unsigned in a list", v: verification("", domain.StatusUnsigned, "", ""), nilBadge: true},
37+ {name: "unsigned on the page", v: verification("", domain.StatusUnsigned, "", ""), long: true, label: "unsigned", class: "sig-none"},
38+ {name: "verified ssh", v: verification(domain.FormatSSH, domain.StatusVerified, sshFP, "laptop"),
39+ proto: "SSH", label: "Verified", class: "sig-ok", title: "Signed with SSH key " + sshFP + " (laptop) — verified for the committer email"},
40+ {name: "verified gpg", v: verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""),
41+ proto: "GPG", label: "Verified", class: "sig-ok", title: "Signed with GPG key " + gpgFP + " — verified for the committer email"},
42+ {name: "unknown key", v: verification(domain.FormatGPG, domain.StatusUnknownKey, gpgFP, ""),
43+ proto: "GPG", label: "Unverified", class: "sig-warn", title: "Signed with an unknown GPG key " + gpgFP},
44+ {name: "email mismatch", v: verification(domain.FormatSSH, domain.StatusEmailMismatch, sshFP, "laptop"),
45+ proto: "SSH", label: "Unverified", class: "sig-warn", title: "this key is not trusted for the committer email"},
46+ {name: "expired", v: verification(domain.FormatGPG, domain.StatusExpired, gpgFP, ""),
47+ proto: "GPG", label: "Unverified", class: "sig-warn", title: "expired"},
48+ {name: "revoked", v: verification(domain.FormatGPG, domain.StatusRevoked, gpgFP, ""),
49+ proto: "GPG", label: "Unverified", class: "sig-warn", title: "revoked"},
50+ {name: "bad signature", v: verification(domain.FormatSSH, domain.StatusBadSignature, "", ""),
51+ proto: "SSH", label: "Bad signature", class: "sig-bad", title: "The signature does not match the commit"},
52+ {name: "x509", v: verification(domain.FormatX509, domain.StatusUnsupported, "", ""),
53+ proto: "X.509", label: "Unverified", class: "sig-warn", title: "X.509 signatures are not checked"},
54+ } {
55+ t.Run(tt.name, func(t *testing.T) {
56+ t.Parallel()
57+ b := signatureBadge(tt.v, tt.long)
58+ if tt.nilBadge {
59+ assert.Nil(t, b)
60+ return
61+ }
62+ require.NotNil(t, b)
63+ assert.Equal(t, tt.proto, b.Proto)
64+ assert.Equal(t, tt.label, b.Label)
65+ assert.Equal(t, tt.class, b.Class)
66+ assert.Contains(t, b.Title, tt.title)
67+ })
68+ }
69+}
70+
71+func rowOf(v domain.CommitVerification) CommitRow {
72+ id := objectID(&testing.T{}, "0123456789abcdef0123456789abcdef01234567")
73+ return commitRow("demo", domain.Commit{
74+ ID: id, Summary: "s", Verification: v,
75+ Author: domain.Signature{Name: "A"}, Committer: domain.Signature{Time: time.Unix(1, 0)},
76+ }, time.Unix(100, 0))
77+}
78+
79+func TestCommitRowsShowTheSignatureBadge(t *testing.T) {
80+ t.Parallel()
81+ for _, tt := range []struct {
82+ name string
83+ v domain.CommitVerification
84+ want []string
85+ none bool
86+ }{
87+ {name: "ssh verified", v: verification(domain.FormatSSH, domain.StatusVerified, sshFP, "laptop"),
88+ want: []string{`class="pill sig sig-ok"`, `<span class="sig-proto">SSH</span> Verified`, `title="Signed with SSH key ` + sshFP}},
89+ {name: "gpg verified", v: verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""),
90+ want: []string{`class="pill sig sig-ok"`, `<span class="sig-proto">GPG</span> Verified`}},
91+ {name: "unknown key", v: verification(domain.FormatGPG, domain.StatusUnknownKey, gpgFP, ""),
92+ want: []string{`class="pill sig sig-warn"`, `GPG</span> Unverified`, `unknown GPG key`}},
93+ {name: "bad signature", v: verification(domain.FormatSSH, domain.StatusBadSignature, "", ""),
94+ want: []string{`class="pill sig sig-bad"`, `SSH</span> Bad signature`}},
95+ {name: "unsigned", v: verification("", domain.StatusUnsigned, "", ""), none: true},
96+ {name: "unchecked", v: domain.CommitVerification{}, none: true},
97+ } {
98+ t.Run(tt.name, func(t *testing.T) {
99+ t.Parallel()
100+ got := renderToString(t, CommitRows([]CommitRow{rowOf(tt.v)}))
101+ if tt.none {
102+ assert.NotContains(t, got, "sig")
103+ assert.NotContains(t, got, "unsigned")
104+ return
105+ }
106+ for _, w := range tt.want {
107+ assert.Contains(t, got, w)
108+ }
109+ })
110+ }
111+}
112+
113+func TestCommitPageShowsTheSignature(t *testing.T) {
114+ t.Parallel()
115+ page := func(v domain.CommitVerification) string {
116+ return renderToString(t, Commit(Layout{Repo: "demo"}, CommitPage{
117+ SHA: "0123456789abcdef0123456789abcdef01234567", Short: "0123456", Subject: "s",
118+ Sig: signatureBadge(v, true), Verification: v,
119+ }))
120+ }
121+ got := page(verification(domain.FormatSSH, domain.StatusVerified, sshFP, "my <laptop>"))
122+ assert.Contains(t, got, "<dt>signature</dt>")
123+ assert.Contains(t, got, `<span class="sig-proto">SSH</span> Verified`)
124+ assert.Contains(t, got, "<code>"+sshFP+"</code>")
125+ assert.Contains(t, got, "my &lt;laptop&gt;")
126+
127+ got = page(verification(domain.FormatGPG, domain.StatusUnknownKey, gpgFP, ""))
128+ assert.Contains(t, got, `sig-warn`)
129+ assert.Contains(t, got, "<code>"+gpgFP+"</code>")
130+ assert.Contains(t, got, `<span class="sig-why dim">Signed with an unknown GPG key `+gpgFP+`</span>`, "the reason is text, not only a tooltip")
131+
132+ got = page(verification("", domain.StatusUnsigned, "", ""))
133+ assert.Contains(t, got, "<dt>signature</dt>")
134+ assert.Contains(t, got, "sig-none")
135+ assert.Contains(t, got, "unsigned")
136+ assert.NotContains(t, got, "<code>")
137+ assert.NotContains(t, got, "sig-why")
138+
139+ got = page(domain.CommitVerification{})
140+ assert.NotContains(t, got, "signature")
141+}
M internal/adapters/web/static/css/20-shell.css +14 -1
@@ -36,7 +36,20 @@
3636 background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap;
3737 &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); }
3838 &.new { color: var(--teal); border-color: currentColor; }
39- }
39+ /* the signature of a commit: the protocol, then the verdict, always as text
40+ (colour never carries it alone). Verified takes the accent, a signature
41+ that cannot be vouched for keeps the neutral pill with the ink of a
42+ warning, a bad one is red, "unsigned" (commit page) is dim. */
43+ &.sig { gap: 5px; }
44+ &.sig .sig-proto { font-weight: 700; letter-spacing: .02em; }
45+ &.sig-ok { color: var(--teal); border-color: currentColor; }
46+ &.sig-warn { color: var(--ink-hi); border-style: dashed; }
47+ &.sig-bad { color: var(--red); border-color: currentColor; }
48+ &.sig-none { color: var(--ink-mute); border-style: dotted; font-weight: 400; }
49+ }
50+ /* Why a signature is or is not verified, as text: a title alone is not
51+ reachable by keyboard, touch or every screen reader. */
52+ .sig-why { display: block; margin-top: 2px; }
4053 .count {
4154 display: inline-block; min-width: 22px; padding: 0 7px; margin-left: var(--s1);
4255 font: 600 var(--fs-xs) / 20px var(--font); text-align: center; color: var(--ink-dim);
M internal/adapters/web/static/css/50-code.css +2 -0
@@ -162,6 +162,8 @@
162162 & td:last-child { padding-right: var(--s4); }
163163 & .c-who { display: none; }
164164 & .c-subject { width: auto; max-width: 0; font-size: var(--fs-s); }
165+ /* the pills are hidden here, the signature one too: the rail is narrow and
166+ the subject is what the row is for; the commit page shows the badge. */
165167 & .c-subject .pill { display: none; }
166168 & .c-ck:empty { padding: 0; }
167169 & .c-when { text-align: right; }
M internal/adapters/web/view.go +2 -0
@@ -180,6 +180,8 @@ type CommitRow struct {
180180 // Checks is the combined state of the CI checks of the commit; empty when
181181 // it has none.
182182 Checks ChecksSummary
183+ // Sig is the signature badge, nil for an unchecked or unsigned commit.
184+ Sig *SignatureBadge
183185 }
184186
185187 // SummaryPage is the repository summary.
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
M internal/app/cli_e2e_test.go +1 -1
@@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
338338 ver := decode[versionOut](t, res.stdout)
339339 assert.Equal(t, c.url, ver.Server.URL)
340340 assert.NotEmpty(t, ver.Server.Version)
341- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts"}, ver.Server.Features)
341+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
342342 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
343343 stdout, stderr, code = c.bin(t, secret, "version")
344344 require.Equal(t, 0, code, stderr)
M internal/app/cli_fake_test.go +16 -0
@@ -33,6 +33,7 @@ type fakeAPI struct {
3333 ermiteconnect.UnimplementedMergeRequestServiceHandler
3434 ermiteconnect.UnimplementedSessionServiceHandler
3535 ermiteconnect.UnimplementedRunServiceHandler
36+ ermiteconnect.UnimplementedTrustedKeyServiceHandler
3637
3738 runners []*ermite.Runner // ListRunners answer; nil means one default runner
3839
@@ -64,6 +65,10 @@ type fakeAPI struct {
6465
6566 listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error)
6667 removePasskey func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error)
68+
69+ listTrustedKeys func(*ermite.ListTrustedKeysRequest) (*ermite.ListTrustedKeysResponse, error)
70+ addTrustedKey func(*ermite.AddTrustedKeyRequest) (*ermite.AddTrustedKeyResponse, error)
71+ removeTrustedKey func(*ermite.RemoveTrustedKeyRequest) (*ermite.RemoveTrustedKeyResponse, error)
6772 }
6873
6974 func (f *fakeAPI) record(name string, h http.Header, req any) {
@@ -152,6 +157,15 @@ func (f *fakeAPI) ListPasskeys(_ context.Context, r *connect.Request[ermite.List
152157 func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) {
153158 return respond(f, "RemovePasskey", r, f.removePasskey)
154159 }
160+func (f *fakeAPI) ListTrustedKeys(_ context.Context, r *connect.Request[ermite.ListTrustedKeysRequest]) (*connect.Response[ermite.ListTrustedKeysResponse], error) {
161+ return respond(f, "ListTrustedKeys", r, f.listTrustedKeys)
162+}
163+func (f *fakeAPI) AddTrustedKey(_ context.Context, r *connect.Request[ermite.AddTrustedKeyRequest]) (*connect.Response[ermite.AddTrustedKeyResponse], error) {
164+ return respond(f, "AddTrustedKey", r, f.addTrustedKey)
165+}
166+func (f *fakeAPI) RemoveTrustedKey(_ context.Context, r *connect.Request[ermite.RemoveTrustedKeyRequest]) (*connect.Response[ermite.RemoveTrustedKeyResponse], error) {
167+ return respond(f, "RemoveTrustedKey", r, f.removeTrustedKey)
168+}
155169 func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error {
156170 f.record("Watch", r.Header(), r.Msg)
157171 if f.watch == nil {
@@ -179,6 +193,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
179193 mount(p, h)
180194 p, h = ermiteconnect.NewRunServiceHandler(f)
181195 mount(p, h)
196+ p, h = ermiteconnect.NewTrustedKeyServiceHandler(f)
197+ mount(p, h)
182198 srv := httptest.NewServer(mux)
183199 t.Cleanup(srv.Close)
184200 return srv.URL
M internal/app/cli_signing.go +247 -3
@@ -3,12 +3,20 @@
33 package app
44
55 import (
6+ "context"
7+ "errors"
68 "fmt"
9+ "io"
10+ "os"
11+ "slices"
12+ "strings"
713
814 "connectrpc.com/connect"
915 "github.com/spf13/cobra"
1016
1117 "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite/internal/adapters/client"
19+ "bnema.dev/ermite/internal/domain"
1220 )
1321
1422 // jsonSigningKey is the stable JSON shape of `signing-key show --json`.
@@ -23,14 +31,250 @@ type jsonSigningKey struct {
2331 func newSigningKeyCmd(env *cliEnv) *cobra.Command {
2432 key := &cobra.Command{
2533 Use: "signing-key",
26- Short: "Show the key that signs the commits the server creates",
34+ Short: "Show the server's signing key and manage the keys that verify pushed commits",
2735 Long: "The server signs the commits it creates (squash merges) with its own SSH key, so that they show as\n" +
28- "verified. Needs a server with the signing_key feature (see `ermite version`).",
36+ "verified: `show` prints that key. Needs a server with the signing_key feature (see `ermite version`).\n\n" +
37+ "The server also checks the signatures of the commits you push, SSH and GPG, against the public keys\n" +
38+ "you trust: `trust add|list|remove` manage them. A commit shows as verified when its signature is valid,\n" +
39+ "made by a trusted key, and the key is trusted for the committer's email. The server's own key is\n" +
40+ "always trusted (builtin) and cannot be removed. Needs the commit_signatures feature (see\n" +
41+ "`ermite version`); adding and removing keys is for the owner.",
2942 }
30- key.AddCommand(newSigningKeyShowCmd(env))
43+ key.AddCommand(newSigningKeyShowCmd(env), newSigningKeyTrustCmd(env))
3144 return key
3245 }
3346
47+// jsonTrustedKey is the stable JSON shape of a trusted key.
48+type jsonTrustedKey struct {
49+ ID string `json:"id"`
50+ Format string `json:"format"`
51+ Title string `json:"title"`
52+ PublicKey string `json:"public_key"`
53+ Emails []string `json:"emails"`
54+ Builtin bool `json:"builtin"`
55+ CreatedAt *string `json:"created_at"`
56+}
57+
58+const jsonTrustedKeyHelp = "\n\nJSON fields of a key (--json): id (SHA256:... for ssh, the fingerprint in hex for gpg), format\n" +
59+ "(ssh, gpg), title, public_key, emails (array), builtin (true for the server's own key), created_at\n" +
60+ "(RFC 3339 UTC, null for the builtin key)."
61+
62+func formatName(f ermite.SignatureFormat) string {
63+ switch f {
64+ case ermite.SignatureFormat_SIGNATURE_FORMAT_SSH:
65+ return "ssh"
66+ case ermite.SignatureFormat_SIGNATURE_FORMAT_GPG:
67+ return "gpg"
68+ case ermite.SignatureFormat_SIGNATURE_FORMAT_X509:
69+ return "x509"
70+ case ermite.SignatureFormat_SIGNATURE_FORMAT_UNSPECIFIED:
71+ }
72+ return "unknown"
73+}
74+
75+func toJSONTrustedKey(k *ermite.TrustedKey) jsonTrustedKey {
76+ return jsonTrustedKey{
77+ ID: k.GetId(), Format: formatName(k.GetFormat()), Title: k.GetTitle(), PublicKey: k.GetPublicKey(),
78+ Emails: append([]string{}, k.GetEmails()...), Builtin: k.GetBuiltin(), CreatedAt: jsonTime(k.GetCreatedAt()),
79+ }
80+}
81+
82+// requireCommitSignatures refuses to go on with a server that does not list
83+// commit_signatures: it has no TrustedKeyService. A server whose features
84+// cannot be read is refused too.
85+func requireCommitSignatures(ctx context.Context, c *client.Client) error {
86+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
87+ if err != nil {
88+ return c.Describe(err)
89+ }
90+ if !slices.Contains(res.Msg.GetFeatures(), client.FeatureCommitSignatures) {
91+ return fmt.Errorf("the server does not support %s, so it cannot manage trusted keys (upgrade the server, see `ermite version`)", client.FeatureCommitSignatures)
92+ }
93+ return nil
94+}
95+
96+func newSigningKeyTrustCmd(env *cliEnv) *cobra.Command {
97+ trust := &cobra.Command{
98+ Use: "trust",
99+ Short: "Manage the keys commit signatures are verified against",
100+ Long: "List, add and remove the public keys the server trusts to verify the signatures of commits (SSH and\n" +
101+ "GPG). Each key is trusted for one to ten committer emails: a commit signed by the key but made\n" +
102+ "under another email shows as unverified. Needs a server with the commit_signatures feature.",
103+ }
104+ trust.AddCommand(newTrustListCmd(env), newTrustAddCmd(env), newTrustRemoveCmd(env))
105+ return trust
106+}
107+
108+func newTrustListCmd(env *cliEnv) *cobra.Command {
109+ cmd := &cobra.Command{
110+ Use: "list",
111+ Short: "List the trusted keys",
112+ Long: "List the trusted keys, the server's own (builtin) key first. Any authenticated token may." + jsonTrustedKeyHelp +
113+ "\n--json prints an array of keys.",
114+ Args: cobra.NoArgs,
115+ }
116+ asJSON := addJSONFlag(cmd)
117+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
118+ c, err := env.client(cmd)
119+ if err != nil {
120+ return err
121+ }
122+ ctx := env.ctx(cmd)
123+ if err := requireCommitSignatures(ctx, c); err != nil {
124+ return err
125+ }
126+ res, err := c.TrustedKeys().ListTrustedKeys(ctx, connect.NewRequest(&ermite.ListTrustedKeysRequest{}))
127+ if err != nil {
128+ return c.Describe(err)
129+ }
130+ keys := res.Msg.GetKeys()
131+ if *asJSON {
132+ out := make([]jsonTrustedKey, 0, len(keys))
133+ for _, k := range keys {
134+ out = append(out, toJSONTrustedKey(k))
135+ }
136+ return writeJSON(cmd.OutOrStdout(), out)
137+ }
138+ if len(keys) == 0 {
139+ noticef(cmd, "no trusted keys")
140+ return nil
141+ }
142+ rows := make([][]string, 0, len(keys))
143+ for _, k := range keys {
144+ builtin := "-"
145+ if k.GetBuiltin() {
146+ builtin = "yes"
147+ }
148+ rows = append(rows, []string{k.GetId(), formatName(k.GetFormat()), k.GetTitle(), strings.Join(k.GetEmails(), ","), builtin})
149+ }
150+ return table(cmd.OutOrStdout(), []string{"ID", "FORMAT", "TITLE", "EMAILS", "BUILTIN"}, rows)
151+ }
152+ return cmd
153+}
154+
155+func newTrustAddCmd(env *cliEnv) *cobra.Command {
156+ var (
157+ format string
158+ title string
159+ emails []string
160+ )
161+ cmd := &cobra.Command{
162+ Use: "add --format ssh|gpg --title <title> --email <email> <file|->",
163+ Short: "Trust a public key",
164+ Long: "Trust a public key for the given committer emails. The key is read from the file, or from standard\n" +
165+ "input with -, up to 64 KiB: an authorized_keys line for --format ssh (for example ~/.ssh/id_ed25519.pub),\n" +
166+ "an armored public key for --format gpg (`gpg --armor --export <fingerprint>`, exactly one key, never\n" +
167+ "a private key). --email is required, repeatable, 1 to 10 addresses; the title is 1 to 100 bytes.\n" +
168+ "Only the owner may add a key. Prints the key id on standard output, details on standard error;\n" +
169+ "with --json, standard output is the key object." + jsonTrustedKeyHelp,
170+ Example: " ermite signing-key trust add --format ssh --title laptop --email me@example.com ~/.ssh/id_ed25519.pub\n" +
171+ " gpg --armor --export 0xABCD | ermite signing-key trust add --format gpg --title work --email me@example.com -",
172+ Args: cobra.ExactArgs(1),
173+ }
174+ cmd.Flags().StringVar(&format, "format", "", "key format: ssh or gpg")
175+ cmd.Flags().StringVar(&title, "title", "", "label of the key")
176+ cmd.Flags().StringSliceVar(&emails, "email", nil, "committer email the key may sign for, repeatable")
177+ _ = cmd.MarkFlagRequired("format")
178+ _ = cmd.MarkFlagRequired("title")
179+ _ = cmd.MarkFlagRequired("email")
180+ _ = cmd.RegisterFlagCompletionFunc("format", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
181+ return []string{"ssh", "gpg"}, cobra.ShellCompDirectiveNoFileComp
182+ })
183+ asJSON := addJSONFlag(cmd)
184+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
185+ var pbFormat ermite.SignatureFormat
186+ switch f, err := domain.ParseSignatureFormat(format); {
187+ case err != nil:
188+ return fmt.Errorf("invalid --format %q: use ssh or gpg", format)
189+ case f == domain.FormatGPG:
190+ pbFormat = ermite.SignatureFormat_SIGNATURE_FORMAT_GPG
191+ default:
192+ pbFormat = ermite.SignatureFormat_SIGNATURE_FORMAT_SSH
193+ }
194+ key, err := readTrustedKey(cmd, args[0])
195+ if err != nil {
196+ return err
197+ }
198+ c, err := env.client(cmd)
199+ if err != nil {
200+ return err
201+ }
202+ ctx := env.ctx(cmd)
203+ if err := requireCommitSignatures(ctx, c); err != nil {
204+ return err
205+ }
206+ res, err := c.TrustedKeys().AddTrustedKey(ctx, connect.NewRequest(&ermite.AddTrustedKeyRequest{
207+ Format: pbFormat, Title: title, PublicKey: key, Emails: emails,
208+ }))
209+ if err != nil {
210+ return c.Describe(err)
211+ }
212+ k := res.Msg.GetKey()
213+ if *asJSON {
214+ return writeJSON(cmd.OutOrStdout(), toJSONTrustedKey(k))
215+ }
216+ noticef(cmd, "trusted %s key %q for %s", formatName(k.GetFormat()), clean(k.GetTitle()), clean(strings.Join(k.GetEmails(), ", ")))
217+ _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetId()))
218+ return err
219+ }
220+ return cmd
221+}
222+
223+// readTrustedKey reads a public key from a file, or from standard input for
224+// "-", refusing more than domain.MaxTrustedKeyBytes.
225+func readTrustedKey(cmd *cobra.Command, path string) (string, error) {
226+ var r io.Reader
227+ name := "standard input"
228+ if path == "-" {
229+ r = cmd.InOrStdin()
230+ } else {
231+ f, err := os.Open(path) //nolint:gosec // the user names the file
232+ if err != nil {
233+ return "", fmt.Errorf("read %s: %w", path, err)
234+ }
235+ defer func() { _ = f.Close() }()
236+ r, name = f, path
237+ }
238+ b, err := io.ReadAll(io.LimitReader(r, domain.MaxTrustedKeyBytes+1))
239+ if err != nil {
240+ return "", fmt.Errorf("read %s: %w", name, err)
241+ }
242+ if len(b) > domain.MaxTrustedKeyBytes {
243+ return "", fmt.Errorf("%s: the key is larger than %d KiB", name, domain.MaxTrustedKeyBytes>>10)
244+ }
245+ key := strings.TrimSpace(string(b))
246+ if key == "" {
247+ return "", errors.New(name + ": no key to read")
248+ }
249+ return key, nil
250+}
251+
252+func newTrustRemoveCmd(env *cliEnv) *cobra.Command {
253+ cmd := &cobra.Command{
254+ Use: "remove <id>",
255+ Short: "Stop trusting a key",
256+ Long: "Remove a trusted key by its id (see `signing-key trust list`). Commits it signed then show as\n" +
257+ "unverified. The server's own (builtin) key cannot be removed. Only the owner may remove a key.",
258+ Args: cobra.ExactArgs(1),
259+ }
260+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
261+ c, err := env.client(cmd)
262+ if err != nil {
263+ return err
264+ }
265+ ctx := env.ctx(cmd)
266+ if err := requireCommitSignatures(ctx, c); err != nil {
267+ return err
268+ }
269+ if _, err := c.TrustedKeys().RemoveTrustedKey(ctx, connect.NewRequest(&ermite.RemoveTrustedKeyRequest{Id: args[0]})); err != nil {
270+ return c.Describe(err)
271+ }
272+ noticef(cmd, "removed trusted key %s", clean(args[0]))
273+ return nil
274+ }
275+ return cmd
276+}
277+
34278 func newSigningKeyShowCmd(env *cliEnv) *cobra.Command {
35279 var allowedSigners, publicKey bool
36280 cmd := &cobra.Command{
A internal/app/cli_signing_test.go +204 -0
@@ -0,0 +1,204 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+
12+ "connectrpc.com/connect"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite-api/gen/ermite"
17+)
18+
19+// withCommitSignatures is a GetVersion that lists commit_signatures.
20+func withCommitSignatures(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
21+ return &ermite.GetVersionResponse{Version: "v0.7.0", Features: []string{"commit_signatures"}}, nil
22+}
23+
24+const testSSHKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE1234"
25+
26+func lastRequest[T any](t *testing.T, f *fakeAPI, call string) *T {
27+ t.Helper()
28+ calls, reqs := f.snapshot()
29+ for i := len(calls) - 1; i >= 0; i-- {
30+ if calls[i] == call {
31+ return reqs[i].(*T)
32+ }
33+ }
34+ t.Fatalf("no %s call", call)
35+ return nil
36+}
37+
38+func TestSigningKeyTrustList(t *testing.T) {
39+ keys := []*ermite.TrustedKey{
40+ {Id: "SHA256:builtin", Format: ermite.SignatureFormat_SIGNATURE_FORMAT_SSH, Title: "ermite", PublicKey: "ssh-ed25519 AAAA", Emails: []string{"ermite@example.com"}, Builtin: true},
41+ {Id: "ABCDEF0123", Format: ermite.SignatureFormat_SIGNATURE_FORMAT_GPG, Title: "work", PublicKey: "-----BEGIN PGP PUBLIC KEY BLOCK-----", Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: ts("2026-10-10T12:00:00Z")},
42+ }
43+ f := &fakeAPI{getVersion: withCommitSignatures, listTrustedKeys: func(*ermite.ListTrustedKeysRequest) (*ermite.ListTrustedKeysResponse, error) {
44+ return &ermite.ListTrustedKeysResponse{Keys: keys}, nil
45+ }}
46+ url := f.serve(t)
47+
48+ res := runCLI(t, envFor(url), "", "signing-key", "trust", "list").ok(t)
49+ lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
50+ require.Len(t, lines, 3, res.stdout)
51+ assert.Regexp(t, `^ID\s+FORMAT\s+TITLE\s+EMAILS\s+BUILTIN$`, lines[0])
52+ assert.Regexp(t, `^SHA256:builtin\s+ssh\s+ermite\s+ermite@example.com\s+yes$`, lines[1])
53+ assert.Regexp(t, `^ABCDEF0123\s+gpg\s+work\s+a@b.c,d@e.f\s+-$`, lines[2])
54+
55+ res = runCLI(t, envFor(url), "", "signing-key", "trust", "list", "--json").ok(t)
56+ assert.JSONEq(t, `[
57+ {"id":"SHA256:builtin","format":"ssh","title":"ermite","public_key":"ssh-ed25519 AAAA","emails":["ermite@example.com"],"builtin":true,"created_at":null},
58+ {"id":"ABCDEF0123","format":"gpg","title":"work","public_key":"-----BEGIN PGP PUBLIC KEY BLOCK-----","emails":["a@b.c","d@e.f"],"builtin":false,"created_at":"2026-10-10T12:00:00Z"}
59+ ]`, res.stdout)
60+}
61+
62+func TestSigningKeyTrustListEmpty(t *testing.T) {
63+ f := &fakeAPI{getVersion: withCommitSignatures, listTrustedKeys: func(*ermite.ListTrustedKeysRequest) (*ermite.ListTrustedKeysResponse, error) {
64+ return &ermite.ListTrustedKeysResponse{}, nil
65+ }}
66+ url := f.serve(t)
67+ res := runCLI(t, envFor(url), "", "signing-key", "trust", "list").ok(t)
68+ assert.Empty(t, res.stdout)
69+ assert.Contains(t, res.stderr, "no trusted keys")
70+ res = runCLI(t, envFor(url), "", "signing-key", "trust", "list", "--json").ok(t)
71+ assert.JSONEq(t, `[]`, res.stdout)
72+}
73+
74+func addEcho(r *ermite.AddTrustedKeyRequest) (*ermite.AddTrustedKeyResponse, error) {
75+ return &ermite.AddTrustedKeyResponse{Key: &ermite.TrustedKey{
76+ Id: "SHA256:new", Format: r.GetFormat(), Title: r.GetTitle(), PublicKey: r.GetPublicKey(), Emails: r.GetEmails(), CreatedAt: ts("2026-10-10T12:00:00Z"),
77+ }}, nil
78+}
79+
80+func TestSigningKeyTrustAdd(t *testing.T) {
81+ t.Run("from a file", func(t *testing.T) {
82+ f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: addEcho}
83+ file := filepath.Join(t.TempDir(), "id_ed25519.pub")
84+ require.NoError(t, os.WriteFile(file, []byte(testSSHKey+" me@laptop\n"), 0o600))
85+ res := runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "add", "--format", "ssh", "--title", "laptop",
86+ "--email", "me@example.com", "--email", "me@work.example", file).ok(t)
87+ got := lastRequest[ermite.AddTrustedKeyRequest](t, f, "AddTrustedKey")
88+ assert.Equal(t, ermite.SignatureFormat_SIGNATURE_FORMAT_SSH, got.GetFormat())
89+ assert.Equal(t, "laptop", got.GetTitle())
90+ assert.Equal(t, testSSHKey+" me@laptop", got.GetPublicKey())
91+ assert.Equal(t, []string{"me@example.com", "me@work.example"}, got.GetEmails())
92+ assert.Equal(t, "SHA256:new\n", res.stdout)
93+ assert.Contains(t, res.stderr, `trusted ssh key "laptop"`)
94+ })
95+
96+ t.Run("gpg from stdin, json", func(t *testing.T) {
97+ f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: addEcho}
98+ armor := "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxxxx\n-----END PGP PUBLIC KEY BLOCK-----\n"
99+ res := runCLI(t, envFor(f.serve(t)), armor, "signing-key", "trust", "add", "--format", "GPG", "--title", "work",
100+ "--email", "me@example.com", "--json", "-").ok(t)
101+ got := lastRequest[ermite.AddTrustedKeyRequest](t, f, "AddTrustedKey")
102+ assert.Equal(t, ermite.SignatureFormat_SIGNATURE_FORMAT_GPG, got.GetFormat())
103+ assert.Equal(t, strings.TrimSpace(armor), got.GetPublicKey())
104+ assert.JSONEq(t, `{"id":"SHA256:new","format":"gpg","title":"work","public_key":"`+strings.ReplaceAll(strings.TrimSpace(armor), "\n", `\n`)+`","emails":["me@example.com"],"builtin":false,"created_at":"2026-10-10T12:00:00Z"}`, res.stdout)
105+ })
106+
107+ t.Run("flag and input errors send nothing", func(t *testing.T) {
108+ big := strings.Repeat("a", 64<<10+1)
109+ for name, tt := range map[string]struct {
110+ stdin string
111+ args []string
112+ want string
113+ }{
114+ "missing email": {testSSHKey, []string{"--format", "ssh", "--title", "t", "-"}, `"email"`},
115+ "missing title": {testSSHKey, []string{"--format", "ssh", "--email", "a@b.c", "-"}, `"title"`},
116+ "missing format": {testSSHKey, []string{"--title", "t", "--email", "a@b.c", "-"}, `"format"`},
117+ "x509": {testSSHKey, []string{"--format", "x509", "--title", "t", "--email", "a@b.c", "-"}, "use ssh or gpg"},
118+ "empty input": {" \n", []string{"--format", "ssh", "--title", "t", "--email", "a@b.c", "-"}, "no key to read"},
119+ "too large": {big, []string{"--format", "ssh", "--title", "t", "--email", "a@b.c", "-"}, "larger than 64 KiB"},
120+ "no such file": {"", []string{"--format", "ssh", "--title", "t", "--email", "a@b.c", "/nonexistent/key.pub"}, "read /nonexistent/key.pub"},
121+ } {
122+ f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: addEcho}
123+ res := runCLI(t, envFor(f.serve(t)), tt.stdin, append([]string{"signing-key", "trust", "add"}, tt.args...)...)
124+ require.Error(t, res.err, name)
125+ assert.Contains(t, res.err.Error(), tt.want, name)
126+ calls, _ := f.snapshot()
127+ assert.NotContains(t, calls, "AddTrustedKey", name)
128+ }
129+ })
130+
131+ t.Run("server errors are described", func(t *testing.T) {
132+ for name, tt := range map[string]struct {
133+ err error
134+ want string
135+ }{
136+ "not the owner": {connect.NewError(connect.CodePermissionDenied, errors.New("owner only")), "permission denied"},
137+ "duplicate": {connect.NewError(connect.CodeAlreadyExists, errors.New("key is already trusted")), "already exists"},
138+ "invalid": {connect.NewError(connect.CodeInvalidArgument, errors.New("not a public key")), "not a public key"},
139+ } {
140+ f := &fakeAPI{getVersion: withCommitSignatures, addTrustedKey: func(*ermite.AddTrustedKeyRequest) (*ermite.AddTrustedKeyResponse, error) { return nil, tt.err }}
141+ res := runCLI(t, envFor(f.serve(t)), testSSHKey, "signing-key", "trust", "add", "--format", "ssh", "--title", "t", "--email", "a@b.c", "-")
142+ require.Error(t, res.err, name)
143+ assert.Contains(t, res.err.Error(), tt.want, name)
144+ assert.Empty(t, res.stdout, name)
145+ }
146+ })
147+}
148+
149+func TestSigningKeyTrustRemove(t *testing.T) {
150+ f := &fakeAPI{getVersion: withCommitSignatures, removeTrustedKey: func(*ermite.RemoveTrustedKeyRequest) (*ermite.RemoveTrustedKeyResponse, error) {
151+ return &ermite.RemoveTrustedKeyResponse{}, nil
152+ }}
153+ res := runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "remove", "SHA256:abc").ok(t)
154+ assert.Equal(t, "SHA256:abc", lastRequest[ermite.RemoveTrustedKeyRequest](t, f, "RemoveTrustedKey").GetId())
155+ assert.Empty(t, res.stdout)
156+ assert.Contains(t, res.stderr, "removed trusted key SHA256:abc")
157+
158+ f = &fakeAPI{getVersion: withCommitSignatures, removeTrustedKey: func(*ermite.RemoveTrustedKeyRequest) (*ermite.RemoveTrustedKeyResponse, error) {
159+ return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the builtin key cannot be removed"))
160+ }}
161+ res = runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "remove", "SHA256:builtin")
162+ require.Error(t, res.err)
163+ assert.Contains(t, res.err.Error(), "builtin key cannot be removed")
164+
165+ require.Error(t, runCLI(t, envFor(f.serve(t)), "", "signing-key", "trust", "remove").err, "the id is required")
166+}
167+
168+func TestSigningKeyTrustNeedsServerFeature(t *testing.T) {
169+ for _, tt := range []struct {
170+ name string
171+ version func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
172+ want string
173+ }{
174+ {"feature missing", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
175+ return &ermite.GetVersionResponse{Version: "v0.6.0", Features: []string{"signing_key"}}, nil
176+ }, "does not support commit_signatures"},
177+ {"version unreadable", nil, "does not implement this call"},
178+ {"server unavailable", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
179+ return nil, connect.NewError(connect.CodeUnavailable, errors.New("connection refused"))
180+ }, "cannot reach the server"},
181+ } {
182+ for _, args := range [][]string{
183+ {"list"},
184+ {"add", "--format", "ssh", "--title", "t", "--email", "a@b.c", "-"},
185+ {"remove", "SHA256:abc"},
186+ } {
187+ f := &fakeAPI{getVersion: tt.version}
188+ res := runCLI(t, envFor(f.serve(t)), testSSHKey, append([]string{"signing-key", "trust"}, args...)...)
189+ require.Error(t, res.err, tt.name)
190+ assert.Contains(t, strings.ToLower(res.err.Error()), tt.want, tt.name)
191+ calls, _ := f.snapshot()
192+ assert.Equal(t, []string{"GetVersion"}, calls, "%s %v: no key call", tt.name, args)
193+ assert.Empty(t, res.stdout)
194+ }
195+ }
196+}
197+
198+func TestSigningKeyHelpMentionsTrust(t *testing.T) {
199+ res := runCLI(t, nil, "", "signing-key", "--help").ok(t)
200+ assert.Contains(t, res.stdout, "trust")
201+ assert.Contains(t, res.stdout, "commit_signatures")
202+ res = runCLI(t, nil, "", "signing-key", "trust", "list", "--help").ok(t)
203+ assert.Contains(t, res.stdout, "JSON fields")
204+}
M internal/app/cli_version.go +1 -0
@@ -122,4 +122,5 @@ var featureUse = map[string]string{
122122 client.FeatureCIDispatch: "`ermite workflow run` needs: the command will fail on this server",
123123 client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server",
124124 client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
125+ client.FeatureCommitSignatures: "`ermite signing-key trust` needs: the commands will fail on this server",
125126 }
M internal/app/cli_version_test.go +5 -5
@@ -17,7 +17,7 @@ import (
1717 )
1818
1919 // allFeatures are the features this client uses.
20-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "passkeys"}
20+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys"}
2121
2222 // Every feature the client uses needs a sentence for the version warning.
2323 func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) {
@@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) {
4545 f := versionFake(allFeatures...)
4646 url := f.serve(t)
4747 res := runCLI(t, envFor(url), "", "version").ok(t)
48- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.artifacts, passkeys\n", res.stdout)
48+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, token.branch_prefixes, ci.runs, ci.runners, ci.secrets, ci.dispatch, ci.artifacts, commit_signatures, passkeys\n", res.stdout)
4949 assert.Empty(t, res.stderr, "no warning when every feature is there")
5050
5151 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
52- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","passkeys"],"missing_features":[]}}`, res.stdout)
52+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":[]}}`, res.stdout)
5353 }
5454
5555 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
56- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "passkeys")
56+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "ci.secrets", "ci.dispatch", "ci.artifacts", "commit_signatures", "passkeys")
5757 url := f.serve(t)
5858 res := runCLI(t, envFor(url), "", "version").ok(t)
5959 assert.Contains(t, res.stdout, "v0.4.0")
@@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
6161 assert.NotContains(t, res.stderr, "repo.update")
6262
6363 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
64- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
64+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","token.branch_prefixes","ci.runs","ci.runners","ci.secrets","ci.dispatch","ci.artifacts","commit_signatures","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
6565 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
6666
6767 f = versionFake()
M internal/app/forge.go +34 -1
@@ -12,6 +12,7 @@ import (
1212 "net"
1313 "os"
1414 "path/filepath"
15+ "strings"
1516 "time"
1617
1718 "github.com/bnema/zerowrap"
@@ -31,6 +32,7 @@ import (
3132 "bnema.dev/ermite/internal/adapters/oidchttp"
3233 "bnema.dev/ermite/internal/adapters/rpc"
3334 "bnema.dev/ermite/internal/adapters/secretbox"
35+ "bnema.dev/ermite/internal/adapters/sigverify"
3436 "bnema.dev/ermite/internal/adapters/sshsig"
3537 "bnema.dev/ermite/internal/adapters/web"
3638 "bnema.dev/ermite/internal/adapters/webauthn"
@@ -114,7 +116,10 @@ type forge struct {
114116 // ServerService.GetSigningKey.
115117 MergeRequests *usecase.MergeRequests
116118 SigningKey *usecase.SigningKey
117- Sessions *usecase.Sessions
119+ // Signatures are the trusted keys and the commit signature checks
120+ // (ADR 0030).
121+ Signatures *usecase.Signatures
122+ Sessions *usecase.Sessions
118123 // Passkeys is nil when passkeys are not available (passkeysAvailable).
119124 Passkeys *usecase.Passkeys
120125 EventLog *usecase.Events
@@ -301,6 +306,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
301306 return nil, err
302307 }
303308 f.SigningKey = usecase.NewSigningKey(signer, committer)
309+ if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil {
310+ return nil, err
311+ }
304312 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
305313 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
306314 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
@@ -353,6 +361,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
353361 Events: f.EventLog,
354362 MergeRequests: f.MergeRequests,
355363 SigningKey: f.SigningKey,
364+ TrustedKeys: f.Signatures,
356365 Sessions: f.Sessions,
357366 Passkeys: apiPasskeys,
358367 Runner: f.CI,
@@ -501,3 +510,27 @@ func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *g
501510 cfg.Fresh = f.reader // a nil *ermitegit.Adapter must not become a non-nil interface
502511 }
503512 }
513+
514+// signatures wires the commit signature checks (ADR 0030): the stored trusted
515+// keys, the instance key as a builtin one for the committer email, the SSH and
516+// OpenPGP verifiers, and the raw commit objects of the selected reader.
517+func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key domain.SigningPublicKey, committer domain.Identity, clock ports.Clock) error {
518+ objects, ok := reader.(ports.CommitObjectReader)
519+ if !ok {
520+ return errors.New("the git reader cannot read raw commit objects")
521+ }
522+ builtin := domain.TrustedKey{
523+ ID: key.Fingerprint, Format: domain.FormatSSH, Title: "ermite instance key",
524+ PublicKey: key.AuthorizedKey, Emails: []string{strings.ToLower(committer.Email)}, Builtin: true,
525+ }
526+ verifiers := map[domain.SignatureFormat]ports.SignatureVerifier{
527+ domain.FormatSSH: sigverify.SSH{}, domain.FormatGPG: sigverify.GPG{},
528+ }
529+ sigs, err := usecase.NewSignatures(store.TrustedKeys(), sigverify.Inspector{}, verifiers, objects, builtin, clock)
530+ if err != nil {
531+ return err
532+ }
533+ f.Signatures = sigs
534+ f.Browse.WithVerifier(sigs)
535+ return nil
536+}
A internal/app/signatures_e2e_test.go +142 -0
@@ -0,0 +1,142 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/http"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "strings"
11+ "testing"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+
17+ pb "bnema.dev/ermite-api/gen/ermite"
18+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+func needTool(t *testing.T, name string) {
23+ t.Helper()
24+ if _, err := exec.LookPath(name); err != nil {
25+ if os.Getenv("CI") != "" {
26+ t.Fatalf("%s is needed", name)
27+ }
28+ t.Skipf("%s not installed", name)
29+ }
30+}
31+
32+// ADR 0030 end to end: commits pushed signed with SSH and OpenPGP keys show
33+// their protocol and state over the API and on the web, and trusting a key
34+// turns them verified.
35+func TestCommitSignaturesEndToEnd(t *testing.T) {
36+ needTool(t, "ssh-keygen")
37+ needTool(t, "gpg")
38+ h := newHarness(t, harnessOpts{})
39+ ctx := h.ctx
40+ h.seed("sig", domain.VisibilityPublic)
41+ wt := h.workTree("main")
42+ keys := t.TempDir()
43+
44+ // SSH key and commit.
45+ sshKey := filepath.Join(keys, "id")
46+ out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "c", "-f", sshKey).CombinedOutput()
47+ require.NoError(t, err, string(out))
48+ h.writeFile(wt, "a.txt", []byte("a\n"))
49+ h.mustGit(wt, "-c", "gpg.format=ssh", "-c", "user.signingkey="+sshKey+".pub", "commit", "-q", "--amend", "-S", "--no-edit")
50+ sshCommit := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
51+
52+ // OpenPGP key and commit.
53+ // A short home: the agent socket path must fit sun_path.
54+ gnupg, err := os.MkdirTemp("", "gpg")
55+ require.NoError(t, err)
56+ t.Cleanup(func() {
57+ _ = exec.Command("gpgconf", "--homedir", gnupg, "--kill", "all").Run()
58+ _ = os.RemoveAll(gnupg)
59+ })
60+ gpg := func(args ...string) string {
61+ cmd := exec.Command("gpg", append([]string{"--batch", "--homedir", gnupg, "--pinentry-mode", "loopback", "--passphrase", ""}, args...)...)
62+ b, err := cmd.CombinedOutput()
63+ require.NoError(t, err, string(b))
64+ return string(b)
65+ }
66+ gpg("--quick-gen-key", "T <t@example.test>", "ed25519", "sign", "never")
67+ wrapper := filepath.Join(keys, "gpg.sh")
68+ require.NoError(t, os.WriteFile(wrapper, []byte("#!/bin/sh\nexec gpg --homedir '"+gnupg+"' --batch --pinentry-mode loopback --passphrase '' \"$@\"\n"), 0o700))
69+ h.writeFile(wt, "b.txt", []byte("b\n"))
70+ h.mustGit(wt, "-c", "gpg.program="+wrapper, "-c", "user.signingkey=t@example.test", "commit", "-q", "--amend", "-S", "--no-edit")
71+ gpgCommit := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
72+ h.writeFile(wt, "c.txt", []byte("c\n"))
73+ unsigned := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
74+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/sig.git"), "main")
75+
76+ root := h.api(adminToken)
77+ trust := ermiteconnect.NewTrustedKeyServiceClient(&http.Client{Transport: bearerTransport{secret: adminToken, next: http.DefaultTransport}}, h.url("/-/api"))
78+ states := func() map[string]*pb.CommitVerification {
79+ res, err := root.git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: "sig"}))
80+ require.NoError(t, err)
81+ m := map[string]*pb.CommitVerification{}
82+ for _, c := range res.Msg.GetCommits() {
83+ m[c.GetId()] = c.GetVerification()
84+ }
85+ return m
86+ }
87+
88+ v, err := root.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
89+ require.NoError(t, err)
90+ assert.Contains(t, v.Msg.GetFeatures(), "commit_signatures")
91+
92+ s := states()
93+ assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_SSH, s[sshCommit].GetFormat())
94+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY, s[sshCommit].GetStatus())
95+ assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_GPG, s[gpgCommit].GetFormat())
96+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY, s[gpgCommit].GetStatus())
97+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED, s[unsigned].GetStatus())
98+
99+ // Trust both keys.
100+ pub, err := os.ReadFile(sshKey + ".pub")
101+ require.NoError(t, err)
102+ _, err = trust.AddTrustedKey(ctx, connect.NewRequest(&pb.AddTrustedKeyRequest{
103+ Format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, Title: "laptop", PublicKey: string(pub), Emails: []string{"T@example.test"},
104+ }))
105+ require.NoError(t, err)
106+ _, err = trust.AddTrustedKey(ctx, connect.NewRequest(&pb.AddTrustedKeyRequest{
107+ Format: pb.SignatureFormat_SIGNATURE_FORMAT_GPG, Title: "gpg", PublicKey: gpg("--armor", "--export", "t@example.test"), Emails: []string{"t@example.test"},
108+ }))
109+ require.NoError(t, err)
110+
111+ s = states()
112+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, s[sshCommit].GetStatus())
113+ assert.Equal(t, "laptop", s[sshCommit].GetKeyTitle())
114+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, s[gpgCommit].GetStatus())
115+
116+ list, err := trust.ListTrustedKeys(ctx, connect.NewRequest(&pb.ListTrustedKeysRequest{}))
117+ require.NoError(t, err)
118+ require.Len(t, list.Msg.GetKeys(), 3)
119+ assert.True(t, list.Msg.GetKeys()[0].GetBuiltin(), "the instance key comes first")
120+
121+ // A token without owner rights cannot change the list.
122+ weak := ermiteconnect.NewTrustedKeyServiceClient(&http.Client{Transport: bearerTransport{
123+ secret: h.token(scopes(domain.ScopeRepoRead)), next: http.DefaultTransport,
124+ }}, h.url("/-/api"))
125+ _, err = weak.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()}))
126+ assert.Equal(t, "permission_denied", codeName(err))
127+
128+ // The web shows the protocol and the state.
129+ code, _, body := h.get("/sig/commit/" + sshCommit)
130+ require.Equal(t, http.StatusOK, code)
131+ assert.Contains(t, body, "sig-ok")
132+ assert.Contains(t, body, "SSH")
133+ code, _, body = h.get("/sig/commit/" + gpgCommit)
134+ require.Equal(t, http.StatusOK, code)
135+ assert.Contains(t, body, "GPG")
136+ assert.Contains(t, body, "sig-ok")
137+
138+ // Removing the key turns the commit unknown again.
139+ _, err = trust.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()}))
140+ require.NoError(t, err)
141+ assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY, states()[sshCommit].GetStatus())
142+}
M internal/app/testdata/web/commit-big.html +4 -0
@@ -109,6 +109,10 @@
109109 <dd>
110110 <a href="/diffs/commit/SHA40">SHA7</a>
111111 </dd>
112+<dt>signature</dt>
113+<dd>
114+<span class="pill sig sig-none" title="This commit is not signed">unsigned</span>
115+</dd>
112116 <dt>refs</dt>
113117 <dd>
114118 <span class="pill">main</span>
M internal/app/testdata/web/commit-binary.html +4 -0
@@ -109,6 +109,10 @@
109109 <dd>
110110 <a href="/diffs/commit/SHA40">SHA7</a>
111111 </dd>
112+<dt>signature</dt>
113+<dd>
114+<span class="pill sig sig-none" title="This commit is not signed">unsigned</span>
115+</dd>
112116 </dl>
113117 </header>
114118 <div class="diff-tools" role="group" aria-label="Diff view">
M internal/app/testdata/web/commit-merge.html +4 -0
@@ -108,6 +108,10 @@
108108 <dt>merge of</dt>
109109 <dd>
110110 <a href="/diffs/commit/SHA40">SHA7</a> + <a href="/diffs/commit/SHA40">SHA7</a> · diff against the first parent</dd>
111+<dt>signature</dt>
112+<dd>
113+<span class="pill sig sig-none" title="This commit is not signed">unsigned</span>
114+</dd>
111115 </dl>
112116 </header>
113117 <div class="diff-tools" role="group" aria-label="Diff view">
M internal/app/testdata/web/commit-modify-split.html +4 -0
@@ -111,6 +111,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
111111 <dd>
112112 <a href="/diffs/commit/SHA40">SHA7</a>
113113 </dd>
114+<dt>signature</dt>
115+<dd>
116+<span class="pill sig sig-none" title="This commit is not signed">unsigned</span>
117+</dd>
114118 <dt>refs</dt>
115119 <dd>
116120 <span class="pill">tag: v1.0.0</span>
M internal/app/testdata/web/commit-modify.html +4 -0
@@ -111,6 +111,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
111111 <dd>
112112 <a href="/diffs/commit/SHA40">SHA7</a>
113113 </dd>
114+<dt>signature</dt>
115+<dd>
116+<span class="pill sig sig-none" title="This commit is not signed">unsigned</span>
117+</dd>
114118 <dt>refs</dt>
115119 <dd>
116120 <span class="pill">tag: v1.0.0</span>
M internal/app/testdata/web/commit-rename.html +4 -0
@@ -109,6 +109,10 @@
109109 <dd>
110110 <a href="/diffs/commit/SHA40">SHA7</a>
111111 </dd>
112+<dt>signature</dt>
113+<dd>
114+<span class="pill sig sig-none" title="This commit is not signed">unsigned</span>
115+</dd>
112116 </dl>
113117 </header>
114118 <div class="diff-tools" role="group" aria-label="Diff view">
M internal/app/testdata/web/commit-root.html +4 -0
@@ -107,6 +107,10 @@
107107 <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd>
108108 <dt>parent</dt>
109109 <dd>none (root commit) </dd>
110+<dt>signature</dt>
111+<dd>
112+<span class="pill sig sig-none" title="This commit is not signed">unsigned</span>
113+</dd>
110114 </dl>
111115 </header>
112116 <div class="diff-tools" role="group" aria-label="Diff view">
M internal/app/testdata/web/commit-trojan.html +4 -0
@@ -110,6 +110,10 @@
110110 <dd>
111111 <a href="/diffs/commit/SHA40">SHA7</a>
112112 </dd>
113+<dt>signature</dt>
114+<dd>
115+<span class="pill sig sig-none" title="This commit is not signed">unsigned</span>
116+</dd>
113117 </dl>
114118 </header>
115119 <div class="diff-tools" role="group" aria-label="Diff view">
M internal/domain/git.go +3 -0
@@ -442,6 +442,9 @@ type Commit struct {
442442 // Message is the whole message without trailing line breaks, converted to
443443 // valid UTF-8.
444444 Message string
445+ // Verification is the signature state (ADR 0030). The readers leave it
446+ // zero (unchecked); the browse use case fills it.
447+ Verification CommitVerification
445448 }
446449
447450 // FileStatus tells what a commit did to a file.
A internal/domain/signature.go +223 -0
@@ -0,0 +1,223 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "bytes"
7+ "fmt"
8+ "net/mail"
9+ "slices"
10+ "strings"
11+ "time"
12+)
13+
14+// SignatureFormat is the protocol of a commit signature (ADR 0030).
15+type SignatureFormat string
16+
17+// Signature formats. FormatNone is an unsigned commit.
18+const (
19+ FormatNone SignatureFormat = ""
20+ FormatSSH SignatureFormat = "ssh"
21+ FormatGPG SignatureFormat = "gpg"
22+ // FormatX509 is a gpgsm (S/MIME) signature: recognized, never verified.
23+ FormatX509 SignatureFormat = "x509"
24+)
25+
26+// ParseSignatureFormat parses the text form of a key format, "ssh" or "gpg".
27+// x509 keys cannot be trusted.
28+func ParseSignatureFormat(s string) (SignatureFormat, error) {
29+ switch f := SignatureFormat(strings.ToLower(strings.TrimSpace(s))); f {
30+ case FormatSSH, FormatGPG:
31+ return f, nil
32+ }
33+ return FormatNone, fmt.Errorf("%w: key format %q, want ssh or gpg", ErrInvalid, s)
34+}
35+
36+// SignatureStatus is the outcome of checking a commit signature.
37+type SignatureStatus string
38+
39+// Signature statuses. StatusUnchecked is the zero value: the commit was not
40+// checked (or could not be read); it is never cached.
41+const (
42+ StatusUnchecked SignatureStatus = ""
43+ // StatusUnsigned: the commit carries no signature.
44+ StatusUnsigned SignatureStatus = "unsigned"
45+ // StatusVerified: valid signature by a trusted key whose emails include
46+ // the committer email.
47+ StatusVerified SignatureStatus = "verified"
48+ // StatusUnknownKey: well-formed signature by a key that is not trusted.
49+ StatusUnknownKey SignatureStatus = "unknown_key"
50+ // StatusBadSignature: malformed, or does not match the commit.
51+ StatusBadSignature SignatureStatus = "bad_signature"
52+ // StatusEmailMismatch: valid signature by a trusted key that is not
53+ // trusted for the committer email.
54+ StatusEmailMismatch SignatureStatus = "email_mismatch"
55+ // StatusExpired: the GPG key or signature had expired.
56+ StatusExpired SignatureStatus = "expired"
57+ // StatusRevoked: the GPG key is revoked.
58+ StatusRevoked SignatureStatus = "revoked"
59+ // StatusUnsupported: a format ermite does not verify (x509).
60+ StatusUnsupported SignatureStatus = "unsupported"
61+)
62+
63+// CommitVerification is what ermite knows about the signature of a commit.
64+type CommitVerification struct {
65+ Format SignatureFormat
66+ Status SignatureStatus
67+ // Fingerprint is the fingerprint of the signing key when it could be
68+ // read: "SHA256:<base64>" for SSH, 40 upper-case hex digits (or the
69+ // 16-digit issuer key id when the key is unknown) for GPG.
70+ Fingerprint string
71+ // KeyID is the ID of the matching trusted key; empty when none matched.
72+ KeyID string
73+ // KeyTitle is the title of the matching trusted key.
74+ KeyTitle string
75+}
76+
77+// Signed reports whether the commit carries a signature.
78+func (v CommitVerification) Signed() bool { return v.Format != FormatNone }
79+
80+// Limits of trusted keys.
81+const (
82+ MaxTrustedKeys = 100
83+ MaxTrustedKeyEmails = 10
84+ MaxTrustedKeyTitleLen = 100
85+ MaxTrustedKeyBytes = 64 << 10
86+ MaxCommitObjectBytes = 1 << 20
87+ // MaxCommitObjectsBytes bounds the total size of the commit objects one
88+ // ReadCommitObjects call returns.
89+ MaxCommitObjectsBytes = 8 << 20
90+ maxTrustedKeyEmailSize = 254
91+)
92+
93+// TrustedKey is a public key whose signatures ermite accepts for the listed
94+// committer emails (ADR 0030).
95+type TrustedKey struct {
96+ // ID is the key fingerprint: "SHA256:<base64>" for SSH, the primary key
97+ // fingerprint in upper-case hex for GPG.
98+ ID string
99+ Format SignatureFormat
100+ Title string
101+ // PublicKey is the key as given: an authorized_keys line for SSH, an
102+ // armored public key block for GPG.
103+ PublicKey string
104+ // Emails are the committer emails the key may sign for, lower-case.
105+ Emails []string
106+ CreatedAt time.Time
107+ // Builtin marks the instance signing key (ADR 0022): listed, never stored
108+ // nor deletable.
109+ Builtin bool
110+}
111+
112+// TrustsEmail reports whether the key may sign for the email (case-insensitive).
113+func (k TrustedKey) TrustsEmail(email string) bool {
114+ return slices.Contains(k.Emails, strings.ToLower(strings.TrimSpace(email)))
115+}
116+
117+// ParseTrustedKeyTitle checks a key title: 1 to MaxTrustedKeyTitleLen bytes
118+// once trimmed, no control characters.
119+func ParseTrustedKeyTitle(s string) (string, error) {
120+ s = strings.TrimSpace(s)
121+ if s == "" || len(s) > MaxTrustedKeyTitleLen || strings.ContainsFunc(s, func(r rune) bool { return r < 0x20 || r == 0x7f }) {
122+ return "", fmt.Errorf("%w: key title must be 1 to %d printable bytes", ErrInvalid, MaxTrustedKeyTitleLen)
123+ }
124+ return s, nil
125+}
126+
127+// NormalizeKeyEmails lower-cases, trims, de-duplicates and checks emails:
128+// 1 to MaxTrustedKeyEmails plain addresses ("a@b", no display name).
129+func NormalizeKeyEmails(in []string) ([]string, error) {
130+ out := make([]string, 0, len(in))
131+ for _, e := range in {
132+ e = strings.ToLower(strings.TrimSpace(e))
133+ a, err := mail.ParseAddress(e)
134+ if err != nil || a.Address != e || len(e) > maxTrustedKeyEmailSize {
135+ return nil, fmt.Errorf("%w: email %q", ErrInvalid, e)
136+ }
137+ if !slices.Contains(out, e) {
138+ out = append(out, e)
139+ }
140+ }
141+ if len(out) == 0 || len(out) > MaxTrustedKeyEmails {
142+ return nil, fmt.Errorf("%w: a key needs 1 to %d emails", ErrInvalid, MaxTrustedKeyEmails)
143+ }
144+ return out, nil
145+}
146+
147+// SplitCommitSignature splits the raw content of a commit object (as stored,
148+// without the "commit <size>\0" prefix) into the payload that was signed and
149+// the signature, exactly as `git verify-commit` does for a SHA-1 repository
150+// (checked against git 2.56 with a fake gpg.program):
151+//
152+// - in the header section only (up to the first empty line, so a message
153+// cannot inject a header), every header whose name starts with "gpgsig"
154+// (gpgsig, gpgsig-sha256, ...) is removed from the payload with its
155+// continuation lines (lines that start with a space);
156+// - the signature is the value of the "gpgsig " headers (name, one space,
157+// value), with the leading space of each continuation line removed.
158+// Several gpgsig headers are concatenated; their order and their line
159+// endings are kept, and no line break is added;
160+// - gpgsig-sha256 is the signature of SHA-256 repositories: it is never
161+// the signature here, so a commit that only has it is unsigned (ok false).
162+//
163+// ok is false for an unsigned commit.
164+func SplitCommitSignature(raw []byte) (payload, signature []byte, ok bool) {
165+ const (
166+ prefix = "gpgsig"
167+ header = "gpgsig "
168+ )
169+ var pay, sig bytes.Buffer
170+ pay.Grow(len(raw))
171+ // inSig: the last header is a "gpgsig " one; skipping: the last header is
172+ // any gpgsig* one, so its continuation lines are removed from the payload.
173+ inSig, skipping, found := false, false, false
174+ rest := raw
175+ for len(rest) > 0 {
176+ i := bytes.IndexByte(rest, '\n')
177+ var line []byte
178+ if i < 0 {
179+ line, rest = rest, nil
180+ } else {
181+ line, rest = rest[:i+1], rest[i+1:]
182+ }
183+ if len(line) == 1 && line[0] == '\n' { // end of headers
184+ pay.Write(line)
185+ pay.Write(rest)
186+ break
187+ }
188+ switch {
189+ case skipping && line[0] == ' ':
190+ if inSig {
191+ sig.Write(line[1:])
192+ }
193+ case bytes.HasPrefix(line, []byte(prefix)):
194+ skipping = true
195+ inSig = bytes.HasPrefix(line, []byte(header))
196+ if inSig {
197+ found = true
198+ sig.Write(line[len(header):])
199+ }
200+ default:
201+ skipping, inSig = false, false
202+ pay.Write(line)
203+ }
204+ }
205+ if !found {
206+ return nil, nil, false
207+ }
208+ return pay.Bytes(), sig.Bytes(), true
209+}
210+
211+// SignatureFormatOf tells the format of an armored signature from its first
212+// line; FormatNone when it is not recognized.
213+func SignatureFormatOf(signature []byte) SignatureFormat {
214+ switch {
215+ case bytes.HasPrefix(signature, []byte("-----BEGIN SSH SIGNATURE-----")):
216+ return FormatSSH
217+ case bytes.HasPrefix(signature, []byte("-----BEGIN PGP SIGNATURE-----")):
218+ return FormatGPG
219+ case bytes.HasPrefix(signature, []byte("-----BEGIN SIGNED MESSAGE-----")):
220+ return FormatX509
221+ }
222+ return FormatNone
223+}
A internal/domain/signature_test.go +72 -0
@@ -0,0 +1,72 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "bnema.dev/ermite/internal/domain"
11+)
12+
13+// TestSplitCommitSignature pins SplitCommitSignature to what `git
14+// verify-commit` hands to gpg.program in a SHA-1 repository: the cases were
15+// captured with a fake gpg.program that dumps its stdin (the payload) and the
16+// signature file (git 2.56, objects made with `git hash-object --literally`).
17+func TestSplitCommitSignature(t *testing.T) {
18+ const (
19+ hdr = "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n"
20+ one = "-----BEGIN PGP SIGNATURE-----\n\nONE\n-----END PGP SIGNATURE-----\n"
21+ two = "-----BEGIN PGP SIGNATURE-----\n\nTWO\n-----END PGP SIGNATURE-----\n"
22+ )
23+ // sigHdr renders a signature as a header: name, then each line, the
24+ // continuation lines starting with a space.
25+ sigHdr := func(name, body string) string {
26+ return name + " -----BEGIN PGP SIGNATURE-----\n \n " + body + "\n -----END PGP SIGNATURE-----\n"
27+ }
28+ tests := []struct {
29+ name string
30+ raw string
31+ wantPayload string
32+ wantSig string
33+ unsigned bool
34+ }{
35+ {"single signature", hdr + sigHdr("gpgsig", "ONE") + "\nmsg\n", hdr + "\nmsg\n", one, false},
36+ {"repeated gpgsig are concatenated, both removed", hdr + sigHdr("gpgsig", "ONE") + sigHdr("gpgsig", "TWO") + "\nmsg\n", hdr + "\nmsg\n", one + two, false},
37+ {"gpgsig and gpgsig-sha256: the sha256 one is removed, not used", hdr + sigHdr("gpgsig", "ONE") + sigHdr("gpgsig-sha256", "TWO") + "\nmsg\n", hdr + "\nmsg\n", one, false},
38+ {"gpgsig-sha256 first", hdr + sigHdr("gpgsig-sha256", "TWO") + sigHdr("gpgsig", "ONE") + "\nmsg\n", hdr + "\nmsg\n", one, false},
39+ {"gpgsig-sha256 only is unsigned", hdr + sigHdr("gpgsig-sha256", "TWO") + "\nmsg\n", "", "", true},
40+ {"gpgsig-looking line in the message is untouched", hdr + "\nmsg\n" + sigHdr("gpgsig", "MSG"), "", "", true},
41+ {"gpgsig in the message of a signed commit is untouched", hdr + sigHdr("gpgsig", "ONE") + "\nmsg\n" + sigHdr("gpgsig", "MSG"), hdr + "\nmsg\n" + sigHdr("gpgsig", "MSG"), one, false},
42+ {"CRLF in the signature is kept", hdr + "gpgsig -----BEGIN PGP SIGNATURE-----\r\n \r\n ONE\r\n -----END PGP SIGNATURE-----\r\n" + "\nmsg\n", hdr + "\nmsg\n", "-----BEGIN PGP SIGNATURE-----\r\n\r\nONE\r\n-----END PGP SIGNATURE-----\r\n", false},
43+ {"CRLF message", hdr + sigHdr("gpgsig", "ONE") + "\r\nmsg\r\n", hdr + "\r\nmsg\r\n", one, false},
44+ {"missing final newline of the message", hdr + sigHdr("gpgsig", "ONE") + "\nmsg", hdr + "\nmsg", one, false},
45+ {"missing final newline of the signature: none is added", hdr + sigHdr("gpgsig", "ONE")[:len(sigHdr("gpgsig", "ONE"))-1], hdr, one[:len(one)-1], false},
46+ {"other headers with continuation lines are kept", hdr + sigHdr("gpgsig", "ONE") + "other x\n cont\n\nmsg\n", hdr + "other x\n cont\n\nmsg\n", one, false},
47+ {"gpgsig between other headers", "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n" + sigHdr("gpgsig", "ONE") + "author A <a@x> 1 +0000\ncommitter A <a@x> 1 +0000\n\nmsg\n", hdr + "\nmsg\n", one, false},
48+ {"headers that start with gpgsig are removed with their continuation lines", hdr + sigHdr("gpgsig", "ONE") + "gpgsigx foo\n c1\ngpgsig-foo bar\n\nmsg\n", hdr + "\nmsg\n", one, false},
49+ {"a gpgsigx block does not extend the signature", hdr + "gpgsig A\n B\ngpgsigx foo\n C\ngpgsig D\n\nmsg\n", hdr + "\nmsg\n", "A\nB\nD\n", false},
50+ {"a gpgsig-sha256 block between two gpgsig", hdr + sigHdr("gpgsig", "ONE") + "gpgsig-sha256 mid\n m1\n" + sigHdr("gpgsig", "TWO") + "\nmsg\n", hdr + "\nmsg\n", one + two, false},
51+ {"a bare gpgsig line (no value) alone is unsigned", hdr + "gpgsig\n c1\n\nmsg\n", "", "", true},
52+ {"a bare gpgsig line next to a signature is removed", hdr + sigHdr("gpgsig", "ONE") + "gpgsig\n c1\n\nmsg\n", hdr + "\nmsg\n", one, false},
53+ {"header names are case-sensitive", hdr + sigHdr("gpgsig", "ONE") + "GPGSIG foo\n\nmsg\n", hdr + "GPGSIG foo\n\nmsg\n", one, false},
54+ {"continuation line of a plain header is kept", hdr + sigHdr("gpgsig", "ONE") + "x y\n z\n\nmsg\n", hdr + "x y\n z\n\nmsg\n", one, false},
55+ {"empty value", hdr + "gpgsig \n\nmsg\n", hdr + "\nmsg\n", "\n", false},
56+ {"unsigned", hdr + "\nmsg\n", "", "", true},
57+ {"empty", "", "", "", true},
58+ }
59+ for _, tt := range tests {
60+ t.Run(tt.name, func(t *testing.T) {
61+ payload, sig, ok := domain.SplitCommitSignature([]byte(tt.raw))
62+ assert.Equal(t, !tt.unsigned, ok)
63+ if tt.unsigned {
64+ assert.Nil(t, payload)
65+ assert.Nil(t, sig)
66+ return
67+ }
68+ assert.Equal(t, tt.wantPayload, string(payload))
69+ assert.Equal(t, tt.wantSig, string(sig))
70+ })
71+ }
72+}
A internal/ports/mocks/CommitObjectReader.go +122 -0
@@ -0,0 +1,122 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewCommitObjectReader creates a new instance of CommitObjectReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewCommitObjectReader(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *CommitObjectReader {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &CommitObjectReader{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// CommitObjectReader is an autogenerated mock type for the CommitObjectReader type
38+type CommitObjectReader struct {
39+ mock.Mock
40+}
41+
42+type CommitObjectReader_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *CommitObjectReader) EXPECT() *CommitObjectReader_Expecter {
47+ return &CommitObjectReader_Expecter{mock: &_m.Mock}
48+}
49+
50+// ReadCommitObjects provides a mock function for the type CommitObjectReader
51+func (_mock *CommitObjectReader) ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
52+ ret := _mock.Called(ctx, repo, ids)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for ReadCommitObjects")
56+ }
57+
58+ var r0 map[domain.ObjectID][]byte
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.ObjectID) (map[domain.ObjectID][]byte, error)); ok {
61+ return returnFunc(ctx, repo, ids)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.ObjectID) map[domain.ObjectID][]byte); ok {
64+ r0 = returnFunc(ctx, repo, ids)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).(map[domain.ObjectID][]byte)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, []domain.ObjectID) error); ok {
71+ r1 = returnFunc(ctx, repo, ids)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// CommitObjectReader_ReadCommitObjects_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadCommitObjects'
79+type CommitObjectReader_ReadCommitObjects_Call struct {
80+ *mock.Call
81+}
82+
83+// ReadCommitObjects is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - ids []domain.ObjectID
87+func (_e *CommitObjectReader_Expecter) ReadCommitObjects(ctx any, repo any, ids any) *CommitObjectReader_ReadCommitObjects_Call {
88+ return &CommitObjectReader_ReadCommitObjects_Call{Call: _e.mock.On("ReadCommitObjects", ctx, repo, ids)}
89+}
90+
91+func (_c *CommitObjectReader_ReadCommitObjects_Call) Run(run func(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID)) *CommitObjectReader_ReadCommitObjects_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 domain.RepoName
98+ if args[1] != nil {
99+ arg1 = args[1].(domain.RepoName)
100+ }
101+ var arg2 []domain.ObjectID
102+ if args[2] != nil {
103+ arg2 = args[2].([]domain.ObjectID)
104+ }
105+ run(
106+ arg0,
107+ arg1,
108+ arg2,
109+ )
110+ })
111+ return _c
112+}
113+
114+func (_c *CommitObjectReader_ReadCommitObjects_Call) Return(objectIDToBytes map[domain.ObjectID][]byte, err error) *CommitObjectReader_ReadCommitObjects_Call {
115+ _c.Call.Return(objectIDToBytes, err)
116+ return _c
117+}
118+
119+func (_c *CommitObjectReader_ReadCommitObjects_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error)) *CommitObjectReader_ReadCommitObjects_Call {
120+ _c.Call.Return(run)
121+ return _c
122+}
A internal/ports/mocks/CommitVerifier.go +113 -0
@@ -0,0 +1,113 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewCommitVerifier creates a new instance of CommitVerifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewCommitVerifier(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *CommitVerifier {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &CommitVerifier{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// CommitVerifier is an autogenerated mock type for the CommitVerifier type
38+type CommitVerifier struct {
39+ mock.Mock
40+}
41+
42+type CommitVerifier_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *CommitVerifier) EXPECT() *CommitVerifier_Expecter {
47+ return &CommitVerifier_Expecter{mock: &_m.Mock}
48+}
49+
50+// VerifyCommits provides a mock function for the type CommitVerifier
51+func (_mock *CommitVerifier) VerifyCommits(ctx context.Context, repo domain.RepoName, commits []domain.Commit) map[domain.ObjectID]domain.CommitVerification {
52+ ret := _mock.Called(ctx, repo, commits)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for VerifyCommits")
56+ }
57+
58+ var r0 map[domain.ObjectID]domain.CommitVerification
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, []domain.Commit) map[domain.ObjectID]domain.CommitVerification); ok {
60+ r0 = returnFunc(ctx, repo, commits)
61+ } else {
62+ if ret.Get(0) != nil {
63+ r0 = ret.Get(0).(map[domain.ObjectID]domain.CommitVerification)
64+ }
65+ }
66+ return r0
67+}
68+
69+// CommitVerifier_VerifyCommits_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifyCommits'
70+type CommitVerifier_VerifyCommits_Call struct {
71+ *mock.Call
72+}
73+
74+// VerifyCommits is a helper method to define mock.On call
75+// - ctx context.Context
76+// - repo domain.RepoName
77+// - commits []domain.Commit
78+func (_e *CommitVerifier_Expecter) VerifyCommits(ctx any, repo any, commits any) *CommitVerifier_VerifyCommits_Call {
79+ return &CommitVerifier_VerifyCommits_Call{Call: _e.mock.On("VerifyCommits", ctx, repo, commits)}
80+}
81+
82+func (_c *CommitVerifier_VerifyCommits_Call) Run(run func(ctx context.Context, repo domain.RepoName, commits []domain.Commit)) *CommitVerifier_VerifyCommits_Call {
83+ _c.Call.Run(func(args mock.Arguments) {
84+ var arg0 context.Context
85+ if args[0] != nil {
86+ arg0 = args[0].(context.Context)
87+ }
88+ var arg1 domain.RepoName
89+ if args[1] != nil {
90+ arg1 = args[1].(domain.RepoName)
91+ }
92+ var arg2 []domain.Commit
93+ if args[2] != nil {
94+ arg2 = args[2].([]domain.Commit)
95+ }
96+ run(
97+ arg0,
98+ arg1,
99+ arg2,
100+ )
101+ })
102+ return _c
103+}
104+
105+func (_c *CommitVerifier_VerifyCommits_Call) Return(objectIDToCommitVerification map[domain.ObjectID]domain.CommitVerification) *CommitVerifier_VerifyCommits_Call {
106+ _c.Call.Return(objectIDToCommitVerification)
107+ return _c
108+}
109+
110+func (_c *CommitVerifier_VerifyCommits_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, commits []domain.Commit) map[domain.ObjectID]domain.CommitVerification) *CommitVerifier_VerifyCommits_Call {
111+ _c.Call.Return(run)
112+ return _c
113+}
A internal/ports/mocks/KeyInspector.go +118 -0
@@ -0,0 +1,118 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "bnema.dev/ermite/internal/domain"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewKeyInspector creates a new instance of KeyInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewKeyInspector(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *KeyInspector {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &KeyInspector{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// KeyInspector is an autogenerated mock type for the KeyInspector type
36+type KeyInspector struct {
37+ mock.Mock
38+}
39+
40+type KeyInspector_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *KeyInspector) EXPECT() *KeyInspector_Expecter {
45+ return &KeyInspector_Expecter{mock: &_m.Mock}
46+}
47+
48+// Inspect provides a mock function for the type KeyInspector
49+func (_mock *KeyInspector) Inspect(format domain.SignatureFormat, publicKey string) (string, string, error) {
50+ ret := _mock.Called(format, publicKey)
51+
52+ if len(ret) == 0 {
53+ panic("no return value specified for Inspect")
54+ }
55+
56+ var r0 string
57+ var r1 string
58+ var r2 error
59+ if returnFunc, ok := ret.Get(0).(func(domain.SignatureFormat, string) (string, string, error)); ok {
60+ return returnFunc(format, publicKey)
61+ }
62+ if returnFunc, ok := ret.Get(0).(func(domain.SignatureFormat, string) string); ok {
63+ r0 = returnFunc(format, publicKey)
64+ } else {
65+ r0 = ret.Get(0).(string)
66+ }
67+ if returnFunc, ok := ret.Get(1).(func(domain.SignatureFormat, string) string); ok {
68+ r1 = returnFunc(format, publicKey)
69+ } else {
70+ r1 = ret.Get(1).(string)
71+ }
72+ if returnFunc, ok := ret.Get(2).(func(domain.SignatureFormat, string) error); ok {
73+ r2 = returnFunc(format, publicKey)
74+ } else {
75+ r2 = ret.Error(2)
76+ }
77+ return r0, r1, r2
78+}
79+
80+// KeyInspector_Inspect_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Inspect'
81+type KeyInspector_Inspect_Call struct {
82+ *mock.Call
83+}
84+
85+// Inspect is a helper method to define mock.On call
86+// - format domain.SignatureFormat
87+// - publicKey string
88+func (_e *KeyInspector_Expecter) Inspect(format any, publicKey any) *KeyInspector_Inspect_Call {
89+ return &KeyInspector_Inspect_Call{Call: _e.mock.On("Inspect", format, publicKey)}
90+}
91+
92+func (_c *KeyInspector_Inspect_Call) Run(run func(format domain.SignatureFormat, publicKey string)) *KeyInspector_Inspect_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 domain.SignatureFormat
95+ if args[0] != nil {
96+ arg0 = args[0].(domain.SignatureFormat)
97+ }
98+ var arg1 string
99+ if args[1] != nil {
100+ arg1 = args[1].(string)
101+ }
102+ run(
103+ arg0,
104+ arg1,
105+ )
106+ })
107+ return _c
108+}
109+
110+func (_c *KeyInspector_Inspect_Call) Return(id string, canonical string, err error) *KeyInspector_Inspect_Call {
111+ _c.Call.Return(id, canonical, err)
112+ return _c
113+}
114+
115+func (_c *KeyInspector_Inspect_Call) RunAndReturn(run func(format domain.SignatureFormat, publicKey string) (string, string, error)) *KeyInspector_Inspect_Call {
116+ _c.Call.Return(run)
117+ return _c
118+}
A internal/ports/mocks/SignatureVerifier.go +121 -0
@@ -0,0 +1,121 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "bnema.dev/ermite/internal/domain"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewSignatureVerifier creates a new instance of SignatureVerifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewSignatureVerifier(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *SignatureVerifier {
18+ if helper, ok := t.(interface{ Helper() }); ok {
19+ helper.Helper()
20+ }
21+
22+ mock := &SignatureVerifier{}
23+ mock.Mock.Test(t)
24+
25+ t.Cleanup(func() {
26+ if helper, ok := t.(interface{ Helper() }); ok {
27+ helper.Helper()
28+ }
29+ mock.AssertExpectations(t)
30+ })
31+
32+ return mock
33+}
34+
35+// SignatureVerifier is an autogenerated mock type for the SignatureVerifier type
36+type SignatureVerifier struct {
37+ mock.Mock
38+}
39+
40+type SignatureVerifier_Expecter struct {
41+ mock *mock.Mock
42+}
43+
44+func (_m *SignatureVerifier) EXPECT() *SignatureVerifier_Expecter {
45+ return &SignatureVerifier_Expecter{mock: &_m.Mock}
46+}
47+
48+// Verify provides a mock function for the type SignatureVerifier
49+func (_mock *SignatureVerifier) Verify(payload []byte, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) domain.CommitVerification {
50+ ret := _mock.Called(payload, signature, committerEmail, signedAt, keys)
51+
52+ if len(ret) == 0 {
53+ panic("no return value specified for Verify")
54+ }
55+
56+ var r0 domain.CommitVerification
57+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte, string, int64, []domain.TrustedKey) domain.CommitVerification); ok {
58+ r0 = returnFunc(payload, signature, committerEmail, signedAt, keys)
59+ } else {
60+ r0 = ret.Get(0).(domain.CommitVerification)
61+ }
62+ return r0
63+}
64+
65+// SignatureVerifier_Verify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Verify'
66+type SignatureVerifier_Verify_Call struct {
67+ *mock.Call
68+}
69+
70+// Verify is a helper method to define mock.On call
71+// - payload []byte
72+// - signature []byte
73+// - committerEmail string
74+// - signedAt int64
75+// - keys []domain.TrustedKey
76+func (_e *SignatureVerifier_Expecter) Verify(payload any, signature any, committerEmail any, signedAt any, keys any) *SignatureVerifier_Verify_Call {
77+ return &SignatureVerifier_Verify_Call{Call: _e.mock.On("Verify", payload, signature, committerEmail, signedAt, keys)}
78+}
79+
80+func (_c *SignatureVerifier_Verify_Call) Run(run func(payload []byte, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey)) *SignatureVerifier_Verify_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 []byte
83+ if args[0] != nil {
84+ arg0 = args[0].([]byte)
85+ }
86+ var arg1 []byte
87+ if args[1] != nil {
88+ arg1 = args[1].([]byte)
89+ }
90+ var arg2 string
91+ if args[2] != nil {
92+ arg2 = args[2].(string)
93+ }
94+ var arg3 int64
95+ if args[3] != nil {
96+ arg3 = args[3].(int64)
97+ }
98+ var arg4 []domain.TrustedKey
99+ if args[4] != nil {
100+ arg4 = args[4].([]domain.TrustedKey)
101+ }
102+ run(
103+ arg0,
104+ arg1,
105+ arg2,
106+ arg3,
107+ arg4,
108+ )
109+ })
110+ return _c
111+}
112+
113+func (_c *SignatureVerifier_Verify_Call) Return(commitVerification domain.CommitVerification) *SignatureVerifier_Verify_Call {
114+ _c.Call.Return(commitVerification)
115+ return _c
116+}
117+
118+func (_c *SignatureVerifier_Verify_Call) RunAndReturn(run func(payload []byte, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) domain.CommitVerification) *SignatureVerifier_Verify_Call {
119+ _c.Call.Return(run)
120+ return _c
121+}
A internal/ports/mocks/TrustedKeyService.go +269 -0
@@ -0,0 +1,269 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewTrustedKeyService creates a new instance of TrustedKeyService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewTrustedKeyService(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *TrustedKeyService {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &TrustedKeyService{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// TrustedKeyService is an autogenerated mock type for the TrustedKeyService type
38+type TrustedKeyService struct {
39+ mock.Mock
40+}
41+
42+type TrustedKeyService_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *TrustedKeyService) EXPECT() *TrustedKeyService_Expecter {
47+ return &TrustedKeyService_Expecter{mock: &_m.Mock}
48+}
49+
50+// AddTrustedKey provides a mock function for the type TrustedKeyService
51+func (_mock *TrustedKeyService) AddTrustedKey(ctx context.Context, p domain.Principal, format domain.SignatureFormat, title string, publicKey string, emails []string) (domain.TrustedKey, error) {
52+ ret := _mock.Called(ctx, p, format, title, publicKey, emails)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for AddTrustedKey")
56+ }
57+
58+ var r0 domain.TrustedKey
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SignatureFormat, string, string, []string) (domain.TrustedKey, error)); ok {
61+ return returnFunc(ctx, p, format, title, publicKey, emails)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.SignatureFormat, string, string, []string) domain.TrustedKey); ok {
64+ r0 = returnFunc(ctx, p, format, title, publicKey, emails)
65+ } else {
66+ r0 = ret.Get(0).(domain.TrustedKey)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.SignatureFormat, string, string, []string) error); ok {
69+ r1 = returnFunc(ctx, p, format, title, publicKey, emails)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// TrustedKeyService_AddTrustedKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddTrustedKey'
77+type TrustedKeyService_AddTrustedKey_Call struct {
78+ *mock.Call
79+}
80+
81+// AddTrustedKey is a helper method to define mock.On call
82+// - ctx context.Context
83+// - p domain.Principal
84+// - format domain.SignatureFormat
85+// - title string
86+// - publicKey string
87+// - emails []string
88+func (_e *TrustedKeyService_Expecter) AddTrustedKey(ctx any, p any, format any, title any, publicKey any, emails any) *TrustedKeyService_AddTrustedKey_Call {
89+ return &TrustedKeyService_AddTrustedKey_Call{Call: _e.mock.On("AddTrustedKey", ctx, p, format, title, publicKey, emails)}
90+}
91+
92+func (_c *TrustedKeyService_AddTrustedKey_Call) Run(run func(ctx context.Context, p domain.Principal, format domain.SignatureFormat, title string, publicKey string, emails []string)) *TrustedKeyService_AddTrustedKey_Call {
93+ _c.Call.Run(func(args mock.Arguments) {
94+ var arg0 context.Context
95+ if args[0] != nil {
96+ arg0 = args[0].(context.Context)
97+ }
98+ var arg1 domain.Principal
99+ if args[1] != nil {
100+ arg1 = args[1].(domain.Principal)
101+ }
102+ var arg2 domain.SignatureFormat
103+ if args[2] != nil {
104+ arg2 = args[2].(domain.SignatureFormat)
105+ }
106+ var arg3 string
107+ if args[3] != nil {
108+ arg3 = args[3].(string)
109+ }
110+ var arg4 string
111+ if args[4] != nil {
112+ arg4 = args[4].(string)
113+ }
114+ var arg5 []string
115+ if args[5] != nil {
116+ arg5 = args[5].([]string)
117+ }
118+ run(
119+ arg0,
120+ arg1,
121+ arg2,
122+ arg3,
123+ arg4,
124+ arg5,
125+ )
126+ })
127+ return _c
128+}
129+
130+func (_c *TrustedKeyService_AddTrustedKey_Call) Return(trustedKey domain.TrustedKey, err error) *TrustedKeyService_AddTrustedKey_Call {
131+ _c.Call.Return(trustedKey, err)
132+ return _c
133+}
134+
135+func (_c *TrustedKeyService_AddTrustedKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, format domain.SignatureFormat, title string, publicKey string, emails []string) (domain.TrustedKey, error)) *TrustedKeyService_AddTrustedKey_Call {
136+ _c.Call.Return(run)
137+ return _c
138+}
139+
140+// ListTrustedKeys provides a mock function for the type TrustedKeyService
141+func (_mock *TrustedKeyService) ListTrustedKeys(ctx context.Context, p domain.Principal) ([]domain.TrustedKey, error) {
142+ ret := _mock.Called(ctx, p)
143+
144+ if len(ret) == 0 {
145+ panic("no return value specified for ListTrustedKeys")
146+ }
147+
148+ var r0 []domain.TrustedKey
149+ var r1 error
150+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]domain.TrustedKey, error)); ok {
151+ return returnFunc(ctx, p)
152+ }
153+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []domain.TrustedKey); ok {
154+ r0 = returnFunc(ctx, p)
155+ } else {
156+ if ret.Get(0) != nil {
157+ r0 = ret.Get(0).([]domain.TrustedKey)
158+ }
159+ }
160+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
161+ r1 = returnFunc(ctx, p)
162+ } else {
163+ r1 = ret.Error(1)
164+ }
165+ return r0, r1
166+}
167+
168+// TrustedKeyService_ListTrustedKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTrustedKeys'
169+type TrustedKeyService_ListTrustedKeys_Call struct {
170+ *mock.Call
171+}
172+
173+// ListTrustedKeys is a helper method to define mock.On call
174+// - ctx context.Context
175+// - p domain.Principal
176+func (_e *TrustedKeyService_Expecter) ListTrustedKeys(ctx any, p any) *TrustedKeyService_ListTrustedKeys_Call {
177+ return &TrustedKeyService_ListTrustedKeys_Call{Call: _e.mock.On("ListTrustedKeys", ctx, p)}
178+}
179+
180+func (_c *TrustedKeyService_ListTrustedKeys_Call) Run(run func(ctx context.Context, p domain.Principal)) *TrustedKeyService_ListTrustedKeys_Call {
181+ _c.Call.Run(func(args mock.Arguments) {
182+ var arg0 context.Context
183+ if args[0] != nil {
184+ arg0 = args[0].(context.Context)
185+ }
186+ var arg1 domain.Principal
187+ if args[1] != nil {
188+ arg1 = args[1].(domain.Principal)
189+ }
190+ run(
191+ arg0,
192+ arg1,
193+ )
194+ })
195+ return _c
196+}
197+
198+func (_c *TrustedKeyService_ListTrustedKeys_Call) Return(trustedKeys []domain.TrustedKey, err error) *TrustedKeyService_ListTrustedKeys_Call {
199+ _c.Call.Return(trustedKeys, err)
200+ return _c
201+}
202+
203+func (_c *TrustedKeyService_ListTrustedKeys_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]domain.TrustedKey, error)) *TrustedKeyService_ListTrustedKeys_Call {
204+ _c.Call.Return(run)
205+ return _c
206+}
207+
208+// RemoveTrustedKey provides a mock function for the type TrustedKeyService
209+func (_mock *TrustedKeyService) RemoveTrustedKey(ctx context.Context, p domain.Principal, id string) error {
210+ ret := _mock.Called(ctx, p, id)
211+
212+ if len(ret) == 0 {
213+ panic("no return value specified for RemoveTrustedKey")
214+ }
215+
216+ var r0 error
217+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) error); ok {
218+ r0 = returnFunc(ctx, p, id)
219+ } else {
220+ r0 = ret.Error(0)
221+ }
222+ return r0
223+}
224+
225+// TrustedKeyService_RemoveTrustedKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveTrustedKey'
226+type TrustedKeyService_RemoveTrustedKey_Call struct {
227+ *mock.Call
228+}
229+
230+// RemoveTrustedKey is a helper method to define mock.On call
231+// - ctx context.Context
232+// - p domain.Principal
233+// - id string
234+func (_e *TrustedKeyService_Expecter) RemoveTrustedKey(ctx any, p any, id any) *TrustedKeyService_RemoveTrustedKey_Call {
235+ return &TrustedKeyService_RemoveTrustedKey_Call{Call: _e.mock.On("RemoveTrustedKey", ctx, p, id)}
236+}
237+
238+func (_c *TrustedKeyService_RemoveTrustedKey_Call) Run(run func(ctx context.Context, p domain.Principal, id string)) *TrustedKeyService_RemoveTrustedKey_Call {
239+ _c.Call.Run(func(args mock.Arguments) {
240+ var arg0 context.Context
241+ if args[0] != nil {
242+ arg0 = args[0].(context.Context)
243+ }
244+ var arg1 domain.Principal
245+ if args[1] != nil {
246+ arg1 = args[1].(domain.Principal)
247+ }
248+ var arg2 string
249+ if args[2] != nil {
250+ arg2 = args[2].(string)
251+ }
252+ run(
253+ arg0,
254+ arg1,
255+ arg2,
256+ )
257+ })
258+ return _c
259+}
260+
261+func (_c *TrustedKeyService_RemoveTrustedKey_Call) Return(err error) *TrustedKeyService_RemoveTrustedKey_Call {
262+ _c.Call.Return(err)
263+ return _c
264+}
265+
266+func (_c *TrustedKeyService_RemoveTrustedKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id string) error) *TrustedKeyService_RemoveTrustedKey_Call {
267+ _c.Call.Return(run)
268+ return _c
269+}
A internal/ports/mocks/TrustedKeyStore.go +224 -0
@@ -0,0 +1,224 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewTrustedKeyStore creates a new instance of TrustedKeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewTrustedKeyStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *TrustedKeyStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &TrustedKeyStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// TrustedKeyStore is an autogenerated mock type for the TrustedKeyStore type
38+type TrustedKeyStore struct {
39+ mock.Mock
40+}
41+
42+type TrustedKeyStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *TrustedKeyStore) EXPECT() *TrustedKeyStore_Expecter {
47+ return &TrustedKeyStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// CreateTrustedKey provides a mock function for the type TrustedKeyStore
51+func (_mock *TrustedKeyStore) CreateTrustedKey(ctx context.Context, k domain.TrustedKey) error {
52+ ret := _mock.Called(ctx, k)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for CreateTrustedKey")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TrustedKey) error); ok {
60+ r0 = returnFunc(ctx, k)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// TrustedKeyStore_CreateTrustedKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateTrustedKey'
68+type TrustedKeyStore_CreateTrustedKey_Call struct {
69+ *mock.Call
70+}
71+
72+// CreateTrustedKey is a helper method to define mock.On call
73+// - ctx context.Context
74+// - k domain.TrustedKey
75+func (_e *TrustedKeyStore_Expecter) CreateTrustedKey(ctx any, k any) *TrustedKeyStore_CreateTrustedKey_Call {
76+ return &TrustedKeyStore_CreateTrustedKey_Call{Call: _e.mock.On("CreateTrustedKey", ctx, k)}
77+}
78+
79+func (_c *TrustedKeyStore_CreateTrustedKey_Call) Run(run func(ctx context.Context, k domain.TrustedKey)) *TrustedKeyStore_CreateTrustedKey_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 domain.TrustedKey
86+ if args[1] != nil {
87+ arg1 = args[1].(domain.TrustedKey)
88+ }
89+ run(
90+ arg0,
91+ arg1,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *TrustedKeyStore_CreateTrustedKey_Call) Return(err error) *TrustedKeyStore_CreateTrustedKey_Call {
98+ _c.Call.Return(err)
99+ return _c
100+}
101+
102+func (_c *TrustedKeyStore_CreateTrustedKey_Call) RunAndReturn(run func(ctx context.Context, k domain.TrustedKey) error) *TrustedKeyStore_CreateTrustedKey_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
106+
107+// DeleteTrustedKey provides a mock function for the type TrustedKeyStore
108+func (_mock *TrustedKeyStore) DeleteTrustedKey(ctx context.Context, id string) error {
109+ ret := _mock.Called(ctx, id)
110+
111+ if len(ret) == 0 {
112+ panic("no return value specified for DeleteTrustedKey")
113+ }
114+
115+ var r0 error
116+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
117+ r0 = returnFunc(ctx, id)
118+ } else {
119+ r0 = ret.Error(0)
120+ }
121+ return r0
122+}
123+
124+// TrustedKeyStore_DeleteTrustedKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteTrustedKey'
125+type TrustedKeyStore_DeleteTrustedKey_Call struct {
126+ *mock.Call
127+}
128+
129+// DeleteTrustedKey is a helper method to define mock.On call
130+// - ctx context.Context
131+// - id string
132+func (_e *TrustedKeyStore_Expecter) DeleteTrustedKey(ctx any, id any) *TrustedKeyStore_DeleteTrustedKey_Call {
133+ return &TrustedKeyStore_DeleteTrustedKey_Call{Call: _e.mock.On("DeleteTrustedKey", ctx, id)}
134+}
135+
136+func (_c *TrustedKeyStore_DeleteTrustedKey_Call) Run(run func(ctx context.Context, id string)) *TrustedKeyStore_DeleteTrustedKey_Call {
137+ _c.Call.Run(func(args mock.Arguments) {
138+ var arg0 context.Context
139+ if args[0] != nil {
140+ arg0 = args[0].(context.Context)
141+ }
142+ var arg1 string
143+ if args[1] != nil {
144+ arg1 = args[1].(string)
145+ }
146+ run(
147+ arg0,
148+ arg1,
149+ )
150+ })
151+ return _c
152+}
153+
154+func (_c *TrustedKeyStore_DeleteTrustedKey_Call) Return(err error) *TrustedKeyStore_DeleteTrustedKey_Call {
155+ _c.Call.Return(err)
156+ return _c
157+}
158+
159+func (_c *TrustedKeyStore_DeleteTrustedKey_Call) RunAndReturn(run func(ctx context.Context, id string) error) *TrustedKeyStore_DeleteTrustedKey_Call {
160+ _c.Call.Return(run)
161+ return _c
162+}
163+
164+// ListTrustedKeys provides a mock function for the type TrustedKeyStore
165+func (_mock *TrustedKeyStore) ListTrustedKeys(ctx context.Context) ([]domain.TrustedKey, error) {
166+ ret := _mock.Called(ctx)
167+
168+ if len(ret) == 0 {
169+ panic("no return value specified for ListTrustedKeys")
170+ }
171+
172+ var r0 []domain.TrustedKey
173+ var r1 error
174+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.TrustedKey, error)); ok {
175+ return returnFunc(ctx)
176+ }
177+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.TrustedKey); ok {
178+ r0 = returnFunc(ctx)
179+ } else {
180+ if ret.Get(0) != nil {
181+ r0 = ret.Get(0).([]domain.TrustedKey)
182+ }
183+ }
184+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
185+ r1 = returnFunc(ctx)
186+ } else {
187+ r1 = ret.Error(1)
188+ }
189+ return r0, r1
190+}
191+
192+// TrustedKeyStore_ListTrustedKeys_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListTrustedKeys'
193+type TrustedKeyStore_ListTrustedKeys_Call struct {
194+ *mock.Call
195+}
196+
197+// ListTrustedKeys is a helper method to define mock.On call
198+// - ctx context.Context
199+func (_e *TrustedKeyStore_Expecter) ListTrustedKeys(ctx any) *TrustedKeyStore_ListTrustedKeys_Call {
200+ return &TrustedKeyStore_ListTrustedKeys_Call{Call: _e.mock.On("ListTrustedKeys", ctx)}
201+}
202+
203+func (_c *TrustedKeyStore_ListTrustedKeys_Call) Run(run func(ctx context.Context)) *TrustedKeyStore_ListTrustedKeys_Call {
204+ _c.Call.Run(func(args mock.Arguments) {
205+ var arg0 context.Context
206+ if args[0] != nil {
207+ arg0 = args[0].(context.Context)
208+ }
209+ run(
210+ arg0,
211+ )
212+ })
213+ return _c
214+}
215+
216+func (_c *TrustedKeyStore_ListTrustedKeys_Call) Return(trustedKeys []domain.TrustedKey, err error) *TrustedKeyStore_ListTrustedKeys_Call {
217+ _c.Call.Return(trustedKeys, err)
218+ return _c
219+}
220+
221+func (_c *TrustedKeyStore_ListTrustedKeys_Call) RunAndReturn(run func(ctx context.Context) ([]domain.TrustedKey, error)) *TrustedKeyStore_ListTrustedKeys_Call {
222+ _c.Call.Return(run)
223+ return _c
224+}
A internal/ports/signatures.go +78 -0
@@ -0,0 +1,78 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ports
4+
5+import (
6+ "context"
7+
8+ "bnema.dev/ermite/internal/domain"
9+)
10+
11+// CommitObjectReader reads raw commit objects (ADR 0030).
12+type CommitObjectReader interface {
13+ // ReadCommitObjects returns the raw content of each commit (without the
14+ // "commit <size>\0" prefix), keyed by id. Ids that are missing, are not
15+ // commits, or exceed domain.MaxCommitObjectBytes are left out of the map.
16+ // The total size of the returned objects stays within
17+ // domain.MaxCommitObjectsBytes: ids are read in order, and the first one
18+ // that would exceed the budget ends the read, so it and the ids after it
19+ // are left out (the caller leaves those commits unchecked). A cancelled
20+ // ctx is returned as its error.
21+ // domain.ErrNotFound when the repository has no directory.
22+ ReadCommitObjects(ctx context.Context, repo domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error)
23+}
24+
25+// KeyInspector parses public keys for the trusted key list.
26+type KeyInspector interface {
27+ // Inspect parses a public key of the given format and returns its ID
28+ // (fingerprint) and its canonical text: the "<type> <base64>" part of an
29+ // authorized_keys line for SSH (comment dropped), the armored public key
30+ // for GPG (exactly one entity, private material refused). Any parse
31+ // failure wraps domain.ErrInvalid.
32+ Inspect(format domain.SignatureFormat, publicKey string) (id, canonical string, err error)
33+}
34+
35+// SignatureVerifier checks one signature against a set of trusted keys. It
36+// never errors: every failure is a status.
37+type SignatureVerifier interface {
38+ // Verify checks signature over payload, signed by committerEmail.
39+ // signedAt is the committer date: the committer chooses it, and it is
40+ // authenticated as part of the payload but is not a trusted timestamp.
41+ // SSH ignores it, and so does GPG, which
42+ // judges key and signature expiry at the signature creation time and
43+ // revocation at the current time. keys are the trusted keys of the
44+ // verifier's format. It returns Format, Status, Fingerprint, and
45+ // KeyID/KeyTitle of the key that verified (even on email_mismatch).
46+ Verify(payload, signature []byte, committerEmail string, signedAt int64, keys []domain.TrustedKey) domain.CommitVerification
47+}
48+
49+// TrustedKeyStore keeps the trusted keys (ADR 0030). It does not bound their
50+// number: the use case checks domain.MaxTrustedKeys.
51+type TrustedKeyStore interface {
52+ // ListTrustedKeys returns every stored key, oldest first.
53+ ListTrustedKeys(ctx context.Context) ([]domain.TrustedKey, error)
54+ // CreateTrustedKey stores a key; domain.ErrAlreadyExists if the ID is taken.
55+ CreateTrustedKey(ctx context.Context, k domain.TrustedKey) error
56+ // DeleteTrustedKey removes a key or returns domain.ErrNotFound.
57+ DeleteTrustedKey(ctx context.Context, id string) error
58+}
59+
60+// TrustedKeyService manages the trusted keys. *usecase.Signatures implements it.
61+type TrustedKeyService interface {
62+ // ListTrustedKeys lists the keys, the builtin instance key first. Any
63+ // authenticated caller may.
64+ ListTrustedKeys(ctx context.Context, p domain.Principal) ([]domain.TrustedKey, error)
65+ // AddTrustedKey adds a key. Owner only.
66+ AddTrustedKey(ctx context.Context, p domain.Principal, format domain.SignatureFormat, title, publicKey string, emails []string) (domain.TrustedKey, error)
67+ // RemoveTrustedKey removes a key by ID. Owner only; the builtin key is
68+ // refused with domain.ErrPreconditionFailed.
69+ RemoveTrustedKey(ctx context.Context, p domain.Principal, id string) error
70+}
71+
72+// CommitVerifier tells the signature state of commits the caller already read.
73+// *usecase.Signatures implements it.
74+type CommitVerifier interface {
75+ // VerifyCommits returns one verification per commit, keyed by id. A
76+ // commit that could not be read is left out (unchecked).
77+ VerifyCommits(ctx context.Context, repo domain.RepoName, commits []domain.Commit) map[domain.ObjectID]domain.CommitVerification
78+}
A internal/readertest/commitobjects.go +158 -0
@@ -0,0 +1,158 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package readertest
4+
5+import (
6+ "bytes"
7+ "context"
8+ "fmt"
9+ "os"
10+ "os/exec"
11+ "path/filepath"
12+ "strings"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// CommitObjectsMaker builds a CommitObjectReader serving the repositories
23+// below data/repos, and returns it with the data directory.
24+type CommitObjectsMaker func(t *testing.T) (ports.CommitObjectReader, string)
25+
26+// CommitObjects runs the CommitObjectReader contract against the readers mk
27+// builds: the raw bytes of unsigned and SSH-signed commits equal `git cat-file
28+// commit`; missing ids, trees and blobs are left out; a missing repository is
29+// domain.ErrNotFound; too many ids are domain.ErrInvalid.
30+func CommitObjects(t *testing.T, mk CommitObjectsMaker) {
31+ ctx := context.Background()
32+ r, data := mk(t)
33+ f := NewFixture(t, data, "signed")
34+
35+ f.Write("a.txt", "one\n")
36+ unsigned := f.Commit("unsigned")
37+ signed := signedCommit(t, f, "b.txt", "signed with ssh")
38+ f.Write("c.txt", "three\n")
39+ again := f.Commit("unsigned again")
40+ tree := f.Git("rev-parse", signed+"^{tree}")
41+ blob := f.Git("rev-parse", signed+":b.txt")
42+ missing := ObjectID(t, strings.Repeat("1", 40))
43+
44+ ids := []domain.ObjectID{ObjectID(t, unsigned), ObjectID(t, signed), ObjectID(t, again)}
45+ got, err := r.ReadCommitObjects(ctx, f.Name, ids)
46+ require.NoError(t, err)
47+ require.Len(t, got, len(ids))
48+ for _, id := range ids {
49+ assert.Equal(t, f.Git("cat-file", "commit", id.String()), strings.TrimSpace(string(got[id])), "commit %s", id)
50+ assert.True(t, bytes.Equal(got[id], rawCommit(t, f, id.String())), "raw bytes of %s", id)
51+ }
52+ assert.Contains(t, string(got[ids[1]]), "gpgsig -----BEGIN SSH SIGNATURE-----")
53+ assert.NotContains(t, string(got[ids[0]]), "gpgsig")
54+
55+ // Missing ids, a tree and a blob are left out; duplicates are fine.
56+ mixed := []domain.ObjectID{ids[1], missing, ObjectID(t, tree), ObjectID(t, blob), ids[1], ids[0]}
57+ got, err = r.ReadCommitObjects(ctx, f.Name, mixed)
58+ require.NoError(t, err)
59+ assert.Len(t, got, 2)
60+ assert.Contains(t, got, ids[0])
61+ assert.Contains(t, got, ids[1])
62+
63+ got, err = r.ReadCommitObjects(ctx, f.Name, nil)
64+ require.NoError(t, err)
65+ assert.Empty(t, got)
66+
67+ // A repository reads the same after its objects were packed.
68+ f.Git("gc", "--quiet")
69+ got, err = r.ReadCommitObjects(ctx, f.Name, ids)
70+ require.NoError(t, err)
71+ assert.Equal(t, rawCommit(t, f, signed), got[ids[1]], "packed")
72+
73+ t.Run("budget", func(t *testing.T) { commitObjectsBudget(t, r, f) })
74+
75+ cancelled, cancel := context.WithCancel(ctx)
76+ cancel()
77+ _, err = r.ReadCommitObjects(cancelled, f.Name, ids)
78+ assert.ErrorIs(t, err, context.Canceled)
79+
80+ _, err = r.ReadCommitObjects(ctx, RepoName(t, "nope"), ids)
81+ assert.ErrorIs(t, err, domain.ErrNotFound)
82+
83+ many := make([]domain.ObjectID, ports.MaxCommitLimit+1)
84+ for i := range many {
85+ many[i] = missing
86+ }
87+ _, err = r.ReadCommitObjects(ctx, f.Name, many)
88+ assert.ErrorIs(t, err, domain.ErrInvalid)
89+ _, err = r.ReadCommitObjects(ctx, f.Name, many[:ports.MaxCommitLimit])
90+ require.NoError(t, err)
91+}
92+
93+// commitObjectsBudget checks the aggregate budget: ten commits of about
94+// 900 KiB each (under the per-object limit) do not fit in
95+// domain.MaxCommitObjectsBytes, so the read stops before the one that would
96+// not fit, and the others are left out.
97+func commitObjectsBudget(t *testing.T, r ports.CommitObjectReader, f *Fixture) {
98+ const n, msgSize = 10, 900 << 10
99+ tree := f.Git("rev-parse", "HEAD^{tree}")
100+ big := make([]domain.ObjectID, n)
101+ for i := range big {
102+ msg := fmt.Sprintf("big %d\n", i) + strings.Repeat("x", msgSize-8) + "\n"
103+ raw := fmt.Sprintf("tree %s\nauthor A <a@x.org> 1 +0000\ncommitter A <a@x.org> 1 +0000\n\n%s", tree, msg)
104+ big[i] = ObjectID(t, f.WriteObject("commit", raw))
105+ }
106+ got, err := r.ReadCommitObjects(context.Background(), f.Name, big)
107+ require.NoError(t, err)
108+ var total int
109+ for id, raw := range got {
110+ assert.Contains(t, big, id)
111+ total += len(raw)
112+ }
113+ assert.LessOrEqual(t, total, domain.MaxCommitObjectsBytes)
114+ assert.Len(t, got, domain.MaxCommitObjectsBytes/(msgSize+200), "as many as fit in the budget")
115+ assert.Less(t, len(got), n)
116+ for _, id := range big[:len(got)] {
117+ assert.Contains(t, got, id, "the budget is spent in the order of the ids")
118+ }
119+}
120+
121+// ObjectID parses a full object id.
122+func ObjectID(t testing.TB, s string) domain.ObjectID {
123+ t.Helper()
124+ id, err := domain.ParseObjectID(s)
125+ require.NoError(t, err)
126+ return id
127+}
128+
129+// rawCommit returns `git cat-file commit <id>` byte for byte (Fixture.Git
130+// trims its output).
131+func rawCommit(t testing.TB, f *Fixture, id string) []byte {
132+ t.Helper()
133+ cmd := exec.Command("git", "--git-dir="+f.Bare, "cat-file", "commit", id)
134+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + f.Work, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
135+ out, err := cmd.Output()
136+ require.NoError(t, err)
137+ return out
138+}
139+
140+// signedCommit writes a file and commits it with an SSH signature (a key made
141+// for the test, `git commit -S` with gpg.format=ssh). Without ssh-keygen it
142+// skips locally and fails when CI is set.
143+func signedCommit(t testing.TB, f *Fixture, file, msg string) string {
144+ t.Helper()
145+ if _, err := exec.LookPath("ssh-keygen"); err != nil {
146+ if os.Getenv("CI") != "" {
147+ t.Fatalf("ssh-keygen not found")
148+ }
149+ t.Skip("ssh-keygen not found")
150+ }
151+ key := filepath.Join(t.TempDir(), "id_ed25519")
152+ out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "test", "-f", key).CombinedOutput()
153+ require.NoError(t, err, string(out))
154+ f.Write(file, msg+"\n")
155+ f.Git("add", "-A")
156+ f.Git("-c", "gpg.format=ssh", "-c", "user.signingkey="+key, "commit", "--quiet", "-S", "-m", msg)
157+ return f.Git("rev-parse", "HEAD")
158+}
M internal/usecase/browse.go +37 -1
@@ -31,6 +31,9 @@ type Browse struct {
3131 history *historyCache
3232 // stats keeps the figures of the home page.
3333 stats statsCache
34+ // verifier fills the signature state of the commits; nil leaves it
35+ // unchecked.
36+ verifier ports.CommitVerifier
3437 }
3538
3639 var _ ports.BrowseService = (*Browse)(nil)
@@ -53,6 +56,28 @@ func (b *Browse) WithHistoryCache(maxEntries int, maxBytes int64) *Browse {
5356 return b
5457 }
5558
59+// WithVerifier sets the checker of commit signatures (ADR 0030): commits
60+// returned by ListCommits and GetCommit then carry their Verification. Call
61+// it before the first request.
62+func (b *Browse) WithVerifier(v ports.CommitVerifier) *Browse {
63+ b.verifier = v
64+ return b
65+}
66+
67+// verify fills the Verification of the commits, in place, with one call. The
68+// slice must not be shared with a cache.
69+func (b *Browse) verify(ctx context.Context, repo domain.RepoName, commits []domain.Commit) {
70+ if b.verifier == nil || len(commits) == 0 {
71+ return
72+ }
73+ res := b.verifier.VerifyCommits(ctx, repo, commits)
74+ for i := range commits {
75+ if v, ok := res[commits[i].ID]; ok {
76+ commits[i].Verification = v
77+ }
78+ }
79+}
80+
5681 // authorize returns the repository if p may read it. The caller uses it to
5782 // tell a repository from a later one created under the same name.
5883 func (b *Browse) authorize(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
@@ -247,6 +272,8 @@ func (b *Browse) ListCommits(ctx context.Context, p domain.Principal, repo domai
247272 if err != nil {
248273 return ports.HistoryPage{}, err
249274 }
275+ // res.Commits is a copy (see listCommits): the cached page is untouched.
276+ b.verify(ctx, repo, res.Commits)
250277 out := ports.HistoryPage{Commits: res.Commits}
251278 if res.HasMore {
252279 out.NextPageToken = encodeCommitToken(res.Head, q.Skip+len(res.Commits), params.Path)
@@ -308,5 +335,14 @@ func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.
308335 if _, err := b.authorize(ctx, p, repo); err != nil {
309336 return domain.CommitDetail{}, err
310337 }
311- return b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
338+ d, err := b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
339+ if err != nil {
340+ return domain.CommitDetail{}, err
341+ }
342+ if b.verifier != nil {
343+ one := []domain.Commit{d.Commit}
344+ b.verify(ctx, repo, one)
345+ d.Commit = one[0]
346+ }
347+ return d, nil
312348 }
M internal/usecase/browse_test.go +95 -0
@@ -567,3 +567,98 @@ func mustPath(t *testing.T, s string) domain.TreePath {
567567 require.NoError(t, err)
568568 return p
569569 }
570+
571+func TestBrowseVerifiesCommits(t *testing.T) {
572+ t.Parallel()
573+ ctx := context.Background()
574+ pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
575+ verified := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, KeyID: "SHA256:k"}
576+ unsigned := domain.CommitVerification{Status: domain.StatusUnsigned}
577+
578+ newFixture := func(t *testing.T) (*browseFixture, *mocks.CommitVerifier) {
579+ f := newBrowseFixture(t)
580+ v := mocks.NewCommitVerifier(t)
581+ f.browse.WithVerifier(v)
582+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
583+ return f, v
584+ }
585+
586+ t.Run("ListCommits fills the page with one call and leaves unchecked commits alone", func(t *testing.T) {
587+ t.Parallel()
588+ f, v := newFixture(t)
589+ cs := []domain.Commit{{ID: oid("a")}, {ID: oid("b")}, {ID: oid("c")}}
590+ f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).Return(ports.CommitPage{Head: oid("a"), Commits: cs}, nil)
591+ v.EXPECT().VerifyCommits(ctx, pub.Name, cs).Return(map[domain.ObjectID]domain.CommitVerification{
592+ oid("a"): verified, oid("b"): unsigned,
593+ }).Once()
594+ page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
595+ require.NoError(t, err)
596+ require.Len(t, page.Commits, 3)
597+ assert.Equal(t, verified, page.Commits[0].Verification)
598+ assert.Equal(t, unsigned, page.Commits[1].Verification)
599+ assert.Equal(t, domain.CommitVerification{}, page.Commits[2].Verification)
600+ })
601+
602+ t.Run("a cached page is not mutated", func(t *testing.T) {
603+ t.Parallel()
604+ f, v := newFixture(t)
605+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil).Maybe()
606+ full := oid("a")
607+ f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
608+ Return(ports.CommitPage{Head: full, Commits: []domain.Commit{{ID: full}}}, nil).Once()
609+ v.EXPECT().VerifyCommits(ctx, pub.Name, mock.Anything).
610+ Return(map[domain.ObjectID]domain.CommitVerification{full: verified}).Twice()
611+ params := ports.ListCommitsParams{Revision: domain.RevisionOf(full), PageSize: 1}
612+ for range 2 { // the second answer comes from the history cache
613+ page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, params)
614+ require.NoError(t, err)
615+ assert.Equal(t, verified, page.Commits[0].Verification)
616+ }
617+ // With the verifier off, the cached entry is still unchecked.
618+ f.browse.WithVerifier(nil)
619+ page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, params)
620+ require.NoError(t, err)
621+ assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)
622+ })
623+
624+ t.Run("GetCommit fills the commit", func(t *testing.T) {
625+ t.Parallel()
626+ f, v := newFixture(t)
627+ c := domain.Commit{ID: oid("a")}
628+ f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, domain.DefaultDiffLimits()).
629+ Return(domain.CommitDetail{Commit: c, Stats: domain.DiffStats{FilesChanged: 2}}, nil)
630+ v.EXPECT().VerifyCommits(ctx, pub.Name, []domain.Commit{c}).
631+ Return(map[domain.ObjectID]domain.CommitVerification{oid("a"): verified})
632+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
633+ require.NoError(t, err)
634+ assert.Equal(t, verified, d.Commit.Verification)
635+ assert.Equal(t, 2, d.Stats.FilesChanged)
636+ })
637+
638+ t.Run("nothing is verified for an empty page or a failed read", func(t *testing.T) {
639+ t.Parallel()
640+ f, _ := newFixture(t) // the verifier mock fails the test if called
641+ f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).Return(ports.CommitPage{}, nil)
642+ _, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
643+ require.NoError(t, err)
644+ f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound)
645+ _, err = f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
646+ assert.ErrorIs(t, err, domain.ErrNotFound)
647+ })
648+
649+ t.Run("without a verifier the commits are unchanged", func(t *testing.T) {
650+ t.Parallel()
651+ f := newBrowseFixture(t)
652+ f.meta.EXPECT().Get(ctx, pub.Name).Return(pub, nil)
653+ f.reader.EXPECT().ListCommits(ctx, pub.Name, mock.Anything).
654+ Return(ports.CommitPage{Commits: []domain.Commit{{ID: oid("a")}}}, nil)
655+ f.reader.EXPECT().GetCommit(ctx, pub.Name, mock.Anything, mock.Anything).
656+ Return(domain.CommitDetail{Commit: domain.Commit{ID: oid("a")}}, nil)
657+ page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{})
658+ require.NoError(t, err)
659+ assert.Equal(t, domain.CommitVerification{}, page.Commits[0].Verification)
660+ d, err := f.browse.GetCommit(ctx, domain.Anonymous(), pub.Name, domain.Revision{})
661+ require.NoError(t, err)
662+ assert.Equal(t, domain.CommitVerification{}, d.Commit.Verification)
663+ })
664+}
M internal/usecase/sessions.go +6 -2
@@ -75,12 +75,16 @@ func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Cloc
7575
7676 // requireOwner is the access rule of the session API: root, or a stored token
7777 // with every scope, no repository list and no branch prefixes.
78-func requireOwner(p domain.Principal) error {
78+func requireOwner(p domain.Principal) error { return requireOwnerFor(p, "managing web sessions") }
79+
80+// requireOwnerFor is the owner rule; action names what was refused in the
81+// error ("managing web sessions").
82+func requireOwnerFor(p domain.Principal, action string) error {
7983 switch {
8084 case p.IsAnonymous():
8185 return domain.ErrUnauthenticated
8286 case !p.HasOwnerRights():
83- return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden)
87+ return fmt.Errorf("%w: %s needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden, action)
8488 }
8589 return nil
8690 }
A internal/usecase/signatures.go +286 -0
@@ -0,0 +1,286 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
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.
18+const 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.
23+type 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+
43+var (
44+ _ ports.TrustedKeyService = (*Signatures)(nil)
45+ _ ports.CommitVerifier = (*Signatures)(nil)
46+)
47+
48+// sigKey identifies a cached verification.
49+type 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.
58+func 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+
70+func 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.
76+func 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.
80+func (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).
99+func (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.
145+func (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.
169+func (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.
185+func (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.
232+func (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.
255+func (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+}
A internal/usecase/signatures_test.go +458 -0
@@ -0,0 +1,458 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+const (
24+ sshArmor = "-----BEGIN SSH SIGNATURE-----\n AAAA\n -----END SSH SIGNATURE-----\n"
25+ gpgArmor = "-----BEGIN PGP SIGNATURE-----\n\n iQ==\n -----END PGP SIGNATURE-----\n"
26+ x509Armor = "-----BEGIN SIGNED MESSAGE-----\n MIIB\n -----END SIGNED MESSAGE-----\n"
27+)
28+
29+var sigNow = time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
30+
31+type sigFixture struct {
32+ store *mocks.TrustedKeyStore
33+ inspector *mocks.KeyInspector
34+ ssh, gpg *mocks.SignatureVerifier
35+ objects *mocks.CommitObjectReader
36+ clock *mocks.Clock
37+ sigs *usecase.Signatures
38+}
39+
40+var builtinKey = domain.TrustedKey{
41+ ID: "SHA256:builtin", Format: domain.FormatSSH, Title: "instance", Emails: []string{"bot@example.org"}, Builtin: true,
42+}
43+
44+func newSigFixture(t *testing.T, stored ...domain.TrustedKey) *sigFixture {
45+ t.Helper()
46+ f := &sigFixture{
47+ store: mocks.NewTrustedKeyStore(t), inspector: mocks.NewKeyInspector(t),
48+ ssh: mocks.NewSignatureVerifier(t), gpg: mocks.NewSignatureVerifier(t),
49+ objects: mocks.NewCommitObjectReader(t), clock: mocks.NewClock(t),
50+ }
51+ f.clock.EXPECT().Now().Return(sigNow).Maybe()
52+ f.store.EXPECT().ListTrustedKeys(mock.Anything).Return(stored, nil)
53+ var err error
54+ f.sigs, err = usecase.NewSignatures(f.store, f.inspector, map[domain.SignatureFormat]ports.SignatureVerifier{
55+ domain.FormatSSH: f.ssh, domain.FormatGPG: f.gpg,
56+ }, f.objects, builtinKey, f.clock)
57+ require.NoError(t, err)
58+ return f
59+}
60+
61+func sigToken(scopes []domain.Scope, repos ...domain.RepoName) domain.Principal {
62+ return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, Repos: repos})
63+}
64+
65+func TestNewSignaturesLoadError(t *testing.T) {
66+ t.Parallel()
67+ store := mocks.NewTrustedKeyStore(t)
68+ store.EXPECT().ListTrustedKeys(mock.Anything).Return(nil, errors.New("boom"))
69+ _, err := usecase.NewSignatures(store, nil, nil, nil, domain.TrustedKey{}, nil)
70+ require.Error(t, err)
71+}
72+
73+func TestTrustedKeyAccess(t *testing.T) {
74+ t.Parallel()
75+ ctx := context.Background()
76+ reader := sigToken([]domain.Scope{domain.ScopeRepoRead})
77+ owner := sigToken(domain.AllScopes())
78+
79+ t.Run("list", func(t *testing.T) {
80+ t.Parallel()
81+ f := newSigFixture(t)
82+ _, err := f.sigs.ListTrustedKeys(ctx, domain.Anonymous())
83+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
84+ for _, p := range []domain.Principal{reader, owner, domain.Root(), domain.Owner()} {
85+ _, err := f.sigs.ListTrustedKeys(ctx, p)
86+ assert.NoError(t, err)
87+ }
88+ })
89+
90+ t.Run("add and remove", func(t *testing.T) {
91+ t.Parallel()
92+ f := newSigFixture(t)
93+ _, err := f.sigs.AddTrustedKey(ctx, domain.Anonymous(), domain.FormatSSH, "t", "k", []string{"a@b.c"})
94+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
95+ err = f.sigs.RemoveTrustedKey(ctx, domain.Anonymous(), "x")
96+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
97+
98+ _, err = f.sigs.AddTrustedKey(ctx, reader, domain.FormatSSH, "t", "k", []string{"a@b.c"})
99+ assert.ErrorIs(t, err, domain.ErrForbidden)
100+ assert.Contains(t, err.Error(), "managing trusted keys needs")
101+ err = f.sigs.RemoveTrustedKey(ctx, reader, "x")
102+ assert.ErrorIs(t, err, domain.ErrForbidden)
103+
104+ limited := sigToken(domain.AllScopes(), name(t, "r"))
105+ _, err = f.sigs.AddTrustedKey(ctx, limited, domain.FormatSSH, "t", "k", []string{"a@b.c"})
106+ assert.ErrorIs(t, err, domain.ErrForbidden)
107+
108+ for _, p := range []domain.Principal{owner, domain.Root(), domain.Owner()} {
109+ id := "SHA256:" + p.Label()
110+ f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return(id, "ssh-ed25519 K", nil).Once()
111+ f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(nil).Once()
112+ _, err := f.sigs.AddTrustedKey(ctx, p, domain.FormatSSH, "t", "k", []string{"a@b.c"})
113+ assert.NoError(t, err)
114+ f.store.EXPECT().DeleteTrustedKey(ctx, id).Return(nil).Once()
115+ assert.NoError(t, f.sigs.RemoveTrustedKey(ctx, p, id))
116+ }
117+ })
118+}
119+
120+func TestAddTrustedKey(t *testing.T) {
121+ t.Parallel()
122+ ctx := context.Background()
123+ owner := domain.Root()
124+
125+ t.Run("stores the canonical key and lists it after the builtin", func(t *testing.T) {
126+ t.Parallel()
127+ f := newSigFixture(t)
128+ f.inspector.EXPECT().Inspect(domain.FormatSSH, "ssh-ed25519 K comment").Return("SHA256:k1", "ssh-ed25519 K", nil)
129+ want := domain.TrustedKey{
130+ ID: "SHA256:k1", Format: domain.FormatSSH, Title: "laptop", PublicKey: "ssh-ed25519 K",
131+ Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: sigNow,
132+ }
133+ f.store.EXPECT().CreateTrustedKey(ctx, want).Return(nil)
134+ got, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, " laptop ", "ssh-ed25519 K comment", []string{"A@B.c", " d@e.f", "a@b.c"})
135+ require.NoError(t, err)
136+ assert.Equal(t, want, got)
137+
138+ keys, err := f.sigs.ListTrustedKeys(ctx, owner)
139+ require.NoError(t, err)
140+ require.Len(t, keys, 2)
141+ assert.Equal(t, builtinKey, keys[0])
142+ assert.Equal(t, want, keys[1])
143+ })
144+
145+ t.Run("validation", func(t *testing.T) {
146+ t.Parallel()
147+ f := newSigFixture(t)
148+ for name, call := range map[string]func() error{
149+ "empty title": func() error {
150+ _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, " ", "k", []string{"a@b.c"})
151+ return err
152+ },
153+ "no email": func() error {
154+ _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", nil)
155+ return err
156+ },
157+ "bad email": func() error {
158+ _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", []string{"not an email"})
159+ return err
160+ },
161+ "x509": func() error {
162+ _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatX509, "t", "k", []string{"a@b.c"})
163+ return err
164+ },
165+ "no format": func() error {
166+ _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatNone, "t", "k", []string{"a@b.c"})
167+ return err
168+ },
169+ "key too big": func() error {
170+ _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatGPG, "t", strings.Repeat("x", domain.MaxTrustedKeyBytes+1), []string{"a@b.c"})
171+ return err
172+ },
173+ } {
174+ assert.ErrorIs(t, call(), domain.ErrInvalid, name)
175+ }
176+ })
177+
178+ t.Run("inspector error is returned", func(t *testing.T) {
179+ t.Parallel()
180+ f := newSigFixture(t)
181+ f.inspector.EXPECT().Inspect(domain.FormatGPG, "bad").Return("", "", fmt.Errorf("%w: nope", domain.ErrInvalid))
182+ _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatGPG, "t", "bad", []string{"a@b.c"})
183+ assert.ErrorIs(t, err, domain.ErrInvalid)
184+ })
185+
186+ t.Run("builtin and duplicates are refused", func(t *testing.T) {
187+ t.Parallel()
188+ f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:old", Format: domain.FormatSSH})
189+ f.inspector.EXPECT().Inspect(domain.FormatSSH, "b").Return(builtinKey.ID, "ssh-ed25519 B", nil)
190+ _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "b", []string{"a@b.c"})
191+ assert.ErrorIs(t, err, domain.ErrAlreadyExists)
192+
193+ f.inspector.EXPECT().Inspect(domain.FormatSSH, "o").Return("SHA256:old", "ssh-ed25519 O", nil)
194+ _, err = f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "o", []string{"a@b.c"})
195+ assert.ErrorIs(t, err, domain.ErrAlreadyExists)
196+ })
197+
198+ t.Run("limit", func(t *testing.T) {
199+ t.Parallel()
200+ var stored []domain.TrustedKey
201+ for i := range domain.MaxTrustedKeys {
202+ stored = append(stored, domain.TrustedKey{ID: fmt.Sprintf("SHA256:%d", i), Format: domain.FormatSSH})
203+ }
204+ f := newSigFixture(t, stored...)
205+ f.inspector.EXPECT().Inspect(domain.FormatSSH, "n").Return("SHA256:new", "ssh-ed25519 N", nil)
206+ _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "n", []string{"a@b.c"})
207+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
208+ })
209+
210+ t.Run("store error leaves the list unchanged", func(t *testing.T) {
211+ t.Parallel()
212+ f := newSigFixture(t)
213+ f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return("SHA256:k", "ssh-ed25519 K", nil)
214+ f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(errors.New("down"))
215+ _, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", []string{"a@b.c"})
216+ require.Error(t, err)
217+ keys, _ := f.sigs.ListTrustedKeys(ctx, owner)
218+ assert.Len(t, keys, 1)
219+ })
220+}
221+
222+func TestRemoveTrustedKey(t *testing.T) {
223+ t.Parallel()
224+ ctx := context.Background()
225+
226+ t.Run("builtin is refused without touching the store", func(t *testing.T) {
227+ t.Parallel()
228+ f := newSigFixture(t)
229+ err := f.sigs.RemoveTrustedKey(ctx, domain.Root(), builtinKey.ID)
230+ assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
231+ })
232+
233+ t.Run("removes from the store and the list", func(t *testing.T) {
234+ t.Parallel()
235+ f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:a"}, domain.TrustedKey{ID: "SHA256:b"})
236+ f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:a").Return(nil)
237+ require.NoError(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:a"))
238+ keys, _ := f.sigs.ListTrustedKeys(ctx, domain.Root())
239+ require.Len(t, keys, 2)
240+ assert.Equal(t, "SHA256:b", keys[1].ID)
241+ })
242+
243+ t.Run("store errors are returned and the key stays", func(t *testing.T) {
244+ t.Parallel()
245+ f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:a"})
246+ f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:a").Return(domain.ErrNotFound)
247+ assert.ErrorIs(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:a"), domain.ErrNotFound)
248+ keys, _ := f.sigs.ListTrustedKeys(ctx, domain.Root())
249+ assert.Len(t, keys, 2)
250+ })
251+}
252+
253+func TestListWithoutBuiltin(t *testing.T) {
254+ t.Parallel()
255+ store := mocks.NewTrustedKeyStore(t)
256+ store.EXPECT().ListTrustedKeys(mock.Anything).Return([]domain.TrustedKey{{ID: "SHA256:a"}}, nil)
257+ s, err := usecase.NewSignatures(store, nil, nil, nil, domain.TrustedKey{}, nil)
258+ require.NoError(t, err)
259+ keys, err := s.ListTrustedKeys(context.Background(), domain.Root())
260+ require.NoError(t, err)
261+ require.Len(t, keys, 1)
262+ assert.Equal(t, "SHA256:a", keys[0].ID)
263+}
264+
265+// rawCommit builds a commit object with the signature armor in a gpgsig header.
266+func rawCommit(armor string) []byte {
267+ var b strings.Builder
268+ b.WriteString("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@b.c> 1 +0000\ncommitter A <a@b.c> 1 +0000\n")
269+ if armor != "" {
270+ lines := strings.Split(strings.TrimSuffix(armor, "\n"), "\n")
271+ b.WriteString("gpgsig " + strings.TrimPrefix(lines[0], " ") + "\n")
272+ for _, l := range lines[1:] {
273+ b.WriteString(" " + strings.TrimPrefix(l, " ") + "\n")
274+ }
275+ }
276+ b.WriteString("\nmessage\n")
277+ return []byte(b.String())
278+}
279+
280+func sigCommit(c string) domain.Commit {
281+ return domain.Commit{ID: oid(c), Committer: domain.Signature{Email: "a@b.c", Time: time.Unix(1700000000, 0)}}
282+}
283+
284+func TestVerifyCommits(t *testing.T) {
285+ t.Parallel()
286+ ctx := context.Background()
287+ repo := name(t, "r")
288+ stored := []domain.TrustedKey{
289+ {ID: "SHA256:s1", Format: domain.FormatSSH, Emails: []string{"a@b.c"}},
290+ {ID: "ABCD", Format: domain.FormatGPG, Emails: []string{"a@b.c"}},
291+ }
292+
293+ t.Run("unsigned, x509 and unknown armor need no verifier", func(t *testing.T) {
294+ t.Parallel()
295+ f := newSigFixture(t, stored...)
296+ cs := []domain.Commit{sigCommit("1"), sigCommit("2"), sigCommit("3")}
297+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1"), oid("2"), oid("3")}).
298+ Return(map[domain.ObjectID][]byte{
299+ oid("1"): rawCommit(""),
300+ oid("2"): rawCommit(x509Armor),
301+ oid("3"): rawCommit("-----BEGIN WHAT-----\n x\n -----END WHAT-----\n"),
302+ }, nil).Once()
303+ got := f.sigs.VerifyCommits(ctx, repo, cs)
304+ assert.Equal(t, map[domain.ObjectID]domain.CommitVerification{
305+ oid("1"): {Status: domain.StatusUnsigned},
306+ oid("2"): {Format: domain.FormatX509, Status: domain.StatusUnsupported},
307+ oid("3"): {Format: domain.FormatNone, Status: domain.StatusBadSignature},
308+ }, got)
309+ })
310+
311+ t.Run("ssh gets the builtin and the ssh keys only", func(t *testing.T) {
312+ t.Parallel()
313+ f := newSigFixture(t, stored...)
314+ c := sigCommit("1")
315+ raw := rawCommit(sshArmor)
316+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).Return(map[domain.ObjectID][]byte{c.ID: raw}, nil)
317+ want := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, Fingerprint: "SHA256:s1", KeyID: "SHA256:s1"}
318+ payload, sig, ok := domain.SplitCommitSignature(raw)
319+ require.True(t, ok)
320+ f.ssh.EXPECT().Verify(payload, sig, "a@b.c", int64(1700000000), []domain.TrustedKey{builtinKey, stored[0]}).Return(want)
321+ assert.Equal(t, want, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})[c.ID])
322+ })
323+
324+ t.Run("gpg gets the gpg keys only", func(t *testing.T) {
325+ t.Parallel()
326+ f := newSigFixture(t, stored...)
327+ c := sigCommit("1")
328+ raw := rawCommit(gpgArmor)
329+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).Return(map[domain.ObjectID][]byte{c.ID: raw}, nil)
330+ want := domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusExpired}
331+ payload, sig, _ := domain.SplitCommitSignature(raw)
332+ f.gpg.EXPECT().Verify(payload, sig, "a@b.c", int64(1700000000), []domain.TrustedKey{stored[1]}).Return(want)
333+ assert.Equal(t, want, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})[c.ID])
334+ })
335+
336+ t.Run("results are cached per repository", func(t *testing.T) {
337+ t.Parallel()
338+ f := newSigFixture(t, stored...)
339+ c := sigCommit("1")
340+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).
341+ Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Once()
342+ first := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
343+ second := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
344+ assert.Equal(t, first, second)
345+
346+ // Another repository is another entry.
347+ other := name(t, "other")
348+ f.objects.EXPECT().ReadCommitObjects(ctx, other, []domain.ObjectID{c.ID}).
349+ Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Once()
350+ f.sigs.VerifyCommits(ctx, other, []domain.Commit{c})
351+ })
352+
353+ t.Run("only the misses are read", func(t *testing.T) {
354+ t.Parallel()
355+ f := newSigFixture(t, stored...)
356+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).
357+ Return(map[domain.ObjectID][]byte{oid("1"): rawCommit("")}, nil).Once()
358+ f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")})
359+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).
360+ Return(map[domain.ObjectID][]byte{oid("2"): rawCommit("")}, nil).Once()
361+ got := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1"), sigCommit("2")})
362+ assert.Len(t, got, 2)
363+ })
364+
365+ t.Run("adding or removing a key invalidates the cache", func(t *testing.T) {
366+ t.Parallel()
367+ f := newSigFixture(t, stored...)
368+ c := sigCommit("1")
369+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).
370+ Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Times(3)
371+ f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
372+
373+ f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return("SHA256:new", "ssh-ed25519 N", nil)
374+ f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(nil)
375+ _, err := f.sigs.AddTrustedKey(ctx, domain.Root(), domain.FormatSSH, "t", "k", []string{"a@b.c"})
376+ require.NoError(t, err)
377+ f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // read 2
378+ f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // cached
379+
380+ f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:new").Return(nil)
381+ require.NoError(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:new"))
382+ f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // read 3
383+ })
384+
385+ t.Run("a read error returns the cached results only", func(t *testing.T) {
386+ t.Parallel()
387+ f := newSigFixture(t, stored...)
388+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).
389+ Return(map[domain.ObjectID][]byte{oid("1"): rawCommit("")}, nil).Once()
390+ f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")})
391+
392+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).Return(nil, errors.New("disk")).Once()
393+ got := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1"), sigCommit("2")})
394+ assert.Equal(t, map[domain.ObjectID]domain.CommitVerification{oid("1"): {Status: domain.StatusUnsigned}}, got)
395+
396+ // A failure is not cached.
397+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).
398+ Return(map[domain.ObjectID][]byte{oid("2"): rawCommit("")}, nil).Once()
399+ assert.Len(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("2")}), 1)
400+ })
401+
402+ t.Run("a commit missing from the raw map is left out and not cached", func(t *testing.T) {
403+ t.Parallel()
404+ f := newSigFixture(t, stored...)
405+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).Return(map[domain.ObjectID][]byte{}, nil).Twice()
406+ assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")}))
407+ assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")}))
408+ })
409+
410+ t.Run("no commits, no read", func(t *testing.T) {
411+ t.Parallel()
412+ f := newSigFixture(t)
413+ assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, nil))
414+ })
415+
416+ t.Run("the cache stays bounded", func(t *testing.T) {
417+ t.Parallel()
418+ f := newSigFixture(t)
419+ read := 0 // ids asked to the reader
420+ f.objects.EXPECT().ReadCommitObjects(ctx, repo, mock.Anything).
421+ RunAndReturn(func(_ context.Context, _ domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
422+ read += len(ids)
423+ m := make(map[domain.ObjectID][]byte, len(ids))
424+ for _, id := range ids {
425+ m[id] = rawCommit("")
426+ }
427+ return m, nil
428+ })
429+ mk := func(from, n int) []domain.Commit {
430+ cs := make([]domain.Commit, n)
431+ for i := range cs {
432+ cs[i] = sigCommit("a")
433+ cs[i].ID = numID(from + i)
434+ }
435+ return cs
436+ }
437+ const total = 2 * usecase.MaxSignatureCacheEntries
438+ for from := 0; from < total; from += 1000 {
439+ assert.Len(t, f.sigs.VerifyCommits(ctx, repo, mk(from, 1000)), 1000)
440+ }
441+ assert.Equal(t, total, read)
442+ // The latest page is still cached.
443+ f.sigs.VerifyCommits(ctx, repo, mk(total-1000, 1000))
444+ assert.Equal(t, total, read)
445+ // At most MaxSignatureCacheEntries of the total are kept.
446+ read = 0
447+ assert.Len(t, f.sigs.VerifyCommits(ctx, repo, mk(0, total)), total)
448+ assert.GreaterOrEqual(t, read, total-usecase.MaxSignatureCacheEntries)
449+ })
450+}
451+
452+func numID(n int) domain.ObjectID {
453+ id, err := domain.ParseObjectID(fmt.Sprintf("%040x", n+1))
454+ if err != nil {
455+ panic(err)
456+ }
457+ return id
458+}
M proto/ermite/storage/signing.proto +16 -1
@@ -1,6 +1,7 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22
3-// Internal, unversioned storage schema of ermite: the commit signing key.
3+// Internal, unversioned storage schema of ermite: the commit signing key and
4+// the trusted signing keys.
45
56 syntax = "proto3";
67
@@ -17,3 +18,17 @@ message SigningKey {
1718 bytes seed = 1;
1819 google.protobuf.Timestamp created_at = 2;
1920 }
21+
22+// TrustedKey is stored under the key "signing/trusted/<id>": a public key
23+// whose commit signatures ermite accepts for the listed committer emails.
24+message TrustedKey {
25+ // The fingerprint: "SHA256:<base64>" for SSH, upper-case hex for GPG.
26+ string id = 1;
27+ // "ssh" or "gpg".
28+ string format = 2;
29+ string title = 3;
30+ // An authorized_keys line (SSH) or an armored public key block (GPG).
31+ string public_key = 4;
32+ repeated string emails = 5;
33+ google.protobuf.Timestamp created_at = 6;
34+}
M skills/ermite/CLI.md +2 -0
@@ -82,6 +82,8 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `
8282 | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
8383 | `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |
8484 | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |
85+| `signing-key trust list [--json]` | keys commit signatures are checked against (the server key first) |
86+| `signing-key trust add --format ssh\|gpg --title <t> --email <e>... <file\|->` / `trust remove <id>` | owner only; a commit shows verified when its committer email is listed for the key |
8587 | `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret |
8688 | `web logout --all` | signs every browser out |
8789 | `web passkey list [--json]` | the owner's passkeys (`id`, `name`, `created_at`, `last_used_at`, `synced`); same rights as `web login`; fails with "passkeys are not available on this server" when the public URL is not https with a host name |

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 42

↑↓ moveenter openesc close