diff --git a/cmd/mesh-control/licence.go b/cmd/mesh-control/licence.go index a23525b..08f2c00 100644 --- a/cmd/mesh-control/licence.go +++ b/cmd/mesh-control/licence.go @@ -204,6 +204,15 @@ func licenceKey(ctx context.Context, args []string) error { return inv.SealingKeyOf(ctx, node) }) if err != nil { + if sealed > 0 { + // Some holders got it and some did not, and the person holding the key is the only + // one who can finish the job. Saying how far it got is the difference between running + // this again knowing what it will do and running it hoping. + return fmt.Errorf( + "%w\n\n%d holder(s) were sealed before this. Running `licence key %s` again "+ + "with the same key seals the rest and changes nothing for those already done", + err, sealed, name) + } return err } // Not echoed back, ever. What is stored is unreadable by whoever holds it, the mesh included, diff --git a/internal/licences/fortest.go b/internal/licences/fortest.go new file mode 100644 index 0000000..10d9276 --- /dev/null +++ b/internal/licences/fortest.go @@ -0,0 +1,82 @@ +package licences + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + + "github.com/novox/mesh-control/internal/store" +) + +// ForTest is a fresh licence store in a database of its own, dropped when the test ends. +// +// The same shape inventory's has, and separate for the same reason the contexts are separate: +// each owns its store, including in a test. +func ForTest(t *testing.T) *Licences { + 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("lic_%d_%s", time.Now().UnixNano()%1_000_000, + strings.ToLower(strings.NewReplacer("/", "", "-", "").Replace(t.Name()))) + if len(name) > 60 { + name = name[:60] + } + + 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") + + held, err := Open(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + held.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 := held.Ready(t.Context(), 20*time.Second); err != nil { + t.Fatal(err) + } + + migrations, err := Migrations() + if err != nil { + t.Fatal(err) + } + if _, err := held.store.Migrate(t.Context(), migrations); err != nil { + t.Fatal(err) + } + return held +} + +// ASealingKey is a public key something can be sealed to. +func ASealingKey(t *testing.T) string { + t.Helper() + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) +} diff --git a/internal/licences/licences_test.go b/internal/licences/licences_test.go new file mode 100644 index 0000000..2969c18 --- /dev/null +++ b/internal/licences/licences_test.go @@ -0,0 +1,162 @@ +package licences + +import ( + "context" + "strings" + "testing" +) + +// These run against a real PostgreSQL, like every other context's. `make check` raises one. +func fresh(t *testing.T) (*Licences, context.Context) { + t.Helper() + return ForTest(t), t.Context() +} + +func aKey(t *testing.T) string { return ASealingKey(t) } + +// The mesh does not keep a key it cannot seal to somebody, because keeping it for later means +// keeping it readably — which is the whole thing this refuses to do. +func TestAKeyWithNobodyToSealItToIsRefused(t *testing.T) { + held, ctx := fresh(t) + if err := held.Add(ctx, "personal", "anthropic", nil); err != nil { + t.Fatal(err) + } + _, err := held.Accept(ctx, "personal", "sk-test", func(string) (string, error) { + return "", nil + }) + if err == nil { + t.Fatal("a key was taken with nobody to seal it to, so it was kept in the open") + } + if !strings.Contains(err.Error(), "consumer on it first") { + t.Fatalf("the refusal does not say what to do: %v", err) + } +} + +// Sealed to each holder, and the plaintext discarded. +func TestAKeyIsSealedToEachHolderAndNotKept(t *testing.T) { + held, ctx := fresh(t) + if err := held.Add(ctx, "personal", "anthropic", map[string]any{"model": "a-model"}); err != nil { + t.Fatal(err) + } + for _, node := range []string{"workstation", "laptop"} { + if err := held.Use(ctx, "personal", node, "assistant"); err != nil { + t.Fatal(err) + } + } + keys := map[string]string{"workstation": aKey(t), "laptop": aKey(t)} + + const value = "sk-the-operators-own-key" + sealed, err := held.Accept(ctx, "personal", value, func(node string) (string, error) { + return keys[node], nil + }) + if err != nil { + t.Fatal(err) + } + if sealed != 2 { + t.Fatalf("%d holder(s) were sealed to, and there are two", sealed) + } + + first, err := held.KeyFor(ctx, "personal", "workstation", "assistant") + if err != nil { + t.Fatal(err) + } + second, err := held.KeyFor(ctx, "personal", "laptop", "assistant") + if err != nil { + t.Fatal(err) + } + if first == "" || second == "" { + t.Fatal("a holder was left with no key") + } + // Sealed to different machines, so the blobs differ even though the key is one key. Two + // identical blobs would mean one of them was sealed to a machine that cannot open it. + if first == second { + t.Fatal("both holders were given the same blob, so one of them cannot open it") + } + // And nowhere in the open. This is the argument, not a detail. + for _, blob := range []string{first, second} { + if strings.Contains(blob, value) { + t.Fatal("the key is in the stored value in the open") + } + } +} + +// A holder recorded after the key was supplied has none, and the mesh cannot make one. +// +// Reported rather than hidden: a machine that resolves cleanly and receives nothing fails later, +// somewhere that names neither the licence nor the mesh. +func TestAHolderAddedAfterTheKeyHasNone(t *testing.T) { + held, ctx := fresh(t) + if err := held.Add(ctx, "personal", "anthropic", nil); err != nil { + t.Fatal(err) + } + if err := held.Use(ctx, "personal", "workstation", "assistant"); err != nil { + t.Fatal(err) + } + key := aKey(t) + if _, err := held.Accept(ctx, "personal", "sk-test", func(string) (string, error) { + return key, nil + }); err != nil { + t.Fatal(err) + } + + if err := held.Use(ctx, "personal", "laptop", "assistant"); err != nil { + t.Fatal(err) + } + later, err := held.KeyFor(ctx, "personal", "laptop", "assistant") + if err != nil { + t.Fatal(err) + } + if later != "" { + t.Fatal("a holder added after the key was discarded was somehow given one") + } + // And the first holder still has theirs — a new holder must not disturb an existing one. + first, err := held.KeyFor(ctx, "personal", "workstation", "assistant") + if err != nil { + t.Fatal(err) + } + if first == "" { + t.Fatal("adding a holder took the key away from one that had it") + } +} + +// Taking a consumer off a licence takes its copy of the key with it. +func TestReleasingAConsumerTakesItsKey(t *testing.T) { + held, ctx := fresh(t) + if err := held.Add(ctx, "personal", "anthropic", nil); err != nil { + t.Fatal(err) + } + if err := held.Use(ctx, "personal", "workstation", "assistant"); err != nil { + t.Fatal(err) + } + key := aKey(t) + if _, err := held.Accept(ctx, "personal", "sk-test", func(string) (string, error) { + return key, nil + }); err != nil { + t.Fatal(err) + } + if err := held.StopUsing(ctx, "personal", "workstation", "assistant"); err != nil { + t.Fatal(err) + } + holders, err := held.HoldersOf(ctx, "personal") + if err != nil { + t.Fatal(err) + } + if len(holders) != 0 { + t.Fatalf("a released consumer is still a holder: %+v", holders) + } +} + +// A licence nobody recorded is not a licence somebody can be put on. +func TestUsingALicenceThatDoesNotExistIsRefused(t *testing.T) { + held, ctx := fresh(t) + err := held.Use(ctx, "invented", "workstation", "assistant") + if err == nil { + t.Fatal("a consumer was put on a licence this mesh has never heard of") + } + // Named, in words a person can act on. The database's own foreign-key message is true and + // mentions a constraint rather than a licence, which sends somebody reading a schema instead + // of typing the name they meant. + if !strings.Contains(err.Error(), `"invented"`) { + t.Fatalf("the refusal does not name the licence: %v", err) + } +}