πŸ” M141-4 β€” HMAC signing protocol

Spec + reference implementations for signing OrchestKit hook deliveries. TypeScript (@orchestkit/hook-contract) and Python (orchestkit-hook-contract) sibling packages both verify against the same 13 golden vectors. Zero runtime deps on either side.

Header format (Stripe-style)

X-CC-Hooks-Signature: t=<unix-seconds>,v1=<hex-sha256>[,v2=...]
                       β”‚           β”‚
                       β”‚           └─ HMAC-SHA256(secret, "t={ts}." + raw_body)
                       └─ timestamp the sender used at signing time

Reject reasons (stable enum, both languages):
  ok                β€” signature verified
  missing_header    β€” header absent or empty
  malformed_header  β€” present but unparseable / unexpected schema label
  stale             β€” |now - t| > tolerance_sec (default 300s)
  signature_mismatchβ€” no scheme/secret pair matched
  weak_secret       β€” advisory only, never blocks ok

What ships in this PR

FilePurpose
packages/hook-contract/docs/signing-rfc.mdLanguage-neutral protocol spec (289 lines). Header grammar, replay window, multi-scheme rotation rules, Reason enum, gap analysis vs the platform consumer.
packages/hook-contract/src/signing.tssign() + verify() using node:crypto (createHmac + timingSafeEqual).
packages/hook-contract-py/src/orchestkit_hook_contract/signing.pysign() + verify() using stdlib hmac + hashlib. Mirrors the TS file byte-for-byte for the same inputs.
packages/hook-contract/test-vectors/signing/*.json13 deterministic vectors (5 positive, 8 negative). Bodies base64-encoded so vectors carry arbitrary bytes. Shared between both languages.
packages/hook-contract/tests/signing.test.ts + tests/test_signing.py27 vitest + 42 pytest cases. Both load the same vectors; both pass.

Security hardening (mirrored both languages)

GuardWhy
8192-byte header cap β†’ malformed_headerDoS guardrail. Attacker can't make the parser walk a megabyte header.
10-digit timestamp cap β†’ staleCross-language parity: TS Number overflows past safe-integer; Python bigints don't. Cap both.
weak_secret fires only after parse succeedsInfo-disclosure tightening. Malformed-input attackers can't probe the verifier's secret-length configuration.
OR-assign on compare_digest resultNon-short-circuit intent explicit. Loop runs to completion regardless of early match.
Element-level isinstance in _normalize_secretsDefense in depth. mypy can't subtract Sequence[bytes|str] from bytes/str (both ARE Sequences); per-element guard restores the narrowing AND catches programmer error at runtime.

Cross-language conformance β€” same 13 vectors, both sides

            β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
            β”‚ test-vectors/signing/*.json                 β”‚
            β”‚ 13 vectors (5 positive, 8 negative)         β”‚
            β”‚ secret_hex / body_b64 / header / now /      β”‚
            β”‚ tolerance_sec / expected{valid, reason}     β”‚
            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                         β”‚                β”‚
                         β–Ό                β–Ό
           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
           β”‚ vitest          β”‚  β”‚ pytest              β”‚
           β”‚ signing.test.ts β”‚  β”‚ test_signing.py     β”‚
           β”‚ 27/27 βœ“         β”‚  β”‚ 42/42 βœ“             β”‚
           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

  A vector's expected.reason MUST match exactly on both sides.
  If they diverge, the cross-language parity gate (M141-6) AND
  this vector matrix catch it before the platform consumer does.

Verified locally

$ cd packages/hook-contract && npm test
27 vectors + signing API: PASS

$ cd packages/hook-contract-py
$ .venv/bin/pytest tests/test_signing.py -v
============================== 42 passed in 0.31s ==============================

$ .venv/bin/mypy src
Success: no issues found in 5 source files

$ .venv/bin/ruff check .
All checks passed!

Out of scope (follow-ups)