412 lines
14 KiB
Go
412 lines
14 KiB
Go
package identity
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestAMachineThatHasNotJoinedHasNoIdentityAndThatIsNotAFault(t *testing.T) {
|
|
// A hosted machine has a host running and no identity. That is a real state, and confusing
|
|
// it with a fault would have every fresh install look broken.
|
|
_, err := Load(Path(filepath.Join(t.TempDir(), "state.json")))
|
|
if !errors.Is(err, ErrNoIdentity) {
|
|
t.Fatalf("a machine that never joined gave %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnUnreadableIdentityIsNotTheSameAsHavingNone(t *testing.T) {
|
|
// The distinction that matters most here. "None" leads to enrolling; if an unreadable
|
|
// identity took that path, a node would discard the identity the mesh still believes and
|
|
// need a person with a new token to get back.
|
|
dir := t.TempDir()
|
|
path := Path(filepath.Join(dir, "state.json"))
|
|
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Fatal("a corrupt identity loaded")
|
|
}
|
|
if errors.Is(err, ErrNoIdentity) {
|
|
t.Fatal("a corrupt identity was reported as having none; this node would re-enrol and " +
|
|
"throw away the identity the mesh believes")
|
|
}
|
|
}
|
|
|
|
// joined is an identity as it exists after enrolment, which is the only kind ever saved:
|
|
// Generate makes the keypair, and the mesh supplies everything under Membership.
|
|
func joined(t *testing.T, name string) Identity {
|
|
t.Helper()
|
|
made, err := Generate(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signer, _, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
made.Membership = Membership{
|
|
Broker: "192.0.2.10:5671",
|
|
Fingerprint: "sha256:" + strings.Repeat("ab", 32),
|
|
Signer: signer,
|
|
Password: "this node's own",
|
|
}
|
|
return made
|
|
}
|
|
|
|
func TestSaveRefusesWhatLoadWouldRefuse(t *testing.T) {
|
|
// The two must agree, or a caller can write a file that cannot be read back — and it would
|
|
// be read back on the next start, on a machine nobody is watching, by which time the token
|
|
// that could have fixed it is spent.
|
|
path := Path(filepath.Join(t.TempDir(), "state.json"))
|
|
unenrolled, err := Generate("workstation")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := Save(path, unenrolled); err == nil {
|
|
t.Fatal("an identity with no membership was saved; Load will not accept it")
|
|
}
|
|
if _, err := os.Stat(path); err == nil {
|
|
t.Error("the refused identity was written anyway")
|
|
}
|
|
}
|
|
|
|
func TestWhatIsSavedIsWhatIsLoaded(t *testing.T) {
|
|
path := Path(filepath.Join(t.TempDir(), "state.json"))
|
|
made := joined(t, "workstation")
|
|
if err := Save(path, made); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
back, err := Load(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back.Node != made.Node || string(back.Public) != string(made.Public) ||
|
|
string(back.Private) != string(made.Private) {
|
|
t.Error("the identity changed across a save and load")
|
|
}
|
|
if back.Membership.Broker != made.Membership.Broker ||
|
|
back.Membership.Fingerprint != made.Membership.Fingerprint ||
|
|
back.Membership.Password != made.Membership.Password ||
|
|
string(back.Membership.Signer) != string(made.Membership.Signer) {
|
|
t.Error("the membership changed across a save and load; this node could not come back")
|
|
}
|
|
}
|
|
|
|
func TestTheIdentityIsNotReadableByAnybodyElse(t *testing.T) {
|
|
// It is the only secret on the machine that identifies it. A mode that let another user on
|
|
// this machine read it would make "compromise of a node is compromise of that node" false in
|
|
// the other direction — any local user could become the node.
|
|
path := Path(filepath.Join(t.TempDir(), "state.json"))
|
|
if err := Save(path, joined(t, "workstation")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm()&0o077 != 0 {
|
|
t.Errorf("the identity is mode %04o; anything but 0600 lets another local user become "+
|
|
"this node", info.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
func TestSavingLeavesNoHalfWrittenIdentity(t *testing.T) {
|
|
// Written and renamed, so power lost mid-write keeps the old identity rather than producing
|
|
// half of one. A node cannot regenerate its way out of a broken identity — the mesh believes
|
|
// the old public key, and a new one needs a person with a new token.
|
|
dir := t.TempDir()
|
|
path := Path(filepath.Join(dir, "state.json"))
|
|
made := joined(t, "workstation")
|
|
for i := 0; i < 3; i++ {
|
|
if err := Save(path, made); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), ".identity-") {
|
|
t.Errorf("a temporary file survived: %s", e.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnIdentityOfTheWrongShapeIsRefused(t *testing.T) {
|
|
// The one that would load happily and fail at the moment it signs, which is during enrolment
|
|
// against a mesh, far from here.
|
|
path := Path(filepath.Join(t.TempDir(), "state.json"))
|
|
raw, err := json.Marshal(Identity{Node: "workstation", Public: []byte("short"), Private: []byte("also short")})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := Load(path); err == nil {
|
|
t.Fatal("an identity with a truncated key loaded")
|
|
}
|
|
}
|
|
|
|
func TestSigningProvesTheNodeIsThatNode(t *testing.T) {
|
|
made, err := Generate("workstation")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
challenge := []byte("prove it")
|
|
if !ed25519.Verify(ed25519.PublicKey(made.Public), challenge, made.Sign(challenge)) {
|
|
t.Fatal("a node's own signature did not verify against the half it publishes")
|
|
}
|
|
}
|
|
|
|
// --- the token, which the control plane writes and this parses ---
|
|
|
|
func encodeToken(t *testing.T, body string) string {
|
|
t.Helper()
|
|
return base64.RawURLEncoding.EncodeToString([]byte(body))
|
|
}
|
|
|
|
func completeToken(t *testing.T) string {
|
|
t.Helper()
|
|
public, _, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := json.Marshal(Token{
|
|
Version: 1, Broker: "192.0.2.10:5671",
|
|
Fingerprint: "sha256:" + strings.Repeat("ab", 32),
|
|
Signer: public, Secret: "one-time",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(raw)
|
|
}
|
|
|
|
func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) {
|
|
// The contract with the control plane, which defines this format separately because the host
|
|
// requires nothing present and does not import it (novox/hq ADR 0005). There is a matching
|
|
// test on the other side. Rename a field on either and both fail, which is the point — the
|
|
// alternative is a rename that only breaks at enrolment, on a real machine.
|
|
public, _, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := json.Marshal(Token{Version: 1, Broker: "b", Fingerprint: "f", Signer: public, Secret: "s"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var fields map[string]any
|
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{"v", "broker", "fingerprint", "signer", "secret"} {
|
|
if _, ok := fields[want]; !ok {
|
|
t.Errorf("the token has no %q field; the control plane writes that name", want)
|
|
}
|
|
}
|
|
if len(fields) != 5 {
|
|
t.Errorf("the token has %d fields, expected 5: %v", len(fields), fields)
|
|
}
|
|
|
|
// novox/hq ADR 0100: an adopted node's token says so, and a converged one's is unchanged.
|
|
raw, err = json.Marshal(Token{Version: 1, Secret: "s", Adopted: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fields = map[string]any{}
|
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fields["adopted"] != true {
|
|
t.Errorf("an adopted token does not say \"adopted\": %v", fields)
|
|
}
|
|
}
|
|
|
|
func TestACompleteTokenParses(t *testing.T) {
|
|
got, err := ParseToken(completeToken(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Broker != "192.0.2.10:5671" || len(got.SignerKey()) != ed25519.PublicKeySize {
|
|
t.Errorf("parsed %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestAPastedTokenTolerantOfWhitespace(t *testing.T) {
|
|
if _, err := ParseToken(" " + completeToken(t) + "\n"); err != nil {
|
|
t.Errorf("a pasted token was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnIncompleteTokenIsRefusedWholeAndSaysWhatIsMissing(t *testing.T) {
|
|
// Not a reduced capability — an unsafe one. Without the fingerprint this node would connect
|
|
// to whatever answers; without the signing key it could not tell a declaration from a
|
|
// forgery, and it applies whatever the link delivers.
|
|
for _, c := range []struct{ body, expect string }{
|
|
{`{"v":1,"fingerprint":"f","signer":"` + base64Key(t) + `","secret":"s"}`, "broker's address"},
|
|
{`{"v":1,"broker":"b","signer":"` + base64Key(t) + `","secret":"s"}`, "fingerprint"},
|
|
{`{"v":1,"broker":"b","fingerprint":"f","secret":"s"}`, "signing key"},
|
|
{`{"v":1,"broker":"b","fingerprint":"f","signer":"` + base64Key(t) + `"}`, "one-time secret"},
|
|
} {
|
|
_, err := ParseToken(encodeToken(t, c.body))
|
|
if err == nil {
|
|
t.Errorf("a token missing %s was accepted", c.expect)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), c.expect) {
|
|
t.Errorf("the refusal does not name %s: %v", c.expect, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestATokenFromAnotherVersionIsRefused(t *testing.T) {
|
|
if _, err := ParseToken(encodeToken(t, `{"v":99,"broker":"b","fingerprint":"f","secret":"s"}`)); err == nil {
|
|
t.Fatal("a token from an unknown version was accepted")
|
|
}
|
|
}
|
|
|
|
func TestGarbageIsRefused(t *testing.T) {
|
|
for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8"} {
|
|
if _, err := ParseToken(bad); err == nil {
|
|
t.Errorf("%q parsed as a token", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func base64Key(t *testing.T) string {
|
|
t.Helper()
|
|
public, _, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(public)
|
|
}
|
|
|
|
func TestAnIdentityThatCannotBeReadIsNotReportedAsAbsent(t *testing.T) {
|
|
// The other half of the distinction above, and the one that was untested: a file that exists
|
|
// and cannot be read. The corrupt case is caught when it fails to parse; this one never gets
|
|
// that far, so it needs its own check — and without it a permissions accident would look
|
|
// exactly like a machine that has never joined, and the node would enrol again and discard
|
|
// the identity the mesh still believes.
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("running as root, which can read anything")
|
|
}
|
|
path := Path(filepath.Join(t.TempDir(), "state.json"))
|
|
if err := Save(path, joined(t, "workstation")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(path, 0o000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := Load(path)
|
|
if err == nil {
|
|
t.Fatal("an unreadable identity loaded")
|
|
}
|
|
if errors.Is(err, ErrNoIdentity) {
|
|
t.Fatal("an unreadable identity was reported as having none; this node would re-enrol " +
|
|
"and throw away the identity the mesh believes")
|
|
}
|
|
if !strings.Contains(err.Error(), "not the same as") {
|
|
t.Errorf("the error does not say why this is different from having none: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestASealingKeyOpensOnlyWhatWasSealedToIt(t *testing.T) {
|
|
mine, err := GenerateSealingKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
theirs, err := GenerateSealingKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sealed, err := Seal(mine.Public, []byte("hunter2"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := mine.Unseal(sealed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != "hunter2" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
if _, err := theirs.Unseal(sealed); err == nil {
|
|
t.Fatal("another node opened it")
|
|
}
|
|
}
|
|
|
|
func TestSealingTheSameValueTwiceLooksDifferent(t *testing.T) {
|
|
// Sealed boxes are randomised, so an observer cannot tell that two nodes were given the same
|
|
// password, nor that a rotation changed nothing. Worth asserting because the alternative is
|
|
// a subtle leak nobody would look for.
|
|
key, _ := GenerateSealingKey()
|
|
first, _ := Seal(key.Public, []byte("same"))
|
|
second, _ := Seal(key.Public, []byte("same"))
|
|
if first == second {
|
|
t.Fatal("sealing is deterministic, so equal secrets are visible as equal blobs")
|
|
}
|
|
}
|
|
|
|
func TestANodeWithNoSealingKeySaysWhatToDo(t *testing.T) {
|
|
// Rather than making one. A key the mesh was never told about is a key nothing can be sealed
|
|
// to, so a node that quietly created one would look fine and receive nothing for ever.
|
|
_, err := LoadSealingKey(t.TempDir() + "/absent.key")
|
|
if err == nil {
|
|
t.Fatal("a sealing key appeared out of nowhere")
|
|
}
|
|
if !strings.Contains(err.Error(), "join again") {
|
|
t.Fatalf("the failure does not say what to do: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestASealingKeyOnDiskSurvivesATrailingNewline(t *testing.T) {
|
|
// It is written with one, the way every other key file here is, and reading it back has to
|
|
// cope — otherwise the key works until the first restart.
|
|
key, _ := GenerateSealingKey()
|
|
path := t.TempDir() + "/sealing.key"
|
|
if err := os.WriteFile(path, []byte(key.Private+"\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := LoadSealingKey(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if back.Public != key.Public {
|
|
t.Fatalf("a round trip through the disk changed the key")
|
|
}
|
|
}
|
|
|
|
func TestATokenSaysWhatTheMeshCallsThisMachine(t *testing.T) {
|
|
// The node cannot work its own name out. The broker account it authenticates as is named
|
|
// after it and exists before this machine has been told anything — so without the name in the
|
|
// token, enrolment is a connection refused with an empty username, which names nothing about
|
|
// the cause. That is exactly how the first end-to-end raise went.
|
|
raw := base64.RawURLEncoding.EncodeToString([]byte(
|
|
`{"v":1,"node":"anchor","broker":"192.0.2.10:5671",` +
|
|
`"fingerprint":"sha256:` + strings.Repeat("ab", 32) + `",` +
|
|
`"signer":"` + base64.StdEncoding.EncodeToString(make([]byte, 32)) + `",` +
|
|
`"secret":"a-one-time-secret"}`))
|
|
token, err := ParseToken(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if token.Node != "anchor" {
|
|
t.Fatalf("the name did not survive the token: %q", token.Node)
|
|
}
|
|
}
|