Shared Apache-2.0 packages for the signed bundle format and the agent certificate conventions, the agent role interface, REST API spec for agents, enrollment tokens, revisions, deployments and leases, and ADR 0021. Generator settings now hold the target paths on the managed hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
6.4 KiB
Go
173 lines
6.4 KiB
Go
// 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)
|
|
}
|
|
}
|
|
})
|
|
}
|