Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 122 additions & 3 deletions docs/app-kit/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -1517,15 +1518,133 @@ 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: <timestamp>:<hmac-sha256>
```

The v2 HMAC uses the app secret as the key, over:

```text
v2:<timestamp>:<nonce>:<method>:<raw-target>:<sha256(body)>
```

`<timestamp>`, `<raw-target>` and `<sha256(body)>` 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
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:

Expand Down
11 changes: 11 additions & 0 deletions docs/architecture/app-platform-trust-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 29 additions & 2 deletions docs/system-specs/modules/app-kit-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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, `<ts>:<hmac>` over
`<ts>:<method>:<raw target>:<sha256(body)>`, 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`, `<ts>:<hmac>`
over `v2:<ts>:<nonce>:<method>:<raw target>:<sha256(body)>` 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
Expand Down
105 changes: 102 additions & 3 deletions src/kiro_crew/apps/proxy_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: <ts>:<hmac_sha256(secret, "v2:<ts>:<nonce>:<method>:<target>:<sha256(body)>")>

: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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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, ``<ts>:<hmac>``, 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:
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/kiro_crew/apps/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading