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:
@@ -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 <name> create a node record
|
||||
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 --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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 <name> a one-time right to join, for an existing record
|
||||
token issue --new <name> 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user