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,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