carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "crypto/hmac"
5 "crypto/sha256"
6 "encoding/hex"
7 "strconv"
8 "strings"
9 "testing"
10 "time"
11)
12
13var callbackTestNow = time.Unix(1_800_000_000, 0)
14
15func callbackTestSigner(t *testing.T, current, previous string) *CallbackSigner {
16 t.Helper()
17 s, err := NewCallbackSigner(current, previous)
18 if err != nil {
19 t.Fatal(err)
20 }
21 return s
22}
23
24func TestSignatureRejectsTamperedBody(t *testing.T) {
25 s := callbackTestSigner(t, "current", "")
26 header := s.Sign("acct", "photos", 0, []byte(`{"verdict":"clean"}`), callbackTestNow)
27 key := DeriveCallbackKey("current", "acct", "photos", 0)
28 if err := VerifyCallback(key, header, []byte(`{"verdict":"infected"}`), callbackTestNow); err == nil {
29 t.Fatal("tampered body verified")
30 }
31}
32
33func TestSignatureIsPerApp(t *testing.T) {
34 s := callbackTestSigner(t, "current", "")
35 body := []byte(`{"verdict":"clean"}`)
36 header := s.Sign("acct", "app-a", 0, body, callbackTestNow)
37 otherKey := DeriveCallbackKey("current", "acct", "app-b", 0)
38 if err := VerifyCallback(otherKey, header, body, callbackTestNow); err == nil {
39 t.Fatal("app A signature verified with app B key")
40 }
41}
42
43func TestSignatureRejectsStaleTimestamp(t *testing.T) {
44 s := callbackTestSigner(t, "current", "")
45 body := []byte("body")
46 key := DeriveCallbackKey("current", "acct", "app", 0)
47 for _, signedAt := range []time.Time{callbackTestNow.Add(-5*time.Minute - time.Second), callbackTestNow.Add(5*time.Minute + time.Second)} {
48 header := s.Sign("acct", "app", 0, body, signedAt)
49 if err := VerifyCallback(key, header, body, callbackTestNow); err == nil {
50 t.Fatalf("timestamp %v verified", signedAt)
51 }
52 }
53}
54
55func TestTwoMasterRotationVerifies(t *testing.T) {
56 s := callbackTestSigner(t, "new", "old")
57 body := []byte("body")
58 header := s.Sign("acct", "app", 3, body, callbackTestNow)
59 for _, master := range []string{"new", "old"} {
60 key := DeriveCallbackKey(master, "acct", "app", 3)
61 if err := VerifyCallback(key, header, body, callbackTestNow); err != nil {
62 t.Fatalf("key derived from %q: %v", master, err)
63 }
64 }
65}
66
67func TestSignatureIsOverRawBodyBytes(t *testing.T) {
68 s := callbackTestSigner(t, "current", "")
69 raw := []byte("{\n \"verdict\": \"clean\"\n}")
70 header := s.Sign("acct", "app", 0, raw, callbackTestNow)
71 key := DeriveCallbackKey("current", "acct", "app", 0)
72 if err := VerifyCallback(key, header, []byte(`{"verdict":"clean"}`), callbackTestNow); err == nil {
73 t.Fatal("re-serialized JSON verified in place of raw body")
74 }
75}
76
77func TestVerifierToleratesTwoSignaturesAndUnknownKeyIds(t *testing.T) {
78 s := callbackTestSigner(t, "current", "")
79 body := []byte("body")
80 header := s.Sign("acct", "app", 0, body, callbackTestNow)
81 unknown := "v1=0000000000000000:" + strings.Repeat("00", 32)
82 header = strings.Replace(header, ",", ","+unknown+",", 1)
83 // A one-master header has no second comma, so append the unknown entry.
84 if !strings.Contains(header, unknown) {
85 header += "," + unknown
86 }
87 key := DeriveCallbackKey("current", "acct", "app", 0)
88 if err := VerifyCallback(key, header, body, callbackTestNow); err != nil {
89 t.Fatal(err)
90 }
91}
92
93func TestPreviousEqualToCurrentIsRefused(t *testing.T) {
94 if _, err := NewCallbackSigner("same", "same"); err == nil {
95 t.Fatal("equal current and previous masters accepted")
96 }
97}
98
99func TestCallbackVerifierRejectsMalformedHeaders(t *testing.T) {
100 for _, header := range []string{
101 "v1=0000000000000000:" + strings.Repeat("00", 32),
102 "t=1800000000",
103 "t=not-a-number,v1=0000000000000000:" + strings.Repeat("00", 32),
104 "t=1800000000,v1=0000000000000000:",
105 "t=1800000000,v1=0000000000000000:0",
106 "t=1800000000,v1=0000000000000000",
107 } {
108 t.Run(header, func(t *testing.T) {
109 if err := VerifyCallback([]byte("key"), header, []byte("body"), callbackTestNow); err == nil {
110 t.Fatal("malformed header verified")
111 }
112 })
113 }
114}
115
116// TestCallbackWireFormatGoldenVector is the only test here that would notice
117// if the SCHEME changed rather than the code.
118//
119// Every other test in this file signs and verifies with the same
120// implementation, so it passes no matter what goes into the MAC — mutation
121// testing confirmed exactly that: dropping the domain label, dropping the 0x00
122// separator, dropping the generation, and changing the delimiter were ALL
123// invisible. Four silent breakages in the one part of this feature that is a
124// wire contract.
125//
126// It matters more here than anywhere else in the system for two reasons. The
127// docs reproduce VerifyCallback for app authors to reimplement in other
128// languages, so these bytes are a published interface. And every tenant app
129// holds a key derived by this function: if the derivation changes, every
130// callback everywhere stops verifying at once, and it looks to each tenant
131// like a bug in their own code.
132//
133// If this test fails, the scheme changed. That is either a mistake, or a
134// deliberate v2 — and a deliberate v2 adds a signature alongside v1 rather
135// than editing these numbers.
136func TestCallbackWireFormatGoldenVector(t *testing.T) {
137 const (
138 master = "master-fixed-for-the-vector"
139 wantKey = "c7aff5eb65835639b68c63a226767768ac2049fe0b57ab83844691ae5cca1d17"
140 wantHead = "t=1800000000,v1=ccd4f98ffb69f5dd:" +
141 "1c24c96520fc34a1275967165de2a0bac68ac5940cd558fbfc9f8f3367a4b2c8"
142 )
143 body := []byte(`{"account":"bab","app":"titogo","key":"uploads/x.pdf","verdict":"clean"}`)
144
145 if got := hex.EncodeToString(DeriveCallbackKey(master, "bab", "titogo", 7)); got != wantKey {
146 t.Errorf("derived key changed:\n got %s\nwant %s", got, wantKey)
147 }
148 s := callbackTestSigner(t, master, "")
149 if got := s.Sign("bab", "titogo", 7, body, time.Unix(1_800_000_000, 0)); got != wantHead {
150 t.Errorf("header changed:\n got %s\nwant %s", got, wantHead)
151 }
152}
153
154// TestDerivationSeparatesEveryInput: each field that goes into the derivation
155// must change the key when it changes, or it is not really in there.
156//
157// The generation is the one that matters most and the one a self-consistent
158// test cannot see. It exists so a tenant whose callback secret leaked can be
159// rotated on its own; if it did not reach the HMAC, `--rotate-callback-key`
160// would report success, issue the same key back, and the leaked secret would
161// keep working. A reclaimed app name would inherit the deleted app's key too.
162func TestDerivationSeparatesEveryInput(t *testing.T) {
163 base := DeriveCallbackKey("m", "bab", "titogo", 0)
164 for _, tc := range []struct {
165 name string
166 key []byte
167 }{
168 {"a different master", DeriveCallbackKey("m2", "bab", "titogo", 0)},
169 {"a different account", DeriveCallbackKey("m", "zab", "titogo", 0)},
170 {"a different app", DeriveCallbackKey("m", "bab", "other", 0)},
171 {"a different generation", DeriveCallbackKey("m", "bab", "titogo", 1)},
172 } {
173 if hmac.Equal(base, tc.key) {
174 t.Errorf("%s produced the same key — that input does not reach the derivation", tc.name)
175 }
176 }
177 // And the label plus separator are not decoration: without them
178 // HMAC(master, "carlos-scan-webhook-v1\x00bab/titogo/0") would collide with
179 // any other future HMAC(master, x) use that happened to pick that string.
180 // Pinned by the golden vector above; asserted here as a property so the
181 // reason survives even if the numbers are regenerated.
182 if len(base) != sha256.Size {
183 t.Errorf("derived key is %d bytes, want %d", len(base), sha256.Size)
184 }
185}
186
187// TestVerifierToleratesUnknownFields pins the forward-compatibility choice, in
188// both directions: an unrecognised field must not break a verifier that a
189// tenant froze years ago, and a header missing t or v1 must still be refused.
190func TestVerifierToleratesUnknownFields(t *testing.T) {
191 s := callbackTestSigner(t, "current", "")
192 body := []byte("body")
193 key := DeriveCallbackKey("current", "acct", "app", 0)
194 header := s.Sign("acct", "app", 0, body, callbackTestNow)
195
196 // A field from a scheme this verifier has never heard of.
197 if err := VerifyCallback(key, header+",v2=whatever:deadbeef", body, callbackTestNow); err != nil {
198 t.Errorf("an unknown field broke verification: %v — adding v2 later would break every app", err)
199 }
200 if err := VerifyCallback(key, "future=1,"+header, body, callbackTestNow); err != nil {
201 t.Errorf("an unknown leading field broke verification: %v", err)
202 }
203 // But the two that are load-bearing are still required.
204 for _, bad := range []string{
205 "v2=whatever:deadbeef", // no t, no v1
206 "t=" + strconv.FormatInt(callbackTestNow.Unix(), 10) + ",v2=x:y", // t but no v1
207 } {
208 if err := VerifyCallback(key, bad, body, callbackTestNow); err == nil {
209 t.Errorf("header %q verified with no usable signature in it", bad)
210 }
211 }
212}
213
214// TestVerifierRefusesATruncatedMAC. hmac.Equal already refuses a length
215// mismatch, so the explicit size check is defence in depth rather than the
216// guard — pinned anyway, because "it happens to be safe downstream" is how a
217// check gets deleted.
218func TestVerifierRefusesATruncatedMAC(t *testing.T) {
219 s := callbackTestSigner(t, "current", "")
220 body := []byte("body")
221 key := DeriveCallbackKey("current", "acct", "app", 0)
222 header := s.Sign("acct", "app", 0, body, callbackTestNow)
223
224 cut := header[:len(header)-2] // one byte off the MAC
225 if err := VerifyCallback(key, cut, body, callbackTestNow); err == nil {
226 t.Fatal("a truncated MAC verified")
227 }
228}
229