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.
119 lines
3.5 KiB
Go
119 lines
3.5 KiB
Go
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
|
|
}
|