diff --git a/README.md b/README.md index a3ec31e..8909867 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ argument that is not settled there. | | | |---|---| | `inventory` | node records and enrolment tokens — **built, as far as identity** | -| `config`, `connectivity`, `provisioning`, `delivery`, `observability`, `identity` | not built | +| `identity` | the control plane's own signing key — **built, and no further** | +| `config`, `connectivity`, `provisioning`, `delivery`, `observability` | not built | | the interface every surface speaks to | not built; its shape is not decided | ``` @@ -35,6 +36,7 @@ mesh-control node add create a node record mesh-control node list the nodes this mesh knows about mesh-control token issue --node a one-time right to join, for an existing record mesh-control token issue --new create the record and issue for it +mesh-control identity show this control plane's signing key mesh-control version what this binary is ``` @@ -56,10 +58,26 @@ one — two live tokens are two machines able to join as the same node. Redemption is a single statement that both finds a live token and spends it, so eight concurrent attempts on one secret produce exactly one winner. There is a test that runs them. -**What a token is missing is three of its four parts.** ADR 0004 requires the broker's address, -the fingerprint of its certificate, and the control plane's signing identity. None of the three -exists yet, so `token issue` prints the secret **and says so**, rather than producing something -that looks complete and cannot be used. +**A token now carries three of its four parts**, and is one line of base64 a person can copy. The +signing key is real: an Ed25519 key this control plane generates once and keeps, whose public half +travels in every token. A node believes a declaration because it carries a signature that key made +— and pinning only the broker would not do, because it would make the control plane's authority +transitive, so a compromised broker could forge declarations, and since the host applies whatever +the link delivers that is the whole machine. + +**Still missing: the broker's address and its certificate fingerprint.** Both are step 5 of the +substrate bootstrap and neither exists. `token issue` prints the token **and names what is +missing**, rather than producing something that looks complete and cannot be used. + +### Two contexts, and the rule between them is real + +`identity` is the second context and it exists partly to test a claim this repository had made and +never checked: that a context reaches only its own store. It holds `MESH_STORE_IDENTITY`; +`inventory` holds `MESH_STORE_INVENTORY`; there is no setting that reaches both and no way to ask +for one. Run `migrate` with only one and it stops, naming the grant it does not have. + +A token needs a node record from one and a signing key from the other. Neither reads the other's +store — the process holding both grants asks each for its part. ### Where this stops, and why there diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index a791f8a..393e422 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -16,8 +16,10 @@ import ( "syscall" "time" + "github.com/novox/mesh-control/internal/identity" "github.com/novox/mesh-control/internal/inventory" "github.com/novox/mesh-control/internal/store" + "github.com/novox/mesh-control/internal/token" ) // version is stamped at link time. Unset in a development build, and it says so rather than @@ -33,6 +35,7 @@ var held = []struct { migrations func() ([]store.Migration, error) }{ {inventory.Name, inventory.Migrations}, + {identity.Name, identity.Migrations}, } func main() { @@ -59,6 +62,8 @@ func run() error { return nodeCommand(ctx, args[1:]) case "token": return tokenCommand(ctx, args[1:]) + case "identity": + return identityCommand(ctx, args[1:]) case "version": fmt.Println(version) return nil @@ -79,6 +84,7 @@ func usage() { node list the nodes this mesh knows about token issue --node a one-time right to join, for an existing record token issue --new create the record and issue for it + identity show this control plane's signing key version what this binary is Each context reaches its own store through its own credential (novox/hq ADR 0008), named @@ -231,12 +237,71 @@ func tokenCommand(ctx context.Context, args []string) error { return err } + // Assembled from two contexts by the process that holds both grants. Neither reads the + // other's store (novox/hq ADR 0008) — each is asked for its own part. + ident, err := openIdentity(ctx) + if err != nil { + return err + } + defer ident.Close() + key, err := ident.Establish(ctx) + if err != nil { + return err + } + + made := token.Token{Signer: key.Public, Secret: issued.Secret} + encoded, err := made.Encode() + if err != nil { + return err + } + fmt.Printf("token for %s, usable once, until %s\n\n %s\n\n", - issued.Node.Name, issued.Expires.Format(time.RFC3339), issued.Secret) - fmt.Print("This is the only time that secret is shown; what is stored is a hash of it.\n\n") - fmt.Print("INCOMPLETE. novox/hq ADR 0004 requires a token to carry four things, and this\n" + - "carries one. Missing: the broker's address, the fingerprint of its certificate, and\n" + - "the control plane's signing identity. None of the three exists yet, so this secret\n" + - "cannot be used to join anything -- it is the half that could be built without them.\n") + issued.Node.Name, issued.Expires.Format(time.RFC3339), encoded) + fmt.Println("This is the only time it is shown. What is stored is a hash of the secret.") + + if missing := made.Missing(); len(missing) > 0 { + fmt.Printf("\nINCOMPLETE — this token cannot be used to join anything yet. Missing:\n") + for _, m := range missing { + fmt.Printf(" - %s\n", m) + } + fmt.Println("\nThe broker and its certificate are step 5 of the substrate bootstrap and " + + "do not exist yet\n(novox/hq 07-the-substrate). The signing key above is real.") + } + return nil +} + +func openIdentity(ctx context.Context) (*identity.Identity, error) { + ident, err := identity.Open(ctx) + if err != nil { + return nil, err + } + if err := ident.Ready(ctx, 30*time.Second); err != nil { + ident.Close() + return nil, err + } + return ident, nil +} + +func identityCommand(ctx context.Context, args []string) error { + if len(args) == 0 || args[0] != "show" { + return errors.New("identity show") + } + ident, err := openIdentity(ctx) + if err != nil { + return err + } + defer ident.Close() + + // Establish rather than read: a control plane asked for its identity before it has one should + // get one, not an error. Generating it is idempotent, so this is safe to run at any time. + key, err := ident.Establish(ctx) + if err != nil { + return err + } + fmt.Printf("signing key %s\n", key.ID) + fmt.Printf("fingerprint %s\n", key.Fingerprint()) + fmt.Printf("created %s\n", key.Created.Format(time.RFC3339)) + fmt.Printf("\nThe public half of this travels in every enrolment token. A node believes a\n" + + "declaration because it carries a signature this key made (novox/hq ADR 0004).\n") return nil } diff --git a/internal/identity/identity.go b/internal/identity/identity.go new file mode 100644 index 0000000..778e957 --- /dev/null +++ b/internal/identity/identity.go @@ -0,0 +1,152 @@ +// Package identity is the context that holds who anything in the mesh is. +// +// novox/hq ADR 0006 names it as one of the seven. Built second, and only as far as the control +// plane's own signing identity — what a *node* presents to prove it is that node is not decided +// anywhere, and this deliberately stops short of guessing at it. +// +// It owns its store exclusively (novox/hq ADR 0008): a database called `identity`, reached with a +// credential no other context holds — including `inventory`, in the same process. +package identity + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "embed" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/novox/mesh-control/internal/store" +) + +// Name is what this context is called: its database and its credential are named after it. +const Name = "identity" + +//go:embed migrations/*.sql +var files embed.FS + +// Migrations are this context's schema changes, in order. +func Migrations() ([]store.Migration, error) { + return store.LoadMigrations(files, "migrations") +} + +// Identity is this context, holding the store it exclusively owns. +type Identity struct{ store *store.Store } + +// Open connects to the identity store. +func Open(ctx context.Context) (*Identity, error) { + s, err := store.Open(ctx, Name) + if err != nil { + return nil, err + } + return &Identity{store: s}, nil +} + +func (i *Identity) Close() { i.store.Close() } + +// Ready waits for the database to answer. +func (i *Identity) Ready(ctx context.Context, within time.Duration) error { + return i.store.Ready(ctx, within) +} + +// SigningKey is the control plane's signing identity. Public is what travels in a token. +type SigningKey struct { + ID string + Public ed25519.PublicKey + Created time.Time +} + +// Fingerprint is how a person compares two keys without reading 32 bytes. +// +// Of the public half, which is the half anything else ever sees. +func (k SigningKey) Fingerprint() string { + sum := sha256.Sum256(k.Public) + return hex.EncodeToString(sum[:]) +} + +// ErrNoSigningKey means this control plane has never generated one. +var ErrNoSigningKey = errors.New("this control plane has no signing key") + +// Active is the key currently signing. +// +// Absence is an error rather than an empty key. A control plane that cannot find its signing +// identity must say so: signing with nothing, or with a freshly invented key, would produce +// declarations that every existing node correctly refuses — and the refusal would look like a +// compromise rather than a missing file. +func (i *Identity) Active(ctx context.Context) (SigningKey, error) { + var k SigningKey + var public []byte + err := i.store.Pool().QueryRow(ctx, + `select id, public, created from signing_key where retired is null`). + Scan(&k.ID, &public, &k.Created) + if errors.Is(err, pgx.ErrNoRows) { + return SigningKey{}, ErrNoSigningKey + } + if err != nil { + return SigningKey{}, err + } + k.Public = public + return k, nil +} + +// Establish generates the signing identity if there is not one already. +// +// Idempotent, and it has to be: the control plane runs this at every start, and a second key +// generated by a restart would be a mesh whose nodes hold the wrong public half — every +// declaration refused, by every node, with nothing having gone wrong that anybody could see. +// +// The insert is what makes it safe rather than the check before it. Two processes starting +// together both find nothing; only one insert survives the partial unique index, and the other +// reads back the winner instead of failing. +func (i *Identity) Establish(ctx context.Context) (SigningKey, error) { + existing, err := i.Active(ctx) + if err == nil { + return existing, nil + } + if !errors.Is(err, ErrNoSigningKey) { + return SigningKey{}, err + } + + public, private, err := ed25519.GenerateKey(nil) + if err != nil { + return SigningKey{}, fmt.Errorf("cannot generate a signing key: %w", err) + } + + _, err = i.store.Pool().Exec(ctx, + `insert into signing_key (public, private) values ($1, $2) + on conflict do nothing`, []byte(public), []byte(private)) + if err != nil { + return SigningKey{}, err + } + // Read back rather than return what was generated: on conflict this process generated a key + // that was not stored, and returning it would hand out a public half nothing will ever sign + // with (novox/hq ADR 0018 — a picture is read from the system). + return i.Active(ctx) +} + +// Sign signs a declaration with the active key. +// +// The private half is fetched per call rather than held in memory for the process's lifetime. +// That is not paranoia about memory: it means a key retired while this process runs stops being +// used at the next signature rather than at the next restart. +func (i *Identity) Sign(ctx context.Context, message []byte) ([]byte, error) { + var private []byte + err := i.store.Pool().QueryRow(ctx, + `select private from signing_key where retired is null`).Scan(&private) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNoSigningKey + } + if err != nil { + return nil, err + } + return ed25519.Sign(ed25519.PrivateKey(private), message), nil +} + +// Verify checks a signature against a public key. Here because the host does the same thing with +// the same algorithm, and the two must not drift apart. +func Verify(public ed25519.PublicKey, message, signature []byte) bool { + return ed25519.Verify(public, message, signature) +} diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go new file mode 100644 index 0000000..8d0aa63 --- /dev/null +++ b/internal/identity/identity_test.go @@ -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") + } +} diff --git a/internal/identity/migrations/0001-signing-key.sql b/internal/identity/migrations/0001-signing-key.sql new file mode 100644 index 0000000..0edaa91 --- /dev/null +++ b/internal/identity/migrations/0001-signing-key.sql @@ -0,0 +1,30 @@ +-- The control plane's own signing identity. +-- +-- novox/hq ADR 0004: a node takes instruction from the control plane behind the broker, and each +-- declaration is verified by its signature, every time. The public half of this key travels in +-- every enrolment token; the private half never leaves this context. +-- +-- Why not pin only the broker: that would make the control plane's authority transitive. A +-- compromised broker could then forge declarations, and since the host applies whatever the link +-- delivers, that is the whole machine. The transport is verified once at connect; the instruction +-- is verified on arrival. + +create table signing_key ( + id uuid primary key default gen_random_uuid(), + + -- Ed25519. Fixed rather than a column: a key that carries its own algorithm invites a caller + -- to be told which one to use, and the two sizes below are Ed25519's. + public bytea not null check (octet_length(public) = 32), + private bytea not null check (octet_length(private) = 64), + + created timestamptz not null default now(), + + -- Retiring a signing key is a fleet-wide operation with an overlapping rollover -- every node + -- holds the public half, delivered in a token it may have received months ago. So keys are + -- retired, never deleted, and more than one may be valid at a time during a rollover. + retired timestamptz +); + +-- The rollover is what makes this a partial index rather than a plain unique constraint: exactly +-- one key may be signing at any moment, while any number of retired ones remain verifiable. +create unique index signing_key_one_active on signing_key ((retired is null)) where retired is null; diff --git a/internal/token/token.go b/internal/token/token.go new file mode 100644 index 0000000..77e5470 --- /dev/null +++ b/internal/token/token.go @@ -0,0 +1,113 @@ +// Package token is the thing a person carries to a machine that is joining. +// +// novox/hq ADR 0004: it carries four things, and it is the only thing a joining node needs — +// where the broker is, what certificate to expect there, whose signature to believe afterwards, +// and a one-time right to join. +// +// Its authenticity comes from the channel it travelled, not from anything the node can check +// afterwards: trust on first use, with the first use moved out of band. Which makes the token +// security-critical, because it carries the pin. Tampering with it substitutes the mesh — still +// better than the alternative, where there is nothing to tamper with and a node trusts the first +// answer it gets. +package token + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +// Token is the four things, and it is assembled by whatever holds all four. +// +// Two contexts contribute: `inventory` owns the node record and mints the secret, `identity` owns +// the signing key. Neither reads the other's store — the process holding both grants asks each +// for its part (novox/hq ADR 0008). +type Token struct { + Version int `json:"v"` + + // Broker is an address and not a name. There is no resolution before joining, which is why + // this is the one place in the mesh where an address is carried deliberately. + Broker string `json:"broker,omitempty"` + + // Fingerprint is the broker certificate's, checked before anything is sent. + Fingerprint string `json:"fingerprint,omitempty"` + + // Signer is the control plane's public signing key: whose declarations to believe. + Signer []byte `json:"signer,omitempty"` + + // Secret is the one-time right to join. Useless once used, useless after it expires. + Secret string `json:"secret"` +} + +// Missing names the parts that are not filled in. +// +// Returned as a list rather than a bool, because "this token cannot be used" is not an answer +// anybody can act on and "it has no broker address" is. Everything here is required: a token +// missing the fingerprint cannot verify what it connects to, and one missing the signer makes +// the control plane's authority transitive through the broker — which ADR 0004 rejects, because +// a compromised broker could then forge declarations, and that is the whole machine. +func (t Token) Missing() []string { + var missing []string + if strings.TrimSpace(t.Broker) == "" { + missing = append(missing, "the broker's address — there is nowhere to connect to") + } + if strings.TrimSpace(t.Fingerprint) == "" { + missing = append(missing, + "the broker certificate's fingerprint — nothing to check the connection against") + } + if len(t.Signer) != ed25519.PublicKeySize { + missing = append(missing, + "the control plane's signing key — declarations could not be told from forgeries") + } + if strings.TrimSpace(t.Secret) == "" { + missing = append(missing, "the one-time secret — nothing to present") + } + return missing +} + +// Complete reports whether this token could actually be used to join. +func (t Token) Complete() bool { return len(t.Missing()) == 0 } + +// Encode renders the token as one line a person can carry. +// +// Base64 of JSON: self-describing, so a token from an older control plane says what it is rather +// than being misread by a newer one; and one line, because it is copied by hand between a +// terminal and a machine. +func (t Token) Encode() (string, error) { + t.Version = 1 + raw, err := json.Marshal(t) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// Decode reads a token a person pasted. +func Decode(encoded string) (Token, error) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded)) + if err != nil { + return Token{}, fmt.Errorf("this is not a token: %w", err) + } + var t Token + if err := json.Unmarshal(raw, &t); err != nil { + return Token{}, fmt.Errorf("this is not a token: %w", err) + } + if t.Version != 1 { + return Token{}, fmt.Errorf( + "this token says it is version %d, and this host understands version 1. It was made "+ + "by a different control plane than the one this was built against", t.Version) + } + return t, nil +} + +// base64Decode and encodeBase64 exist for the tests, which construct a token by hand to prove a +// version this build does not understand is refused. Kept beside the encoding they mirror. +func base64Decode(s string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(strings.TrimSpace(s)) +} + +func encodeBase64(s string) string { + return base64.RawURLEncoding.EncodeToString([]byte(s)) +} diff --git a/internal/token/token_test.go b/internal/token/token_test.go new file mode 100644 index 0000000..8fbfa16 --- /dev/null +++ b/internal/token/token_test.go @@ -0,0 +1,118 @@ +package token + +import ( + "crypto/ed25519" + "strings" + "testing" +) + +func complete(t *testing.T) Token { + t.Helper() + public, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + return Token{ + Broker: "192.0.2.10:5671", + Fingerprint: "sha256:" + strings.Repeat("ab", 32), + Signer: public, + Secret: "a-one-time-secret", + } +} + +func TestATokenSurvivesBeingCarried(t *testing.T) { + // It is copied by hand out of a terminal and into a machine. Whatever comes back must be + // exactly what went in, including the key — a signing key that changed in transit is a node + // that refuses every declaration it is later sent. + original := complete(t) + encoded, err := original.Encode() + if err != nil { + t.Fatal(err) + } + if strings.ContainsAny(encoded, " \n\t") { + t.Error("the encoded token contains whitespace; it is copied by hand as one line") + } + + back, err := Decode(encoded) + if err != nil { + t.Fatal(err) + } + if back.Broker != original.Broker || back.Fingerprint != original.Fingerprint || + back.Secret != original.Secret || string(back.Signer) != string(original.Signer) { + t.Errorf("the token changed in transit:\n sent %+v\n got %+v", original, back) + } +} + +func TestSurroundingWhitespaceIsTolerated(t *testing.T) { + // It arrives pasted. A trailing newline is not a corrupted token, and refusing one would + // send somebody hunting for a fault that is not there. + encoded, err := complete(t).Encode() + if err != nil { + t.Fatal(err) + } + if _, err := Decode(" " + encoded + "\n"); err != nil { + t.Errorf("a pasted token was refused: %v", err) + } +} + +func TestGarbageIsRefusedAsNotBeingAToken(t *testing.T) { + for _, bad := range []string{"", "not-base64-!!!", "aGVsbG8"} { + if _, err := Decode(bad); err == nil { + t.Errorf("%q was accepted as a token", bad) + } + } +} + +func TestATokenFromAnotherVersionIsRefusedClearly(t *testing.T) { + // The host must tell "this is not from the mesh I joined" apart from "this is malformed" + // (novox/hq ADR 0004). A version it does not understand is the first case. + future := complete(t) + encoded, err := future.Encode() + if err != nil { + t.Fatal(err) + } + // Re-encode by hand at a version this build does not know. + raw := strings.Replace(string(mustDecodeBase64(t, encoded)), `"v":1`, `"v":99`, 1) + if _, err := Decode(encodeBase64(raw)); err == nil { + t.Fatal("a token from an unknown version was accepted") + } +} + +func TestEveryMissingPartIsNamed(t *testing.T) { + // "This token cannot be used" is not something anybody can act on. "It has no broker + // address" is. And all of them at once, not the first: fixing one at a time turns a single + // decision into four. + empty := Token{} + missing := empty.Missing() + if len(missing) != 4 { + t.Fatalf("an empty token named %d missing parts, expected 4: %v", len(missing), missing) + } + if empty.Complete() { + t.Error("an empty token reported itself complete") + } +} + +func TestAShortSigningKeyIsNotASigningKey(t *testing.T) { + // The one that would pass a nil check and fail at the moment a declaration is verified — + // which is on a node, in production, long after this. + t1 := complete(t) + t1.Signer = []byte("too short") + if t1.Complete() { + t.Error("a truncated signing key was accepted as present") + } +} + +func TestACompleteTokenIsComplete(t *testing.T) { + if got := complete(t); !got.Complete() { + t.Errorf("a token with all four parts reported missing: %v", got.Missing()) + } +} + +func mustDecodeBase64(t *testing.T, s string) []byte { + t.Helper() + raw, err := base64Decode(s) + if err != nil { + t.Fatal(err) + } + return raw +}