| 1 | package objstore |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | func testSigner(t *testing.T, current, previous string) *TagSigner { |
| 11 | t.Helper() |
| 12 | s, err := NewTagSigner(current, previous) |
| 13 | if err != nil { |
| 14 | t.Fatalf("NewTagSigner: %v", err) |
| 15 | } |
| 16 | return s |
| 17 | } |
| 18 | |
| 19 | // The signature is over ALL of it, and changing any single field must break |
| 20 | // it. This is the whole trust boundary: a verdict that verifies is one the |
| 21 | // console wrote, about these bytes, at that moment. |
| 22 | // |
| 23 | // The table is per-field on purpose. A signature over (verdict) alone would |
| 24 | // let an attacker move a clean signature from one object to another; over |
| 25 | // (key, verdict) it would survive an overwrite; without `at` two verdicts |
| 26 | // written a year apart are interchangeable. |
| 27 | func TestTagSignatureCoversEveryField(t *testing.T) { |
| 28 | s := testSigner(t, "master-key-one", "") |
| 29 | |
| 30 | base := scanFacts{ |
| 31 | bucket: "carlos-store-tito-bab-titogo", |
| 32 | key: "uploads/invoice.pdf", |
| 33 | version: "d41d8cd98f00b204e9800998ecf8427e@2026-09-05T01:02:03Z", |
| 34 | verdict: string(VerdictClean), |
| 35 | at: "2026-09-05T01:02:04Z", |
| 36 | } |
| 37 | sig := s.Sign(base) |
| 38 | if ok, _ := s.Verify(base, sig); !ok { |
| 39 | t.Fatal("a signature this signer just produced does not verify") |
| 40 | } |
| 41 | |
| 42 | for _, tc := range []struct { |
| 43 | name string |
| 44 | edit func(*scanFacts) |
| 45 | }{ |
| 46 | {"bucket", func(f *scanFacts) { f.bucket = "carlos-store-tito-bab-other" }}, |
| 47 | {"key", func(f *scanFacts) { f.key = "uploads/other.pdf" }}, |
| 48 | {"version", func(f *scanFacts) { f.version = "0000000000000000000000000000@2026-09-05T01:02:03Z" }}, |
| 49 | {"verdict", func(f *scanFacts) { f.verdict = string(VerdictInfected) }}, |
| 50 | {"at", func(f *scanFacts) { f.at = "2026-09-05T01:02:05Z" }}, |
| 51 | } { |
| 52 | t.Run("changing the "+tc.name+" breaks it", func(t *testing.T) { |
| 53 | f := base |
| 54 | tc.edit(&f) |
| 55 | if ok, _ := s.Verify(f, sig); ok { |
| 56 | t.Fatalf("the signature still verified after changing the %s", tc.name) |
| 57 | } |
| 58 | }) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | // THE ENCODING IS INJECTIVE, and this is the test that says why it cannot be |
| 63 | // string concatenation with a delimiter. |
| 64 | // |
| 65 | // An S3 object key may contain any UTF-8, including whatever separator looks |
| 66 | // safe — `|`, `\n`, `\x00`. With a delimiter, an attacker who controls a key |
| 67 | // can arrange for two different (bucket, key, version, verdict) tuples to |
| 68 | // serialise to the same bytes, and then a signature legitimately issued for |
| 69 | // one is valid for the other. Length-prefixing removes the possibility rather |
| 70 | // than hiding it behind a character nobody expects to appear. |
| 71 | func TestTagSignatureEncodingCannotBeConfused(t *testing.T) { |
| 72 | s := testSigner(t, "master-key-one", "") |
| 73 | |
| 74 | // Two tuples that a naive `bucket|key|version|verdict|at` join renders |
| 75 | // identically: the boundary between key and version is moved into the key. |
| 76 | a := scanFacts{bucket: "b", key: "x", version: "y@z", verdict: "clean", at: "t"} |
| 77 | b := scanFacts{bucket: "b", key: "x|y@z", version: "", verdict: "clean", at: "t"} |
| 78 | |
| 79 | if s.Sign(a) == s.Sign(b) { |
| 80 | t.Fatal("two different tuples produced the same signature — the encoding is not injective, and a signature issued for one object is valid for another") |
| 81 | } |
| 82 | |
| 83 | // The same confusion attempted with every separator a hand-rolled encoder |
| 84 | // might have chosen, INCLUDING multi-byte ones. |
| 85 | // |
| 86 | // The first version of this test tried only single characters, and a |
| 87 | // mutation replacing the length prefix with a constant four zero bytes — |
| 88 | // a fixed four-byte delimiter, which is exactly the bug this test exists |
| 89 | // to catch — SURVIVED it. The lesson is that a test against "a delimiter" |
| 90 | // has to mean any delimiter, and a test written against the one the author |
| 91 | // happened to imagine proves nothing about the others. |
| 92 | for _, sep := range []string{ |
| 93 | "\n", "\x00", "|", ":", "\x1f", |
| 94 | "\x00\x00\x00\x00", // four zero bytes: a zeroed length prefix |
| 95 | "\x00\x00\x00\x01", |
| 96 | "\xff\xff\xff\xff", |
| 97 | } { |
| 98 | // Move the boundary between two fields into the data of the first. |
| 99 | c := scanFacts{bucket: "b", key: "x" + sep + "y", version: "v", verdict: "clean", at: "t"} |
| 100 | d := scanFacts{bucket: "b", key: "x", version: "y" + sep + "v", verdict: "clean", at: "t"} |
| 101 | if s.Sign(c) == s.Sign(d) { |
| 102 | t.Fatalf("confusable across %q — the encoding is delimited, not length-prefixed", sep) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // And the general property the length prefix actually buys, stated without |
| 107 | // guessing at a separator: shifting one character across every adjacent |
| 108 | // field boundary must change the signature. A delimiter scheme survives |
| 109 | // this only while the delimiter cannot appear in the data, which for an S3 |
| 110 | // object key is never true. |
| 111 | for _, pair := range []struct { |
| 112 | name string |
| 113 | a, b scanFacts |
| 114 | }{ |
| 115 | {"bucket/key", scanFacts{bucket: "ab", key: "c", version: "v", verdict: "clean", at: "t"}, |
| 116 | scanFacts{bucket: "a", key: "bc", version: "v", verdict: "clean", at: "t"}}, |
| 117 | {"key/version", scanFacts{bucket: "b", key: "ab", version: "c", verdict: "clean", at: "t"}, |
| 118 | scanFacts{bucket: "b", key: "a", version: "bc", verdict: "clean", at: "t"}}, |
| 119 | {"version/verdict", scanFacts{bucket: "b", key: "k", version: "ab", verdict: "c", at: "t"}, |
| 120 | scanFacts{bucket: "b", key: "k", version: "a", verdict: "bc", at: "t"}}, |
| 121 | {"verdict/at", scanFacts{bucket: "b", key: "k", version: "v", verdict: "ab", at: "c"}, |
| 122 | scanFacts{bucket: "b", key: "k", version: "v", verdict: "a", at: "bc"}}, |
| 123 | } { |
| 124 | if s.Sign(pair.a) == s.Sign(pair.b) { |
| 125 | t.Fatalf("a character moved across the %s boundary did not change the signature", pair.name) |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // A different deployment's key must not verify another's. Without this a |
| 131 | // single leaked or shared secret makes every deployment's verdicts |
| 132 | // interchangeable. |
| 133 | func TestTagSignatureIsPerKey(t *testing.T) { |
| 134 | one := testSigner(t, "master-key-one", "") |
| 135 | two := testSigner(t, "master-key-two", "") |
| 136 | |
| 137 | f := scanFacts{bucket: "b", key: "k", version: "v", verdict: "clean", at: "t"} |
| 138 | if ok, _ := two.Verify(f, one.Sign(f)); ok { |
| 139 | t.Fatal("one deployment's signature verified under another's key") |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // Rotation: the previous key still verifies, and says so, so the sweep can |
| 144 | // re-sign a still-valid verdict instead of re-scanning bytes it has already |
| 145 | // judged. `stale` is the whole reason Verify returns two values. |
| 146 | func TestTagSignatureAcceptsThePreviousKeyAndSaysSo(t *testing.T) { |
| 147 | old := testSigner(t, "master-key-old", "") |
| 148 | rotated := testSigner(t, "master-key-new", "master-key-old") |
| 149 | |
| 150 | f := scanFacts{bucket: "b", key: "k", version: "v", verdict: "clean", at: "t"} |
| 151 | |
| 152 | ok, stale := rotated.Verify(f, old.Sign(f)) |
| 153 | if !ok { |
| 154 | t.Fatal("a verdict signed under the previous key stopped verifying after rotation — every stored verdict would be rescanned at once") |
| 155 | } |
| 156 | if !stale { |
| 157 | t.Fatal("Verify did not report the previous key, so nothing would ever re-sign and the old key could never be dropped") |
| 158 | } |
| 159 | |
| 160 | ok, stale = rotated.Verify(f, rotated.Sign(f)) |
| 161 | if !ok || stale { |
| 162 | t.Fatalf("current key: ok=%v stale=%v, want true/false", ok, stale) |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | // Dropping the previous key is what finishes a rotation, and it must actually |
| 167 | // stop accepting it — otherwise "rotated" means nothing after a compromise. |
| 168 | func TestTagSignatureRefusesADroppedKey(t *testing.T) { |
| 169 | old := testSigner(t, "master-key-old", "") |
| 170 | current := testSigner(t, "master-key-new", "") |
| 171 | |
| 172 | f := scanFacts{bucket: "b", key: "k", version: "v", verdict: "clean", at: "t"} |
| 173 | if ok, _ := current.Verify(f, old.Sign(f)); ok { |
| 174 | t.Fatal("a signature under a dropped key still verified") |
| 175 | } |
| 176 | } |
| 177 | |
| 178 | // The value carries a format version and a key id, so a deployment holding two |
| 179 | // keys knows which one to try first and a future format can be told apart from |
| 180 | // a corrupt one. |
| 181 | func TestTagSignatureValueShape(t *testing.T) { |
| 182 | s := testSigner(t, "master-key-one", "") |
| 183 | sig := s.Sign(scanFacts{bucket: "b", key: "k", version: "v", verdict: "clean", at: "t"}) |
| 184 | |
| 185 | parts := strings.Split(sig, ":") |
| 186 | if len(parts) != 3 { |
| 187 | t.Fatalf("signature %q is not v1:<key-id>:<mac>", sig) |
| 188 | } |
| 189 | if parts[0] != "v1" { |
| 190 | t.Fatalf("format = %q, want v1", parts[0]) |
| 191 | } |
| 192 | if parts[1] == "" || parts[1] == "master-key-one" { |
| 193 | t.Fatalf("key id = %q — it must identify the key without being the key", parts[1]) |
| 194 | } |
| 195 | if len(parts[2]) != 64 { |
| 196 | t.Fatalf("mac is %d hex chars, want 64 (HMAC-SHA256)", len(parts[2])) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // Everything malformed reads as "no valid signature", never as an error and |
| 201 | // never as a pass. A verdict tag with a rubbish signature is exactly what a |
| 202 | // forgery looks like, and the caller's job is to treat it as unknown and scan. |
| 203 | func TestTagSignatureRefusesMalformedValues(t *testing.T) { |
| 204 | s := testSigner(t, "master-key-one", "") |
| 205 | f := scanFacts{bucket: "b", key: "k", version: "v", verdict: "clean", at: "t"} |
| 206 | good := s.Sign(f) |
| 207 | |
| 208 | for _, tc := range []struct{ name, sig string }{ |
| 209 | {"empty", ""}, |
| 210 | {"no structure", "deadbeef"}, |
| 211 | {"two fields", "v1:abcd1234"}, |
| 212 | {"four fields", good + ":extra"}, |
| 213 | {"unknown format", "v2" + strings.TrimPrefix(good, "v1")}, |
| 214 | {"unknown key id", "v1:0000000000000000:" + strings.Split(good, ":")[2]}, |
| 215 | {"non-hex mac", "v1:" + strings.Split(good, ":")[1] + ":" + strings.Repeat("z", 64)}, |
| 216 | {"truncated mac", good[:len(good)-2]}, |
| 217 | {"empty mac", "v1:" + strings.Split(good, ":")[1] + ":"}, |
| 218 | } { |
| 219 | t.Run(tc.name, func(t *testing.T) { |
| 220 | if ok, _ := s.Verify(f, tc.sig); ok { |
| 221 | t.Fatalf("Verify accepted %q", tc.sig) |
| 222 | } |
| 223 | }) |
| 224 | } |
| 225 | } |
| 226 | |
| 227 | // A signer needs a key. Constructing one without a current key must fail |
| 228 | // loudly at boot rather than producing something that signs with nothing — |
| 229 | // which would verify every forgery in the world. |
| 230 | func TestTagSignerRefusesAnEmptyKey(t *testing.T) { |
| 231 | if _, err := NewTagSigner("", ""); err == nil { |
| 232 | t.Fatal("NewTagSigner accepted an empty current key") |
| 233 | } |
| 234 | if _, err := NewTagSigner("", "previous-only"); err == nil { |
| 235 | t.Fatal("NewTagSigner accepted a previous key with no current key") |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | // Two keys that happen to be equal are a configuration mistake — a rotation |
| 240 | // that rotated to itself — and silently accepting it would make the operator |
| 241 | // believe a rotation had happened. |
| 242 | func TestTagSignerRefusesIdenticalKeys(t *testing.T) { |
| 243 | if _, err := NewTagSigner("same", "same"); err == nil { |
| 244 | t.Fatal("NewTagSigner accepted identical current and previous keys — a rotation that rotated to itself") |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | // The constructor is where a missing key has to be caught, because it is the |
| 249 | // last moment at which nobody has been told anything. |
| 250 | // |
| 251 | // A scanner with no signer would write verdicts nothing can verify and, worse, |
| 252 | // read every forgery as genuine — so it would report an app's own |
| 253 | // `carlos-scan=clean` as a real clean verdict. That is strictly worse than |
| 254 | // having no scanner: no scanner reports nothing, this reports the attacker's |
| 255 | // answer. The console turns this refusal into a fatal at boot, naming the |
| 256 | // variable, before a single tenant byte is read. |
| 257 | func TestNewClamAVScannerRefusesWithoutASigner(t *testing.T) { |
| 258 | _, err := NewClamAVScanner(context.Background(), "eu-west-1", "", "/run/clamav/clamd.ctl", 1<<20, nil) |
| 259 | if !errors.Is(err, ErrNoTagKey) { |
| 260 | t.Fatalf("NewClamAVScanner with no signer = %v, want ErrNoTagKey — a scanner that cannot sign reads every forgery as genuine", err) |
| 261 | } |
| 262 | } |
| 263 | |