|
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + |
| 3 | +// zz_fuzz_test.go — adversarial/fuzz coverage for the trust-decision |
| 4 | +// loader. The trusted-agents list is fetched over the network and |
| 5 | +// embedded at build time; a malformed, oversized, or hostile document |
| 6 | +// must NEVER panic the loader or silently open auto-accept trust. |
| 7 | +// |
| 8 | +// Two invariants are fuzzed here: |
| 9 | +// |
| 10 | +// - FuzzLoad: Load over arbitrary bytes never panics, and whenever it |
| 11 | +// returns nil the resulting in-memory list is internally consistent |
| 12 | +// and fail-closed (no zero/empty entries trusted, no node trusted |
| 13 | +// with a different key than its pin). |
| 14 | +// |
| 15 | +// - FuzzDecodePin: the per-entry pin decoder never panics and only |
| 16 | +// yields a non-nil key of the exact Ed25519 size — a malformed pin |
| 17 | +// is an error, never a silently-accepted short/long key. |
| 18 | +// |
| 19 | +// These complement the deterministic matrix in zz_pubkey_pin_test.go; |
| 20 | +// they don't restate it, they stress the parser boundary around it. |
| 21 | + |
| 22 | +package trustedagents |
| 23 | + |
| 24 | +import ( |
| 25 | + "crypto/ed25519" |
| 26 | + "crypto/rand" |
| 27 | + "encoding/base64" |
| 28 | + "strings" |
| 29 | + "testing" |
| 30 | +) |
| 31 | + |
| 32 | +// loaderConsistent asserts the package-global trust state is internally |
| 33 | +// coherent after a successful Load: every indexed node is non-zero and |
| 34 | +// named, and any pinned node refuses a deliberately-wrong key while |
| 35 | +// accepting nothing it should not. Run under the package lock by the |
| 36 | +// caller's contract (we take RLock here). |
| 37 | +func loaderConsistent(t *testing.T) { |
| 38 | + t.Helper() |
| 39 | + mu.RLock() |
| 40 | + defer mu.RUnlock() |
| 41 | + for nodeID, e := range byNode { |
| 42 | + if nodeID == 0 { |
| 43 | + t.Fatalf("fail-closed violated: node_id 0 is indexed (name=%q)", e.name) |
| 44 | + } |
| 45 | + if e.name == "" { |
| 46 | + t.Fatalf("fail-closed violated: node_id %d indexed with empty name", nodeID) |
| 47 | + } |
| 48 | + if e.pubKey != nil && len(e.pubKey) != ed25519.PublicKeySize { |
| 49 | + t.Fatalf("node_id %d pinned with non-Ed25519-size key (%d bytes)", nodeID, len(e.pubKey)) |
| 50 | + } |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +// FuzzLoad throws arbitrary bytes at Load. Contract under test: |
| 55 | +// - Load never panics. |
| 56 | +// - On success (err == nil) the trust index is fail-closed and a |
| 57 | +// pinned entry never trusts a freshly-generated random key. |
| 58 | +func FuzzLoad(f *testing.F) { |
| 59 | + // Seed corpus: valid, malformed, oversized, duplicate, pinned, |
| 60 | + // and boundary documents. |
| 61 | + _, goodPin := func() (ed25519.PublicKey, string) { |
| 62 | + pub, _, _ := ed25519.GenerateKey(rand.Reader) |
| 63 | + return pub, base64.StdEncoding.EncodeToString(pub) |
| 64 | + }() |
| 65 | + seeds := []string{ |
| 66 | + `{"agents":[]}`, |
| 67 | + `{"agents":[{"hostname":"a","node_id":1}]}`, |
| 68 | + `{"agents":[{"hostname":"a","node_id":0}]}`, // zero id dropped |
| 69 | + `{"agents":[{"hostname":"","node_id":5}]}`, // empty host dropped |
| 70 | + `{"agents":[{"hostname":"a","node_id":1},{"hostname":"b","node_id":1}]}`, // duplicate → error |
| 71 | + `{"agents":[{"hostname":"a","node_id":1,"public_key":"` + goodPin + `"}]}`, |
| 72 | + `{"agents":[{"hostname":"a","node_id":1,"public_key":"!!!"}]}`, // bad base64 |
| 73 | + `{"agents":[{"hostname":"a","node_id":1,"public_key":"AAAA"}]}`, // short key |
| 74 | + `{"agents":[{"hostname":"a","node_id":1,"tier":"x","extra":42}]}`, // extra fields |
| 75 | + `{not json`, |
| 76 | + ``, |
| 77 | + `null`, |
| 78 | + `{"agents":null}`, |
| 79 | + `[]`, |
| 80 | + `{"agents":` + strings.Repeat("[", 4096) + `}`, // deeply nested / oversized |
| 81 | + } |
| 82 | + for _, s := range seeds { |
| 83 | + f.Add([]byte(s)) |
| 84 | + } |
| 85 | + |
| 86 | + // Snapshot and restore the global list around the whole fuzz run so |
| 87 | + // we don't corrupt state for other tests in the package. |
| 88 | + restore := SetForTest(nil) |
| 89 | + defer restore() |
| 90 | + |
| 91 | + f.Fuzz(func(t *testing.T, raw []byte) { |
| 92 | + // Reset to a known-empty baseline each iteration so a prior |
| 93 | + // successful Load can't mask a later failing one. |
| 94 | + _ = Load([]byte(`{"agents":[]}`)) |
| 95 | + |
| 96 | + // The contract: never panic, whatever the input. |
| 97 | + err := Load(raw) |
| 98 | + if err != nil { |
| 99 | + // A failed Load must NOT have replaced the active list with a |
| 100 | + // partially-built or corrupt one. The empty baseline must hold: |
| 101 | + // nothing new should be trusted. |
| 102 | + loaderConsistent(t) |
| 103 | + return |
| 104 | + } |
| 105 | + |
| 106 | + // Success: index must be fail-closed and self-consistent. |
| 107 | + loaderConsistent(t) |
| 108 | + |
| 109 | + // For any node that ended up pinned, a random key must be refused |
| 110 | + // (constant-time mismatch path), and the key-less IsTrusted must |
| 111 | + // still answer by node_id. |
| 112 | + randPub, _, _ := ed25519.GenerateKey(rand.Reader) |
| 113 | + for nodeID, e := range snapshotIndex() { |
| 114 | + if _, ok := IsTrusted(nodeID); !ok { |
| 115 | + t.Fatalf("node_id %d in index but IsTrusted says untrusted", nodeID) |
| 116 | + } |
| 117 | + if e.pubKey != nil { |
| 118 | + if _, ok := IsTrustedWithKey(nodeID, randPub); ok { |
| 119 | + t.Fatalf("pinned node_id %d trusted a random key — pin not enforced", nodeID) |
| 120 | + } |
| 121 | + } |
| 122 | + } |
| 123 | + }) |
| 124 | +} |
| 125 | + |
| 126 | +// snapshotIndex returns a copy of the current byNode map so the fuzz |
| 127 | +// body can iterate without holding the package lock across IsTrusted* |
| 128 | +// calls (which take the lock themselves). |
| 129 | +func snapshotIndex() map[uint32]entry { |
| 130 | + mu.RLock() |
| 131 | + defer mu.RUnlock() |
| 132 | + out := make(map[uint32]entry, len(byNode)) |
| 133 | + for k, v := range byNode { |
| 134 | + out[k] = v |
| 135 | + } |
| 136 | + return out |
| 137 | +} |
| 138 | + |
| 139 | +// FuzzDecodePin throws arbitrary strings at the pin decoder. Contract: |
| 140 | +// - never panics; |
| 141 | +// - returns either (nil, err) or a key of EXACTLY ed25519.PublicKeySize; |
| 142 | +// - a successfully-decoded non-empty pin round-trips and verifies a |
| 143 | +// signature only for its own keypair (sanity that we built a real key). |
| 144 | +func FuzzDecodePin(f *testing.F) { |
| 145 | + good := base64.StdEncoding.EncodeToString(func() []byte { |
| 146 | + pub, _, _ := ed25519.GenerateKey(rand.Reader) |
| 147 | + return pub |
| 148 | + }()) |
| 149 | + for _, s := range []string{"", good, "!!!", "AAAA", "Zm9v", strings.Repeat("A", 10000)} { |
| 150 | + f.Add(s) |
| 151 | + } |
| 152 | + |
| 153 | + f.Fuzz(func(t *testing.T, s string) { |
| 154 | + key, err := decodePin(s) |
| 155 | + if err != nil { |
| 156 | + if key != nil { |
| 157 | + t.Fatalf("decodePin(%q) returned a key alongside an error", s) |
| 158 | + } |
| 159 | + return |
| 160 | + } |
| 161 | + if s == "" { |
| 162 | + if key != nil { |
| 163 | + t.Fatalf("decodePin(\"\") must be (nil,nil), got %d bytes", len(key)) |
| 164 | + } |
| 165 | + return |
| 166 | + } |
| 167 | + if len(key) != ed25519.PublicKeySize { |
| 168 | + t.Fatalf("decodePin(%q) returned %d bytes, want %d", s, len(key), ed25519.PublicKeySize) |
| 169 | + } |
| 170 | + }) |
| 171 | +} |
| 172 | + |
| 173 | +// TestLoad_OversizedDocDoesNotPanic is a deterministic guard alongside |
| 174 | +// the fuzzer: a pathologically large but well-formed agents array must |
| 175 | +// load (or error) without panicking or wedging. Runtime caps the fetch |
| 176 | +// at 1 MiB; this exercises the in-process loader directly above that. |
| 177 | +func TestLoad_OversizedDocDoesNotPanic(t *testing.T) { |
| 178 | + restore := SetForTest(nil) |
| 179 | + t.Cleanup(restore) |
| 180 | + |
| 181 | + var b strings.Builder |
| 182 | + b.WriteString(`{"agents":[`) |
| 183 | + const n = 50000 |
| 184 | + for i := 1; i <= n; i++ { |
| 185 | + if i > 1 { |
| 186 | + b.WriteByte(',') |
| 187 | + } |
| 188 | + // node_id i, unique hostname; no pins so Load stays cheap. |
| 189 | + b.WriteString(`{"hostname":"h`) |
| 190 | + b.WriteString(itoa(i)) |
| 191 | + b.WriteString(`","node_id":`) |
| 192 | + b.WriteString(itoa(i)) |
| 193 | + b.WriteByte('}') |
| 194 | + } |
| 195 | + b.WriteString(`]}`) |
| 196 | + |
| 197 | + if err := Load([]byte(b.String())); err != nil { |
| 198 | + t.Fatalf("oversized well-formed doc must load: %v", err) |
| 199 | + } |
| 200 | + if _, ok := IsTrusted(n); !ok { |
| 201 | + t.Fatalf("last entry node_id %d should be trusted after large Load", n) |
| 202 | + } |
| 203 | +} |
| 204 | + |
| 205 | +// TestLoad_DuplicateWithPinsRejected confirms the duplicate-node_id |
| 206 | +// guard still fires when the colliding entries carry pins — the loader |
| 207 | +// must reject rather than letting the second pin silently win. |
| 208 | +func TestLoad_DuplicateWithPinsRejected(t *testing.T) { |
| 209 | + restore := SetForTest(nil) |
| 210 | + t.Cleanup(restore) |
| 211 | + |
| 212 | + pubA, _, _ := ed25519.GenerateKey(rand.Reader) |
| 213 | + pubB, _, _ := ed25519.GenerateKey(rand.Reader) |
| 214 | + a := base64.StdEncoding.EncodeToString(pubA) |
| 215 | + bk := base64.StdEncoding.EncodeToString(pubB) |
| 216 | + doc := `{"agents":[` + |
| 217 | + `{"hostname":"x","node_id":7,"public_key":"` + a + `"},` + |
| 218 | + `{"hostname":"y","node_id":7,"public_key":"` + bk + `"}]}` |
| 219 | + if err := Load([]byte(doc)); err == nil { |
| 220 | + t.Fatal("duplicate node_id with pins must be rejected") |
| 221 | + } |
| 222 | + // The failed Load must not have trusted either pin. |
| 223 | + if _, ok := IsTrustedWithKey(7, pubA); ok { |
| 224 | + t.Fatal("node_id 7 must not be trusted after a rejected duplicate Load") |
| 225 | + } |
| 226 | + if _, ok := IsTrustedWithKey(7, pubB); ok { |
| 227 | + t.Fatal("node_id 7 must not be trusted after a rejected duplicate Load") |
| 228 | + } |
| 229 | +} |
| 230 | + |
| 231 | +// itoa is a tiny allocation-light int formatter for the oversized-doc |
| 232 | +// builder (avoids pulling strconv into the hot loop's import surface). |
| 233 | +func itoa(n int) string { |
| 234 | + if n == 0 { |
| 235 | + return "0" |
| 236 | + } |
| 237 | + var buf [20]byte |
| 238 | + i := len(buf) |
| 239 | + for n > 0 { |
| 240 | + i-- |
| 241 | + buf[i] = byte('0' + n%10) |
| 242 | + n /= 10 |
| 243 | + } |
| 244 | + return string(buf[i:]) |
| 245 | +} |
0 commit comments