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 Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe 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 | |||
| 130 | 130 | - [Git over HTTP](docs/git-http.md): URLs and authentication | |
| 131 | 131 | - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens | |
| 132 | 132 | - [Merge requests](docs/merge-requests.md) | |
| 133 | + | - [Commit signatures](docs/commit-signatures.md): SSH and OpenPGP badges, trusted keys | |
| 133 | 134 | - [Web sign-in](docs/web-sign-in.md): passkeys and one-time links | |
| 134 | 135 | - [CI](docs/ci.md): runners, trust, concurrency, reusable workflows | |
| 135 | 136 | - [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 ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 15 | bnema.dev/ermite-actions v0.2.0 | |
| 16 | - | bnema.dev/ermite-api v0.5.0 | |
| 16 | + | bnema.dev/ermite-api v0.6.0 | |
| 17 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.1.0 | |
| 19 | 19 | connectrpc.com/connect v1.21.0 | |
| 20 | + | github.com/ProtonMail/go-crypto v1.4.1 | |
| 20 | 21 | github.com/a-h/templ v0.3.1020 | |
| 21 | 22 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 22 | 23 | github.com/bnema/kvconf v0.1.2 | |
| @@ -71,6 +72,7 @@ require ( | |||
| 71 | 72 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| 72 | 73 | github.com/cli/browser v1.3.0 // indirect | |
| 73 | 74 | github.com/clipperhouse/uax29/v2 v2.6.0 // indirect | |
| 75 | + | github.com/cloudflare/circl v1.6.2 // indirect | |
| 74 | 76 | github.com/containerd/errdefs v1.0.0 // indirect | |
| 75 | 77 | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |
| 76 | 78 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | |
M go.sum +6 -2
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M= | |
| 2 | 2 | bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4= | |
| 3 | - | bnema.dev/ermite-api v0.5.0 h1:LAhstFCx4NZuJZFifuavezwue8IOENPdyS7kda4lUQU= | |
| 4 | - | bnema.dev/ermite-api v0.5.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 3 | + | bnema.dev/ermite-api v0.6.0 h1:MHAH4Mzpnaxu6VhXQTnMZsWWmzrnRPPlO2uqlB5NgJI= | |
| 4 | + | bnema.dev/ermite-api v0.6.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8= | |
| 5 | 5 | bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI= | |
| 6 | 6 | bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg= | |
| 7 | 7 | bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE= | |
| @@ -54,6 +54,8 @@ github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8R | |||
| 54 | 54 | github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA= | |
| 55 | 55 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= | |
| 56 | 56 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= | |
| 57 | + | github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= | |
| 58 | + | github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= | |
| 57 | 59 | github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= | |
| 58 | 60 | github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= | |
| 59 | 61 | github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw= | |
| @@ -134,6 +136,8 @@ github.com/clipperhouse/displaywidth v0.10.0 h1:GhBG8WuerxjFQQYeuZAeVTuyxuX+Urai | |||
| 134 | 136 | github.com/clipperhouse/displaywidth v0.10.0/go.mod h1:XqJajYsaiEwkxOj4bowCTMcT1SgvHo9flfF3jQasdbs= | |
| 135 | 137 | github.com/clipperhouse/uax29/v2 v2.6.0 h1:z0cDbUV+aPASdFb2/ndFnS9ts/WNXgTNNGFoKXuhpos= | |
| 136 | 138 | github.com/clipperhouse/uax29/v2 v2.6.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= | |
| 139 | + | github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= | |
| 140 | + | github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= | |
| 137 | 141 | github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= | |
| 138 | 142 | github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= | |
| 139 | 143 | github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= | |
M internal/adapters/client/client.go +5 -0
| @@ -110,6 +110,11 @@ func (c *Client) Environments() ermiteconnect.EnvironmentServiceClient { | |||
| 110 | 110 | return ermiteconnect.NewEnvironmentServiceClient(c.http, c.base, c.opts...) | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 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 | + | ||
| 113 | 118 | // Server returns the ServerService client. | |
| 114 | 119 | func (c *Client) Server() ermiteconnect.ServerServiceClient { | |
| 115 | 120 | 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) { | |||
| 139 | 139 | func TestMissingFeatures(t *testing.T) { | |
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | 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}, | |
| 143 | 143 | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | 145 | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, | |
| 146 | 146 | client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners, client.FeatureCISecrets, client.FeatureCIDispatch, | |
| 147 | - | client.FeatureCIArtifacts, client.FeaturePasskeys, | |
| 147 | + | client.FeatureCIArtifacts, client.FeaturePasskeys, client.FeatureCommitSignatures, | |
| 148 | 148 | })) | |
| 149 | 149 | assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{ | |
| 150 | 150 | 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, | |
| 152 | 152 | })) | |
| 153 | 153 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 154 | 154 | } | |
M internal/adapters/client/features.go +4 -1
| @@ -45,11 +45,14 @@ const ( | |||
| 45 | 45 | // FeatureCIArtifacts is ArtifactService (`ermite run artifacts`, | |
| 46 | 46 | // `run download`). | |
| 47 | 47 | FeatureCIArtifacts = "ci.artifacts" | |
| 48 | + | // FeatureCommitSignatures is TrustedKeyService and Commit.verification | |
| 49 | + | // (`ermite signing-key trust`). | |
| 50 | + | FeatureCommitSignatures = "commit_signatures" | |
| 48 | 51 | ) | |
| 49 | 52 | ||
| 50 | 53 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 51 | 54 | 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} | |
| 53 | 56 | } | |
| 54 | 57 | ||
| 55 | 58 | // 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) { | |||
| 28 | 28 | return a, data | |
| 29 | 29 | }) | |
| 30 | 30 | } | |
| 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 | |||
| 34 | 34 | // Features are the optional capabilities of the contract that this server | |
| 35 | 35 | // always implements, as GetVersion lists them. FeaturePasskeys comes on top | |
| 36 | 36 | // when Config.Passkeys is set, and FeatureCISecrets when the settings services | |
| 37 | - | // are. | |
| 37 | + | // are, and FeatureCommitSignatures when Config.TrustedKeys is set. | |
| 38 | 38 | 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"} | |
| 39 | 39 | ||
| 40 | 40 | // FeaturePasskeys is listed by GetVersion when passkeys are available. | |
| @@ -92,6 +92,10 @@ type Config struct { | |||
| 92 | 92 | // Same rule as Sessions about typed nil pointers. | |
| 93 | 93 | JobArtifacts ports.JobArtifactService | |
| 94 | 94 | 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 | |
| 95 | 99 | } | |
| 96 | 100 | ||
| 97 | 101 | // API is the set of Connect handlers of the contract. | |
| @@ -133,11 +137,17 @@ func New(cfg Config) *API { | |||
| 133 | 137 | if cfg.OIDCKeys != nil { | |
| 134 | 138 | features = append(features, FeatureCIOIDC) | |
| 135 | 139 | } | |
| 140 | + | if cfg.TrustedKeys != nil { | |
| 141 | + | features = append(features, FeatureCommitSignatures) | |
| 142 | + | } | |
| 136 | 143 | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, features: features, key: cfg.SigningKey, oidc: cfg.OIDCKeys}, opts...)) | |
| 137 | 144 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | |
| 138 | 145 | if cfg.Sessions != nil { | |
| 139 | 146 | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...)) | |
| 140 | 147 | } | |
| 148 | + | if cfg.TrustedKeys != nil { | |
| 149 | + | mux.Handle(ermiteconnect.NewTrustedKeyServiceHandler(&trustedKeyService{cfg.TrustedKeys}, opts...)) | |
| 150 | + | } | |
| 141 | 151 | if settings { | |
| 142 | 152 | mux.Handle(ermiteconnect.NewSecretServiceHandler(&secretService{cfg.Secrets}, opts...)) | |
| 143 | 153 | 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 { | |||
| 227 | 227 | for _, p := range c.Parents { | |
| 228 | 228 | out.ParentIds = append(out.ParentIds, p.String()) | |
| 229 | 229 | } | |
| 230 | + | if c.Verification.Status != domain.StatusUnchecked { | |
| 231 | + | out.Verification = verificationToPB(c.Verification) | |
| 232 | + | } | |
| 230 | 233 | return out | |
| 231 | 234 | } | |
| 232 | 235 | ||
| 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 | + | ||
| 233 | 309 | func treeEntryToPB(e domain.TreeEntry) *pb.TreeEntry { | |
| 234 | 310 | out := &pb.TreeEntry{ | |
| 235 | 311 | 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 { | |||
| 46 | 46 | Invisible bool | |
| 47 | 47 | // Checks is the combined state of the CI checks of the commit. | |
| 48 | 48 | 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 | |
| 49 | 53 | } | |
| 50 | 54 | ||
| 51 | 55 | // 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, | |||
| 121 | 125 | Adds: res.Stats.Additions, | |
| 122 | 126 | Dels: res.Stats.Deletions, | |
| 123 | 127 | Files: s.hl.files(ctx, res.Diff.Files), | |
| 128 | + | ||
| 129 | + | Sig: signatureBadge(c.Verification, true), | |
| 130 | + | Verification: c.Verification, | |
| 124 | 131 | } | |
| 125 | 132 | contextGaps(p.Files, res.Diff.Files, repoHref(d.name(), "context", cid)) | |
| 126 | 133 | 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) { | |||
| 193 | 193 | if c.Merge { | |
| 194 | 194 | <span class="pill">merge</span> | |
| 195 | 195 | } | |
| 196 | + | if c.Sig != nil { | |
| 197 | + | @signaturePill(c.Sig) | |
| 198 | + | } | |
| 196 | 199 | for _, b := range c.Branches { | |
| 197 | 200 | <span class="pill">{ b }</span> | |
| 198 | 201 | } | |
| @@ -208,3 +211,14 @@ templ CommitRows(rows []CommitRow) { | |||
| 208 | 211 | </tbody> | |
| 209 | 212 | </table> | |
| 210 | 213 | } | |
| 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) { | |||
| 40 | 40 | { " · diff against the first parent" } | |
| 41 | 41 | } | |
| 42 | 42 | </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 | + | } | |
| 43 | 58 | if p.Checks.Total > 0 { | |
| 44 | 59 | <dt>checks</dt> | |
| 45 | 60 | <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 { | |||
| 272 | 272 | Short: short(c.ID.String()), Subject: visibleText(c.Summary), | |
| 273 | 273 | CHref: repoHref(repo, "commit", c.ID.String()), | |
| 274 | 274 | Author: visibleText(c.Author.Name), Merge: len(c.Parents) > 1, | |
| 275 | + | Sig: signatureBadge(c.Verification, false), | |
| 275 | 276 | } | |
| 276 | 277 | row.When, row.WhenAt = whenOf(now, c.Committer.Time) | |
| 277 | 278 | 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 <laptop>") | |
| 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 @@ | |||
| 36 | 36 | background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 999px; white-space: nowrap; | |
| 37 | 37 | &.tag::before { content: ""; width: 6px; height: 6px; margin-right: 6px; border: 1.5px solid currentColor; border-radius: 1px; transform: rotate(45deg); } | |
| 38 | 38 | &.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; } | |
| 40 | 53 | .count { | |
| 41 | 54 | display: inline-block; min-width: 22px; padding: 0 7px; margin-left: var(--s1); | |
| 42 | 55 | 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 @@ | |||
| 162 | 162 | & td:last-child { padding-right: var(--s4); } | |
| 163 | 163 | & .c-who { display: none; } | |
| 164 | 164 | & .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. */ | |
| 165 | 167 | & .c-subject .pill { display: none; } | |
| 166 | 168 | & .c-ck:empty { padding: 0; } | |
| 167 | 169 | & .c-when { text-align: right; } | |
M internal/adapters/web/view.go +2 -0
| @@ -180,6 +180,8 @@ type CommitRow struct { | |||
| 180 | 180 | // Checks is the combined state of the CI checks of the commit; empty when | |
| 181 | 181 | // it has none. | |
| 182 | 182 | Checks ChecksSummary | |
| 183 | + | // Sig is the signature badge, nil for an unchecked or unsigned commit. | |
| 184 | + | Sig *SignatureBadge | |
| 183 | 185 | } | |
| 184 | 186 | ||
| 185 | 187 | // SummaryPage is the repository summary. | |
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 198 | 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) | |
| 200 | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 201 | 201 | } | |
| 202 | 202 | }) | |
M internal/app/cli_e2e_test.go +1 -1
| @@ -338,7 +338,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 338 | 338 | ver := decode[versionOut](t, res.stdout) | |
| 339 | 339 | assert.Equal(t, c.url, ver.Server.URL) | |
| 340 | 340 | 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) | |
| 342 | 342 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 343 | 343 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 344 | 344 | require.Equal(t, 0, code, stderr) | |
M internal/app/cli_fake_test.go +16 -0
| @@ -33,6 +33,7 @@ type fakeAPI struct { | |||
| 33 | 33 | ermiteconnect.UnimplementedMergeRequestServiceHandler | |
| 34 | 34 | ermiteconnect.UnimplementedSessionServiceHandler | |
| 35 | 35 | ermiteconnect.UnimplementedRunServiceHandler | |
| 36 | + | ermiteconnect.UnimplementedTrustedKeyServiceHandler | |
| 36 | 37 | ||
| 37 | 38 | runners []*ermite.Runner // ListRunners answer; nil means one default runner | |
| 38 | 39 | ||
| @@ -64,6 +65,10 @@ type fakeAPI struct { | |||
| 64 | 65 | ||
| 65 | 66 | listPasskeys func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error) | |
| 66 | 67 | 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) | |
| 67 | 72 | } | |
| 68 | 73 | ||
| 69 | 74 | 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 | |||
| 152 | 157 | func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) { | |
| 153 | 158 | return respond(f, "RemovePasskey", r, f.removePasskey) | |
| 154 | 159 | } | |
| 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 | + | } | |
| 155 | 169 | func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error { | |
| 156 | 170 | f.record("Watch", r.Header(), r.Msg) | |
| 157 | 171 | if f.watch == nil { | |
| @@ -179,6 +193,8 @@ func (f *fakeAPI) serve(t *testing.T) string { | |||
| 179 | 193 | mount(p, h) | |
| 180 | 194 | p, h = ermiteconnect.NewRunServiceHandler(f) | |
| 181 | 195 | mount(p, h) | |
| 196 | + | p, h = ermiteconnect.NewTrustedKeyServiceHandler(f) | |
| 197 | + | mount(p, h) | |
| 182 | 198 | srv := httptest.NewServer(mux) | |
| 183 | 199 | t.Cleanup(srv.Close) | |
| 184 | 200 | return srv.URL | |
M internal/app/cli_signing.go +247 -3
| @@ -3,12 +3,20 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 6 | 8 | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | "slices" | |
| 12 | + | "strings" | |
| 7 | 13 | ||
| 8 | 14 | "connectrpc.com/connect" | |
| 9 | 15 | "github.com/spf13/cobra" | |
| 10 | 16 | ||
| 11 | 17 | "bnema.dev/ermite-api/gen/ermite" | |
| 18 | + | "bnema.dev/ermite/internal/adapters/client" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 12 | 20 | ) | |
| 13 | 21 | ||
| 14 | 22 | // jsonSigningKey is the stable JSON shape of `signing-key show --json`. | |
| @@ -23,14 +31,250 @@ type jsonSigningKey struct { | |||
| 23 | 31 | func newSigningKeyCmd(env *cliEnv) *cobra.Command { | |
| 24 | 32 | key := &cobra.Command{ | |
| 25 | 33 | 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", | |
| 27 | 35 | 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.", | |
| 29 | 42 | } | |
| 30 | - | key.AddCommand(newSigningKeyShowCmd(env)) | |
| 43 | + | key.AddCommand(newSigningKeyShowCmd(env), newSigningKeyTrustCmd(env)) | |
| 31 | 44 | return key | |
| 32 | 45 | } | |
| 33 | 46 | ||
| 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 | + | ||
| 34 | 278 | func newSigningKeyShowCmd(env *cliEnv) *cobra.Command { | |
| 35 | 279 | var allowedSigners, publicKey bool | |
| 36 | 280 | 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{ | |||
| 122 | 122 | client.FeatureCIDispatch: "`ermite workflow run` needs: the command will fail on this server", | |
| 123 | 123 | client.FeatureCIArtifacts: "`ermite run artifacts` and `run download` need: the commands will fail on this server", | |
| 124 | 124 | 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", | |
| 125 | 126 | } | |
M internal/app/cli_version_test.go +5 -5
| @@ -17,7 +17,7 @@ import ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // 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"} | |
| 21 | 21 | ||
| 22 | 22 | // Every feature the client uses needs a sentence for the version warning. | |
| 23 | 23 | func TestEveryUsedFeatureExplainsWhatBreaks(t *testing.T) { | |
| @@ -45,15 +45,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 45 | 45 | f := versionFake(allFeatures...) | |
| 46 | 46 | url := f.serve(t) | |
| 47 | 47 | 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) | |
| 49 | 49 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 50 | 50 | ||
| 51 | 51 | 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) | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | 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") | |
| 57 | 57 | url := f.serve(t) | |
| 58 | 58 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 59 | 59 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -61,7 +61,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 61 | 61 | assert.NotContains(t, res.stderr, "repo.update") | |
| 62 | 62 | ||
| 63 | 63 | 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) | |
| 65 | 65 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 66 | 66 | ||
| 67 | 67 | f = versionFake() | |
M internal/app/forge.go +34 -1
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "net" | |
| 13 | 13 | "os" | |
| 14 | 14 | "path/filepath" | |
| 15 | + | "strings" | |
| 15 | 16 | "time" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/bnema/zerowrap" | |
| @@ -31,6 +32,7 @@ import ( | |||
| 31 | 32 | "bnema.dev/ermite/internal/adapters/oidchttp" | |
| 32 | 33 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 33 | 34 | "bnema.dev/ermite/internal/adapters/secretbox" | |
| 35 | + | "bnema.dev/ermite/internal/adapters/sigverify" | |
| 34 | 36 | "bnema.dev/ermite/internal/adapters/sshsig" | |
| 35 | 37 | "bnema.dev/ermite/internal/adapters/web" | |
| 36 | 38 | "bnema.dev/ermite/internal/adapters/webauthn" | |
| @@ -114,7 +116,10 @@ type forge struct { | |||
| 114 | 116 | // ServerService.GetSigningKey. | |
| 115 | 117 | MergeRequests *usecase.MergeRequests | |
| 116 | 118 | 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 | |
| 118 | 123 | // Passkeys is nil when passkeys are not available (passkeysAvailable). | |
| 119 | 124 | Passkeys *usecase.Passkeys | |
| 120 | 125 | EventLog *usecase.Events | |
| @@ -301,6 +306,9 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 301 | 306 | return nil, err | |
| 302 | 307 | } | |
| 303 | 308 | f.SigningKey = usecase.NewSigningKey(signer, committer) | |
| 309 | + | if err := f.signatures(store, reader, signer.PublicKey(), committer, clock); err != nil { | |
| 310 | + | return nil, err | |
| 311 | + | } | |
| 304 | 312 | f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ | |
| 305 | 313 | Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer, | |
| 306 | 314 | 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 | |||
| 353 | 361 | Events: f.EventLog, | |
| 354 | 362 | MergeRequests: f.MergeRequests, | |
| 355 | 363 | SigningKey: f.SigningKey, | |
| 364 | + | TrustedKeys: f.Signatures, | |
| 356 | 365 | Sessions: f.Sessions, | |
| 357 | 366 | Passkeys: apiPasskeys, | |
| 358 | 367 | Runner: f.CI, | |
| @@ -501,3 +510,27 @@ func (f *forge) pullRequests(cfg *usecase.CIConfig, store *kvstore.Store, git *g | |||
| 501 | 510 | cfg.Fresh = f.reader // a nil *ermitegit.Adapter must not become a non-nil interface | |
| 502 | 511 | } | |
| 503 | 512 | } | |
| 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 @@ | |||
| 109 | 109 | <dd> | |
| 110 | 110 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 111 | 111 | </dd> | |
| 112 | + | <dt>signature</dt> | |
| 113 | + | <dd> | |
| 114 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 115 | + | </dd> | |
| 112 | 116 | <dt>refs</dt> | |
| 113 | 117 | <dd> | |
| 114 | 118 | <span class="pill">main</span> | |
M internal/app/testdata/web/commit-binary.html +4 -0
| @@ -109,6 +109,10 @@ | |||
| 109 | 109 | <dd> | |
| 110 | 110 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 111 | 111 | </dd> | |
| 112 | + | <dt>signature</dt> | |
| 113 | + | <dd> | |
| 114 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 115 | + | </dd> | |
| 112 | 116 | </dl> | |
| 113 | 117 | </header> | |
| 114 | 118 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
M internal/app/testdata/web/commit-merge.html +4 -0
| @@ -108,6 +108,10 @@ | |||
| 108 | 108 | <dt>merge of</dt> | |
| 109 | 109 | <dd> | |
| 110 | 110 | <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> | |
| 111 | 115 | </dl> | |
| 112 | 116 | </header> | |
| 113 | 117 | <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 <b>html</b> & "quotes".</pre> | |||
| 111 | 111 | <dd> | |
| 112 | 112 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 113 | 113 | </dd> | |
| 114 | + | <dt>signature</dt> | |
| 115 | + | <dd> | |
| 116 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 117 | + | </dd> | |
| 114 | 118 | <dt>refs</dt> | |
| 115 | 119 | <dd> | |
| 116 | 120 | <span class="pill">tag: v1.0.0</span> | |
M internal/app/testdata/web/commit-modify.html +4 -0
| @@ -111,6 +111,10 @@ And <b>html</b> & "quotes".</pre> | |||
| 111 | 111 | <dd> | |
| 112 | 112 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 113 | 113 | </dd> | |
| 114 | + | <dt>signature</dt> | |
| 115 | + | <dd> | |
| 116 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 117 | + | </dd> | |
| 114 | 118 | <dt>refs</dt> | |
| 115 | 119 | <dd> | |
| 116 | 120 | <span class="pill">tag: v1.0.0</span> | |
M internal/app/testdata/web/commit-rename.html +4 -0
| @@ -109,6 +109,10 @@ | |||
| 109 | 109 | <dd> | |
| 110 | 110 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 111 | 111 | </dd> | |
| 112 | + | <dt>signature</dt> | |
| 113 | + | <dd> | |
| 114 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 115 | + | </dd> | |
| 112 | 116 | </dl> | |
| 113 | 117 | </header> | |
| 114 | 118 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
M internal/app/testdata/web/commit-root.html +4 -0
| @@ -107,6 +107,10 @@ | |||
| 107 | 107 | <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd> | |
| 108 | 108 | <dt>parent</dt> | |
| 109 | 109 | <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> | |
| 110 | 114 | </dl> | |
| 111 | 115 | </header> | |
| 112 | 116 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
M internal/app/testdata/web/commit-trojan.html +4 -0
| @@ -110,6 +110,10 @@ | |||
| 110 | 110 | <dd> | |
| 111 | 111 | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 112 | 112 | </dd> | |
| 113 | + | <dt>signature</dt> | |
| 114 | + | <dd> | |
| 115 | + | <span class="pill sig sig-none" title="This commit is not signed">unsigned</span> | |
| 116 | + | </dd> | |
| 113 | 117 | </dl> | |
| 114 | 118 | </header> | |
| 115 | 119 | <div class="diff-tools" role="group" aria-label="Diff view"> | |
M internal/domain/git.go +3 -0
| @@ -442,6 +442,9 @@ type Commit struct { | |||
| 442 | 442 | // Message is the whole message without trailing line breaks, converted to | |
| 443 | 443 | // valid UTF-8. | |
| 444 | 444 | 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 | |
| 445 | 448 | } | |
| 446 | 449 | ||
| 447 | 450 | // 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 { | |||
| 31 | 31 | history *historyCache | |
| 32 | 32 | // stats keeps the figures of the home page. | |
| 33 | 33 | stats statsCache | |
| 34 | + | // verifier fills the signature state of the commits; nil leaves it | |
| 35 | + | // unchecked. | |
| 36 | + | verifier ports.CommitVerifier | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 36 | 39 | var _ ports.BrowseService = (*Browse)(nil) | |
| @@ -53,6 +56,28 @@ func (b *Browse) WithHistoryCache(maxEntries int, maxBytes int64) *Browse { | |||
| 53 | 56 | return b | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 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 | + | ||
| 56 | 81 | // authorize returns the repository if p may read it. The caller uses it to | |
| 57 | 82 | // tell a repository from a later one created under the same name. | |
| 58 | 83 | 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 | |||
| 247 | 272 | if err != nil { | |
| 248 | 273 | return ports.HistoryPage{}, err | |
| 249 | 274 | } | |
| 275 | + | // res.Commits is a copy (see listCommits): the cached page is untouched. | |
| 276 | + | b.verify(ctx, repo, res.Commits) | |
| 250 | 277 | out := ports.HistoryPage{Commits: res.Commits} | |
| 251 | 278 | if res.HasMore { | |
| 252 | 279 | 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. | |||
| 308 | 335 | if _, err := b.authorize(ctx, p, repo); err != nil { | |
| 309 | 336 | return domain.CommitDetail{}, err | |
| 310 | 337 | } | |
| 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 | |
| 312 | 348 | } | |
M internal/usecase/browse_test.go +95 -0
| @@ -567,3 +567,98 @@ func mustPath(t *testing.T, s string) domain.TreePath { | |||
| 567 | 567 | require.NoError(t, err) | |
| 568 | 568 | return p | |
| 569 | 569 | } | |
| 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 | |||
| 75 | 75 | ||
| 76 | 76 | // requireOwner is the access rule of the session API: root, or a stored token | |
| 77 | 77 | // 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 { | |
| 79 | 83 | switch { | |
| 80 | 84 | case p.IsAnonymous(): | |
| 81 | 85 | return domain.ErrUnauthenticated | |
| 82 | 86 | 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) | |
| 84 | 88 | } | |
| 85 | 89 | return nil | |
| 86 | 90 | } | |
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 @@ | |||
| 1 | 1 | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | 2 | ||
| 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. | |
| 4 | 5 | ||
| 5 | 6 | syntax = "proto3"; | |
| 6 | 7 | ||
| @@ -17,3 +18,17 @@ message SigningKey { | |||
| 17 | 18 | bytes seed = 1; | |
| 18 | 19 | google.protobuf.Timestamp created_at = 2; | |
| 19 | 20 | } | |
| 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`, ` | |||
| 82 | 82 | | `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features | | |
| 83 | 83 | | `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 | | |
| 84 | 84 | | `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 | | |
| 85 | 87 | | `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 | | |
| 86 | 88 | | `web logout --all` | signs every browser out | | |
| 87 | 89 | | `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 | | |