From 2ab1d4a412095d0a01602d5c87d32d56309aaf29 Mon Sep 17 00:00:00 2001 From: Pearce Kieser <5055971+Pearcekieser@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:14:12 +0000 Subject: [PATCH] feat(apps): replay-proof v2 proxy signature with a per-request nonce --- docs/app-kit/api-reference.md | 125 +++++++++++- docs/architecture/app-platform-trust-model.md | 11 ++ docs/system-specs/modules/app-kit-platform.md | 31 ++- src/kiro_crew/apps/proxy_auth.py | 105 +++++++++- src/kiro_crew/apps/routes.py | 4 + src/kiro_crew/apps/routes_proxy.py | 27 ++- test/test_app_proxy_auth_v2.py | 182 ++++++++++++++++++ test/test_app_proxy_auth_v2_node_reference.py | 120 ++++++++++++ ...t_gateway_appkit_endpoints_signed_proxy.py | 63 ++++++ 9 files changed, 656 insertions(+), 12 deletions(-) create mode 100644 test/test_app_proxy_auth_v2.py create mode 100644 test/test_app_proxy_auth_v2_node_reference.py diff --git a/docs/app-kit/api-reference.md b/docs/app-kit/api-reference.md index c13270e3de4..1cc27d3cd9c 100644 --- a/docs/app-kit/api-reference.md +++ b/docs/app-kit/api-reference.md @@ -1220,7 +1220,8 @@ Use these main-package helpers in Python app backends: | `proxy_secret()` | `str` | Read the injected `KIROCREW_PROXY_SECRET`, or an empty string | | `verify_proxy_request(header, *, method, target, body, secret=None, now=None)` | `bool` | Verify the body-bound HMAC and fixed ±60-second freshness window; fail closed on malformed input | | `verify_proxy_principal_claim(header, *, method, target, body, replay_cache, secret=None, now=None)` | `ProxyPrincipalClaim \| None` | Verify and consume one `X-KiroCrew-Principal` claim for an app that sets `backend.signedPrincipal`. Returns `None` for any malformed, stale, mismatched or replayed claim. See [Signed principal claim](#signed-principal-claim) | -| `ProxyPrincipalReplayCache(*, max_entries=4096)` | cache | The replay cache the verifier needs. Create one per backend process | +| `verify_proxy_request_v2(header, nonce, *, method, target, body, replay_cache, secret=None, now=None)` | `bool` | Verify and consume one `X-KiroCrew-Proxy-V2` signature and its `X-KiroCrew-Proxy-Nonce`. Refuses a nonce it has already accepted. See [Replay-proof v2 signature](#replay-proof-v2-signature) | +| `ProxyPrincipalReplayCache(*, max_entries=4096)` | cache | The replay cache the verifiers need. Create one per backend process | --- @@ -1517,6 +1518,123 @@ the value in the `X-KiroCrew-Proxy` header (constant-time), rejecting stale time > HMACs fails verification against any verifier that omits the body hash, so a > backend that implements the HMAC itself has to be updated in lockstep with the gateway. +#### Replay-proof v2 signature + +`X-KiroCrew-Proxy` has no nonce. Anyone who sees one signed request can send the +same bytes to the backend port again, and a v1 verifier accepts the copy until the +60-second window closes. To close that gap, the gateway also sends two more headers +on every proxied request, to every app, with no manifest change: + +```text +X-KiroCrew-Proxy-Nonce: <32 lowercase hex characters, fresh per request> +X-KiroCrew-Proxy-V2: : +``` + +The v2 HMAC uses the app secret as the key, over: + +```text +v2::::: +``` + +``, `` and `` are the same values v1 signs. +The `v2:` prefix keeps the two MACs apart, so neither header can stand in for the +other. A backend that accepts a request only once per nonce refuses a replay. + +v1 still works. `X-KiroCrew-Proxy` keeps its name, its value and its signed string, +and a backend that does not know the new headers ignores them. Moving to v2 is +optional and adds replay protection. The gateway strips any copy of either new +header that the caller sent, so a page cannot pick its own nonce. + +A Python backend that can import `kiro_crew` verifies v2 with the gateway's helper: + +```python +from kiro_crew.apps.proxy_auth import ( + ProxyPrincipalReplayCache, + raw_request_target, + verify_proxy_request_v2, +) + +proxy_replays = ProxyPrincipalReplayCache() # one per process, not per request + +body = await request.read() +if not verify_proxy_request_v2( + request.headers.get('X-KiroCrew-Proxy-V2', ''), + request.headers.get('X-KiroCrew-Proxy-Nonce', ''), + method=request.method, + target=raw_request_target(request), + body=body, + replay_cache=proxy_replays, +): + return Response(status=401) +``` + +The cache keeps each accepted nonce until its 60-second window closes. When it is +full, it refuses new requests instead of evicting a live nonce, because an evicted +nonce could be replayed. Each nonce stays in the cache for 60 seconds, so the +default `max_entries=4096` holds about 68 requests a second. Above that rate the +verifier refuses real traffic with 401. A busier backend sets `max_entries` to at +least 60 times its peak requests per second. + +A Node.js backend has no SDK package to import. This dependency-free reference +verifier does the same checks; copy it into the backend: + +```js +import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; + +const MAX_SKEW_SECONDS = 60; +const NONCE_RE = /^[0-9a-f]{32}$/; +const TIMESTAMP_RE = /^[0-9]{1,12}$/; + +// Create one verifier per backend process, so its nonce cache outlives each request. +export function createProxyV2Verifier(secret, { maxEntries = 4096 } = {}) { + const seen = new Map(); // nonce -> expiry, in epoch seconds + + // signature: the X-KiroCrew-Proxy-V2 header. nonce: X-KiroCrew-Proxy-Nonce. + // target: req.url, which is the raw request-target. body: the raw body as a Buffer. + return function verify({ method, target, body, signature, nonce, now = Date.now() / 1000 }) { + if (!secret || typeof signature !== 'string' || typeof nonce !== 'string') return false; + if (!NONCE_RE.test(nonce)) return false; + const sep = signature.indexOf(':'); + if (sep < 0) return false; + const ts = signature.slice(0, sep); + const given = Buffer.from(signature.slice(sep + 1), 'utf8'); + if (!TIMESTAMP_RE.test(ts) || Math.abs(now - Number(ts)) > MAX_SKEW_SECONDS) return false; + + const bodyHash = createHash('sha256').update(body ?? Buffer.alloc(0)).digest('hex'); + const expected = Buffer.from( + createHmac('sha256', secret) + .update(`v2:${ts}:${nonce}:${method}:${target}:${bodyHash}`) + .digest('hex'), + 'utf8', + ); + if (given.length !== expected.length || !timingSafeEqual(given, expected)) return false; + + // Record the nonce last, so only a request that passed every check takes an entry. + for (const [key, expiry] of seen) if (expiry < now) seen.delete(key); + if (seen.has(nonce) || seen.size >= maxEntries) return false; + seen.set(nonce, Number(ts) + MAX_SKEW_SECONDS); + return true; + }; +} +``` + +Call it from the request handler after reading the whole body: + +```js +const verifyProxy = createProxyV2Verifier(process.env.KIROCREW_PROXY_SECRET ?? ''); + +if (!verifyProxy({ + method: req.method, + target: req.url, + body: rawBody, + signature: req.headers['x-kirocrew-proxy-v2'], + nonce: req.headers['x-kirocrew-proxy-nonce'], +})) { + res.writeHead(401).end(); + return; +} +``` + #### Signed principal claim `X-KiroCrew-Proxy` proves that the gateway forwarded a request. It does not say who @@ -1524,8 +1642,9 @@ the gateway authenticated. An app that needs to know sets [`backend.signedPrincipal: true`](manifest-reference.md) in its manifest. The gateway then adds `X-KiroCrew-Principal` to every request it proxies to that backend. An app without that exact opt-in receives no new header, and its `X-KiroCrew-Proxy` bytes -do not change. The gateway strips any `X-KiroCrew-Proxy` or `X-KiroCrew-Principal` -header the caller sent, so a backend only ever sees the gateway's own values. +do not change. The gateway strips any `X-KiroCrew-Proxy`, `X-KiroCrew-Proxy-V2`, +`X-KiroCrew-Proxy-Nonce` or `X-KiroCrew-Principal` header the caller sent, so a +backend only ever sees the gateway's own values. The header value is the encoded claim, a period, and an HMAC: diff --git a/docs/architecture/app-platform-trust-model.md b/docs/architecture/app-platform-trust-model.md index e51ab6af63d..0a6b92e8794 100644 --- a/docs/architecture/app-platform-trust-model.md +++ b/docs/architecture/app-platform-trust-model.md @@ -121,6 +121,17 @@ are **deny-by-default** confined by the dashboard auth middleware independently re-checks that the caller's token app matches the target app, since the proxy signs requests with the target app's secret. +That signature is how a backend tells a gateway-forwarded request from a direct +connection to its loopback port. The v1 `X-KiroCrew-Proxy` HMAC carries no nonce, +so a copy of one signed request verifies again for up to 60 seconds. Every +forwarded request therefore also carries `X-KiroCrew-Proxy-Nonce` and an +`X-KiroCrew-Proxy-V2` HMAC that binds that nonce. A backend that verifies v2 with a +process-lifetime replay cache accepts each request once. v1 is unchanged and still +verifies, so a backend that never moves to v2 keeps working but stays replayable +inside the window. The proxy strips caller-sent copies of all these headers, so a +page cannot choose the nonce. Wire format and verifiers: +[api-reference](../app-kit/api-reference.md#replay-proof-v2-signature). + User-session routes add a second, semantic check. An enabled app must declare `permissions.sessionApproval: true` before its app token can send a message to an existing local user-owned session, choose a generated response option, diff --git a/docs/system-specs/modules/app-kit-platform.md b/docs/system-specs/modules/app-kit-platform.md index db7399ae78e..a411dae5e11 100644 --- a/docs/system-specs/modules/app-kit-platform.md +++ b/docs/system-specs/modules/app-kit-platform.md @@ -4289,7 +4289,7 @@ the issue time. The wire format and the verifier's steps are in The proxy strips any caller-sent `X-KiroCrew-Proxy` and `X-KiroCrew-Principal` for every app, opted in or not, so a backend sees only the gateway's values. An -app that does not opt in receives exactly the headers it received before. A +app that does not opt in receives no principal header. A signer refusal returns 502 `proxy_principal_sign_failed` rather than forwarding an opted-in request without its claim. @@ -4304,8 +4304,35 @@ that stays the backend's own check. The flag is part of the manifest's `backend` dict, so an admission signature covers it. Only the JSON boolean `true` turns it on. +### 24a. Every proxied request carries a replay-proof v2 signature + +The v1 `X-KiroCrew-Proxy` value, `:` over +`:::`, has no nonce, so a backend cannot +refuse a copy of a request it already served. `handle_app_api_proxy` therefore +also sends, to every app and with no manifest field, `X-KiroCrew-Proxy-Nonce` +(`new_proxy_nonce`, 32 lowercase hex) and `X-KiroCrew-Proxy-V2`, `:` +over `v2:::::` with the same secret +and the same `ts`. The `v2:` prefix keeps the two MAC inputs disjoint. + +- v1 is byte-for-byte unchanged. Builtin and third-party verifiers that parse it + strictly keep working, and a backend that does not know the new headers + ignores them. Opting in would only delay protection, and the new headers cost + an unaware backend nothing, so they are always sent. +- The proxy strips caller-sent `X-KiroCrew-Proxy-V2` and `X-KiroCrew-Proxy-Nonce` + for every app, like the v1 and principal headers above. +- The nonce and a principal claim's `requestId` are independent random values of + one shape, checked by one helper, `_is_request_id`. +- `verify_proxy_request_v2` fails closed on a missing secret or cache, a nonce + outside the one shape, a non-ASCII or over-12-digit timestamp, a timestamp + outside 60 seconds, and a signature mismatch, compares as bytes in constant + time, and consumes the nonce last. A full cache refuses instead of evicting. +- Node backends have no SDK package. The api reference carries a dependency-free + reference verifier, and a test extracts that snippet and runs it under node + against the gateway's signer. + Writers: `apps/routes_proxy.py`, `apps/proxy_auth.py`, `apps/manifest_sections.py`. Tests: -`test/test_app_proxy_auth.py`, `test/test_app_manifest.py`, +`test/test_app_proxy_auth.py`, `test/test_app_proxy_auth_v2.py`, +`test/test_app_proxy_auth_v2_node_reference.py`, `test/test_app_manifest.py`, `test/test_gateway_appkit_endpoints_signed_proxy.py`. ## 25. `platform.os` and `platform.installMode` are closed vocabularies, refused at validation diff --git a/src/kiro_crew/apps/proxy_auth.py b/src/kiro_crew/apps/proxy_auth.py index 56bb48d5cb7..5788b3897af 100644 --- a/src/kiro_crew/apps/proxy_auth.py +++ b/src/kiro_crew/apps/proxy_auth.py @@ -19,6 +19,17 @@ The gateway's own health probe hits the backend directly (unsigned), so callers must leave the health endpoint unauthenticated — see the backends' dispatch. +The v1 signature carries no nonce, so a backend cannot tell a first delivery from +a copy sent again inside the freshness window. Every forwarded request therefore +also carries a replay-proof v2 signature in two further headers:: + + X-KiroCrew-Proxy-Nonce: <32 lowercase hex> + X-KiroCrew-Proxy-V2: :::::")> + +:func:`verify_proxy_request_v2`, called with one long-lived +:class:`ProxyPrincipalReplayCache`, accepts each nonce once. The v1 header is unchanged, so +a backend that verifies only v1 keeps working and simply ignores the new headers. + An app whose manifest sets ``backend.signedPrincipal`` also receives ``X-KiroCrew-Principal``: a base64url canonical-JSON claim naming the kind of caller the gateway authenticated, a period, and an HMAC that binds that exact @@ -46,6 +57,12 @@ _ENV_KEY = "KIROCREW_PROXY_SECRET" _MAX_SKEW_SECONDS = 60 +PROXY_V2_HEADER = "X-KiroCrew-Proxy-V2" +PROXY_NONCE_HEADER = "X-KiroCrew-Proxy-Nonce" +# A ten-digit epoch second covers every date until 2286; the bound keeps an +# attacker-chosen timestamp from reaching ``int()`` as a multi-kilobyte string. +_MAX_TS_DIGITS = 12 + PROXY_PRINCIPAL_HEADER = "X-KiroCrew-Principal" PRINCIPAL_OWNER_SESSION = "owner-session" PRINCIPAL_APP_TOKEN = "app-token" @@ -163,6 +180,90 @@ def accept(self, request_id: str, *, expires_at: float, now: float) -> bool: return True +def _is_request_id(value: Any) -> bool: + """Whether *value* has the one request-id shape: 32 lowercase hex characters. + + A v2 nonce and a principal claim's ``requestId`` share this shape. The gateway + draws each one independently, and each verifier records only its own value. + """ + if not isinstance(value, str) or len(value) != _REQUEST_ID_HEX_LENGTH: + return False + return all(char in "0123456789abcdef" for char in value) + + +def new_proxy_nonce() -> str: + """A fresh per-request nonce: 32 lowercase hexadecimal characters.""" + return secrets.token_hex(_REQUEST_ID_HEX_LENGTH // 2) + + +def _proxy_v2_signature( + key: str, ts: str, nonce: str, *, method: str, target: str, body: bytes +) -> str: + # The ``v2:`` prefix keeps this MAC's input disjoint from v1's, which always + # starts with the timestamp's digits, so neither signature can stand in for the other. + body_hash = hashlib.sha256(body or b"").hexdigest() + message = f"v2:{ts}:{nonce}:{method}:{target}:{body_hash}" + return hmac.new(key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest() + + +def sign_proxy_request_v2( + *, secret: str, ts: int, nonce: str, method: str, target: str, body: bytes +) -> str: + """Build the ``X-KiroCrew-Proxy-V2`` value, ``:``, for one request. + + Raises ``ValueError`` for an empty secret, a negative timestamp or a nonce + outside the one valid shape, so the gateway never forwards a signature that + every verifier would refuse. + """ + if not secret or ts < 0 or not _is_request_id(nonce): + raise ValueError("invalid proxy v2 signature input") + ts_str = str(ts) + signature = _proxy_v2_signature(secret, ts_str, nonce, method=method, target=target, body=body) + return f"{ts_str}:{signature}" + + +def verify_proxy_request_v2( + header_value: str, + nonce: str, + *, + method: str, + target: str, + body: bytes, + replay_cache: ProxyPrincipalReplayCache, + secret: str | None = None, + now: float | None = None, +) -> bool: + """Verify and consume one ``X-KiroCrew-Proxy-V2`` signature and its nonce. + + Returns ``False`` for a missing secret or cache, a malformed header or nonce, + a timestamp outside ±60s, a signature mismatch, a nonce already accepted, or a + full cache. The replay check runs last, so only a request that passed every + other check takes a cache entry. + """ + key = proxy_secret() if secret is None else secret + if not key or replay_cache is None or not header_value or not nonce: + return False + if not _is_request_id(nonce): + return False + ts_str, _, signature = header_value.partition(":") + # ``str.isdigit`` alone admits non-ASCII digits such as "²", which ``int`` rejects. + if not (ts_str.isascii() and ts_str.isdigit()) or len(ts_str) > _MAX_TS_DIGITS: + return False + if not signature: + return False + ts = int(ts_str) + clock = time.time() if now is None else now + if not clock - _MAX_SKEW_SECONDS <= ts <= clock + _MAX_SKEW_SECONDS: + return False + expected = _proxy_v2_signature(key, ts_str, nonce, method=method, target=target, body=body) + # Compared as bytes for the reason ``verify_proxy_request`` gives above. + if not hmac.compare_digest( + expected.encode("utf-8", "surrogatepass"), signature.encode("utf-8", "surrogatepass") + ): + return False + return replay_cache.accept(nonce, expires_at=ts + _MAX_SKEW_SECONDS, now=clock) + + def _claim_shape_valid( *, kind: Any, owner_id: Any, issued_at: Any, request_id: Any, version: Any ) -> bool: @@ -182,9 +283,7 @@ def _claim_shape_valid( # so a backend can never read an identity off a claim that does not vouch for one. if (kind == PRINCIPAL_OWNER_SESSION) != bool(owner_id): return False - if not isinstance(request_id, str) or len(request_id) != _REQUEST_ID_HEX_LENGTH: - return False - return all(char in "0123456789abcdef" for char in request_id) + return _is_request_id(request_id) def _encode_claim(claim: ProxyPrincipalClaim) -> str: diff --git a/src/kiro_crew/apps/routes.py b/src/kiro_crew/apps/routes.py index c3e01a8d1b2..a36eeaf70c2 100644 --- a/src/kiro_crew/apps/routes.py +++ b/src/kiro_crew/apps/routes.py @@ -98,8 +98,12 @@ PRINCIPAL_APP_TOKEN, PRINCIPAL_NONE, PRINCIPAL_OWNER_SESSION, + PROXY_NONCE_HEADER, PROXY_PRINCIPAL_HEADER, + PROXY_V2_HEADER, + new_proxy_nonce, sign_proxy_principal_claim, + sign_proxy_request_v2, ) from kiro_crew.apps.registry import ( _ART_IMAGE_EXTENSIONS, diff --git a/src/kiro_crew/apps/routes_proxy.py b/src/kiro_crew/apps/routes_proxy.py index 54a1b2edef9..348fc382d1f 100644 --- a/src/kiro_crew/apps/routes_proxy.py +++ b/src/kiro_crew/apps/routes_proxy.py @@ -33,8 +33,12 @@ PRINCIPAL_APP_TOKEN, PRINCIPAL_NONE, PRINCIPAL_OWNER_SESSION, + PROXY_NONCE_HEADER, PROXY_PRINCIPAL_HEADER, + PROXY_V2_HEADER, + new_proxy_nonce, sign_proxy_principal_claim, + sign_proxy_request_v2, ) from kiro_crew.sel import sel @@ -115,12 +119,13 @@ def invalidate_app_secret_cache(name: str) -> None: # all the backend needs to trust a proxied request, so dropping the rest is a # pure hardening with no functional loss. # -# The two gateway-authored proxy headers are stripped too. For an app that has +# The gateway-authored proxy headers are stripped too. For an app that has # not opted in to ``backend.signedPrincipal``, a caller-sent # ``x-kirocrew-principal`` would otherwise reach a backend that never asked for -# one, and could pass for the gateway's. For ``x-kirocrew-proxy``, stripping the -# caller's copy first keeps the outgoing request from depending on which spelling -# of a header the HTTP client lets win when the gateway sets its own value below. +# one, and could pass for the gateway's. For the proxy signature and nonce +# headers, stripping the caller's copy first keeps the outgoing request from +# depending on which spelling of a header the HTTP client lets win when the +# gateway sets its own value below, so a page can never supply its own nonce. # Relay identity headers (``x-kiro-relay-*``) are dropped by prefix, ``is_relay_header``. _PROXY_STRIP_HEADERS = _PROXY_HOP_HEADERS | frozenset( { @@ -131,6 +136,8 @@ def invalidate_app_secret_cache(name: str) -> None: "x-session-key", "x-internal-caller", "x-kirocrew-proxy", + "x-kirocrew-proxy-v2", + "x-kirocrew-proxy-nonce", "x-kirocrew-principal", } ) @@ -319,6 +326,18 @@ async def handle_app_api_proxy(request: web.Request) -> web.StreamResponse: msg = f"{ts}:{request.method}:{wire_target}:{body_hash}" sig = _hmac.new(secret.encode(), msg.encode(), hashlib.sha256).hexdigest() headers["X-KiroCrew-Proxy"] = f"{ts}:{sig}" + # The replay-proof v2 signature rides in separate headers beside the + # unchanged v1 value, so a backend that only knows v1 ignores them. + nonce = new_proxy_nonce() + headers[PROXY_NONCE_HEADER] = nonce + headers[PROXY_V2_HEADER] = sign_proxy_request_v2( + secret=secret, + ts=int(ts), + nonce=nonce, + method=request.method, + target=wire_target, + body=body or b"", + ) except OSError as exc: # nosemgrep: python.lang.security.audit.logging.logger-credential-leak.python-logger-credential-disclosure -- logs the app name and the OSError only and never the secret value logger.warning("Failed to read app secret for %s: %s", name, exc) diff --git a/test/test_app_proxy_auth_v2.py b/test/test_app_proxy_auth_v2.py new file mode 100644 index 00000000000..a154bc1b156 --- /dev/null +++ b/test/test_app_proxy_auth_v2.py @@ -0,0 +1,182 @@ +"""Tests for the replay-proof v2 proxy signature and its nonce cache.""" + +from __future__ import annotations + +import hashlib +import hmac + +import pytest + +from kiro_crew.apps.proxy_auth import ( + ProxyPrincipalReplayCache, + new_proxy_nonce, + sign_proxy_request_v2, + verify_proxy_request, + verify_proxy_request_v2, +) + +SECRET = "s3cret-app-key" +NONCE = "0123456789abcdef" * 2 +NOW = 1_000_000 +TARGET = "/api/write?page=2&q=a%20b" +BODY = b'{"text":"hi"}' + + +def _sign(**overrides) -> str: + args = { + "secret": SECRET, + "ts": NOW, + "nonce": NONCE, + "method": "POST", + "target": TARGET, + "body": BODY, + } + args.update(overrides) + return sign_proxy_request_v2(**args) + + +def _verify(header: str, nonce: str = NONCE, **overrides) -> bool: + args = { + "method": "POST", + "target": TARGET, + "body": BODY, + "replay_cache": ProxyPrincipalReplayCache(), + "secret": SECRET, + "now": NOW, + } + args.update(overrides) + return verify_proxy_request_v2(header, nonce, **args) + + +def test_signature_matches_the_documented_algorithm(): + ts, _, sig = _sign().partition(":") + body_hash = hashlib.sha256(BODY).hexdigest() + message = f"v2:{NOW}:{NONCE}:POST:{TARGET}:{body_hash}" + assert ts == str(NOW) + assert sig == hmac.new(SECRET.encode(), message.encode(), hashlib.sha256).hexdigest() + + +def test_valid_signature_is_accepted(): + assert _verify(_sign()) + + +def test_an_identical_replay_is_refused(): + cache = ProxyPrincipalReplayCache() + header = _sign() + assert _verify(header, replay_cache=cache) + assert not _verify(header, replay_cache=cache) + assert not _verify(header, replay_cache=cache, now=NOW + 59) + + +def test_a_fresh_nonce_on_the_same_request_is_accepted(): + cache = ProxyPrincipalReplayCache() + other = "f" * 32 + assert _verify(_sign(), replay_cache=cache) + assert _verify(_sign(nonce=other), other, replay_cache=cache) + + +@pytest.mark.parametrize( + ("verify_at", "accepted"), + [(NOW - 60, True), (NOW + 60, True), (NOW - 61, False), (NOW + 61, False)], +) +def test_freshness_window_is_sixty_seconds_each_way(verify_at, accepted): + assert _verify(_sign(), now=verify_at) is accepted + + +@pytest.mark.parametrize( + "overrides", + [ + {"method": "PUT"}, + {"target": "/api/write?page=3&q=a%20b"}, + {"body": b'{"text":"bye"}'}, + {"secret": "other-secret"}, + ], +) +def test_a_tampered_field_fails_the_signature(overrides): + assert not _verify(_sign(), **overrides) + + +def test_a_swapped_nonce_fails_the_signature(): + assert not _verify(_sign(), "a" * 32) + + +def test_a_rejected_request_takes_no_cache_entry(): + cache = ProxyPrincipalReplayCache() + assert not _verify(_sign(), method="PUT", replay_cache=cache) + assert _verify(_sign(), replay_cache=cache) + + +def test_a_full_cache_refuses_instead_of_evicting(): + cache = ProxyPrincipalReplayCache(max_entries=1) + other = "f" * 32 + assert _verify(_sign(), replay_cache=cache) + assert not _verify(_sign(nonce=other), other, replay_cache=cache) + # The live nonce is still held, so it is not replayable either. + assert not _verify(_sign(), replay_cache=cache) + # Once its window closes, room returns. + assert _verify(_sign(ts=NOW + 61, nonce=other), other, replay_cache=cache, now=NOW + 61) + + +@pytest.mark.parametrize( + "header", + [ + "", + "no-colon", + f"{NOW}:", + ":abc", + f"x{NOW}:abc", + f"-{NOW}:abc", + "\u00b2:abc", + "9" * 13 + ":abc", + "9" * 5000 + ":abc", + ], +) +def test_a_malformed_header_is_a_verdict_not_an_exception(header): + assert not _verify(header) + + +@pytest.mark.parametrize( + "nonce", + ["", "short", NONCE.upper(), NONCE + "0", NONCE[:-1] + "g", NONCE[:-1] + "\udcff"], +) +def test_a_malformed_nonce_is_refused(nonce): + assert not _verify(_sign(), nonce) + + +def test_a_non_ascii_signature_is_a_verdict_not_a_type_error(): + ts, _, sig = _sign().partition(":") + assert not _verify(f"{ts}:{sig[:-1]}\u00e9") + assert not _verify(f"{ts}:{sig}\udcff") + + +def test_missing_secret_or_cache_fails_closed(monkeypatch): + monkeypatch.delenv("KIROCREW_PROXY_SECRET", raising=False) + assert not _verify(_sign(), secret=None) + assert not _verify(_sign(), secret="") + assert not _verify(_sign(), replay_cache=None) + + +def test_secret_falls_back_to_the_backend_environment(monkeypatch): + monkeypatch.setenv("KIROCREW_PROXY_SECRET", SECRET) + assert _verify(_sign(), secret=None) + + +@pytest.mark.parametrize( + "overrides", [{"secret": ""}, {"ts": -1}, {"nonce": "short"}, {"nonce": NONCE.upper()}] +) +def test_signer_refuses_input_no_verifier_would_accept(overrides): + with pytest.raises(ValueError): + _sign(**overrides) + + +def test_a_v2_signature_is_not_a_valid_v1_signature(): + assert not verify_proxy_request( + _sign(), method="POST", target=TARGET, body=BODY, secret=SECRET, now=NOW + ) + + +def test_new_nonces_have_the_signed_shape_and_differ(): + first, second = new_proxy_nonce(), new_proxy_nonce() + assert first != second + for nonce in (first, second): + assert len(nonce) == 32 and set(nonce) <= set("0123456789abcdef") diff --git a/test/test_app_proxy_auth_v2_node_reference.py b/test/test_app_proxy_auth_v2_node_reference.py new file mode 100644 index 00000000000..946e4526d52 --- /dev/null +++ b/test/test_app_proxy_auth_v2_node_reference.py @@ -0,0 +1,120 @@ +"""Run the documented Node.js v2 proxy verifier against the gateway's own signer. + +The verifier is copied out of ``docs/app-kit/api-reference.md`` at test time, so +the snippet third-party backends copy is the code under test. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +from kiro_crew.apps.proxy_auth import sign_proxy_request_v2 + +_DOC = Path(__file__).resolve().parent.parent / "docs" / "app-kit" / "api-reference.md" +_SECRET = "s3cret-app-key" +_NONCE = "0123456789abcdef" * 2 +_OTHER_NONCE = "f" * 32 +_NOW = 1_000_000 +_TARGET = "/api/write?page=2&q=a%20b" +_BODY = '{"text":"hi"}' + +_DRIVER = """ +import { createProxyV2Verifier } from './verifier.mjs'; +const cases = JSON.parse(process.argv[2]); +const results = []; +let verify = null; +for (const c of cases) { + if (c.fresh || verify === null) verify = createProxyV2Verifier(c.secret, { maxEntries: c.maxEntries ?? 4096 }); + results.push(verify({ ...c.args, body: Buffer.from(c.args.body, 'utf8') })); +} +process.stdout.write(JSON.stringify(results)); +""" + + +def _reference_verifier() -> str: + blocks = re.findall(r"```js\n(.*?)```", _DOC.read_text(encoding="utf-8"), flags=re.S) + matches = [block for block in blocks if "export function createProxyV2Verifier" in block] + assert len(matches) == 1, "the api reference must carry exactly one Node v2 verifier" + return matches[0] + + +def _sig(*, ts: int = _NOW, nonce: str = _NONCE, method: str = "POST", body: str = _BODY) -> str: + return sign_proxy_request_v2( + secret=_SECRET, ts=ts, nonce=nonce, method=method, target=_TARGET, body=body.encode() + ) + + +def _case(*, fresh: bool = False, max_entries: int = 4096, secret: str = _SECRET, **args): + request = { + "method": "POST", + "target": _TARGET, + "body": _BODY, + "signature": _sig(), + "nonce": _NONCE, + "now": _NOW, + } + request.update(args) + return {"fresh": fresh, "maxEntries": max_entries, "secret": secret, "args": request} + + +def test_the_reference_verifier_is_present_in_the_docs(): + source = _reference_verifier() + assert "timingSafeEqual" in source + assert "new Map()" in source + + +@pytest.mark.skipif(shutil.which("node") is None, reason="the reference verifier runs under node") +def test_the_documented_node_verifier_agrees_with_the_gateway(tmp_path: Path): + ts, _, mac = _sig().partition(":") + cases = [ + (_case(fresh=True), True), + (_case(), False), # identical replay + (_case(signature=_sig(nonce=_OTHER_NONCE), nonce=_OTHER_NONCE), True), + (_case(fresh=True, now=_NOW + 60), True), + (_case(fresh=True, now=_NOW - 61), False), # future timestamp + (_case(fresh=True, now=_NOW + 61), False), # stale timestamp + (_case(fresh=True, method="PUT"), False), + (_case(fresh=True, target="/api/write?page=3"), False), + (_case(fresh=True, body='{"text":"bye"}'), False), + (_case(fresh=True, nonce=_OTHER_NONCE), False), # swapped nonce + (_case(fresh=True, nonce=_NONCE.upper()), False), + (_case(fresh=True, signature=f"{ts}:{mac[:-1]}\u00e9"), False), + (_case(fresh=True, signature="no-colon"), False), + (_case(fresh=True, signature="9" * 13 + ":" + mac), False), + (_case(fresh=True, secret=""), False), + (_case(fresh=True, secret="other-secret"), False), + # A rejected request takes no entry, so the genuine one still passes after it. + (_case(fresh=True, method="PUT"), False), + (_case(), True), + # A full cache refuses instead of evicting, then frees room once a window closes. + (_case(fresh=True, max_entries=1), True), + (_case(signature=_sig(nonce=_OTHER_NONCE), nonce=_OTHER_NONCE), False), + (_case(), False), + ( + _case( + signature=_sig(ts=_NOW + 61, nonce=_OTHER_NONCE), + nonce=_OTHER_NONCE, + now=_NOW + 61, + ), + True, + ), + ] + (tmp_path / "verifier.mjs").write_text(_reference_verifier(), encoding="utf-8") + (tmp_path / "driver.mjs").write_text(_DRIVER, encoding="utf-8") + done = subprocess.run( + [shutil.which("node") or "node", "driver.mjs", json.dumps([c for c, _ in cases])], + cwd=tmp_path, + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + check=False, + ) + assert done.returncode == 0, done.stderr + assert json.loads(done.stdout) == [expected for _, expected in cases] diff --git a/test/test_gateway_appkit_endpoints_signed_proxy.py b/test/test_gateway_appkit_endpoints_signed_proxy.py index ce6e8a9c8a2..6cf2f726763 100644 --- a/test/test_gateway_appkit_endpoints_signed_proxy.py +++ b/test/test_gateway_appkit_endpoints_signed_proxy.py @@ -278,6 +278,69 @@ async def test_caller_sent_proxy_header_is_replaced_by_the_gateway_one(self, mon secret=self._SECRET, ) + def _v2_verifies(self, received: dict[str, list[str]], cache=None) -> bool: + from kiro_crew.apps.proxy_auth import ProxyPrincipalReplayCache, verify_proxy_request_v2 + + return verify_proxy_request_v2( + self._only(received, "x-kirocrew-proxy-v2"), + self._only(received, "x-kirocrew-proxy-nonce"), + method="GET", + target="/api/claim-test", + body=b"", + secret=self._SECRET, + replay_cache=ProxyPrincipalReplayCache() if cache is None else cache, + ) + + @pytest.mark.parametrize("opted_in", [False, True]) + @pytest.mark.asyncio + async def test_every_app_receives_a_v2_signature_beside_an_unchanged_v1( + self, monkeypatch, opted_in + ): + from kiro_crew.apps.proxy_auth import ProxyPrincipalReplayCache, verify_proxy_request + + received, _ = await self._proxy(monkeypatch, opted_in=opted_in, context=self._OWNER_CONTEXT) + v1 = self._only(received, "x-kirocrew-proxy") + assert verify_proxy_request( + v1, method="GET", target="/api/claim-test", body=b"", secret=self._SECRET + ) + # Both signatures carry the same issue time, and only v1 lives in v1's header. + assert v1.partition(":")[0] == self._only(received, "x-kirocrew-proxy-v2").partition(":")[0] + cache = ProxyPrincipalReplayCache() + assert self._v2_verifies(received, cache) + assert not self._v2_verifies(received, cache) + + @pytest.mark.asyncio + async def test_each_proxied_request_gets_its_own_nonce(self, monkeypatch): + first, _ = await self._proxy(monkeypatch, opted_in=False, context=self._OWNER_CONTEXT) + second, _ = await self._proxy(monkeypatch, opted_in=False, context=self._OWNER_CONTEXT) + assert self._only(first, "x-kirocrew-proxy-nonce") != self._only( + second, "x-kirocrew-proxy-nonce" + ) + + @pytest.mark.parametrize( + "header_name", + [ + "X-KiroCrew-Proxy-V2", + "x-kirocrew-proxy-v2", + "X-KiroCrew-Proxy-Nonce", + "x-KIROCREW-proxy-nonce", + ], + ) + @pytest.mark.asyncio + async def test_caller_sent_v2_headers_are_replaced_by_the_gateway_ones( + self, monkeypatch, header_name + ): + received, _ = await self._proxy( + monkeypatch, + opted_in=False, + context=self._OWNER_CONTEXT, + headers={header_name: "0123456789abcdef0123456789abcdef"}, + ) + assert self._only(received, "x-kirocrew-proxy-nonce") != ( + "0123456789abcdef0123456789abcdef" + ) + assert self._v2_verifies(received) + @pytest.mark.asyncio async def test_unsignable_owner_id_refuses_instead_of_forwarding(self, monkeypatch): received, body = await self._proxy(