// SPDX-License-Identifier: AGPL-3.0-or-later
package usecase_test
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"bnema.dev/ermite/internal/domain"
"bnema.dev/ermite/internal/ports"
"bnema.dev/ermite/internal/ports/mocks"
"bnema.dev/ermite/internal/usecase"
)
const (
sshArmor = "-----BEGIN SSH SIGNATURE-----\n AAAA\n -----END SSH SIGNATURE-----\n"
gpgArmor = "-----BEGIN PGP SIGNATURE-----\n\n iQ==\n -----END PGP SIGNATURE-----\n"
x509Armor = "-----BEGIN SIGNED MESSAGE-----\n MIIB\n -----END SIGNED MESSAGE-----\n"
)
var sigNow = time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
type sigFixture struct {
store *mocks.TrustedKeyStore
inspector *mocks.KeyInspector
ssh, gpg *mocks.SignatureVerifier
objects *mocks.CommitObjectReader
clock *mocks.Clock
sigs *usecase.Signatures
}
var builtinKey = domain.TrustedKey{
ID: "SHA256:builtin", Format: domain.FormatSSH, Title: "instance", Emails: []string{"bot@example.org"}, Builtin: true,
}
func newSigFixture(t *testing.T, stored ...domain.TrustedKey) *sigFixture {
t.Helper()
f := &sigFixture{
store: mocks.NewTrustedKeyStore(t), inspector: mocks.NewKeyInspector(t),
ssh: mocks.NewSignatureVerifier(t), gpg: mocks.NewSignatureVerifier(t),
objects: mocks.NewCommitObjectReader(t), clock: mocks.NewClock(t),
}
f.clock.EXPECT().Now().Return(sigNow).Maybe()
f.store.EXPECT().ListTrustedKeys(mock.Anything).Return(stored, nil)
var err error
f.sigs, err = usecase.NewSignatures(f.store, f.inspector, map[domain.SignatureFormat]ports.SignatureVerifier{
domain.FormatSSH: f.ssh, domain.FormatGPG: f.gpg,
}, f.objects, builtinKey, f.clock)
require.NoError(t, err)
return f
}
func sigToken(scopes []domain.Scope, repos ...domain.RepoName) domain.Principal {
return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, Repos: repos})
}
func TestNewSignaturesLoadError(t *testing.T) {
t.Parallel()
store := mocks.NewTrustedKeyStore(t)
store.EXPECT().ListTrustedKeys(mock.Anything).Return(nil, errors.New("boom"))
_, err := usecase.NewSignatures(store, nil, nil, nil, domain.TrustedKey{}, nil)
require.Error(t, err)
}
func TestTrustedKeyAccess(t *testing.T) {
t.Parallel()
ctx := context.Background()
reader := sigToken([]domain.Scope{domain.ScopeRepoRead})
owner := sigToken(domain.AllScopes())
t.Run("list", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t)
_, err := f.sigs.ListTrustedKeys(ctx, domain.Anonymous())
assert.ErrorIs(t, err, domain.ErrUnauthenticated)
for _, p := range []domain.Principal{reader, owner, domain.Root(), domain.Owner()} {
_, err := f.sigs.ListTrustedKeys(ctx, p)
assert.NoError(t, err)
}
})
t.Run("add and remove", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t)
_, err := f.sigs.AddTrustedKey(ctx, domain.Anonymous(), domain.FormatSSH, "t", "k", []string{"a@b.c"})
assert.ErrorIs(t, err, domain.ErrUnauthenticated)
err = f.sigs.RemoveTrustedKey(ctx, domain.Anonymous(), "x")
assert.ErrorIs(t, err, domain.ErrUnauthenticated)
_, err = f.sigs.AddTrustedKey(ctx, reader, domain.FormatSSH, "t", "k", []string{"a@b.c"})
assert.ErrorIs(t, err, domain.ErrForbidden)
assert.Contains(t, err.Error(), "managing trusted keys needs")
err = f.sigs.RemoveTrustedKey(ctx, reader, "x")
assert.ErrorIs(t, err, domain.ErrForbidden)
limited := sigToken(domain.AllScopes(), name(t, "r"))
_, err = f.sigs.AddTrustedKey(ctx, limited, domain.FormatSSH, "t", "k", []string{"a@b.c"})
assert.ErrorIs(t, err, domain.ErrForbidden)
for _, p := range []domain.Principal{owner, domain.Root(), domain.Owner()} {
id := "SHA256:" + p.Label()
f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return(id, "ssh-ed25519 K", nil).Once()
f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(nil).Once()
_, err := f.sigs.AddTrustedKey(ctx, p, domain.FormatSSH, "t", "k", []string{"a@b.c"})
assert.NoError(t, err)
f.store.EXPECT().DeleteTrustedKey(ctx, id).Return(nil).Once()
assert.NoError(t, f.sigs.RemoveTrustedKey(ctx, p, id))
}
})
}
func TestAddTrustedKey(t *testing.T) {
t.Parallel()
ctx := context.Background()
owner := domain.Root()
t.Run("stores the canonical key and lists it after the builtin", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t)
f.inspector.EXPECT().Inspect(domain.FormatSSH, "ssh-ed25519 K comment").Return("SHA256:k1", "ssh-ed25519 K", nil)
want := domain.TrustedKey{
ID: "SHA256:k1", Format: domain.FormatSSH, Title: "laptop", PublicKey: "ssh-ed25519 K",
Emails: []string{"a@b.c", "d@e.f"}, CreatedAt: sigNow,
}
f.store.EXPECT().CreateTrustedKey(ctx, want).Return(nil)
got, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, " laptop ", "ssh-ed25519 K comment", []string{"A@B.c", " d@e.f", "a@b.c"})
require.NoError(t, err)
assert.Equal(t, want, got)
keys, err := f.sigs.ListTrustedKeys(ctx, owner)
require.NoError(t, err)
require.Len(t, keys, 2)
assert.Equal(t, builtinKey, keys[0])
assert.Equal(t, want, keys[1])
})
t.Run("validation", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t)
for name, call := range map[string]func() error{
"empty title": func() error {
_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, " ", "k", []string{"a@b.c"})
return err
},
"no email": func() error {
_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", nil)
return err
},
"bad email": func() error {
_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", []string{"not an email"})
return err
},
"x509": func() error {
_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatX509, "t", "k", []string{"a@b.c"})
return err
},
"no format": func() error {
_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatNone, "t", "k", []string{"a@b.c"})
return err
},
"key too big": func() error {
_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatGPG, "t", strings.Repeat("x", domain.MaxTrustedKeyBytes+1), []string{"a@b.c"})
return err
},
} {
assert.ErrorIs(t, call(), domain.ErrInvalid, name)
}
})
t.Run("inspector error is returned", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t)
f.inspector.EXPECT().Inspect(domain.FormatGPG, "bad").Return("", "", fmt.Errorf("%w: nope", domain.ErrInvalid))
_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatGPG, "t", "bad", []string{"a@b.c"})
assert.ErrorIs(t, err, domain.ErrInvalid)
})
t.Run("builtin and duplicates are refused", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:old", Format: domain.FormatSSH})
f.inspector.EXPECT().Inspect(domain.FormatSSH, "b").Return(builtinKey.ID, "ssh-ed25519 B", nil)
_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "b", []string{"a@b.c"})
assert.ErrorIs(t, err, domain.ErrAlreadyExists)
f.inspector.EXPECT().Inspect(domain.FormatSSH, "o").Return("SHA256:old", "ssh-ed25519 O", nil)
_, err = f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "o", []string{"a@b.c"})
assert.ErrorIs(t, err, domain.ErrAlreadyExists)
})
t.Run("limit", func(t *testing.T) {
t.Parallel()
var stored []domain.TrustedKey
for i := range domain.MaxTrustedKeys {
stored = append(stored, domain.TrustedKey{ID: fmt.Sprintf("SHA256:%d", i), Format: domain.FormatSSH})
}
f := newSigFixture(t, stored...)
f.inspector.EXPECT().Inspect(domain.FormatSSH, "n").Return("SHA256:new", "ssh-ed25519 N", nil)
_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "n", []string{"a@b.c"})
assert.ErrorIs(t, err, domain.ErrResourceExhausted)
})
t.Run("store error leaves the list unchanged", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t)
f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return("SHA256:k", "ssh-ed25519 K", nil)
f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(errors.New("down"))
_, err := f.sigs.AddTrustedKey(ctx, owner, domain.FormatSSH, "t", "k", []string{"a@b.c"})
require.Error(t, err)
keys, _ := f.sigs.ListTrustedKeys(ctx, owner)
assert.Len(t, keys, 1)
})
}
func TestRemoveTrustedKey(t *testing.T) {
t.Parallel()
ctx := context.Background()
t.Run("builtin is refused without touching the store", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t)
err := f.sigs.RemoveTrustedKey(ctx, domain.Root(), builtinKey.ID)
assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
})
t.Run("removes from the store and the list", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:a"}, domain.TrustedKey{ID: "SHA256:b"})
f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:a").Return(nil)
require.NoError(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:a"))
keys, _ := f.sigs.ListTrustedKeys(ctx, domain.Root())
require.Len(t, keys, 2)
assert.Equal(t, "SHA256:b", keys[1].ID)
})
t.Run("store errors are returned and the key stays", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, domain.TrustedKey{ID: "SHA256:a"})
f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:a").Return(domain.ErrNotFound)
assert.ErrorIs(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:a"), domain.ErrNotFound)
keys, _ := f.sigs.ListTrustedKeys(ctx, domain.Root())
assert.Len(t, keys, 2)
})
}
func TestListWithoutBuiltin(t *testing.T) {
t.Parallel()
store := mocks.NewTrustedKeyStore(t)
store.EXPECT().ListTrustedKeys(mock.Anything).Return([]domain.TrustedKey{{ID: "SHA256:a"}}, nil)
s, err := usecase.NewSignatures(store, nil, nil, nil, domain.TrustedKey{}, nil)
require.NoError(t, err)
keys, err := s.ListTrustedKeys(context.Background(), domain.Root())
require.NoError(t, err)
require.Len(t, keys, 1)
assert.Equal(t, "SHA256:a", keys[0].ID)
}
// rawCommit builds a commit object with the signature armor in a gpgsig header.
func rawCommit(armor string) []byte {
var b strings.Builder
b.WriteString("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A 1 +0000\ncommitter A 1 +0000\n")
if armor != "" {
lines := strings.Split(strings.TrimSuffix(armor, "\n"), "\n")
b.WriteString("gpgsig " + strings.TrimPrefix(lines[0], " ") + "\n")
for _, l := range lines[1:] {
b.WriteString(" " + strings.TrimPrefix(l, " ") + "\n")
}
}
b.WriteString("\nmessage\n")
return []byte(b.String())
}
func sigCommit(c string) domain.Commit {
return domain.Commit{ID: oid(c), Committer: domain.Signature{Email: "a@b.c", Time: time.Unix(1700000000, 0)}}
}
func TestVerifyCommits(t *testing.T) {
t.Parallel()
ctx := context.Background()
repo := name(t, "r")
stored := []domain.TrustedKey{
{ID: "SHA256:s1", Format: domain.FormatSSH, Emails: []string{"a@b.c"}},
{ID: "ABCD", Format: domain.FormatGPG, Emails: []string{"a@b.c"}},
}
t.Run("unsigned, x509 and unknown armor need no verifier", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, stored...)
cs := []domain.Commit{sigCommit("1"), sigCommit("2"), sigCommit("3")}
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1"), oid("2"), oid("3")}).
Return(map[domain.ObjectID][]byte{
oid("1"): rawCommit(""),
oid("2"): rawCommit(x509Armor),
oid("3"): rawCommit("-----BEGIN WHAT-----\n x\n -----END WHAT-----\n"),
}, nil).Once()
got := f.sigs.VerifyCommits(ctx, repo, cs)
assert.Equal(t, map[domain.ObjectID]domain.CommitVerification{
oid("1"): {Status: domain.StatusUnsigned},
oid("2"): {Format: domain.FormatX509, Status: domain.StatusUnsupported},
oid("3"): {Format: domain.FormatNone, Status: domain.StatusBadSignature},
}, got)
})
t.Run("ssh gets the builtin and the ssh keys only", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, stored...)
c := sigCommit("1")
raw := rawCommit(sshArmor)
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).Return(map[domain.ObjectID][]byte{c.ID: raw}, nil)
want := domain.CommitVerification{Format: domain.FormatSSH, Status: domain.StatusVerified, Fingerprint: "SHA256:s1", KeyID: "SHA256:s1"}
payload, sig, ok := domain.SplitCommitSignature(raw)
require.True(t, ok)
f.ssh.EXPECT().Verify(payload, sig, "a@b.c", int64(1700000000), []domain.TrustedKey{builtinKey, stored[0]}).Return(want)
assert.Equal(t, want, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})[c.ID])
})
t.Run("gpg gets the gpg keys only", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, stored...)
c := sigCommit("1")
raw := rawCommit(gpgArmor)
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).Return(map[domain.ObjectID][]byte{c.ID: raw}, nil)
want := domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusExpired}
payload, sig, _ := domain.SplitCommitSignature(raw)
f.gpg.EXPECT().Verify(payload, sig, "a@b.c", int64(1700000000), []domain.TrustedKey{stored[1]}).Return(want)
assert.Equal(t, want, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})[c.ID])
})
t.Run("results are cached per repository", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, stored...)
c := sigCommit("1")
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).
Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Once()
first := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
second := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
assert.Equal(t, first, second)
// Another repository is another entry.
other := name(t, "other")
f.objects.EXPECT().ReadCommitObjects(ctx, other, []domain.ObjectID{c.ID}).
Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Once()
f.sigs.VerifyCommits(ctx, other, []domain.Commit{c})
})
t.Run("only the misses are read", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, stored...)
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).
Return(map[domain.ObjectID][]byte{oid("1"): rawCommit("")}, nil).Once()
f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")})
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).
Return(map[domain.ObjectID][]byte{oid("2"): rawCommit("")}, nil).Once()
got := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1"), sigCommit("2")})
assert.Len(t, got, 2)
})
t.Run("adding or removing a key invalidates the cache", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, stored...)
c := sigCommit("1")
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{c.ID}).
Return(map[domain.ObjectID][]byte{c.ID: rawCommit("")}, nil).Times(3)
f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c})
f.inspector.EXPECT().Inspect(domain.FormatSSH, "k").Return("SHA256:new", "ssh-ed25519 N", nil)
f.store.EXPECT().CreateTrustedKey(ctx, mock.Anything).Return(nil)
_, err := f.sigs.AddTrustedKey(ctx, domain.Root(), domain.FormatSSH, "t", "k", []string{"a@b.c"})
require.NoError(t, err)
f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // read 2
f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // cached
f.store.EXPECT().DeleteTrustedKey(ctx, "SHA256:new").Return(nil)
require.NoError(t, f.sigs.RemoveTrustedKey(ctx, domain.Root(), "SHA256:new"))
f.sigs.VerifyCommits(ctx, repo, []domain.Commit{c}) // read 3
})
t.Run("a read error returns the cached results only", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, stored...)
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).
Return(map[domain.ObjectID][]byte{oid("1"): rawCommit("")}, nil).Once()
f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")})
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).Return(nil, errors.New("disk")).Once()
got := f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1"), sigCommit("2")})
assert.Equal(t, map[domain.ObjectID]domain.CommitVerification{oid("1"): {Status: domain.StatusUnsigned}}, got)
// A failure is not cached.
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("2")}).
Return(map[domain.ObjectID][]byte{oid("2"): rawCommit("")}, nil).Once()
assert.Len(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("2")}), 1)
})
t.Run("a commit missing from the raw map is left out and not cached", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t, stored...)
f.objects.EXPECT().ReadCommitObjects(ctx, repo, []domain.ObjectID{oid("1")}).Return(map[domain.ObjectID][]byte{}, nil).Twice()
assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")}))
assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, []domain.Commit{sigCommit("1")}))
})
t.Run("no commits, no read", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t)
assert.Empty(t, f.sigs.VerifyCommits(ctx, repo, nil))
})
t.Run("the cache stays bounded", func(t *testing.T) {
t.Parallel()
f := newSigFixture(t)
read := 0 // ids asked to the reader
f.objects.EXPECT().ReadCommitObjects(ctx, repo, mock.Anything).
RunAndReturn(func(_ context.Context, _ domain.RepoName, ids []domain.ObjectID) (map[domain.ObjectID][]byte, error) {
read += len(ids)
m := make(map[domain.ObjectID][]byte, len(ids))
for _, id := range ids {
m[id] = rawCommit("")
}
return m, nil
})
mk := func(from, n int) []domain.Commit {
cs := make([]domain.Commit, n)
for i := range cs {
cs[i] = sigCommit("a")
cs[i].ID = numID(from + i)
}
return cs
}
const total = 2 * usecase.MaxSignatureCacheEntries
for from := 0; from < total; from += 1000 {
assert.Len(t, f.sigs.VerifyCommits(ctx, repo, mk(from, 1000)), 1000)
}
assert.Equal(t, total, read)
// The latest page is still cached.
f.sigs.VerifyCommits(ctx, repo, mk(total-1000, 1000))
assert.Equal(t, total, read)
// At most MaxSignatureCacheEntries of the total are kept.
read = 0
assert.Len(t, f.sigs.VerifyCommits(ctx, repo, mk(0, total)), total)
assert.GreaterOrEqual(t, read, total-usecase.MaxSignatureCacheEntries)
})
}
func numID(n int) domain.ObjectID {
id, err := domain.ParseObjectID(fmt.Sprintf("%040x", n+1))
if err != nil {
panic(err)
}
return id
}