zkwid.at (N): measured-workload keys and OAuth proxy, atproto-native
Context
A hosted service H (e.g. a knotless CI runner) runs instances of a requester workload R: a shim plus stage2..N WASM. Each instance must act on behalf of user U with exactly the OAuth scopes U approved for that workload.
N (zkwid.at) is the service built here. It does four things:
- For each workload record in U's repo, it hosts a dedicated OAuth client. U consents once per workload, and N stores the resulting session.
- It creates
at.zkwid.key(K) in U's repo only after a successful ZK exchange. H proves in RISC Zero that the measured shim (chain M) produced K's public key for N's challenge. U's workload record must list M and list H as an allowed host. H holds K's private key.- Only N can write K, using N's own OAuth client and its
repo:at.zkwid.keyscope. - Workload clients never get any
at.zkwid.*scope, so workloads cannot mint keys.
- Only N can write K, using N's own OAuth client and its
- It reverse-proxies XRPC calls from instances that sign service-auth JWTs with a K key, forwarding them through U's session within the workload's scopes.
- Revocation is a record deletion. Deleting K revokes one instance; deleting the workload record revokes all of them.
Scope:
- H is a stub. U_ATRepo is a real
atproto-pdsfrom ../atproto-crates. - Changes to knotless and socialweb-computer-kcp are documented as future work only, not built.
- ZK gates key creation only.
- Backend: RISC Zero 3.0.x, using
RISC0_DEV_MODEreceipts in the devnet and real succinct receipts in production. - M is an ordered list of blob CIDs ("DICE-lite"). Full DICE is deferred.
- Backend: RISC Zero 3.0.x, using
- Code stays minimal: no comments, small crates,
at.zkwid.*NSIDs only. - Auth, OAuth, CIDs and keys all come from atproto-crates.
Actors and identifiers
| Actor | Identifier | Dev host (via CONNECT proxy) |
|---|---|---|
| N | did:web:zkwid.test, service #zkwid | zkwid.test |
| H stub | did:web:h.zkwid.test, #atproto key | h.zkwid.test |
| U | did:plc from a fake PLC directory | account on pds.zkwid.test |
| Instance | did:key:<K pub>, self-certifying iss | none |
- Client ID:
https://zkwid.test/<handle>/<rkey>/oauth-client-metadata.json. - We use the path form. The spec allows any path. The subdomain form can exceed the 63-byte DNS label limit, and handles that already contain dashes make it ambiguous.
- If U's handle changes, the client ID changes. This is documented.
Lexicons (lexicons/at/zkwid/)
at.zkwid.workload(record, in U's repo, rkeyany, used in the URL):{ measurements: [{ name, wasm: blob }], scope: string, hosts: [did], createdAt }. The PDS hosts the stage blobs.at.zkwid.key(record, in U's repo, written by N, rkey tid):{ key: did:key, workload: strongRef, host: did, instance: string, createdAt, expiresAt }.at.zkwid.createChallenge(procedure on N, caller H): input{ repo, workload: strongRef }, output{ challenge }.- The challenge is a stateless JWT signed by N's
#atprotokey: ES256,typ: zkwid-challenge+jwt, claims{ iss: N, sub: H, repo: U, workload: { uri, cid }, exp: now + 300 s, jti }. - N stores nothing for it.
- The challenge is a stateless JWT signed by N's
at.zkwid.createKey(procedure on N, caller H): input{ challenge, instance, receipt: bytes }, output{ uri, cid, key }.- The key and the measurements come from the receipt journal, never from caller fields.
- K's rkey is
base32(sha256(challenge)), so a reused challenge collides with the existing record. That makes it single-use, with U's repo as the only state. - Errors:
WorkloadNotFound,MeasurementMismatch,HostNotAllowed,InvalidReceipt,ChallengeInvalid,ChallengeUsed,NotAuthorized.
- Journal, committed by the guest:
{ measurements: [cid], key: did:key, nonce: sha256(challenge), aud: did }.- The guest measures every stage blob and runs the shim in wasmi.
- The shim calls the guest import
keygen(seed), which derives a K256 key natively in the guest with the acceleratedk256crate. The seed is a private witness held by H.
at.zkwid.deleteKey(procedure on N, caller H):{ uri }.at.zkwid.demo.log(record): the demo workload writes this through the proxy.
N behaviour
Client metadata at GET /<handle>/<rkey>/oauth-client-metadata.json:
- N resolves the handle to the DID and PDS, then fetches
at.zkwid.workload/<rkey>. - The document is a confidential web client with:
scope = "atproto " + workload.scope- N refuses to serve the document (404) if
workload.scopenames anyat.zkwid.*resource private_key_jwtwith ES256 and an inlinejwksholding a client key per workload, stored in the DBredirect_uris = [https://zkwid.test/<handle>/<rkey>/callback]dpop_bound_access_tokens: true
- It is served with status 200 and no redirect.
OAuth flow:
GET /<handle>/<rkey>/loginrunsatproto_oauth::workflow::oauth_init(PAR, PKCE, DPoP).GET /<handle>/<rkey>/callbackrunsoauth_complete_with_bindingand stores the session keyed by (U DID, rkey).- Refresh goes through
atproto_oauth::refresh::RefreshCoordinator, with an atomic swap in the store.
N's own client (https://zkwid.test/oauth-client-metadata.json):
- Scope is
atproto repo:at.zkwid.key. U consents to it once, during onboarding. - N stores this session per U. It is the only credential that ever writes or deletes K.
at.zkwid.createChallenge:
- Verify H's service auth (
lxm= createChallenge). - Fetch the workload by strongRef and check that
iss ∈ workload.hosts. - Mint the challenge JWT with
atproto_oauth::jwt::mint. N stores nothing.
at.zkwid.createKey:
- Run
atproto_xrpcs::verify_service_authwithiss= H,aud=did:web:zkwid.test#zkwidandlxm= createKey. No jti cache is needed, because a replayed request hits the fixed rkey. - Verify the challenge JWT:
- the signature is N's key;
typiszkwid-challenge+jwt;- it has not expired;
subequals the caller.
- Fetch the workload record from the challenge's strongRef, requiring the CID to match. Check that
iss ∈ hostsand that both N's own session and the workload session exist for U. - Run
receipt.verify(ZKWID_GUEST_ID)and decode the journal. Require:journal.measurements == workload.measurements[*].wasm.refjournal.nonce == sha256(challenge)journal.aud == did:web:zkwid.test
createRecordK at rkeybase32(sha256(challenge))using N's own session. If the record already exists, returnChallengeUsed. Otherwise indexdid:key→ K uri.- The verifier is built with
disable-dev-modeunless the devnet flagZKWID_DEV_RECEIPTS=1is set.
Proxy at ANY /xrpc/<nsid>, for anything that is not an at.zkwid.* method:
- Verify the JWT with
verify_service_auththrough a resolver that synthesizes a document fordid:keyissuers and delegates everything else toDidResolver. Checkaud= N,lxm= path NSID, jti not replayed. - Look up K from the
did:keyindex, thengetRecordboth K and the workload live (30 s cache) to confirm neither was revoked. - Check scope with
atproto_oauth::scopes::ScopesSet:allows_repo(collection, action)for repo writes, reading the collection from the body.allows_rpc(lxm, aud)for other calls, usingaudfrom theatproto-proxyheader.- Always deny
at.zkwid.keyandat.zkwid.workload.
- Forward the call to U's PDS with
atproto_clientDPoP using the stored session.- Buffer the body so DPoP-nonce and refresh retries can resend it.
- Return the status and body verbatim and strip forwarded headers. This follows the atproto-reverse-proxy pattern, but nothing is ported from its Go code.
Store: SQLite holding:
- per-workload client keys and sessions
- N's own sessions
- the
did:key→ K index - the proxy jti cache
It holds no challenge state.
Repo layout
Cargo.toml workspace; path deps ../atproto-crates/crates/{atproto-oauth,atproto-xrpcs,atproto-identity,atproto-client,atproto-dasl,atproto-record,atproto-testkit}
lexicons/at/zkwid/ workload.json key.json createKey.json deleteKey.json demo/log.json
crates/zkwid/ lib: lexicon types, measure (compute_raw_cid), client-metadata builder, scope check, did:key resolver
crates/zkwid-n/ bin N: axum routes above + store.rs
crates/zkwid-zkvm/ risc0 methods crate (build.rs embed_methods) + guest/: measure stages, run shim in wasmi, keygen import, commit Journal
crates/zkwid-h/ stub H: did:web doc, seed, prove via zkwid-zkvm, createChallenge/createKey/deleteKey, instance runs stage2 in wasmi, proxy calls
crates/zkwid-dev/ devnet: FakePlcDirectory, spawns `pds`, local CA (rcgen) + CONNECT proxy, U account, seeds U_ATRepo, headless consent
stages/shim stages/stage2 wasm32 no_std; stage2 emits the demo.log record body
tests/e2e.rs in crates/zkwid-dev: full flow + negatives
docs/future-work.md knotless, socialweb-computer-kcp, ZK/DICE
Dev network (mirrors atproto-crates/tools/conformance)
- Start
FakePlcDirectoryand thepdsbinary (--features clap) with:PDS_HOSTNAME=pds.zkwid.testPDS_TRUSTED_PROXY_HOPS=1PDS_ALLOW_DEV_DEFAULTS- non-production egress
- Generate a CA and leaves with rcgen. Run a CONNECT proxy that maps
{zkwid,h.zkwid,pds.zkwid}.test:443to loopback TLS terminators. - Every process gets
HTTPS_PROXYandSSL_CERT_FILE. This requires Linux, as in the conformance harness. - Create U, upload the stage blobs, and write
at.zkwid.workload/demo. - Approve consent headlessly through the PDS one-step JSON authorize POST.
Waves of parallel subagents
Every wave ends with a gate: cargo build --workspace && cargo test --workspace, run by the coordinator (me). Agents work in separate crates, so they never edit the same files. The coordinator owns Cargo.toml and the lexicons.
- Wave 0 (coordinator, serial). Workspace skeleton, lexicon JSON, frozen interfaces in
crates/zkwid(type and fn signatures,todo!()bodies), theDevnetstruct API, N's route table, and env vars. Spike: buildpdsand boot it once. - Wave 1 (5 agents in parallel):
- A:
crates/zkwidbodies and unit tests. - B:
crates/zkwid-devdevnet: PLC, PDS, CA, proxy, account, seeding, both consents. - C:
stages/*and the H stub core (did:web doc, seed, the native wasmi runner for stage2). - D: N OAuth (N's own client plus per-workload clients, metadata routes, login, callback, session store, refresh).
- Z:
crates/zkwid-zkvmguest and host prove fn,Journal, and a dev-mode round-trip test.
- A:
- Wave 2 (3 agents in parallel):
- E: N
createChallenge,createKey(receipt verify), anddeleteKey. - F: N proxy (did:key JWT verify, live K check, scope check, forward).
- G: H stub client calls (challenge, prove, createKey as H, instance JWT, proxy writes).
- E: N
- Wave 3 (2 agents in parallel):
- H:
tests/e2e.rs, the full flow plus negatives. - I:
docs/future-work.mdand the README.
- H:
- Wave 4 (coordinator):
/code-reviewand/simplifypasses, then fixes.
Each agent brief contains its crate path, the frozen interfaces, the atproto-crates functions to reuse (listed above), the no-comments/minimal style rule, and its gate command.
Verification
cargo test -p zkwid-dev --test e2e boots the devnet and asserts:
Happy path:
- The metadata scope equals
atproto <workload.scope> repo:at.zkwid.key. - After consent, N holds a session.
- createKey from H puts K in U's repo.
- The instance JWT is accepted, and the proxied
createRecord at.zkwid.demo.loglands in U's repo.
Negatives, each refused:
- workload client metadata never contains
at.zkwid, and a workload whose scope namesat.zkwid.keygets a 404 for its metadata - a proxied workload call that tries to create
at.zkwid.key→ 403, and none is created - createKey with no receipt, a tampered journal, or a wrong ImageID →
InvalidReceipt - an expired, forged, or re-signed challenge, or one whose
subis another H →ChallengeInvalid - a reused challenge, even with a fresh receipt →
ChallengeUsed, leaving exactly one K for that challenge - a journal key K created without any receipt cannot exist: every K maps to a verified receipt in N's log
- measurement mismatch →
MeasurementMismatchand no K - H not in hosts →
HostNotAllowed - K deleted → proxy 401
- workload deleted → 401
- out-of-scope collection → 403
- proxy write to
at.zkwid.key→ 403 - replayed jti → 401
- expired JWT → 401
- wrong
aud→ 401
Future work (documented only)
- knotless (H):
- The runner measures the workload stages, generates a per-run key, calls
at.zkwid.createKeywith its did:web service auth, passes N's URL and the key to the instance, and callsdeleteKeywhen the run finishes. - The run identity is the
dev.knotless.checkat-uri, used asinstance.
- The runner measures the workload stages, generates a per-run key, calls
- socialweb-computer-kcp:
- Accept
at.zkwid.keyas a signer-binding kind, in place ofbadgeBlueKeys market_requester. The RFP is then signed by the instance key. - Add a policy workflow that checks K → workload → measurements and hosts.
- Accept
- ZK / DICE:
- Real succinct receipts on a GPU or Boundless prover. Measure prove time.
- A chain-head CDI.
- Publish the receipt as a blob referenced from K, so third parties can audit it.
- Note the limit: without a TEE, H knows the seed. The proof binds K to an execution of the measured shim and to N's challenge. It does not protect the key from H, which U already trusts.
- Lexicon publishing: a
_lexicon.zkwid.atTXT record andcom.atproto.lexicon.schemarecords.