The control plane's signing key, and a second context to hold it
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.
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user