Files
privatae-bastion/client-go/owner_test.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

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")
}
}