One name per thing, per the HQ glossary: the module/container/image/binary/repo becomes mesh-controller, the seat the-controller, and the store+broker pair the foundation (embedded base bundles, default template and example lock renamed with their go:embed directives). No behaviour change — a pure vocabulary rename. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
418 lines
12 KiB
Go
418 lines
12 KiB
Go
package identity
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/novox/mesh-controller/internal/store"
|
|
)
|
|
|
|
func fresh(t *testing.T) *Identity {
|
|
t.Helper()
|
|
admin := os.Getenv("MESH_TEST_POSTGRES")
|
|
if admin == "" {
|
|
t.Skip("no MESH_TEST_POSTGRES; run `make check` to raise one")
|
|
}
|
|
name := fmt.Sprintf("ident_%d", time.Now().UnixNano()%10_000_000)
|
|
|
|
conn, err := pgx.Connect(t.Context(), admin)
|
|
if err != nil {
|
|
t.Fatalf("cannot reach the test PostgreSQL: %v", err)
|
|
}
|
|
if _, err := conn.Exec(t.Context(), "create database "+name); err != nil {
|
|
t.Fatalf("cannot create %s: %v", name, err)
|
|
}
|
|
conn.Close(t.Context())
|
|
|
|
cut := strings.LastIndex(admin, "/")
|
|
t.Setenv(store.Variable(Name), admin[:cut]+"/"+name+"?sslmode=disable")
|
|
|
|
ident, err := Open(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
ident.Close()
|
|
c, err := pgx.Connect(context.Background(), admin)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer c.Close(context.Background())
|
|
_, _ = c.Exec(context.Background(), "drop database if exists "+name+" with (force)")
|
|
})
|
|
if err := ident.Ready(t.Context(), 20*time.Second); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
migrations, err := Migrations()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ident.store.Migrate(t.Context(), migrations); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ident
|
|
}
|
|
|
|
func TestNoKeyIsAnErrorRatherThanAnEmptyKey(t *testing.T) {
|
|
// Signing with nothing, or with a key invented on the spot, produces declarations every
|
|
// existing node correctly refuses — and that refusal looks like a compromise rather than a
|
|
// control plane that lost its key.
|
|
ident := fresh(t)
|
|
if _, err := ident.Active(t.Context()); !errors.Is(err, ErrNoSigningKey) {
|
|
t.Fatalf("expected ErrNoSigningKey, got %v", err)
|
|
}
|
|
if _, err := ident.Sign(t.Context(), []byte("anything")); !errors.Is(err, ErrNoSigningKey) {
|
|
t.Fatalf("signing without a key gave %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEstablishingTwiceKeepsTheFirstKey(t *testing.T) {
|
|
// The control plane runs this at every start. A second key generated by a restart is a mesh
|
|
// whose nodes all hold the wrong public half — every declaration refused, by every node,
|
|
// with nothing visibly having gone wrong.
|
|
ident := fresh(t)
|
|
first, err := ident.Establish(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
second, err := ident.Establish(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if first.ID != second.ID || string(first.Public) != string(second.Public) {
|
|
t.Error("a second Establish replaced the signing key; every node would hold the wrong one")
|
|
}
|
|
}
|
|
|
|
func TestTwoProcessesStartingTogetherAgreeOnOneKey(t *testing.T) {
|
|
// A restart while another copy is coming up. Both find nothing and both generate; only one
|
|
// insert may survive, and the loser must read back the winner rather than return the key it
|
|
// generated and did not store.
|
|
ident := fresh(t)
|
|
|
|
var wg sync.WaitGroup
|
|
keys := make([]SigningKey, 6)
|
|
errs := make([]error, 6)
|
|
for i := range keys {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
keys[i], errs[i] = ident.Establish(context.Background())
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("establish %d failed: %v", i, err)
|
|
}
|
|
}
|
|
for i, k := range keys {
|
|
if k.ID != keys[0].ID {
|
|
t.Errorf("establish %d got key %s, establish 0 got %s — they disagree", i, k.ID, keys[0].ID)
|
|
}
|
|
}
|
|
|
|
var count int
|
|
if err := ident.store.Pool().QueryRow(t.Context(),
|
|
`select count(*) from signing_key`).Scan(&count); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if count != 1 {
|
|
t.Errorf("%d signing keys exist; exactly one may be active", count)
|
|
}
|
|
}
|
|
|
|
func TestASignatureVerifiesAgainstThePublicHalfThatTravels(t *testing.T) {
|
|
// The whole point: a node holds only the public half, from a token it may have received
|
|
// months ago, and must be able to tell a real declaration from a forged one.
|
|
ident := fresh(t)
|
|
key, err := ident.Establish(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
declaration := []byte(`{"declaration":1,"resources":[]}`)
|
|
signature, err := ident.Sign(t.Context(), declaration)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !Verify(key.Public, declaration, signature) {
|
|
t.Fatal("a declaration this control plane signed did not verify against the key it hands out")
|
|
}
|
|
}
|
|
|
|
func TestATamperedDeclarationDoesNotVerify(t *testing.T) {
|
|
// Since the host applies whatever the link delivers, a forged declaration is the whole
|
|
// machine. This is the check that stands between those two facts.
|
|
ident := fresh(t)
|
|
key, err := ident.Establish(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signature, err := ident.Sign(t.Context(), []byte(`{"resources":["harmless"]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if Verify(key.Public, []byte(`{"resources":["something else entirely"]}`), signature) {
|
|
t.Fatal("a signature made over one declaration verified against a different one")
|
|
}
|
|
}
|
|
|
|
func TestAnotherControlPlanesSignatureIsRefused(t *testing.T) {
|
|
// "This is not from the mesh I joined" — the case ADR 0004 requires a host to tell apart
|
|
// from "this is malformed".
|
|
mine := fresh(t)
|
|
theirs := fresh(t)
|
|
myKey, err := mine.Establish(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := theirs.Establish(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
declaration := []byte(`{"declaration":1}`)
|
|
theirSignature, err := theirs.Sign(t.Context(), declaration)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if Verify(myKey.Public, declaration, theirSignature) {
|
|
t.Fatal("a signature from a different control plane verified against this one's key")
|
|
}
|
|
}
|
|
|
|
func TestTheFingerprintIsOfThePublicHalf(t *testing.T) {
|
|
ident := fresh(t)
|
|
key, err := ident.Establish(t.Context())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(key.Fingerprint()) != 64 {
|
|
t.Errorf("fingerprint is %q", key.Fingerprint())
|
|
}
|
|
// And it must not be derivable from something that is not the key.
|
|
if key.Fingerprint() == (SigningKey{Public: make([]byte, 32)}).Fingerprint() {
|
|
t.Error("the fingerprint does not depend on the key")
|
|
}
|
|
}
|
|
|
|
func TestANodeIsVerifiedByAKeyItGenerated(t *testing.T) {
|
|
// The whole of proving a node is that node. The mesh holds only the public half, so this
|
|
// verification is the same operation the node performs on every declaration, in reverse.
|
|
ident := fresh(t)
|
|
public, private, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
node := uuid(t)
|
|
|
|
if _, err := ident.RecordNodeKey(t.Context(), node, public); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
challenge := []byte("prove you are that node")
|
|
if err := ident.VerifyNode(t.Context(), node, challenge, ed25519.Sign(private, challenge)); err != nil {
|
|
t.Fatalf("a node signing with its own key was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnotherMachinesKeyDoesNotIdentifyThisNode(t *testing.T) {
|
|
ident := fresh(t)
|
|
mine, _, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, theirPrivate, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
node := uuid(t)
|
|
if _, err := ident.RecordNodeKey(t.Context(), node, mine); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
challenge := []byte("prove you are that node")
|
|
err = ident.VerifyNode(t.Context(), node, challenge, ed25519.Sign(theirPrivate, challenge))
|
|
if !errors.Is(err, ErrNotThisNode) {
|
|
t.Fatalf("a different machine's signature was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestReEnrolmentRevokesTheMachineItReplaced(t *testing.T) {
|
|
// novox/hq ADR 0004's stolen-laptop case. Two live identities for one node record means the
|
|
// machine that was replaced goes on being believed, which is the thing revocation exists for.
|
|
ident := fresh(t)
|
|
node := uuid(t)
|
|
oldPublic, oldPrivate, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ident.RecordNodeKey(t.Context(), node, oldPublic); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
newPublic, newPrivate, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ident.RecordNodeKey(t.Context(), node, newPublic); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
challenge := []byte("still me?")
|
|
if err := ident.VerifyNode(t.Context(), node, challenge, ed25519.Sign(oldPrivate, challenge)); !errors.Is(err, ErrNotThisNode) {
|
|
t.Error("the replaced machine is still believed")
|
|
}
|
|
if err := ident.VerifyNode(t.Context(), node, challenge, ed25519.Sign(newPrivate, challenge)); err != nil {
|
|
t.Errorf("the new machine was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOnlyOneKeyIsEverLiveForANode(t *testing.T) {
|
|
// Enforced by the database rather than by the order of two statements, because the window
|
|
// between them is exactly when two live identities would exist.
|
|
ident := fresh(t)
|
|
node := uuid(t)
|
|
for i := 0; i < 4; i++ {
|
|
public, _, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ident.RecordNodeKey(t.Context(), node, public); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
var live int
|
|
if err := ident.store.Pool().QueryRow(t.Context(),
|
|
`select count(*) from node_key where node = $1 and revoked is null`, node).Scan(&live); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if live != 1 {
|
|
t.Errorf("%d live keys for one node; exactly one may be", live)
|
|
}
|
|
}
|
|
|
|
func TestOnlyThePublicHalfIsEverStored(t *testing.T) {
|
|
// The property that makes a copy of this database worthless to whoever takes it: it is a list
|
|
// of who to believe, not a set of credentials.
|
|
ident := fresh(t)
|
|
public, private, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
node := uuid(t)
|
|
if _, err := ident.RecordNodeKey(t.Context(), node, public); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var stored []byte
|
|
if err := ident.store.Pool().QueryRow(t.Context(),
|
|
`select public from node_key where node = $1`, node).Scan(&stored); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(stored) != ed25519.PublicKeySize {
|
|
t.Errorf("stored %d bytes for a node key; a public key is %d and a private key is %d",
|
|
len(stored), ed25519.PublicKeySize, ed25519.PrivateKeySize)
|
|
}
|
|
if bytes.Contains(private, stored) && bytes.Contains(stored, private[:32]) {
|
|
t.Error("what is stored looks like part of the private key")
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownNodeAndARevokedKeyAreRefusedAlike(t *testing.T) {
|
|
ident := fresh(t)
|
|
_, private, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
challenge := []byte("hello")
|
|
err = ident.VerifyNode(t.Context(), uuid(t), challenge, ed25519.Sign(private, challenge))
|
|
if !errors.Is(err, ErrNotThisNode) {
|
|
t.Fatalf("an unknown node gave %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSomethingThatIsNotAKeyIsRefused(t *testing.T) {
|
|
ident := fresh(t)
|
|
if _, err := ident.RecordNodeKey(t.Context(), uuid(t), []byte("far too short")); err == nil {
|
|
t.Fatal("a truncated key was recorded as a node's identity")
|
|
}
|
|
}
|
|
|
|
// uuid gives each test its own node id. The node records live in another context's database
|
|
// (novox/hq ADR 0008), so there is nothing here to reference and nothing to create.
|
|
func uuid(t *testing.T) string {
|
|
t.Helper()
|
|
var id string
|
|
if err := freshConn(t).QueryRow(t.Context(), `select gen_random_uuid()`).Scan(&id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
func freshConn(t *testing.T) *pgx.Conn {
|
|
t.Helper()
|
|
conn, err := pgx.Connect(t.Context(), os.Getenv("MESH_TEST_POSTGRES"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { conn.Close(context.Background()) })
|
|
return conn
|
|
}
|
|
|
|
func TestTwoEnrolmentsAtOnceStillLeaveOneLiveKey(t *testing.T) {
|
|
// The case the database constraint is for, and the only one: sequential calls are already
|
|
// safe because RecordNodeKey revokes before it inserts. Two at once both revoke what they
|
|
// found and both insert, and without the partial unique index the node ends with two live
|
|
// identities — the replaced machine still believed, which is the whole thing revocation
|
|
// exists to prevent.
|
|
//
|
|
// Some of these are expected to fail. What must not happen is two of them succeeding.
|
|
ident := fresh(t)
|
|
node := uuid(t)
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, 6)
|
|
for i := range errs {
|
|
public, _, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wg.Add(1)
|
|
go func(i int, public ed25519.PublicKey) {
|
|
defer wg.Done()
|
|
_, errs[i] = ident.RecordNodeKey(context.Background(), node, public)
|
|
}(i, public)
|
|
}
|
|
wg.Wait()
|
|
|
|
var live int
|
|
if err := ident.store.Pool().QueryRow(t.Context(),
|
|
`select count(*) from node_key where node = $1 and revoked is null`, node).Scan(&live); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if live != 1 {
|
|
t.Errorf("%d live keys after six concurrent enrolments; exactly one may be live", live)
|
|
}
|
|
|
|
succeeded := 0
|
|
for _, err := range errs {
|
|
if err == nil {
|
|
succeeded++
|
|
}
|
|
}
|
|
if succeeded == 0 {
|
|
t.Error("every concurrent enrolment failed; at least one must win")
|
|
}
|
|
}
|