Skip to content

About

Verifies attested-key canonical requests (RFC 9421) from World App and proxies them to a sibling service.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

attested-proxy

Verification of World App attested-key canonical requests, as Rust crates and as a sidecar container that sits in front of a service.

A World App request is signed by a device key that the Attestation Gateway attested. The gateway's JWT (Integrity-Token) carries that key as cnf.jwk. The app signs an RFC 9421 signature base covering the method, scheme, authority, path, query, body digest and the token itself; a verifier rebuilds the same base from the request it received and checks the signature with the attested key. The wire format is the TFH RFC 9421 Integrity Request Signing Profile.

Verification proves that an attested app on an attested device signed this exact request. It does not identify a user, a PCP owner or a relying party, and it authorizes nothing. See Authorized Principal for that layer.

Crates

Crate Use it to
attested-request Sign and verify canonical requests without any transport. Types, Signature-Input parsing and canonical serialization, the signature base, Attestation Gateway token verification (ES256 only), iOS App Attest and Android signature checks, a Signer trait for clients, an optional replay guard. The remote-jwks feature adds a cached HTTPS JWKS; test-util adds software signers and a fake gateway.
attested-request-tower Verify in-process, in any tower or axum service. AttestedRequestLayer puts a VerifiedAttestedKeyContext in the request extensions; the axum feature adds the AttestedKey extractor.
attested-proxy Verify in a sidecar, so the service itself needs no attestation code.

A Rust service can embed the tower layer instead of running the sidecar; the sidecar is built on that same layer.

let app = axum::Router::new()
    .route("/v1/config", axum::routing::post(handler))
    .route_layer(AttestedRequestLayer::new(Arc::new(verifier)));

async fn handler(AttestedKey(context): AttestedKey) -> String {
    format!("{} device {}", context.device.platform, context.device.key.thumbprint())
}

The sidecar

