Skip to content

Phase 4b: ed25519 signed measurements (x402 trust layer) - #69

Closed
Svaag wants to merge 1 commit into
feat/real-path-probesfrom
feat/signed-measurements
Closed

Phase 4b: ed25519 signed measurements (x402 trust layer)#69
Svaag wants to merge 1 commit into
feat/real-path-probesfrom
feat/signed-measurements

Conversation

@Svaag

@Svaag Svaag commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

What

Every paid catalog 2xx JSON response now carries a detached ed25519 signature over its exact body, so a buyer (or a third party) can prove a Hyrule measurement is authentic and unaltered. This is the x402 ecosystem's most-requested missing piece — a verifiable trust layer, which no other provider offers.

Ships dark: no key configured ⇒ no signing, nothing advertised. Stacked on #67. Operator enablement in docs/runbooks/response-signing.md.

How

  • services/signing.pyResponseSigner (ed25519), verify_signature, load_signer (returns None when unconfigured), and the published key document.
  • middleware/signing.py — pure-ASGI middleware that buffers a paid 2xx application/json body and adds Hyrule-Signature: ed25519=<b64> + Hyrule-Signature-Key: <kid>, exposed via Access-Control-Expose-Headers. 402/501, free (non-catalog) endpoints, and non-JSON/streaming (icons, snapshots) pass through untouched. A signing failure returns the body unsigned rather than 500ing.
  • app.py — loads the signer in lifespan (dark unless keyed), registers the middleware outermost, serves /.well-known/hyrule-signing-key.json (404 when unconfigured), adds the /llms.txt verification note.
  • discovery.pybuild_x402_manifest advertises a signingKey block only when configured.
  • config / stateresponse_signing_key / response_signing_key_id, AppState.response_signer.
  • canary--verify-signature asserts every paid 2xx carries a valid signature against the published key.

Signed vs not

Response Signed?
Paid catalog op, 2xx, application/json Hyrule-Signature + key id
402 / 501 (unpaid / gated)
Free endpoint (/health, pricing, capabilities)
Non-JSON / streaming (icon, bgp snapshot) ❌ (passthrough)

Tests

tests/test_signing.py: signer roundtrip + tamper-detection, load-gating, key-document shape, middleware behaviour (paid-2xx signed & verifies against the published key; 402/501/non-JSON/free not signed; no-signer no-ops), and manifest + well-known advertisement.

416 passed, ruff check + mypy --strict clean.

Operator enablement (not in this PR)

Generate a 32-byte seed → Vault kv/hyrule-cloud response_signing_key + response_signing_key_id → restart api. Verify with x402_canary.py dns --verify-signature. Rotation = dual-publish. See the runbook.

🤖 Generated with Claude Code

Every paid catalog 2xx JSON response now carries a detached ed25519 signature
over its exact body, so a buyer can prove a measurement came from Hyrule and was
not altered — the ecosystem's most-requested missing piece. Ships dark: no key
configured => no signing, no manifest advertisement.

- services/signing.py: ResponseSigner (ed25519), verify_signature, load_signer
  (None when unconfigured), and the published key document.
- middleware/signing.py: pure-ASGI middleware that buffers the body of a paid
  2xx application/json response and adds Hyrule-Signature (ed25519=<b64>) +
  Hyrule-Signature-Key, exposed via CORS. 402/501, free endpoints, and
  non-JSON/streaming responses pass through untouched; a signing failure returns
  the body unsigned rather than 500ing.
- app.py: load the signer in lifespan (dark unless keyed), register the
  middleware outermost, serve /.well-known/hyrule-signing-key.json (404 when
  unconfigured), add the llms.txt verification note.
- discovery.py: manifest advertises signingKey only when configured.
- config.py/state.py: response_signing_key/_id + AppState.response_signer.
- canary: --verify-signature asserts every paid 2xx carries a valid signature
  against the published key.
- runbook + .env.example document key provisioning and dual-publish rotation.

Tests: signer roundtrip + tamper, load gating, key document, middleware
(paid-2xx signed & verifies; 402/501/non-JSON/free not signed; no-signer no-ops),
manifest + well-known advertisement. 416 pass, ruff + mypy strict clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

67 - Partially compliant

Compliant requirements:

(empty – this PR implements signing, not the prober sidecar)

Non-compliant requirements:

Requires further human verification:

(empty)

⏱️ Estimated effort to review: 2 🔵🔵⚪⚪⚪
🏅 Score: 92
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Silent signing failure

The middleware buffers the entire response body and attempts ed25519 signing. If signing raises an exception it logs a warning but returns the body unsigned without altering the response status or adding signature headers. A misconfigured or corrupted key seed will silently disable signing with no visible failure. An operator who restarts after provisioning a key and does not run the canary (e.g., due to monitoring gap) may believe all paid responses are verifiable when they are not. The design is intentional (never break the response), but it introduces a trust-footgun: signing can drop silently in production without any alert.

body = b"".join(chunks)
headers: list[tuple[bytes, bytes]] = list(start_message.get("headers", []))
try:
    signature = signer.sign(body)
    headers.append((b"hyrule-signature", f"ed25519={signature}".encode()))
    headers.append((b"hyrule-signature-key", signer.key_id.encode()))
    _merge_expose_headers(headers)
except Exception as exc:  # never fail the response over signing
    log.warning("response_signing_failed", error=str(exc))
await send({**start_message, "headers": headers})
await send({"type": "http.response.body", "body": body, "more_body": False})
Silent signer load failure

load_signer returns None on any exception (malformed key, decoding error). This means a bad key in the environment variable is silently ignored – the feature ships dark even when the operator intended it to be active. Coupled with the middleware's own silent failure above, a signing outage can go entirely unnoticed until a buyer reports missing headers. The only detection path is running the external canary (x402_canary.py --verify-signature), which is not part of the health check or Prometheus metrics.

try:
    return ResponseSigner(key, key_id)
except Exception as exc:
    log.warning("response_signer_load_failed", error=str(exc))
    return None

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix base64 padding calculation

The padding calculation "=" * (-len(text) % 4) is incorrect for already-padded
strings; it can add extra padding characters, causing base64.b64decode to fail. Use
base64.b64decode(text, validate=False) which handles missing padding automatically,
or use a more robust padding approach.

hyrule_cloud/services/signing.py [33-44]

 def _decode_seed(value: str) -> bytes:
     """Decode a 32-byte ed25519 seed from base64 (std or url, padded or not)."""
     text = value.strip()
-    padded = text + "=" * (-len(text) % 4)
     for decoder in (base64.b64decode, base64.urlsafe_b64decode):
         try:
-            raw = decoder(padded)
+            raw = decoder(text)
         except (binascii.Error, ValueError):
             continue
         if len(raw) == 32:
             return raw
     raise ValueError("response signing key must be a base64-encoded 32-byte ed25519 seed")
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential issue with the padding calculation for already-padded strings, which could cause decoding failures. The improved_code removes the manual padding and relies on base64.b64decode's built-in handling, which is cleaner and more robust. This is a valid bug fix with moderate impact.

Medium

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 62e1c3aaa0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +109 to +111
"keys": [
{
"kid": signer.key_id,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Publish the retiring signing key during rotation

After an operator rotates HYRULE_RESPONSE_SIGNING_KEY, this document always publishes only the new active key, despite the response header carrying a key ID and the runbook requiring dual publication. A buyer who retrieves a response signed before the rotation and then fetches the well-known document cannot locate that response's old key or verify it, so the promised durable verifiability breaks at every rotation. Add configuration/storage for retired public keys and include them in keys[] until their verification window expires.

Useful? React with 👍 / 👎.

Svaag added a commit that referenced this pull request Jul 16, 2026
Bring #69's ed25519 response-body signing under the canonical trust
layer instead of shipping a parallel signing stack. Measurements attest
the DATA; receipts attest the transaction — independent flags, one key
namespace, one JWKS.

- trust/measurements.py: MeasurementSigner (ed25519 detached signature
  over exact 2xx JSON body bytes), verify_measurement_signature,
  load_measurement_signer, enforce_measurement_key_guard (fail-closed
  boot guard mirroring receipts), measurement_jwks_entries.
- middleware/signing.py: ResponseSigningMiddleware — signs paid 2xx JSON
  from the enabled catalog, Hyrule-Signature/-Key headers + CORS expose;
  passthrough when unconfigured (soft-fail).
- config TrustConfig: TRUST_MEASUREMENT_SIGNING_ENABLED/KEY/KEY_ID/
  RETIRED_JWKS_JSON (default off).
- Unified JWKS: build_jwks appends the OKP/Ed25519 key (active + retired)
  so one /.well-known/jwks.json verifies receipts AND measurements.
- Advertise under agent-registration signedMeasurements only — NOT the
  x402 manifest, preserving the byte-identical-off invariant.
- app.py: enforce_measurement_key_guard + middleware install.
- x402_canary.py: --verify-signature re-derives the pubkey from jwks.
- Docs: trust-layer component row, trust-keys runbook section + rotation,
  .env.example block. 9 new tests; 397 pass, mypy strict + ruff clean.

Supersedes and closes #69.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Svaag

Svaag commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

Folded into the canonical trust layer (#56) rather than shipping a parallel signing stack — per operator decision to keep one key namespace, one JWKS, and one runbook for everything the service signs.

Where it went (commit b46e429 on feat/agent-trust-layer):

  • hyrule_cloud/trust/measurements.pyMeasurementSigner (ed25519 detached signature over the exact 2xx JSON body bytes), verify_measurement_signature, load_measurement_signer, and a fail-closed enforce_measurement_key_guard that mirrors the receipt boot guard.
  • hyrule_cloud/middleware/signing.pyResponseSigningMiddleware, reading the signer off trust state; passthrough when unconfigured (soft-fail).
  • Key is now published in the same /.well-known/jwks.json as the receipt keys (OKP/Ed25519 entry via build_jwks), and advertised under the agent-registration signedMeasurements block — not the x402 manifest, so /.well-known/x402.json stays byte-identical with the flag off.
  • Config flags renamed into the trust namespace: TRUST_MEASUREMENT_SIGNING_ENABLED / _KEY / _KEY_ID / _RETIRED_JWKS_JSON (all default off).
  • Canary keeps the --verify-signature check; docs live in docs/runbooks/trust-keys.md (generation, enable, rotation) and docs/trust-layer.md.

9 dedicated tests carried over; full suite 397 pass, mypy strict + ruff clean. Closing in favor of #56.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant