carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package objstore
2
3import (
4 "context"
5 "errors"
6 "strings"
7 "testing"
8)
9
10func 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.
27func 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.
71func 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.
133func 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.
146func 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.
168func 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.
181func 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.
203func 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.
230func 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.
242func 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.
257func 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