Files
privatae-bastion/client-go/dcn_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

287 lines
11 KiB
Go

package main
// DCN handler walls — the verification gates that are testable without a
// live relay: signature verify + tamper + expiry (dcnVerifyRec), the path sandbox
// incl. symlink escape (dcnResolvePath), excludes matching, and mask checks.
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func signedRec(t *testing.T, priv ed25519.PrivateKey, terms map[string]any) dcnGrantRec {
t.Helper()
canonical, err := json.Marshal(terms)
if err != nil {
t.Fatal(err)
}
return dcnGrantRec{
TermsCanonical: base64.StdEncoding.EncodeToString(canonical),
Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(priv, canonical)),
}
}
func TestDcnVerifyRec(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(nil)
pubB64 := base64.StdEncoding.EncodeToString(pub)
terms := map[string]any{"kind": "dcn_store_grant", "class": "machine",
"server": "box1", "root": "/data", "mask": []string{"read"},
"expires": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)}
rec := signedRec(t, priv, terms)
got, err := dcnVerifyRec(pubB64, rec)
if err != nil || got.Root != "/data" {
t.Fatalf("valid grant must verify: %v", err)
}
// Tamper: widen the mask inside the canonical bytes → signature must fail.
bad := terms
bad["mask"] = []string{"read", "write"}
canonical, _ := json.Marshal(bad)
tampered := dcnGrantRec{TermsCanonical: base64.StdEncoding.EncodeToString(canonical),
Signature: rec.Signature}
if _, err := dcnVerifyRec(pubB64, tampered); err == nil {
t.Fatal("a grant mutated after signing must NOT verify")
}
// Wrong key → fail.
otherPub, _, _ := ed25519.GenerateKey(nil)
if _, err := dcnVerifyRec(base64.StdEncoding.EncodeToString(otherPub), rec); err == nil {
t.Fatal("a grant must not verify under a different pinned key")
}
// Signed expiry in the past → fail (the signed expiry is authoritative).
terms["mask"] = []string{"read"}
terms["expires"] = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
if _, err := dcnVerifyRec(pubB64, signedRec(t, priv, terms)); err == nil {
t.Fatal("an expired grant must NOT verify")
}
}
func TestDcnResolvePathSandbox(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "sub", "a.txt"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if _, ok := dcnResolvePath(root, "sub/a.txt"); !ok {
t.Fatal("an in-root path must resolve")
}
for _, esc := range []string{"../etc/passwd", "sub/../../x", "/etc/passwd", ""} {
if _, ok := dcnResolvePath(root, esc); ok {
t.Fatalf("path %q must be rejected (traversal wall)", esc)
}
}
// Symlink escape: a link inside the store pointing outside it must be refused.
outside := t.TempDir()
if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("s"), 0o600); err != nil {
t.Fatal(err)
}
link := filepath.Join(root, "leak")
if err := os.Symlink(outside, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if _, ok := dcnResolvePath(root, "leak/secret"); ok {
t.Fatal("a symlink pointing outside the root must be rejected (store escape)")
}
// A missing path inside the root is fine (stat-of-missing → typed gone, not escape).
if _, ok := dcnResolvePath(root, "sub/nope.txt"); !ok {
t.Fatal("a missing in-root path must still resolve (it becomes state=gone)")
}
// A broken link is fine as the last part (a stat reads it as gone, a move
// renames the link itself); a path THROUGH it is refused.
if err := os.Symlink(filepath.Join(outside, "missing"), filepath.Join(root, "broken")); err != nil {
t.Fatal(err)
}
if _, ok := dcnResolvePath(root, "broken"); !ok {
t.Fatal("a broken link inside the root must still resolve as itself")
}
if _, ok := dcnResolvePath(root, "broken/x"); ok {
t.Fatal("a path through a broken link must be refused")
}
if len(dcnCheckPlan(root, nil, []dcnMove{{From: "broken", To: "sub/moved-link"}})) != 0 {
t.Fatal("moving a broken link renames the link, and must pass")
}
}
func TestDcnExcludes(t *testing.T) {
excludes := []string{".ssh", "*.key", ".env*", "id_rsa*"}
for _, hit := range []string{".ssh/id_rsa", "deep/.ssh/config", "certs/server.key",
".env.production", "keys/id_rsa.pub"} {
if !dcnExcluded(excludes, hit) {
t.Fatalf("%q must be excluded", hit)
}
}
// A host's path is judged as cleanRel will open it: these are spelled with
// whitespace cleanRel trims, and a ".." it resolves.
for _, hit := range []string{" .ssh/id_rsa", "certs/server.key ", "\t.env", "docs/../.ssh/x"} {
if !dcnExcludedAsSent(excludes, "/srv/store", hit) {
t.Fatalf("%q must be excluded as sent", hit)
}
}
for _, ok := range []string{"docs/readme.md", "ssh-notes.txt", "envelope.pdf"} {
if dcnExcluded(excludes, ok) {
t.Fatalf("%q must NOT be excluded", ok)
}
}
}
// Names are compared the way each platform's file system tells them apart.
// Judged per platform from any OS, so a Windows or macOS rule is tested on Linux.
func TestDcnExcludesCompareNamesAsTheFileSystemDoes(t *testing.T) {
excludes := []string{".ssh", "*.key"}
for _, c := range []struct {
goos, path string
want bool
}{
{"linux", ".SSH/id_rsa", false}, // a different folder on Linux
{"darwin", ".SSH/id_rsa", true},
{"windows", ".SSH/id_rsa", true},
{"windows", ".ssh./id_rsa", true},
{"windows", ".ssh /id_rsa", true},
{"windows", "certs/server.key::$DATA", true},
{"windows", "certs/SERVER.KEY", true},
{"linux", "certs/server.key.", false},
{"darwin", "docs/readme.md", false},
{"windows", "docs/readme.md", false},
} {
if got := dcnExcludedOn(c.goos, excludes, c.path); got != c.want {
t.Errorf("%s: %q excluded=%v, want %v", c.goos, c.path, got, c.want)
}
}
}
// A link inside the store to an excluded folder opens that folder, so a path
// through it is excluded, for a read and for a move alike.
func TestDcnExcludesHoldThroughALinkInsideTheStore(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".ssh"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".ssh", "id_rsa"), []byte("k"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(".ssh", filepath.Join(root, "keys")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if err := os.Symlink(".ssh/id_rsa", filepath.Join(root, "key")); err != nil {
t.Fatal(err)
}
excludes := []string{".ssh"}
for _, p := range []string{"keys/id_rsa", "keys", "key", " keys/id_rsa"} {
if !dcnExcludedAsSent(excludes, root, p) {
t.Errorf("%q opens the excluded .ssh, and must be excluded", p)
}
}
if dcnExcludedAsSent(excludes, root, "docs/new.txt") {
t.Error("a path that leads nowhere excluded must not be")
}
if len(dcnCheckPlan(root, excludes, []dcnMove{{From: "keys/id_rsa", To: "moved"}})) == 0 {
t.Error("a move out of the excluded folder through a link must be refused")
}
if len(dcnCheckPlan(root, excludes, []dcnMove{{From: "docs", To: "keys/x"}})) == 0 {
t.Error("a move into the excluded folder through a link must be refused")
}
}
func TestDcnMask(t *testing.T) {
terms := &dcnTerms{Mask: []string{"read", "search"}}
if !dcnHasMask(terms, "read") || dcnHasMask(terms, "write") {
t.Fatal("mask membership check broken")
}
if dcnHasMask(terms, "delete") {
t.Fatal("delete can never be in a mask")
}
}
// A revoke reaches the box — pushed (dcn_grant_drop)
// or reconciled from the heartbeat reply — instead of living on until the signed
// expiry. The absent field is "no instruction", never "drop everything".
func TestDcnGrantDropAndReconcile(t *testing.T) {
dir := t.TempDir()
pub, priv, _ := ed25519.GenerateKey(nil)
mk := func(server string) dcnGrantRec {
return signedRec(t, priv, map[string]any{"kind": "dcn_store_grant", "class": "machine",
"server": server, "root": "/data", "mask": []string{"read"},
"expires": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)})
}
f := &dcnGrantFile{VerifyPubkey: base64.StdEncoding.EncodeToString(pub),
Stores: map[string]dcnGrantRec{"1": mk("box1"), "2": mk("box1"), "3": mk("box1")}}
if err := dcnSaveFile(dir, f); err != nil {
t.Fatal(err)
}
stores := func() map[string]dcnGrantRec {
g, err := dcnLoadFile(dir)
if err != nil {
t.Fatal(err)
}
return g.Stores
}
// 1. absent field → nothing happens
r := maybeReconcileGrants([]byte(`{"ok":true}`), dir)
if len(r.Dropped)+len(r.Stale)+len(r.Missing) != 0 || len(stores()) != 3 {
t.Fatalf("no dcn_grants field must be a no-op: %+v stores=%d", r, len(stores()))
}
if reconcileReport(dir) != nil {
t.Fatal("a clean reconcile must leave no pending report")
}
// 2. host lists 1 (same sig), 2 (different sig), 9 (not on disk); 3 unlisted
body := []byte(`{"dcn_grants":{"stores":[{"store_id":1,"signature":"` + f.Stores["1"].Signature +
`"},{"store_id":2,"signature":"different"},{"store_id":9,"signature":"x"}]}}`)
r = maybeReconcileGrants(body, dir)
if len(r.Dropped) != 0 || len(stores()) != 3 {
t.Fatalf("ONE unlisted reply must not drop (a sync in flight is unlisted by timing): %+v", r)
}
r = maybeReconcileGrants(body, dir)
if len(r.Dropped) != 1 || r.Dropped[0] != 3 {
t.Fatalf("store 3 (unlisted twice) must be dropped: %+v", r)
}
if len(r.Stale) != 1 || r.Stale[0] != 2 {
t.Fatalf("store 2 (signature differs) must be reported stale, not dropped: %+v", r)
}
if len(r.Missing) != 1 || r.Missing[0] != 9 {
t.Fatalf("store 9 (listed, not on disk) must be reported missing: %+v", r)
}
s := stores()
if _, ok := s["3"]; ok {
t.Fatal("dropped store must be gone from grants.json")
}
if _, ok := s["2"]; !ok {
t.Fatal("a stale store must stay on disk (the box never fabricates or discards a valid grant on a hint)")
}
if rep := reconcileReport(dir); rep == nil || len(rep.Stale) != 1 || len(rep.Missing) != 1 {
t.Fatalf("drift must be carried for the next heartbeat: %+v", rep)
}
// 3. explicit empty list (twice) → the host says this box holds nothing → all dropped
maybeReconcileGrants([]byte(`{"dcn_grants":{"stores":[]}}`), dir)
r = maybeReconcileGrants([]byte(`{"dcn_grants":{"stores":[]}}`), dir)
if len(r.Dropped) != 2 || len(stores()) != 0 {
t.Fatalf("an explicit empty set must drop every store: %+v stores=%d", r, len(stores()))
}
// 4. pushed drop on an absent store is a clean no-op, not an error
if present, err := dcnDropStore(dir, 42, "test"); err != nil || present {
t.Fatalf("dropping an unknown store: present=%v err=%v", present, err)
}
// audit rows landed for the drops
raw, _ := os.ReadFile(filepath.Join(dir, "audit.jsonl"))
if !strings.Contains(string(raw), `"op":"grant_drop"`) {
t.Fatal("grant drops must be audited on the box")
}
}