Everything is blocked on what a node presents to prove which node it is. This builds the other direction, which is not blocked: what a node believes. identity is the second of the seven contexts. It holds an Ed25519 signing key the control plane generates once, whose public half now travels in every enrolment token. A node believes a declaration because it carries a signature that key made -- pinning only the broker would make the control plane's authority transitive, and since the host applies whatever the link delivers, a compromised broker forging declarations is the whole machine. Establishing the key is idempotent, and it has to be: a second key generated by a restart is a mesh where every node holds the wrong public half, so every declaration is refused by every node with nothing visibly wrong. The guarantee is a partial unique index plus a read-back, not the check before the insert -- six processes racing to establish all agree on one key, and there is a test that runs them. Tokens are now one line of base64 carrying three of their four parts. The missing two are the broker's address and its certificate fingerprint, both step 5 of the bootstrap. The command prints the token and names what is missing rather than emitting something that looks usable. The second context also tests a claim this repository had made and never checked: that a context reaches only its own store. Two databases, two credentials, no setting that reaches both. Running migrate with one stops and names the grant it lacks -- verified, not asserted. Assembling a token needs a node record from one and a key from the other, and neither reads the other's store; the process holding both grants asks each for its part. 45 tests, none skipped. Fault injection found one test whose property is enforced somewhere other than where I injected -- idempotency comes from the database constraint, not from the early return, which is what the code comment already said.
206 lines
5.9 KiB
Go
206 lines
5.9 KiB
Go
package identity
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/novox/mesh-control/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")
|
|
}
|
|
}
|