173 lines
5.8 KiB
Go
173 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"os"
|
|
"testing"
|
|
)
|
|
|
|
// A machine with no claim reports none. This is the NORMAL state, not an error:
|
|
// claim-owner is optional and an unclaimed box must work exactly as before.
|
|
func TestNoClaimReportsNothing(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
if got := currentOwnerClaim(); got != nil {
|
|
t.Fatalf("an unclaimed machine must report no owner, got %+v", got)
|
|
}
|
|
body := buildHeartbeatBody("", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if contains(body, "owner_claim") {
|
|
t.Errorf("heartbeat carried an owner_claim on an unclaimed machine:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// writeClaim forges a claim file directly (the real one needs root), signing it
|
|
// the same way cmdClaimOwner does so the verify path sees a genuine claim.
|
|
func writeClaim(t *testing.T, owner, claimedAt string) ed25519.PrivateKey {
|
|
t.Helper()
|
|
pub, priv, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sig := ed25519.Sign(priv, ownerSigningInput(owner, claimedAt))
|
|
f := ownerFile{
|
|
Owner: owner,
|
|
ClaimedAt: claimedAt,
|
|
PublicKey: base64.StdEncoding.EncodeToString(pub),
|
|
PrivateKey: base64.StdEncoding.EncodeToString(priv),
|
|
Signature: base64.StdEncoding.EncodeToString(sig),
|
|
}
|
|
b, _ := json.MarshalIndent(f, "", " ")
|
|
p, err := ownerKeyPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, b, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return priv
|
|
}
|
|
|
|
func TestValidClaimTravelsOnTheHeartbeat(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
writeClaim(t, "Acme IT", "2026-09-07T12:00:00Z")
|
|
|
|
got := currentOwnerClaim()
|
|
if got == nil {
|
|
t.Fatal("a validly signed claim must be reported")
|
|
}
|
|
if got.Owner != "Acme IT" {
|
|
t.Errorf("owner = %q, want %q", got.Owner, "Acme IT")
|
|
}
|
|
body := buildHeartbeatBody("", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if !contains(body, "owner_claim") || !contains(body, "Acme IT") {
|
|
t.Errorf("heartbeat did not carry the owner claim:\n%s", body)
|
|
}
|
|
// The PRIVATE key must never leave the machine — it IS the claim.
|
|
if contains(body, got.PublicKey) && contains(body, "private_key") {
|
|
t.Errorf("heartbeat carried a private_key field:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// THE ATTACK THIS GUARDS: hand-editing owner.json to a different name. The file
|
|
// is 0600 root-owned, but a root user editing their own box is exactly who this
|
|
// is meant to be honest about — and the signature is over the name, so a renamed
|
|
// claim no longer verifies. It must then report NOTHING rather than travelling
|
|
// for the host to reject: an unverifiable claim must never look like a claim.
|
|
func TestTamperedOwnerNameIsNotReported(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
writeClaim(t, "Acme IT", "2026-09-07T12:00:00Z")
|
|
|
|
p, _ := ownerKeyPath()
|
|
raw, _ := os.ReadFile(p)
|
|
var f ownerFile
|
|
_ = json.Unmarshal(raw, &f)
|
|
f.Owner = "Someone Else" // signature still covers "Acme IT"
|
|
b, _ := json.MarshalIndent(f, "", " ")
|
|
_ = os.WriteFile(p, b, 0o600)
|
|
|
|
if got := currentOwnerClaim(); got != nil {
|
|
t.Fatalf("a tampered owner name must not be reported, got %+v", got)
|
|
}
|
|
body := buildHeartbeatBody("", "box", "linux", "test", "pub", "fp", "", "", false, nil, nil, nil)
|
|
if contains(body, "Someone Else") {
|
|
t.Errorf("a forged owner name reached the heartbeat:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// A truncated or malformed file is "no claim", never a crash and never a
|
|
// half-reported owner.
|
|
func TestMalformedClaimIsNoClaim(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
p, err := ownerKeyPath()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, junk := range []string{"", "{", `{"owner":"x"}`, `{"owner":"x","claimed_at":"y","public_key":"!!","signature":"!!"}`} {
|
|
if err := os.WriteFile(p, []byte(junk), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := currentOwnerClaim(); got != nil {
|
|
t.Errorf("malformed claim %q was reported as %+v", junk, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// claim-owner refuses without root — running as root IS the proof, so a claim
|
|
// minted without it would attest nothing. As root, the same test drives the
|
|
// command for real instead of skipping: whichever way this suite runs, one of
|
|
// the two halves exercises cmdClaimOwner rather than only its helpers.
|
|
func TestClaimOwner(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
|
|
if os.Geteuid() != 0 {
|
|
if err := cmdClaimOwner("Acme IT"); err == nil {
|
|
t.Fatal("claim-owner must refuse when not root")
|
|
}
|
|
if got := currentOwnerClaim(); got != nil {
|
|
t.Fatal("a refused claim must not have been written")
|
|
}
|
|
return
|
|
}
|
|
|
|
if err := cmdClaimOwner("Acme IT"); err != nil {
|
|
t.Fatalf("claim-owner as root: %v", err)
|
|
}
|
|
got := currentOwnerClaim()
|
|
if got == nil || got.Owner != "Acme IT" {
|
|
t.Fatalf("the signed claim did not round-trip: %+v", got)
|
|
}
|
|
if got.Signature == "" || got.PublicKey == "" || got.ClaimedAt == "" {
|
|
t.Fatalf("claim is missing a field: %+v", got)
|
|
}
|
|
|
|
// The key file must not be world-readable — the private key IS the claim.
|
|
p, _ := ownerKeyPath()
|
|
st, err := os.Stat(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if st.Mode().Perm() != 0o600 {
|
|
t.Errorf("owner key mode = %o, want 600", st.Mode().Perm())
|
|
}
|
|
|
|
// Re-claiming under a new name KEEPS the machine's key. Rotating it would
|
|
// make an ordinary rename indistinguishable from a different machine
|
|
// answering — the one signal a peer actually needs from this.
|
|
first := got.PublicKey
|
|
if err := cmdClaimOwner("Acme IT (EU)"); err != nil {
|
|
t.Fatalf("re-claim: %v", err)
|
|
}
|
|
again := currentOwnerClaim()
|
|
if again == nil || again.Owner != "Acme IT (EU)" {
|
|
t.Fatalf("re-claim did not take: %+v", again)
|
|
}
|
|
if again.PublicKey != first {
|
|
t.Errorf("re-claiming rotated the machine key (%s -> %s); a rename must not look like a different machine",
|
|
first, again.PublicKey)
|
|
}
|
|
if again.Signature == got.Signature {
|
|
t.Error("the signature did not change with the owner name")
|
|
}
|
|
}
|