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:
2026-08-29 15:05:23 +02:00
parent 66768208d2
commit 7553af6c5a
7 changed files with 712 additions and 11 deletions
+23 -5
View File
@@ -26,7 +26,8 @@ argument that is not settled there.
| | | | | |
|---|---| |---|---|
| `inventory` | node records and enrolment tokens — **built, as far as identity** | | `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 | | the interface every surface speaks to | not built; its shape is not decided |
``` ```
@@ -35,6 +36,7 @@ mesh-control node add <name> create a node record
mesh-control node list the nodes this mesh knows about mesh-control node list the nodes this mesh knows about
mesh-control token issue --node <name> a one-time right to join, for an existing record mesh-control token issue --node <name> a one-time right to join, for an existing record
mesh-control token issue --new <name> create the record and issue for it mesh-control token issue --new <name> create the record and issue for it
mesh-control identity show this control plane's signing key
mesh-control version what this binary is 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 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. 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, **A token now carries three of its four parts**, and is one line of base64 a person can copy. The
the fingerprint of its certificate, and the control plane's signing identity. None of the three signing key is real: an Ed25519 key this control plane generates once and keeps, whose public half
exists yet, so `token issue` prints the secret **and says so**, rather than producing something travels in every token. A node believes a declaration because it carries a signature that key made
that looks complete and cannot be used. — 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 ### Where this stops, and why there
+71 -6
View File
@@ -16,8 +16,10 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/novox/mesh-control/internal/identity"
"github.com/novox/mesh-control/internal/inventory" "github.com/novox/mesh-control/internal/inventory"
"github.com/novox/mesh-control/internal/store" "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 // 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) migrations func() ([]store.Migration, error)
}{ }{
{inventory.Name, inventory.Migrations}, {inventory.Name, inventory.Migrations},
{identity.Name, identity.Migrations},
} }
func main() { func main() {
@@ -59,6 +62,8 @@ func run() error {
return nodeCommand(ctx, args[1:]) return nodeCommand(ctx, args[1:])
case "token": case "token":
return tokenCommand(ctx, args[1:]) return tokenCommand(ctx, args[1:])
case "identity":
return identityCommand(ctx, args[1:])
case "version": case "version":
fmt.Println(version) fmt.Println(version)
return nil return nil
@@ -79,6 +84,7 @@ func usage() {
node list the nodes this mesh knows about node list the nodes this mesh knows about
token issue --node <name> a one-time right to join, for an existing record token issue --node <name> a one-time right to join, for an existing record
token issue --new <name> create the record and issue for it token issue --new <name> create the record and issue for it
identity show this control plane's signing key
version what this binary is version what this binary is
Each context reaches its own store through its own credential (novox/hq ADR 0008), named 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 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", fmt.Printf("token for %s, usable once, until %s\n\n %s\n\n",
issued.Node.Name, issued.Expires.Format(time.RFC3339), issued.Secret) issued.Node.Name, issued.Expires.Format(time.RFC3339), encoded)
fmt.Print("This is the only time that secret is shown; what is stored is a hash of it.\n\n") fmt.Println("This is the only time it is shown. What is stored is a hash of the secret.")
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" + if missing := made.Missing(); len(missing) > 0 {
"the control plane's signing identity. None of the three exists yet, so this secret\n" + fmt.Printf("\nINCOMPLETE — this token cannot be used to join anything yet. Missing:\n")
"cannot be used to join anything -- it is the half that could be built without them.\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 return nil
} }
+152
View File
@@ -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)
}
+205
View File
@@ -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")
}
}
@@ -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;
+113
View File
@@ -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))
}
+118
View File
@@ -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
}