{
  "version": 1,
  "algorithm": "HMAC-SHA256",
  "secret": "cc237f7fe354609bb41360c6530a2deb7926f3cc55e91df3ff90e37202950b6c",
  "secret_provenance": "SHA-256 hash of the ASCII string 'adcp-webhook-hmac-test-vector-v1-DO-NOT-USE-IN-PRODUCTION'. 256 bits of entropy from a documented preimage — deterministic across implementations so cross-language test runs stay reproducible.",
  "WARNING": "This secret is published in the public AdCP repository. Implementations MUST NOT use this value (or any value derived from a fixed string) in production. Production secrets MUST be generated by a cryptographically secure random source (e.g., crypto.randomBytes(32)) with at least 256 bits of entropy, stored in a secret manager / KMS, and rotated per operator policy. Copying this vector into a production config — or an .env file, or a committed example — is a security incident.",
  "specification": "https://adcontextprotocol.org/docs/building/implementation/webhooks",
  "status": "legacy",
  "deprecation": "AdCP 3.0 unifies webhook signing on the RFC 9421 webhook profile (see docs/building/implementation/security.mdx#webhook-callbacks). This HMAC-SHA256 path is the legacy fallback selected when a buyer populates push_notification_config.authentication.credentials; it is deprecated and removed in AdCP 4.0. Conformance vectors for the 9421 webhook profile will ship alongside the request-signing vectors.",
  "description": "Test vectors for AdCP webhook HMAC-SHA256 signature verification (legacy / deprecated path). Each vector contains a stable `id` (kebab-case; the stable reference for cross-SDK conformance), a human-readable `description`, a `raw_body` (the exact bytes on the wire), a `timestamp` (Unix seconds), and the expected signature. Downstream tests SHOULD look up vectors by `id`; `description` prose may be revised without notice. Implementations supporting the legacy path MUST produce matching signatures for every vector and MUST use constant-time comparison when verifying signatures in production. Sign the raw_body as-is — never re-serialize it. When constructing a body from a JSON value, use compact separators (',' and ':', no whitespace between tokens) so the signed bytes match the bytes on the wire.",
  "vectors": [
    {
      "id": "compact-js-style",
      "description": "compact JSON (JS-style JSON.stringify)",
      "timestamp": 1700000000,
      "raw_body": "{\"event\":\"creative.status_changed\",\"creative_id\":\"creative_123\",\"status\":\"approved\"}",
      "expected_signature": "sha256=0987f9b3e89d331142297dbc0e992edbc13b2ae29e7038ed8f948b565aa05c4b"
    },
    {
      "id": "spaced-python-default",
      "description": "spaced JSON (Python-style json.dumps with default separators)",
      "timestamp": 1700000000,
      "raw_body": "{\"event\": \"creative.status_changed\", \"creative_id\": \"creative_123\", \"status\": \"approved\"}",
      "expected_signature": "sha256=cb4ae9d57971c70226a2aaab646c59c344a4bcf91075adcfc1939e4b3207856d"
    },
    {
      "id": "empty-object",
      "description": "empty object",
      "timestamp": 1700000000,
      "raw_body": "{}",
      "expected_signature": "sha256=06338bfc687c7a3677ea16420e527469d6717bb966451d29c685de8f0270f073"
    },
    {
      "id": "nested",
      "description": "nested objects and arrays",
      "timestamp": 1700000000,
      "raw_body": "{\"task_id\":\"task_456\",\"operation_id\":\"op_789\",\"result\":{\"media_buy_id\":\"mb_001\",\"packages\":[{\"package_id\":\"pkg_1\"},{\"package_id\":\"pkg_2\"}]}}",
      "expected_signature": "sha256=87e50a904acee36e04a9a4bb2a02281ac0fbbef0be129258a8500da84567570a"
    },
    {
      "id": "unicode-utf8",
      "description": "unicode characters (literal UTF-8, not escaped)",
      "timestamp": 1700000000,
      "raw_body": "{\"brand_name\":\"Café Münchën\",\"tagline\":\"日本語テスト\"}",
      "expected_signature": "sha256=0e445863247724694581d538497527218443961f15c9645d656b52d8fdca4f9c"
    },
    {
      "id": "pretty-printed",
      "description": "pretty-printed JSON (multiline with indentation)",
      "timestamp": 1700000000,
      "raw_body": "{\n  \"status\": \"completed\",\n  \"result\": {\n    \"id\": \"mb_001\"\n  }\n}",
      "expected_signature": "sha256=0ee5202fd647ce9390bc8b0a220edf5c98c791ffeecbbd781a54c1d9a80cd9e3"
    },
    {
      "id": "scalars-mixed",
      "description": "numeric values, booleans, and null",
      "timestamp": 1700000000,
      "raw_body": "{\"price\":19.99,\"count\":1000,\"active\":true,\"discount\":null}",
      "expected_signature": "sha256=f9dde4b6d4e096d7283fb14cb74b4b143627c17245957e33660a430dfd705d4f"
    },
    {
      "id": "empty-body",
      "description": "empty body",
      "timestamp": 1700000000,
      "raw_body": "",
      "expected_signature": "sha256=d9f70ba6cb43e92a8f55c1fc73bec0f8f07fa3a652e1ec1d5ee69846cfde9fe8"
    },
    {
      "id": "timestamp-zero",
      "description": "timestamp zero",
      "timestamp": 0,
      "raw_body": "{\"event\":\"test\"}",
      "expected_signature": "sha256=e098ea1f745765614027918d988930b96c38a2f8e2b5d05560f4bffa53d77dcd"
    },
    {
      "id": "timestamp-2040",
      "description": "large timestamp (year 2040)",
      "timestamp": 2208988800,
      "raw_body": "{\"event\":\"test\"}",
      "expected_signature": "sha256=4b9923c61f2d51b20e4df1231827dd8b760b0ab99f80cab739cdead6841f0d69"
    },
    {
      "id": "null-bytes",
      "description": "null bytes in body",
      "timestamp": 1700000000,
      "raw_body": "{\"event\":\"test\u0000null\"}",
      "expected_signature": "sha256=8c58874bc393c7941088d48da3af913c4dcb3fb20244bf0ab0543cb872cb5330"
    },
    {
      "id": "trailing-newline",
      "description": "trailing newline in body",
      "timestamp": 1700000000,
      "raw_body": "{\"event\":\"test\"}\n",
      "expected_signature": "sha256=a1ea163af47d6b34ce0de5d27c4c88b5dd9f348dd8a2bc2751a71f8f27622738"
    },
    {
      "id": "compact-whitespace-keys",
      "description": "compact separators with whitespace-sensitive keys and nested objects/arrays — canonical on-wire form",
      "timestamp": 1700000000,
      "raw_body": "{\"key with spaces\":1,\"nested\":{\"inner key\":[1,2,{\"deep key\":\"v\"}],\"flag\":true}}",
      "expected_signature": "sha256=5c78d76825b71f081e28376f8ab4a66bfd4efb888e3d212c63a99f94acbf2785"
    },
    {
      "id": "unicode-ascii-escaped",
      "description": "ascii-escaped unicode (\\u00e9, \\u65e5) — produces a different signature than the raw-UTF-8 vector above; unicode-escape policy is not canonicalized, so signers and verifiers MUST compare bytes",
      "timestamp": 1700000000,
      "raw_body": "{\"name\":\"caf\\u00e9\",\"region\":\"\\u65e5\\u672c\"}",
      "expected_signature": "sha256=f079a1c8b2501ac4ac1ba22895853d1901730a3b6db8f3844dfcdb0b213b1c91"
    },
    {
      "id": "duplicate-keys-conflicting-values",
      "description": "body contains a duplicate object key with conflicting values; see security.mdx §duplicate-object-keys for the normative MUST-reject semantics, `verifier_action_values` for the required verifier action, and `non_conformant_outcomes` for forbidden failure modes",
      "timestamp": 1700000000,
      "raw_body": "{\"event\":\"creative.status_changed\",\"creative_id\":\"creative_123\",\"status\":\"approved\",\"status\":\"rejected\"}",
      "expected_signature": "sha256=7fd84f13f71228b5c0dc24b58acafaa62855b26fe5477a31e88884ed24ae5b7f",
      "expected_verifier_action": "reject-malformed",
      "rfc9421_error_code": "webhook_body_malformed"
    }
  ],
  "verifier_action_values": {
    "accept": "Documentation only — NOT a conformant outcome for this fixture. Included so SDK harnesses that reuse this token across other vectors have a stable definition: the verifier returns a success result after HMAC verification. A verifier that returns `accept` on the `duplicate-keys-conflicting-values` fixture is non-conformant.",
    "reject-malformed": "The verifier returns a failure result with a malformed-body error class, distinct from a signature-mismatch error (the signature IS valid; the body is malformed). This is the REQUIRED outcome for any vector whose `raw_body` contains duplicate object keys. A verifier that crashes / fails-closed is conformant-but-suboptimal — the request is not silently accepted, but senders receive no actionable error code; verifiers SHOULD return a structured malformed-body error instead."
  },
  "non_conformant_outcomes": [
    "Silent accept where the signature verifier's parse of the payload diverges from the downstream consumer's parse (e.g., verifier sees status=approved, business logic sees status=rejected) — the CVE-2017-12635 attack class. This is now forbidden by security.mdx §duplicate-object-keys (MUST-reject).",
    "Returning a signature-mismatch error for a body whose HMAC is mathematically valid — the signature IS valid; the failure class is malformed-body, not signature-invalid.",
    "Using a last-wins or first-wins JSON parser that silently discards duplicate keys before the verifier can detect them — the verifier MUST use a parse mode that exposes duplicate-key state."
  ],
  "rejection_vectors": [
    {
      "id": "truncated-signature",
      "description": "truncated signature",
      "timestamp": 1700000000,
      "raw_body": "{\"event\":\"test\"}",
      "signature": "sha256=c4faf8",
      "reason": "Signature length mismatch — MUST reject before HMAC comparison"
    },
    {
      "id": "wrong-algorithm-prefix",
      "description": "wrong algorithm prefix",
      "timestamp": 1700000000,
      "raw_body": "{\"event\":\"test\"}",
      "signature": "sha512=0ae7bf3ebd08098a9bec251fd1405a852dfbe3f75f5f19d9aa3e81af0f2617ca",
      "reason": "Only sha256= prefix is valid"
    },
    {
      "id": "empty-signature",
      "description": "empty signature header",
      "timestamp": 1700000000,
      "raw_body": "{\"event\":\"test\"}",
      "signature": "",
      "reason": "Missing or empty signature MUST be rejected"
    },
    {
      "id": "missing-signature",
      "description": "missing signature header",
      "timestamp": 1700000000,
      "raw_body": "{\"event\":\"test\"}",
      "signature": null,
      "reason": "Null signature MUST be rejected"
    },
    {
      "id": "timestamp-too-old",
      "description": "timestamp outside window (5 minutes in the past)",
      "timestamp": 1699999399,
      "raw_body": "{\"event\":\"test\"}",
      "signature": "sha256=valid_but_irrelevant",
      "current_time": 1700000000,
      "reason": "Timestamp 601 seconds old — exceeds 300-second window"
    },
    {
      "id": "timestamp-too-future",
      "description": "timestamp outside window (5 minutes in the future)",
      "timestamp": 1700000401,
      "raw_body": "{\"event\":\"test\"}",
      "signature": "sha256=valid_but_irrelevant",
      "current_time": 1700000000,
      "reason": "Timestamp 401 seconds in the future — exceeds 300-second window"
    },
    {
      "id": "non-numeric-timestamp",
      "description": "non-numeric timestamp",
      "timestamp": "not-a-number",
      "raw_body": "{\"event\":\"test\"}",
      "signature": "sha256=anything",
      "reason": "Non-numeric timestamp MUST be rejected before HMAC computation"
    },
    {
      "id": "body-tampered",
      "description": "body modified after signing",
      "timestamp": 1700000000,
      "raw_body": "{\"event\":\"hacked\"}",
      "signature": "sha256=2a96056c93bf02b81076e11769945c8d9bf47cccf5073310f9736a8e5b08063c",
      "reason": "Signature was computed over {\"event\":\"test\"} — modified body MUST not verify"
    },
    {
      "id": "double-prefix",
      "description": "double prefix",
      "timestamp": 1700000000,
      "raw_body": "{\"event\":\"test\"}",
      "signature": "sha256=sha256=2a96056c93bf02b81076e11769945c8d9bf47cccf5073310f9736a8e5b08063c",
      "reason": "Double sha256= prefix produces wrong length and MUST be rejected"
    },
    {
      "id": "signer-spaced-wire-compact",
      "description": "signer used Python-default spaced separators but client wrote compact bytes on the wire",
      "timestamp": 1700000000,
      "raw_body": "{\"key with spaces\":1,\"nested\":{\"inner key\":[1,2,{\"deep key\":\"v\"}],\"flag\":true}}",
      "signature": "sha256=301a461556bd0b6b32450c4299a7889437a4616ac70c8bdfd911e7a6a00d914d",
      "reason": "Signature was computed over the spaced-separator form '{\"key with spaces\": 1, ...}' while the wire carries the compact form — the canonical on-wire form is compact (','/':') so this MUST NOT verify. This is the exact cross-SDK bug that motivated pinning compact separators in the legacy scheme."
    }
  ],
  "secret_rejection_vectors": [
    {
      "description": "secret exactly 31 bytes (one below the minimum)",
      "secret": "1234567890abcdef1234567890abcde",
      "reason": "Secret MUST be at least 32 bytes / 256 bits of entropy. A 31-byte secret MUST be rejected at configuration time, before any signature is computed. Implementations that silently accept short secrets admit brute-force attacks on the HMAC key."
    },
    {
      "description": "secret is empty string",
      "secret": "",
      "reason": "Empty secret MUST be rejected at configuration time."
    },
    {
      "description": "secret is all-zero bytes (no entropy)",
      "secret": "00000000000000000000000000000000",
      "reason": "32 bytes of zeros meets the length bar but has zero entropy. Implementations SHOULD reject well-known weak values (all-zero, all-one, repeating ASCII) and MUST NOT ship them as defaults. See security.mdx §secret-entropy for the entropy MUST."
    },
    {
      "description": "secret repeats a single ASCII character",
      "secret": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "reason": "32 'a' characters meets the byte-length bar but has no entropy. Implementations SHOULD reject."
    }
  ],
  "signer_side": {
    "action_values": {
      "reject-input-before-sign": "The signer returns a failure result to its caller without producing a signed frame. The signer MUST NOT emit headers, MUST NOT compute the HMAC, and MUST surface an error distinguishable from network or configuration failures (the caller needs to fix the upstream input, not retry). Typical shapes: language-idiomatic exception / error-return with a `duplicate_key_input` discriminator and optionally the offending key names (subject to the same step 14b sanitization rules when logging — truncate at first non-printable to `<sanitized:N>`, truncate to last UTF-8 codepoint at or below 32 bytes, cap count at 4).",
      "sign-and-emit": "The signer processes the clean input, computes the HMAC, and emits the signed frame. This is the positive-path action — exercised by `positive_vectors` so that a signer that rejects everything does not trivially pass conformance."
    },
    "rejection_vectors": [
      {
        "id": "signer-upstream-duplicate-key-rejection",
        "description": "signer received a pre-serialized body from an upstream caller with duplicate object keys at the top level; signer MUST reject before signing and MUST NOT emit a signed frame",
        "signer_input_body": "{\"event\":\"creative.status_changed\",\"creative_id\":\"creative_123\",\"status\":\"approved\",\"status\":\"rejected\"}",
        "expected_signer_action": "reject-input-before-sign",
        "rationale": "Signer-side MUST per security.mdx §duplicate-object-keys. A signer that silently collapses duplicate-key input produces a cryptographically-clean signed frame whose parsed semantics differ from caller intent — verifiers cannot detect the upstream divergence from the wire, so the MUST applies at the signer even though it is unverifiable on-wire. This fixture probes signer implementations that accept pre-serialized bodies; signers that accept structured objects (dicts / structs where duplicate keys cannot exist) MUST apply the equivalent check to any duplicate-tolerant upstream structure (Python list-of-tuples with repeated first elements, ordered-dict variants that permit duplicates). Conformance is out-of-band: an interop harness asserts the signer returns an error on this input rather than a signed frame."
      },
      {
        "id": "signer-upstream-duplicate-key-deep-nested",
        "description": "signer received a pre-serialized body where the duplicate object key appears in a nested object; signer MUST reject at any nesting depth, not only at the top level",
        "signer_input_body": "{\"event\":\"creative.status_changed\",\"result\":{\"media_buy_id\":\"mb_001\",\"media_buy_id\":\"mb_evil\"}}",
        "expected_signer_action": "reject-input-before-sign",
        "rationale": "Signer-side check MUST recurse into nested objects. A signer that only checks top-level keys would accept this input and emit a signed frame carrying semantic ambiguity on the nested `media_buy_id` — the same parser-differential attack class that motivates the top-level rule. Interop harnesses that only exercise flat-body vectors would silently pass a signer with shallow checking; this vector catches the gap."
      },
      {
        "id": "signer-upstream-duplicate-key-array-contained",
        "description": "signer received a pre-serialized body where the duplicate object key appears inside an object that is itself an array element; signer MUST descend into array-contained objects, not only into plain object values",
        "signer_input_body": "{\"event\":\"media_buy.package_update\",\"packages\":[{\"package_id\":\"pkg_1\",\"package_id\":\"pkg_evil\"}]}",
        "expected_signer_action": "reject-input-before-sign",
        "rationale": "A signer that recurses into object values but does not descend into array elements would pass the top-level and plain-nested vectors above and silently ship the exact attack surface the rule targets — real-world AdCP payloads routinely place state-change fields inside array-contained objects (e.g., `packages[]`, `creative_assets[]`, `events[]`). The array-contained case is a known blind spot in hand-rolled recursive validators; this vector catches it."
      },
      {
        "id": "signer-upstream-duplicate-key-three-deep",
        "description": "signer received a pre-serialized body where the duplicate object key appears three nesting levels deep; signer MUST walk to arbitrary depth, not halt at a shallow fixed bound",
        "signer_input_body": "{\"level_1\":{\"level_2\":{\"level_3_key\":\"original\",\"level_3_key\":\"tampered\"}}}",
        "expected_signer_action": "reject-input-before-sign",
        "rationale": "A correct recursive walk handles arbitrary depth by construction, but hand-rolled walkers sometimes halt at a hardcoded depth (1 or 2 levels) for performance or to prevent stack overflow. A signer with a 2-deep bound would pass the one-level-nested vector above and fail this vector; without an explicit three-deep fixture the bug ships silently. Paired with the plain-nested and array-contained vectors, this triple gives interop harnesses enough coverage to catch the common depth-bound and shape-bound blind spots."
      }
    ],
    "positive_vectors": [
      {
        "id": "signer-upstream-clean-input",
        "description": "signer received a pre-serialized body with all-unique object keys at every depth; signer MUST sign normally and emit the signed frame",
        "signer_input_body": "{\"event\":\"creative.status_changed\",\"creative_id\":\"creative_123\",\"status\":\"approved\",\"result\":{\"media_buy_id\":\"mb_001\",\"packages\":[{\"package_id\":\"pkg_1\"},{\"package_id\":\"pkg_2\"}]}}",
        "expected_signer_action": "sign-and-emit",
        "rationale": "Negative-only fixtures let a signer that rejects every input trivially pass conformance. This positive vector exercises the happy path across the same shape-classes as the negative vectors (top-level, nested, array-contained) with all keys unique, so a conformance harness can assert both that duplicate-key inputs are rejected AND that clean inputs of the same structural complexity are accepted. A signer that rejects this vector is non-conformant."
      }
    ]
  }
}