The sidecar is the service's only ingress. For each request it:

  1. forwards it unverified if its path is listed in ATTESTED_PROXY_UNPROTECTED_PATHS (exact match, meant for the load balancer's health check);
  2. otherwise reads the body under a size limit and deadline, and verifies the request, answering {"error": "<reason>"} with the reason's status when verification fails;
  3. forwards the verified request to the one configured upstream. Clients never choose the destination. WebSocket upgrades are verified on the handshake and then tunnelled byte for byte.

Everything that can fail does so closed: an unreachable JWKS, an unreachable replay store and an unreadable body all refuse the request.

What the upstream receives

Header Value
x-attested-platform ios or android
x-attested-key-thumbprint RFC 7638 thumbprint of the device key: a stable per-device identifier, for example to rate limit on
x-attested-request-binding base64(SHA-256(signature base)) of this exact request

Client-sent copies of these headers are removed on every path, and the Integrity-Token, Signature-Input and Signature headers are not forwarded. The upstream can trust these headers only because it is reachable solely through the sidecar, so keep its port off the Service and the load balancer.

Configuration

Every flag has an ATTESTED_PROXY_* environment variable; attested-proxy --help lists them.

Variable Default
ATTESTED_PROXY_UPSTREAM required The sibling container, http://127.0.0.1:8000.
ATTESTED_PROXY_AUTHORITY required The host clients dial, as signed in @authority.
ATTESTED_PROXY_SCHEME https As signed in @scheme. wss:// clients sign https.
ATTESTED_PROXY_AUDIENCES required Accepted token audiences, comma-separated.
ATTESTED_PROXY_ISSUERS required Comma-separated issuer=https://... or issuer=file:///... JWKS sources.
ATTESTED_PROXY_UNPROTECTED_PATHS none Exact paths forwarded without verification.
ATTESTED_PROXY_LISTEN 0.0.0.0:8080 Proxy listener.
ATTESTED_PROXY_ADMIN_LISTEN 0.0.0.0:8081 /health (liveness) and /ready (readiness).
ATTESTED_PROXY_MAX_AGE_SECS 300 Oldest accepted created.
ATTESTED_PROXY_MAX_FUTURE_SKEW_SECS 60 Furthest-ahead accepted created.
ATTESTED_PROXY_MAX_BODY_BYTES 1048576 Largest body verified.
ATTESTED_PROXY_BODY_READ_TIMEOUT_SECS 10 Deadline for the request body.
ATTESTED_PROXY_HEADER_READ_TIMEOUT_SECS 10 Deadline for the request headers.
ATTESTED_PROXY_UPSTREAM_CONNECT_TIMEOUT_MS 2000 Deadline for connecting upstream.
ATTESTED_PROXY_UPSTREAM_RESPONSE_TIMEOUT_SECS 60 Deadline for upstream response headers. Open tunnels have none; the upstream owns their idle timeout.
ATTESTED_PROXY_MAX_CONNECTIONS 1024 Connections and tunnels together. Beyond it, connections wait in the listen backlog.
ATTESTED_PROXY_SHUTDOWN_GRACE_SECS 45 How long in-flight work may finish after SIGTERM.
ATTESTED_PROXY_REPLAY_REDIS_URL none Enables replay tracking (redis:// or rediss://).
ATTESTED_PROXY_REPLAY_TIMEOUT_MS 250 Deadline for one replay-store command.

For example:

ATTESTED_PROXY_ISSUERS='attestation.worldcoin.dev=https://attestation.worldcoin.dev/.well-known/jwks.json,ci.attestation.worldcoin.dev=file:///etc/attested-proxy/ci/jwks.json'

The issuer is matched exactly against the token's iss. Invalid or empty entries, duplicate issuers, unsupported URL schemes and unreadable or invalid local files fail startup. File URLs must be local, with no query or fragment; percent-encoded paths are decoded. Encode commas inside URLs as %2C. This setting replaces the previous issuer and JWKS environment variables. All issuers use the configured audiences and the same ES256 token and request-signature checks; CI requests still need the device private key matching the token's cnf.jwk.

HTTPS sources refresh independently. Local public JWKS files are loaded once at startup, must be at most 64 KiB and must contain usable ES256 keys. Keep token-signing private keys on the client; mount only public JWKS in the proxy. To rotate local keys, publish overlapping old and new kids, roll the proxy pods, switch the signer, then remove the old key and roll again after old tokens expire.

Telemetry is configured by telemetry-batteries (TELEMETRY_PRESET=datadog, TELEMETRY_SERVICE_NAME, TELEMETRY_METRICS_BACKEND=statsd, …), as in Flamingo.

Deploying next to a service

In a common-app chart, add the sidecar and move traffic to its port. For Flamingo:

service:
  port: 8080
httpRoute:
  port: 8080
targetGroupConfigurations:
  - defaultConfiguration:
      healthCheckConfig:
        healthCheckPath: /ready   # unprotected, forwarded to Flamingo's enclave-aware /ready
        healthCheckPort: "8080"
sidecars:
  - name: attested-proxy
    image: <attested-proxy image>
    env:
      - { name: ATTESTED_PROXY_UPSTREAM, value: "http://127.0.0.1:8000" }
      - { name: ATTESTED_PROXY_AUTHORITY, value: flamingo-verifier.toolsforhumanity.com }
      - { name: ATTESTED_PROXY_AUDIENCES, value: <flamingo audience> }
      - { name: ATTESTED_PROXY_ISSUERS, value: "attestation.worldcoin.dev=https://attestation.worldcoin.dev/.well-known/jwks.json" }
      - { name: ATTESTED_PROXY_UNPROTECTED_PATHS, value: "/health,/ready" }
    ports:
      - { name: proxy, containerPort: 8080 }
    livenessProbe: { httpGet: { path: /health, port: 8081 } }
    readinessProbe: { httpGet: { path: /ready, port: 8081 } }

For CI, extend the issuer list and add a read-only volume mount to the sidecar, and the volume to the pod spec (or the chart's equivalent fields):

# Sidecar fields:
env:
  - name: ATTESTED_PROXY_ISSUERS
    value: "attestation.worldcoin.dev=https://attestation.worldcoin.dev/.well-known/jwks.json,ci.attestation.worldcoin.dev=file:///etc/attested-proxy/ci/jwks.json"
volumeMounts:
  - { name: ci-jwks, mountPath: /etc/attested-proxy/ci, readOnly: true }
# Pod field:
volumes:
  - name: ci-jwks
    secret:
      secretName: attested-proxy-ci-public-jwks

The Secret's jwks.json entry contains public keys only.

The pod is ready only when both containers are: the service's own probe as before, and the sidecar's once any configured key source is usable. A successfully loaded local JWKS remains usable until restart. An unavailable remote source returns 503 for affected requests; other issuers can continue serving traffic. The sidecar stops reporting ready as soon as it receives SIGTERM, then drains.

Health, metrics and logs

Metric
attested_request.verified Verified requests, by platform.
attested_request.rejected Refused requests, by reason, platform and status.
attested_request.verify.duration Verification latency, by outcome.
attested_proxy.upstream.errors Upstream failures, by kind (unavailable, timeout).
attested_proxy.tunnels.opened, .failed, .active WebSocket tunnels.
attested_proxy.connections.active Open client connections.
attested_proxy.jwks.age_seconds Age of the cached JWKS; alert well before it reaches six hours, when verification starts failing.
attested_proxy.jwks.usable 1 while the cached JWKS can verify tokens, 0 before the first fetch and once it is too stale. Alert on 0: age_seconds reports nothing until a fetch succeeds.
attested_proxy.jwks.fetch_failures Failed JWKS fetches across startup, periodic and request-triggered refreshes (once per shared fetch), by class (the HTTP status code, timeout, request, invalid, ...).

The attested_proxy.jwks.* metrics describe HTTPS sources and carry an issuer label containing the configured issuer identifier. Local sources do not fetch or refresh.

One INFO event at startup reports the accepted issuer count, HTTPS/file source counts and loaded file-key count. It confirms configuration loading; remote keys may not have been fetched yet.

Client rejections are counted, not logged. Failures of our own dependencies (JWKS, replay store, upstream) are logged at warn. Tokens, signatures and bodies are never logged.

Replay tracking

created bounds how long a captured request can be replayed (five minutes by default). Replay tracking additionally refuses a second use inside that window, at the cost of a store on the request path that must be up for any request to succeed. It is off unless ATTESTED_PROXY_REPLAY_REDIS_URL is set. A route whose effect can be repeated needs it; a route that only opens a session, such as Flamingo's WebSocket handshake, usually does not.

The replay store must use maxmemory-policy noeviction: evicting an unexpired claim allows that request to be replayed. Provision enough memory for the acceptance window; when full, writes must fail so verification fails closed. Losing claims during a restart or failover also permits replay until those requests expire; configure persistence and recovery accordingly.

Profile notes

  • @signature-params is serialized, not copied. RFC 9421 §3.2 step 7 builds it by serializing the parsed Signature-Input member. Some existing verifiers copy the raw substring after integrity= instead. Current signers emit the canonical form, so both approaches accept their signatures today; only this one also accepts equivalent spellings, such as extra whitespace, that Structured Fields allows.
  • @scheme and @authority come from configuration, never from the Host header.
  • Reject reasons and statuses match go-sonic's canonical request middleware, so clients see one taxonomy across services.
  • iOS assertions are checked against cnf.jwk only. Their rpId and counter are not checked: App Attest keys are app-scoped, the gateway verified the app when it attested the key, and freshness comes from created.

Test vectors

test-vectors/ holds vectors for any implementation of the profile:

  • signature-base.json: signature bases and their hashes, including equivalent whitespace, parameter order and derived components. Generated by generate_signature_base.py, independently of the Rust code.
  • verification.json: signed requests and the verdict a verifier must reach, from a fixed JWKS and clock. Generated by cargo run -p attested-request --example generate-vectors --features test-util.

CI fails if either file differs from what its generator produces.

Development

nix develop            # or install the toolchain in rust-toolchain.toml
cargo test --workspace --all-features
nix flake check        # clippy, tests, fmt and the package, as in CI
docker build .

Container image

The sidecar image is published to GHCR as ghcr.io/worldcoin/attested-proxy:

  • every push to main publishes :latest and :sha-<sha>;
  • a pushed attested-proxy/vX.Y.Z tag (or a manual run of the release image workflow) publishes :vX.Y.Z and opens a draft GitHub release recording the image digest;
  • pull requests that touch the build inputs build without pushing.

Deploy by digest (ghcr.io/worldcoin/attested-proxy@sha256:…), not by tag.

gh workflow run release-image.yml -f ref=main -f version=0.1.0 -f dry_run=false

About

Verifies attested-key canonical requests (RFC 9421) from World App and proxies them to a sibling service.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages