package console import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "strings" "testing" "time" ) var callbackTestNow = time.Unix(1_800_000_000, 0) func callbackTestSigner(t *testing.T, current, previous string) *CallbackSigner { t.Helper() s, err := NewCallbackSigner(current, previous) if err != nil { t.Fatal(err) } return s } func TestSignatureRejectsTamperedBody(t *testing.T) { s := callbackTestSigner(t, "current", "") header := s.Sign("acct", "photos", 0, []byte(`{"verdict":"clean"}`), callbackTestNow) key := DeriveCallbackKey("current", "acct", "photos", 0) if err := VerifyCallback(key, header, []byte(`{"verdict":"infected"}`), callbackTestNow); err == nil { t.Fatal("tampered body verified") } } func TestSignatureIsPerApp(t *testing.T) { s := callbackTestSigner(t, "current", "") body := []byte(`{"verdict":"clean"}`) header := s.Sign("acct", "app-a", 0, body, callbackTestNow) otherKey := DeriveCallbackKey("current", "acct", "app-b", 0) if err := VerifyCallback(otherKey, header, body, callbackTestNow); err == nil { t.Fatal("app A signature verified with app B key") } } func TestSignatureRejectsStaleTimestamp(t *testing.T) { s := callbackTestSigner(t, "current", "") body := []byte("body") key := DeriveCallbackKey("current", "acct", "app", 0) for _, signedAt := range []time.Time{callbackTestNow.Add(-5*time.Minute - time.Second), callbackTestNow.Add(5*time.Minute + time.Second)} { header := s.Sign("acct", "app", 0, body, signedAt) if err := VerifyCallback(key, header, body, callbackTestNow); err == nil { t.Fatalf("timestamp %v verified", signedAt) } } } func TestTwoMasterRotationVerifies(t *testing.T) { s := callbackTestSigner(t, "new", "old") body := []byte("body") header := s.Sign("acct", "app", 3, body, callbackTestNow) for _, master := range []string{"new", "old"} { key := DeriveCallbackKey(master, "acct", "app", 3) if err := VerifyCallback(key, header, body, callbackTestNow); err != nil { t.Fatalf("key derived from %q: %v", master, err) } } } func TestSignatureIsOverRawBodyBytes(t *testing.T) { s := callbackTestSigner(t, "current", "") raw := []byte("{\n \"verdict\": \"clean\"\n}") header := s.Sign("acct", "app", 0, raw, callbackTestNow) key := DeriveCallbackKey("current", "acct", "app", 0) if err := VerifyCallback(key, header, []byte(`{"verdict":"clean"}`), callbackTestNow); err == nil { t.Fatal("re-serialized JSON verified in place of raw body") } } func TestVerifierToleratesTwoSignaturesAndUnknownKeyIds(t *testing.T) { s := callbackTestSigner(t, "current", "") body := []byte("body") header := s.Sign("acct", "app", 0, body, callbackTestNow) unknown := "v1=0000000000000000:" + strings.Repeat("00", 32) header = strings.Replace(header, ",", ","+unknown+",", 1) // A one-master header has no second comma, so append the unknown entry. if !strings.Contains(header, unknown) { header += "," + unknown } key := DeriveCallbackKey("current", "acct", "app", 0) if err := VerifyCallback(key, header, body, callbackTestNow); err != nil { t.Fatal(err) } } func TestPreviousEqualToCurrentIsRefused(t *testing.T) { if _, err := NewCallbackSigner("same", "same"); err == nil { t.Fatal("equal current and previous masters accepted") } } func TestCallbackVerifierRejectsMalformedHeaders(t *testing.T) { for _, header := range []string{ "v1=0000000000000000:" + strings.Repeat("00", 32), "t=1800000000", "t=not-a-number,v1=0000000000000000:" + strings.Repeat("00", 32), "t=1800000000,v1=0000000000000000:", "t=1800000000,v1=0000000000000000:0", "t=1800000000,v1=0000000000000000", } { t.Run(header, func(t *testing.T) { if err := VerifyCallback([]byte("key"), header, []byte("body"), callbackTestNow); err == nil { t.Fatal("malformed header verified") } }) } } // TestCallbackWireFormatGoldenVector is the only test here that would notice // if the SCHEME changed rather than the code. // // Every other test in this file signs and verifies with the same // implementation, so it passes no matter what goes into the MAC — mutation // testing confirmed exactly that: dropping the domain label, dropping the 0x00 // separator, dropping the generation, and changing the delimiter were ALL // invisible. Four silent breakages in the one part of this feature that is a // wire contract. // // It matters more here than anywhere else in the system for two reasons. The // docs reproduce VerifyCallback for app authors to reimplement in other // languages, so these bytes are a published interface. And every tenant app // holds a key derived by this function: if the derivation changes, every // callback everywhere stops verifying at once, and it looks to each tenant // like a bug in their own code. // // If this test fails, the scheme changed. That is either a mistake, or a // deliberate v2 — and a deliberate v2 adds a signature alongside v1 rather // than editing these numbers. func TestCallbackWireFormatGoldenVector(t *testing.T) { const ( master = "master-fixed-for-the-vector" wantKey = "c7aff5eb65835639b68c63a226767768ac2049fe0b57ab83844691ae5cca1d17" wantHead = "t=1800000000,v1=ccd4f98ffb69f5dd:" + "1c24c96520fc34a1275967165de2a0bac68ac5940cd558fbfc9f8f3367a4b2c8" ) body := []byte(`{"account":"bab","app":"titogo","key":"uploads/x.pdf","verdict":"clean"}`) if got := hex.EncodeToString(DeriveCallbackKey(master, "bab", "titogo", 7)); got != wantKey { t.Errorf("derived key changed:\n got %s\nwant %s", got, wantKey) } s := callbackTestSigner(t, master, "") if got := s.Sign("bab", "titogo", 7, body, time.Unix(1_800_000_000, 0)); got != wantHead { t.Errorf("header changed:\n got %s\nwant %s", got, wantHead) } } // TestDerivationSeparatesEveryInput: each field that goes into the derivation // must change the key when it changes, or it is not really in there. // // The generation is the one that matters most and the one a self-consistent // test cannot see. It exists so a tenant whose callback secret leaked can be // rotated on its own; if it did not reach the HMAC, `--rotate-callback-key` // would report success, issue the same key back, and the leaked secret would // keep working. A reclaimed app name would inherit the deleted app's key too. func TestDerivationSeparatesEveryInput(t *testing.T) { base := DeriveCallbackKey("m", "bab", "titogo", 0) for _, tc := range []struct { name string key []byte }{ {"a different master", DeriveCallbackKey("m2", "bab", "titogo", 0)}, {"a different account", DeriveCallbackKey("m", "zab", "titogo", 0)}, {"a different app", DeriveCallbackKey("m", "bab", "other", 0)}, {"a different generation", DeriveCallbackKey("m", "bab", "titogo", 1)}, } { if hmac.Equal(base, tc.key) { t.Errorf("%s produced the same key — that input does not reach the derivation", tc.name) } } // And the label plus separator are not decoration: without them // HMAC(master, "carlos-scan-webhook-v1\x00bab/titogo/0") would collide with // any other future HMAC(master, x) use that happened to pick that string. // Pinned by the golden vector above; asserted here as a property so the // reason survives even if the numbers are regenerated. if len(base) != sha256.Size { t.Errorf("derived key is %d bytes, want %d", len(base), sha256.Size) } } // TestVerifierToleratesUnknownFields pins the forward-compatibility choice, in // both directions: an unrecognised field must not break a verifier that a // tenant froze years ago, and a header missing t or v1 must still be refused. func TestVerifierToleratesUnknownFields(t *testing.T) { s := callbackTestSigner(t, "current", "") body := []byte("body") key := DeriveCallbackKey("current", "acct", "app", 0) header := s.Sign("acct", "app", 0, body, callbackTestNow) // A field from a scheme this verifier has never heard of. if err := VerifyCallback(key, header+",v2=whatever:deadbeef", body, callbackTestNow); err != nil { t.Errorf("an unknown field broke verification: %v — adding v2 later would break every app", err) } if err := VerifyCallback(key, "future=1,"+header, body, callbackTestNow); err != nil { t.Errorf("an unknown leading field broke verification: %v", err) } // But the two that are load-bearing are still required. for _, bad := range []string{ "v2=whatever:deadbeef", // no t, no v1 "t=" + strconv.FormatInt(callbackTestNow.Unix(), 10) + ",v2=x:y", // t but no v1 } { if err := VerifyCallback(key, bad, body, callbackTestNow); err == nil { t.Errorf("header %q verified with no usable signature in it", bad) } } } // TestVerifierRefusesATruncatedMAC. hmac.Equal already refuses a length // mismatch, so the explicit size check is defence in depth rather than the // guard — pinned anyway, because "it happens to be safe downstream" is how a // check gets deleted. func TestVerifierRefusesATruncatedMAC(t *testing.T) { s := callbackTestSigner(t, "current", "") body := []byte("body") key := DeriveCallbackKey("current", "acct", "app", 0) header := s.Sign("acct", "app", 0, body, callbackTestNow) cut := header[:len(header)-2] // one byte off the MAC if err := VerifyCallback(key, cut, body, callbackTestNow); err == nil { t.Fatal("a truncated MAC verified") } }