| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "crypto/hmac" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "strconv" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | var callbackTestNow = time.Unix(1_800_000_000, 0) |
| 14 | |
| 15 | func 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 | |
| 24 | func 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 | |
| 33 | func 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 | |
| 43 | func 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 | |
| 55 | func 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 | |
| 67 | func 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 | |
| 77 | func 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 | |
| 93 | func TestPreviousEqualToCurrentIsRefused(t *testing.T) { |
| 94 | if _, err := NewCallbackSigner("same", "same"); err == nil { |
| 95 | t.Fatal("equal current and previous masters accepted") |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func 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. |
| 136 | func 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. |
| 162 | func 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. |
| 190 | func 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. |
| 218 | func 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 | |