Introduces a `config.Now` field which all components should defer to when getting the current time instead of hardcoded calls to `model.DefaultClock`.
453 lines
14 KiB
Go
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")))
|
|
})
|
|
}
|