WORLDLINE public verifier (service/)¶
Included from the repository
This page is service/README.md from the repository, included as written.
Live deployment: https://worldline-verifier-301330323690.europe-north1.run.app
A FastAPI service, deployed to Google Cloud Run, that does two things:
- Serves the built viewer at
/together with the pipeline's output underdata/(the same files GitLab Pages publishes). - Exposes the evidence kernel's verification half as a public HTTP API: anyone can POST a
bundle.jsonand get back whether it is intact and kernel-signed, and whether it would pass the release policy.
The image contains only the kernel's public key (policies/kernel_public.pem) and the release policy (policies/release.yaml). It cannot seal, attest or authorize anything on its own; policies/kernel_private.pem is excluded by .dockerignore and deleted again in the CI job before the source upload.
Why a public verifier¶
WORLDLINE's claim is that a release is gated on evidence the kernel recomputed and signed, not on an agent's say-so. That claim is only worth something if someone outside the pipeline can check it. With this service:
- A reviewer takes the
bundle.jsonattached to a release and POSTs it to/api/verify. The hash chain and the Ed25519 checkpoint are re-checked against the public key. An edited, dropped, reordered or truncated record shows up as a finding (RECORD_EDITED,RECORD_DROPPED,RECORDS_REORDERED,TRUNCATED, ...); a bundle signed with another key isUNKNOWN_SIGNER. - The reviewer then POSTs the same bundle to
/api/authorize?artifact_digest=<commit>and sees exactly theAUTHORIZED/DENIEDdecision the CI gate produced, with every reason, satisfied requirement, stale and downgraded record. No CI access, no trust in the runner, no private key. /api/evidencereturns the bundle and authorization that this very deployment was built from, so the viewer's Evidence tab and the API agree by construction.
The service is deliberately read-only and stateless; it is safe to leave unauthenticated.
API¶
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
The viewer (ui/dist), with data/*.json next to it. |
GET |
/api/healthz |
Liveness: {status, public_key, data, viewer}. |
GET |
/api/readyz |
Readiness: 200 {status: "ready", checks} when the public key parses, the release policy loads and the data dir has index.json; 503 {status: "unavailable", checks} otherwise. |
GET |
/api/version |
Git commit, kernel public key id, policy name, limits. |
POST |
/api/verify |
Body: bundle JSON. Returns ok, findings[], root, signer_key_id, verifier_key_id and a per-record summary (index, kind, producer, claim). |
POST |
/api/authorize?artifact_digest=...[&policy=release][&now=ISO] |
Body: bundle JSON. Returns the kernel Authorization (decision, reasons, satisfied, missing, stale, downgraded, policy). now evaluates at a given time; by default the current time, so evidence older than the policy's max_age_s (24 h) is stale. |
POST/GET |
/api/verify/release?tag=v0.1.3-transcend[&artifact_digest=...][&policy=release][&now=ISO] |
Fetches bundle.json for the tag from the GitLab generic package registry (worldline-evidence/<tag>/), then returns {tag, package_url, bundle_url, artifact_digest, artifact_digest_source, evaluated_at, verify, authorize}, where verify and authorize have exactly the /api/verify and /api/authorize shapes. The commit comes from the GitLab release (commit.id, else the Commit: line of the notes), else from index.json in the same package. The gate is evaluated at the bundle's seal time unless now is given. Only https://gitlab.com is fetched, 10 s deadline, cached 5 min per tag. |
GET |
/api/evidence |
{source, bundle, authorization} from the data baked into the image, with an ETag (If-None-Match gives 304). |
GET |
/api/docs |
Swagger UI, with examples for every endpoint. |
GET |
/api/openapi.json |
The OpenAPI document. |
Rules: JSON only (415 otherwise), bodies capped at 5 MB (413) and 64 levels of nesting (400, checked before parsing), malformed bundles 400, errors as {"error": "..."}. A body must arrive within 15 s (408) and verification must finish within 20 s (503). POST /api/* and GET /api/verify/release are rate limited per client IP with a token bucket (default 30 a minute, WORLDLINE_RATE_LIMIT_PER_MIN), and an empty bucket gives 429 with Retry-After. CORS allows GET/POST from any origin on /api/* only; it is a public verifier, while the viewer stays same-origin.
Every response carries:
X-Request-ID: yours if you sent a sane one, else a generated one. It is also in the log line.Content-Security-Policy: scripts from self only, styles and fonts from self plus Google Fonts,connect-src 'self',frame-ancestors 'none'./api/docsalone also allows jsDelivr for Swagger UI.X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin, a restrictivePermissions-Policy,X-Frame-Options: DENY, andStrict-Transport-Securitywhen the request came over https.Cache-Control:immutablefor the hashed/assets/*,max-age=60for/data/*.json,no-cachefor/and/api/evidence(revalidate with the ETag),no-storefor the rest of/api/*.
Logs are one JSON object per line on stdout in Cloud Logging's shape (severity, httpRequest, plus request_id, method, path, status, latency_ms, client_ip). Bodies and query strings are never logged. Uvicorn's own access log is turned off.
curl examples¶
BASE=https://worldline-verifier-<hash>-lz.a.run.app # or http://127.0.0.1:8081 locally
curl -s $BASE/api/version
# {"service":"worldline-verifier","git_commit":"...","kernel_public_key_id":"ed25519:...","policy":"release",...}
# Verify a bundle you obtained from a release (or from this deployment)
curl -s $BASE/api/evidence | python -c "import json,sys; json.dump(json.load(sys.stdin)['bundle'], open('bundle.json','w'))"
curl -s -X POST $BASE/api/verify -H 'content-type: application/json' --data-binary @bundle.json
# {"ok":true,"findings":[],"root":"sha256:...","signer_key_id":"ed25519:...","n_records":31,"records":[...]}
# Edit one byte of a record and verify again
python -c "import json; d=json.load(open('bundle.json')); d['records'][0]['result']['x']=1; json.dump(d, open('tampered.json','w'))"
curl -s -X POST $BASE/api/verify -H 'content-type: application/json' --data-binary @tampered.json
# {"ok":false,"findings":[{"code":"RECORD_EDITED","index":0,...},{"code":"ROOT_MISMATCH",...}],...}
# Release gate for a commit
curl -s -X POST "$BASE/api/authorize?artifact_digest=$(git rev-parse HEAD)" -H 'content-type: application/json' --data-binary @bundle.json
# {"decision":"AUTHORIZED","artifact_digest":"...","reasons":["all 5 requirement(s) met by 8 record(s)"],...}
# Same bundle, wrong artifact -> DENIED with subject_mismatch reasons
curl -s -X POST "$BASE/api/authorize?artifact_digest=deadbeef" -H 'content-type: application/json' --data-binary @bundle.json
# A published release, straight from the GitLab package registry (no download on your side)
curl -s "$BASE/api/verify/release?tag=v0.1.3-transcend"
# {"tag":"v0.1.3-transcend","artifact_digest":"1bfce462...","artifact_digest_source":"release.commit",
# "verify":{"ok":true,"findings":[],...},"authorize":{"decision":"AUTHORIZED",...}}
# Conditional fetch of the baked-in evidence
ETAG=$(curl -sI $BASE/api/evidence | grep -i '^etag' | cut -d' ' -f2 | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' -H "If-None-Match: $ETAG" $BASE/api/evidence # 304
Run locally¶
uv sync --extra dev --extra service
cd ui && npm ci && npm run build && cd .. # optional: without ui/dist only the API is served
uv run uvicorn service.app:app --port 8081 --reload --no-access-log
# http://127.0.0.1:8081/ viewer
# http://127.0.0.1:8081/api/docs OpenAPI (Swagger UI); /api/openapi.json for the document
uv run pytest tests/test_service.py -q
Data resolution: WORLDLINE_DATA_DIR if set, else ui/dist/data, else experiments/out, else ui/fixtures. Viewer: WORLDLINE_UI_DIST or ui/dist. Policy and public key: WORLDLINE_POLICY_DIR or policies/. GIT_COMMIT (or CI_COMMIT_SHA) overrides the commit reported by /api/version. Every other setting (rate limit, trusted proxy hops, log level, release source and host allowlist) is listed in docs/OPERATIONS.md.
Container¶
Dockerfile at the repo root has three stages, and both base images are pinned by digest:
node:20-alpinebuildsui/. Itscopy-datastep takesexperiments/outwhen present in the build context, elseui/fixtures.python:3.12-slimwithuvrunsuv sync --frozen --extra servicefromuv.lockinto/opt/venv. No pip is involved, and uv stays out of the final image.- A clean
python:3.12-slimgets the venv,service/,ui/dist, the policy and the public key. As uid 10001 it runsexec uvicorn --workers $WEB_CONCURRENCY(default 2, which fits in 512 MiB at about 57 MB idle and 80 MB peak per worker), with--no-access-log,--timeout-graceful-shutdown 8for Cloud Run'sSIGTERM, and aHEALTHCHECKon/api/readyz.
The image is roughly 200 MB uncompressed and about 70 MB compressed, estimated from the layers. See docs/OPERATIONS.md for the runbook: deploy, roll back, key rotation, logs, alerts and cost.
docker build -t worldline-verifier --build-arg GIT_COMMIT=$(git rev-parse HEAD) .
docker run --rm -p 8080:8080 worldline-verifier
Deploy to Cloud Run¶
One-off from a workstation (source deploy; Cloud Build builds the Dockerfile):
export GCP_PROJECT=<your-project-id>
gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com --project $GCP_PROJECT
gcloud run deploy worldline-verifier --source . --region europe-north1 --allow-unauthenticated --project $GCP_PROJECT
gcloud run services describe worldline-verifier --region europe-north1 --project $GCP_PROJECT --format 'value(status.url)'
Run the pipeline first (uv run worldline demo) so experiments/out/bundle.json exists and gets baked in; otherwise the image ships the synthetic fixtures.
From GitLab CI¶
.gitlab/ci/gcp.yml defines deploy-gcp: stage pages, manual, protected refs only, needs: [explore, kernel-verify, authorize], image google/cloud-sdk:slim. It authenticates with gcloud auth activate-service-account --key-file $GCP_SA_KEY, deletes any private key from the checkout, runs gcloud run deploy worldline-verifier --source . with GIT_COMMIT=$CI_COMMIT_SHA, prints the service URL, smoke-tests /api/version and writes experiments/out/deploy.json ({service, region, project, url, commit, pipeline, deployed_at}) as an artifact. Include it from .gitlab-ci.yml:
CI/CD variables (protected):
| Variable | Type | Value |
|---|---|---|
GCP_PROJECT |
variable | GCP project id |
GCP_SA_KEY |
file | JSON key of the deploy service account |
GCP_REGION |
variable, optional | default europe-north1 |
GCP_SERVICE |
variable, optional | default worldline-verifier |
Service account and IAM roles¶
SA=worldline-deploy@$GCP_PROJECT.iam.gserviceaccount.com
gcloud iam service-accounts create worldline-deploy --project $GCP_PROJECT --display-name "WORLDLINE CI deploy"
for ROLE in roles/run.admin roles/cloudbuild.builds.editor roles/artifactregistry.writer roles/iam.serviceAccountUser roles/storage.admin; do
gcloud projects add-iam-policy-binding $GCP_PROJECT --member serviceAccount:$SA --role $ROLE
done
gcloud iam service-accounts keys create gcp-sa-key.json --iam-account $SA # upload as GCP_SA_KEY (file), then delete locally
| Role | Why |
|---|---|
Cloud Run Admin (roles/run.admin) |
create/update the service and set --allow-unauthenticated |
Cloud Build Editor (roles/cloudbuild.builds.editor) |
--source deploys submit a Cloud Build |
Artifact Registry Writer (roles/artifactregistry.writer) |
push the built image to the cloud-run-source-deploy repository |
Service Account User (roles/iam.serviceAccountUser) |
act as the Cloud Run runtime and Cloud Build service accounts |
Storage Admin (roles/storage.admin) |
upload the source tarball to the Cloud Build staging bucket |
The Cloud Build service account itself (<project-number>-compute@developer.gserviceaccount.com on new projects) also needs roles/artifactregistry.writer and roles/logging.logWriter; gcloud run deploy --source prompts for these grants on the first run.
Security notes¶
- No private key in the image or the job; the service can only verify, never sign.
- Policy names are restricted to file stems inside
policies/(../kernel_privateis rejected). - 5 MB body limit, 64-level JSON depth limit, JSON only, per-IP rate limit, stateless, no persistence, non-root container user.
/api/verify/releasefetches onlyhttpsURLs on allowlisted hosts (gitlab.com), re-checks every redirect hop, caps each download at 5 MB and has a 10 s deadline, so it cannot be used to reach internal addresses.- The rate limit uses the right-most
X-Forwarded-Forentry (the one Cloud Run's front end adds), so a client cannot dodge it by sending its own header. - Cloud Run
--max-instances 3bounds cost on a public endpoint; raise if needed.