Files
brianmcgee 27ea5601c5 {cmd, pkg}: remove direct calls to model.DefaultClock
Introduces a `config.Now` field which all components should defer to when getting the current time instead of hardcoded calls to `model.DefaultClock`.
2026-04-10 15:30:45 +01:00

453 lines
14 KiB
Go

package model_test
import (
"crypto/rand"
"strings"
"testing"
"time"
"git.clan.lol/clan/data-mesher/pkg/crypto"
"git.clan.lol/clan/data-mesher/pkg/model"
"git.clan.lol/clan/data-mesher/test"
"github.com/stretchr/testify/require"
)
func TestSignature(t *testing.T) {
t.Parallel()
keys := test.GenerateKeys(t, 3)
networkID := keys[2].Public
clock := model.Now
t.Run("sign and verify", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFile("test.txt", networkID, keys[0], clock, strings.NewReader("hello"))
rq.NoError(err)
rq.NoError(sig.Verify(strings.NewReader("hello")))
rq.ErrorIs(sig.Verify(strings.NewReader("world")), model.ErrSignatureNotValid)
})
t.Run("verify fails if SignedAt is modified", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFile("test.txt", networkID, keys[0], clock, strings.NewReader("hello"))
rq.NoError(err)
sig.SignedAt = sig.SignedAt.Add(1)
rq.ErrorIs(sig.Verify(strings.NewReader("hello")), model.ErrSignatureNotValid)
})
t.Run("verify fails if SignedBy is modified", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFile("test.txt", networkID, keys[0], clock, strings.NewReader("hello"))
rq.NoError(err)
sig.SignedBy = keys[1].Public
rq.ErrorIs(sig.Verify(strings.NewReader("hello")), model.ErrSignatureNotValid)
})
t.Run("verify fails if Signature is modified", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFile("test.txt", networkID, keys[0], clock, strings.NewReader("hello"))
rq.NoError(err)
sig.Signature = []byte("foo")
rq.ErrorIs(sig.Verify(strings.NewReader("hello")), model.ErrSignatureNotValid)
})
t.Run("size is correctly set when signing", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
content := "hello world"
sig, err := model.SignFile("test.txt", networkID, keys[0], clock, strings.NewReader(content))
rq.NoError(err)
rq.Equal(int64(len(content)), sig.FileSize)
})
t.Run("verify failure if file size is tampered with", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFile("test.txt", networkID, keys[0], clock, strings.NewReader("hello"))
rq.NoError(err)
// Tamper with the file size field
sig.FileSize = 999
rq.ErrorIs(sig.Verify(strings.NewReader("hello")), model.ErrSignatureNotValid)
})
t.Run("verify failure on content length mismatch", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFile("test.txt", networkID, keys[0], clock, strings.NewReader("hello"))
rq.NoError(err)
// Provide content with different length than signed
err = sig.Verify(strings.NewReader("hello world"))
rq.ErrorIs(err, model.ErrSignatureNotValid)
rq.ErrorContains(err, "size mismatch")
})
t.Run("empty file has zero size", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFile("empty.txt", networkID, keys[0], clock, strings.NewReader(""))
rq.NoError(err)
rq.Equal(int64(0), sig.FileSize)
rq.NoError(sig.Verify(strings.NewReader("")))
})
t.Run("verify fails with wrong network ID", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
otherKeys := test.GenerateKeys(t, 1)
otherNetworkID := otherKeys[0].Public
sig, err := model.SignFile("test.txt", networkID, keys[0], clock, strings.NewReader("hello"))
rq.NoError(err)
// Mutate network ID to a different one
sig.NetworkID = otherNetworkID
rq.ErrorIs(sig.Verify(strings.NewReader("hello")), model.ErrSignatureNotValid)
})
t.Run("SignFile produces zero ValidFor (backward compat)", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFile("test.txt", networkID, keys[0], clock, strings.NewReader("hello"))
rq.NoError(err)
// SignFile must produce signatures that are byte-identical to the
// pre-TTL format. ValidFor must be zero, and verification must
// reconstruct the buffer without the conditional tail.
rq.Equal(time.Duration(0), sig.ValidFor)
rq.NoError(sig.Verify(strings.NewReader("hello")))
})
t.Run("SignFileWithTTL roundtrip", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFileWithTTL("test.txt", networkID, keys[0], clock, 10*time.Minute, strings.NewReader("hello"))
rq.NoError(err)
rq.Equal(10*time.Minute, sig.ValidFor)
rq.NoError(sig.Verify(strings.NewReader("hello")))
rq.ErrorIs(sig.Verify(strings.NewReader("world")), model.ErrSignatureNotValid)
})
t.Run("SignFileWithTTL with zero validFor matches SignFile bytes", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
// Two sigs with the same content, same key, same timestamp — one via
// SignFile, one via SignFileWithTTL with validFor=0. They must
// produce byte-identical signatures, proving the conditional tail is
// genuinely omitted when ValidFor==0.
fixedClock := func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }
sigA, err := model.SignFile("test.txt", networkID, keys[0], fixedClock, strings.NewReader("hello"))
rq.NoError(err)
sigB, err := model.SignFileWithTTL("test.txt", networkID, keys[0], fixedClock, 0, strings.NewReader("hello"))
rq.NoError(err)
rq.Equal([]byte(sigA.Signature), []byte(sigB.Signature))
})
t.Run("verify fails if ValidFor is tampered", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFileWithTTL("test.txt", networkID, keys[0], clock, 10*time.Minute, strings.NewReader("hello"))
rq.NoError(err)
// Bump ValidFor by one nanosecond — verifier reconstructs a
// different buffer and ed25519 rejects.
sig.ValidFor = 10*time.Minute + 1
rq.ErrorIs(sig.Verify(strings.NewReader("hello")), model.ErrSignatureNotValid)
})
t.Run("strip resistance: zeroing ValidFor breaks verify", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFileWithTTL("test.txt", networkID, keys[0], clock, 10*time.Minute, strings.NewReader("hello"))
rq.NoError(err)
// A MITM strips ValidFor in transit, hoping to present the file as
// non-expiring. The signed buffer was constructed with the
// conditional tail; the verifier without it reconstructs a shorter
// buffer that doesn't match. Verification fails.
sig.ValidFor = 0
rq.ErrorIs(sig.Verify(strings.NewReader("hello")), model.ErrSignatureNotValid)
})
t.Run("Expired predicate", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
// No TTL — never expired
sigA, err := model.SignFile("test.txt", networkID, keys[0], clock, strings.NewReader("hello"))
rq.NoError(err)
rq.False(sigA.Expired(time.Now().Add(100 * 365 * 24 * time.Hour)))
// Short TTL — not expired immediately
fixedNow := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
fixedClock := func() time.Time { return fixedNow }
sigB, err := model.SignFileWithTTL(
"test.txt", networkID, keys[0], fixedClock, time.Minute, strings.NewReader("hello"),
)
rq.NoError(err)
rq.False(sigB.Expired(fixedNow))
rq.False(sigB.Expired(fixedNow.Add(30 * time.Second)))
rq.True(sigB.Expired(fixedNow.Add(time.Minute + time.Nanosecond)))
rq.True(sigB.Expired(fixedNow.Add(time.Hour)))
})
}
func TestTombstone(t *testing.T) {
t.Parallel()
keys := test.GenerateKeys(t, 3)
networkID := keys[2].Public
clock := model.Now
t.Run("sign and verify tombstone", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignTombstone("test.txt", networkID, keys[0], clock)
rq.NoError(err)
rq.Equal(model.SignatureTypeTombstone, sig.Type, "expected a tombstone signature, found %v instead", sig.Type)
rq.Equal("test.txt", sig.Name)
rq.NotEmpty(sig.Signature)
rq.NoError(sig.Verify(nil))
})
t.Run("verify tombstone fails if SignedAt is modified", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignTombstone("test.txt", networkID, keys[0], clock)
rq.NoError(err)
sig.SignedAt = sig.SignedAt.Add(1)
rq.ErrorIs(sig.Verify(nil), model.ErrSignatureNotValid)
})
t.Run("verify tombstone fails if SignedBy is modified", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignTombstone("test.txt", networkID, keys[0], clock)
rq.NoError(err)
sig.SignedBy = keys[1].Public
rq.ErrorIs(sig.Verify(nil), model.ErrSignatureNotValid)
})
t.Run("verify tombstone fails if name is modified", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignTombstone("test.txt", networkID, keys[0], clock)
rq.NoError(err)
sig.Name = "other.txt"
rq.ErrorIs(sig.Verify(nil), model.ErrSignatureNotValid)
})
t.Run("verify tombstone fails with wrong network ID", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
otherKeys := test.GenerateKeys(t, 1)
otherNetworkID := otherKeys[0].Public
sig, err := model.SignTombstone("test.txt", networkID, keys[0], clock)
rq.NoError(err)
// Mutate network ID to a different one
sig.NetworkID = otherNetworkID
rq.ErrorIs(sig.Verify(nil), model.ErrSignatureNotValid)
})
t.Run("file signature and tombstone have different signatures for same name", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
// Use a fixed clock so timestamps match
fixedClock := func() model.Clock {
called := false
return func() time.Time {
if !called {
called = true
return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
}
return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
}
}
fileSig, err := model.SignFile("test.txt", networkID, keys[0], fixedClock(), strings.NewReader(""))
rq.NoError(err)
tombstoneSig, err := model.SignTombstone("test.txt", networkID, keys[0], fixedClock())
rq.NoError(err)
// Even with same name, key, and timestamp, signatures should differ
// because tombstone includes "tombstone" marker
rq.NotEqual(fileSig.Signature, tombstoneSig.Signature)
})
}
func TestSignatureWithCertificate(t *testing.T) {
t.Parallel()
// networkKey acts as the CA
networkKey, err := crypto.GenerateKey(rand.Reader)
require.NoError(t, err)
machineKey, err := crypto.GenerateKey(rand.Reader)
require.NoError(t, err)
clock := model.Now
t.Run("valid cert verifies successfully", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
cert, err := crypto.SignCertificate(
"test-machine", machineKey.Public, networkKey,
time.Now().Add(-time.Hour), time.Now().Add(time.Hour),
)
rq.NoError(err)
sig, err := model.SignFile("test.txt", networkKey.Public, machineKey, clock, strings.NewReader("hello"))
rq.NoError(err)
sig.Certificate = cert
rq.NoError(sig.Verify(strings.NewReader("hello")))
})
t.Run("expired cert fails verification", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
// cert was valid in the past, SignedAt (now) falls outside that window
cert, err := crypto.SignCertificate(
"test-machine", machineKey.Public, networkKey,
time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour),
)
rq.NoError(err)
sig, err := model.SignFile("test.txt", networkKey.Public, machineKey, clock, strings.NewReader("hello"))
rq.NoError(err)
sig.Certificate = cert
err = sig.Verify(strings.NewReader("hello"))
rq.ErrorIs(err, model.ErrSignatureNotValid)
rq.ErrorContains(err, "signature time outside certificate validity")
})
t.Run("not-yet-valid cert fails verification", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
// cert becomes valid in the future, SignedAt (now) falls before that window
cert, err := crypto.SignCertificate(
"test-machine", machineKey.Public, networkKey,
time.Now().Add(time.Hour), time.Now().Add(2*time.Hour),
)
rq.NoError(err)
sig, err := model.SignFile("test.txt", networkKey.Public, machineKey, clock, strings.NewReader("hello"))
rq.NoError(err)
sig.Certificate = cert
err = sig.Verify(strings.NewReader("hello"))
rq.ErrorIs(err, model.ErrSignatureNotValid)
rq.ErrorContains(err, "signature time outside certificate validity")
})
t.Run("cert key mismatch fails verification", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
otherKey, err := crypto.GenerateKey(rand.Reader)
rq.NoError(err)
// cert is for otherKey, but signature is by machineKey
cert, err := crypto.SignCertificate(
"test-machine", otherKey.Public, networkKey,
time.Now().Add(-time.Hour), time.Now().Add(time.Hour),
)
rq.NoError(err)
sig, err := model.SignFile("test.txt", networkKey.Public, machineKey, clock, strings.NewReader("hello"))
rq.NoError(err)
sig.Certificate = cert
err = sig.Verify(strings.NewReader("hello"))
rq.ErrorIs(err, model.ErrSignatureNotValid)
rq.ErrorContains(err, "certificate key does not match signing key")
})
t.Run("cert signed by wrong network fails verification", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
wrongNetwork, err := crypto.GenerateKey(rand.Reader)
rq.NoError(err)
// cert signed by wrongNetwork, but signature uses networkKey as NetworkID
cert, err := crypto.SignCertificate(
"test-machine", machineKey.Public, wrongNetwork,
time.Now().Add(-time.Hour), time.Now().Add(time.Hour),
)
rq.NoError(err)
sig, err := model.SignFile("test.txt", networkKey.Public, machineKey, clock, strings.NewReader("hello"))
rq.NoError(err)
sig.Certificate = cert
err = sig.Verify(strings.NewReader("hello"))
rq.ErrorIs(err, model.ErrSignatureNotValid)
rq.ErrorIs(err, crypto.ErrInvalidSignature)
})
t.Run("no cert still verifies (backward compat)", func(t *testing.T) {
t.Parallel()
rq := require.New(t)
sig, err := model.SignFile("test.txt", networkKey.Public, machineKey, clock, strings.NewReader("hello"))
rq.NoError(err)
rq.Nil(sig.Certificate)
rq.NoError(sig.Verify(strings.NewReader("hello")))
})
}