# 🔐 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

File | Purpose
--- | ---
`packages/hook-contract/docs/signing-rfc.md` | Language-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.ts` | `sign()` + `verify()` using `node:crypto` (`createHmac` + `timingSafeEqual`).
`packages/hook-contract-py/src/orchestkit_hook_contract/signing.py` | `sign()` + `verify()` using stdlib `hmac` + `hashlib`. Mirrors the TS file byte-for-byte for the same inputs.
`packages/hook-contract/test-vectors/signing/*.json` | 13 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.py` | 27 vitest + 42 pytest cases. Both load the same vectors; both pass.

## Security hardening (mirrored both languages)

Guard | Why
--- | ---
8192-byte header cap → `malformed_header` | DoS guardrail. Attacker can't make the parser walk a megabyte header.
10-digit timestamp cap → `stale` | Cross-language parity: TS `Number` overflows past safe-integer; Python bigints don't. Cap both.
`weak_secret` fires only after parse succeeds | Info-disclosure tightening. Malformed-input attackers can't probe the verifier's secret-length configuration.
OR-assign on `compare_digest` result | Non-short-circuit intent explicit. Loop runs to completion regardless of early match.
Element-level isinstance in `_normalize_secrets` | Defense 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)

- M141-5 (#1806) — OpenAPI 3.1 spec for HTTP sink. Will reference this signing scheme as the auth mechanism.
- M141-7 (#1808) — generic-client export sample, proves the contract is consumable outside `yonatan-hq/platform`.
- M141-8 (#1809) — migrate `yonatan-hq/platform` consumer from its hand-written HMAC verifier to this package.