// SPDX-License-Identifier: Apache-2.0 package bundle import ( "bytes" "crypto/ed25519" "strings" "testing" "time" ) var testTime = time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) func testKey(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { t.Helper() seed := bytes.Repeat([]byte{7}, ed25519.SeedSize) priv := ed25519.NewKeyFromSeed(seed) return priv.Public().(ed25519.PublicKey), priv } func sample() []Artifact { return []Artifact{ {Path: "ddi-suite.conf", Mode: 0o640, Content: []byte("zone \"a.test\" {};\n")}, {Path: "zones/db.a.test", Mode: 0o640, Content: []byte("$TTL 3600\n")}, {Path: "zones/db.dyn.test", Mode: 0o640, Content: []byte("dyn\n"), CreateOnly: true}, } } func contents(arts []Artifact) map[string][]byte { m := make(map[string][]byte, len(arts)) for _, a := range arts { m[a.Path] = a.Content } return m } func TestSignVerifyRoundTrip(t *testing.T) { pub, priv := testKey(t) arts := sample() man, sig, err := Sign(priv, "agent-1", "dns", 42, testTime, arts) if err != nil { t.Fatal(err) } m, err := Verify(pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dns", MinRevision: 42}) if err != nil { t.Fatal(err) } if m.Revision != 42 || len(m.Files) != 3 || !m.Files[2].CreateOnly || m.Files[0].Mode != 0o640 || !m.CreatedAt.Equal(testTime) || m.Files[1].Size != int64(len(arts[1].Content)) { t.Errorf("manifest %+v", m) } // Signing is deterministic: same input, same bytes. man2, sig2, _ := Sign(priv, "agent-1", "dns", 42, testTime, arts) if !bytes.Equal(man, man2) || !bytes.Equal(sig, sig2) { t.Error("signing is not deterministic") } } func TestVerifyRejects(t *testing.T) { pub, priv := testKey(t) arts := sample() man, sig, err := Sign(priv, "agent-1", "dns", 5, testTime, arts) if err != nil { t.Fatal(err) } ok := Expect{AgentID: "agent-1", Role: "dns"} otherPub, _, _ := ed25519.GenerateKey(nil) tampered := bytes.Replace(man, []byte(`"revision":5`), []byte(`"revision":6`), 1) changed := contents(arts) changed["zones/db.a.test"] = []byte("$TTL 3601\n") missing := contents(arts) delete(missing, "zones/db.a.test") extra := contents(arts) extra["other"] = nil short := contents(arts) short["zones/db.a.test"] = []byte("x") cases := []struct { name string pub ed25519.PublicKey man, sig []byte files map[string][]byte want Expect msg string }{ {"wrong key", otherPub, man, sig, contents(arts), ok, "signature"}, {"bad key length", pub[:5], man, sig, contents(arts), ok, "signing key"}, {"tampered manifest", pub, tampered, sig, contents(arts), ok, "signature"}, {"other agent", pub, man, sig, contents(arts), Expect{AgentID: "agent-2", Role: "dns"}, "agent"}, {"other role", pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dhcp"}, "role"}, {"replay", pub, man, sig, contents(arts), Expect{AgentID: "agent-1", Role: "dns", MinRevision: 6}, "older"}, {"changed content", pub, man, sig, changed, ok, "SHA-256"}, {"wrong size", pub, man, sig, short, ok, "size"}, {"missing file", pub, man, sig, missing, ok, "files"}, {"extra file", pub, man, sig, extra, ok, "files"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { _, err := Verify(c.pub, c.man, c.sig, c.files, c.want) if err == nil || !strings.Contains(err.Error(), c.msg) { t.Errorf("err = %v, want %q", err, c.msg) } }) } } func TestVerifyRejectsUnsafeManifests(t *testing.T) { pub, priv := testKey(t) sign := func(raw string) ([]byte, []byte) { return []byte(raw), ed25519.Sign(priv, []byte(raw)) } cases := map[string]string{ "format": `{"format":2,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[]}`, "no rev": `{"format":1,"agent_id":"a","role":"dns","revision":0,"created_at":"2026-10-13T12:00:00Z","files":[]}`, "unknown": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[],"x":1}`, "trailing": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[]} {}`, "escape": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"../etc/passwd","mode":420,"sha256":"","size":0}]}`, "mode": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":2048,"sha256":"","size":0}]}`, "dup": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":0},{"path":"x","mode":420,"sha256":"","size":0}]}`, "negative": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":-1}]}`, "huge": `{"format":1,"agent_id":"a","role":"dns","revision":1,"created_at":"2026-10-13T12:00:00Z","files":[{"path":"x","mode":420,"sha256":"","size":1073741824}]}`, } for name, raw := range cases { t.Run(name, func(t *testing.T) { man, sig := sign(raw) if _, err := Verify(pub, man, sig, map[string][]byte{}, Expect{AgentID: "a", Role: "dns"}); err == nil { t.Error("accepted") } }) } if _, _, err := Sign(priv[:3], "a", "dns", 1, testTime, nil); err == nil { t.Error("Sign accepted a short key") } if _, _, err := Sign(priv, "a", "dns", 1, testTime, []Artifact{{Path: "/abs"}}); err == nil { t.Error("Sign accepted an absolute path") } } func TestCheckPath(t *testing.T) { good := []string{"ddi-suite.conf", "zones/db.office.test", "kea-dhcp4.conf", "a/b/c+d@e_f-g"} bad := []string{"", "/abs", "../x", "a/../b", "a//b", "a/", ".hidden", "zones/.x", "a b", "a\\b", "รค", strings.Repeat("a", MaxPathLen+1)} for _, p := range good { if err := CheckPath(p); err != nil { t.Errorf("%q: %v", p, err) } } for _, p := range bad { if CheckPath(p) == nil { t.Errorf("%q accepted", p) } } } func FuzzVerify(f *testing.F) { pub, priv := testKey(&testing.T{}) man, _, _ := Sign(priv, "a", "dns", 1, testTime, sample()) f.Add(man) f.Fuzz(func(t *testing.T, raw []byte) { // Whatever is signed, Verify must not panic and must never accept a // path outside the root. m, err := Verify(pub, raw, ed25519.Sign(priv, raw), map[string][]byte{}, Expect{AgentID: "a", Role: "dns"}) if err != nil { return } for _, file := range m.Files { if CheckPath(file.Path) != nil { t.Fatalf("accepted unsafe path %q", file.Path) } } }) }