287 lines
11 KiB
Go
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")
|
|
}
|
|
}
|