The control plane's signing key, and a second context to hold it
Everything is blocked on what a node presents to prove which node it is. This builds the other direction, which is not blocked: what a node believes. identity is the second of the seven contexts. It holds an Ed25519 signing key the control plane generates once, whose public half now travels in every enrolment token. A node believes a declaration because it carries a signature that key made -- pinning only the broker would make the control plane's authority transitive, and since the host applies whatever the link delivers, a compromised broker forging declarations is the whole machine. Establishing the key is idempotent, and it has to be: a second key generated by a restart is a mesh where every node holds the wrong public half, so every declaration is refused by every node with nothing visibly wrong. The guarantee is a partial unique index plus a read-back, not the check before the insert -- six processes racing to establish all agree on one key, and there is a test that runs them. Tokens are now one line of base64 carrying three of their four parts. The missing two are the broker's address and its certificate fingerprint, both step 5 of the bootstrap. The command prints the token and names what is missing rather than emitting something that looks usable. The second context also tests a claim this repository had made and never checked: that a context reaches only its own store. Two databases, two credentials, no setting that reaches both. Running migrate with one stops and names the grant it lacks -- verified, not asserted. Assembling a token needs a node record from one and a key from the other, and neither reads the other's store; the process holding both grants asks each for its part. 45 tests, none skipped. Fault injection found one test whose property is enforced somewhere other than where I injected -- idempotency comes from the database constraint, not from the early return, which is what the code comment already said.
This commit is contained in:
@@ -0,0 +1,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))
|
||||
}
|
||||
Reference in New Issue
Block a user