~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/zkwid

0
0
main
214 lines · 13.4 KB Raw History

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:

  1. 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.
  2. 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.key scope.
    • Workload clients never get any at.zkwid.* scope, so workloads cannot mint keys.
  3. 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.
  4. 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-pds from ../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_MODE receipts in the devnet and real succinct receipts in production.
    • M is an ordered list of blob CIDs ("DICE-lite"). Full DICE is deferred.
  • Code stays minimal: no comments, small crates, at.zkwid.* NSIDs only.
  • Auth, OAuth, CIDs and keys all come from atproto-crates.

Actors and identifiers

ActorIdentifierDev host (via CONNECT proxy)
Ndid:web:zkwid.test, service #zkwidzkwid.test
H stubdid:web:h.zkwid.test, #atproto keyh.zkwid.test
Udid:plc from a fake PLC directoryaccount on pds.zkwid.test
Instancedid:key:<K pub>, self-certifying issnone
  • 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, rkey any, 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 #atproto key: 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.
  • 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 accelerated k256 crate. 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.scope names any at.zkwid.* resource
    • private_key_jwt with ES256 and an inline jwks holding a client key per workload, stored in the DB
    • redirect_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>/login runs atproto_oauth::workflow::oauth_init (PAR, PKCE, DPoP).
  • GET /<handle>/<rkey>/callback runs oauth_complete_with_binding and 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:

  1. Run atproto_xrpcs::verify_service_auth with iss = H, aud = did:web:zkwid.test#zkwid and lxm = createKey. No jti cache is needed, because a replayed request hits the fixed rkey.
  2. Verify the challenge JWT:
    • the signature is N's key;
    • typ is zkwid-challenge+jwt;
    • it has not expired;
    • sub equals the caller.
  3. Fetch the workload record from the challenge's strongRef, requiring the CID to match. Check that iss ∈ hosts and that both N's own session and the workload session exist for U.
  4. Run receipt.verify(ZKWID_GUEST_ID) and decode the journal. Require:
    • journal.measurements == workload.measurements[*].wasm.ref
    • journal.nonce == sha256(challenge)
    • journal.aud == did:web:zkwid.test
  5. createRecord K at rkey base32(sha256(challenge)) using N's own session. If the record already exists, return ChallengeUsed. Otherwise index did:key → K uri.
  6. The verifier is built with disable-dev-mode unless the devnet flag ZKWID_DEV_RECEIPTS=1 is set.

Proxy at ANY /xrpc/<nsid>, for anything that is not an at.zkwid.* method:

  1. Verify the JWT with verify_service_auth through a resolver that synthesizes a document for did:key issuers and delegates everything else to DidResolver. Check aud = N, lxm = path NSID, jti not replayed.
  2. Look up K from the did:key index, then getRecord both K and the workload live (30 s cache) to confirm neither was revoked.
  3. 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, using aud from the atproto-proxy header.
    • Always deny at.zkwid.key and at.zkwid.workload.
  4. Forward the call to U's PDS with atproto_client DPoP 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)

  1. Start FakePlcDirectory and the pds binary (--features clap) with:
    • PDS_HOSTNAME=pds.zkwid.test
    • PDS_TRUSTED_PROXY_HOPS=1
    • PDS_ALLOW_DEV_DEFAULTS
    • non-production egress
  2. Generate a CA and leaves with rcgen. Run a CONNECT proxy that maps {zkwid,h.zkwid,pds.zkwid}.test:443 to loopback TLS terminators.
  3. Every process gets HTTPS_PROXY and SSL_CERT_FILE. This requires Linux, as in the conformance harness.
  4. Create U, upload the stage blobs, and write at.zkwid.workload/demo.
  5. 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), the Devnet struct API, N's route table, and env vars. Spike: build pds and boot it once.
  • Wave 1 (5 agents in parallel):
    • A: crates/zkwid bodies and unit tests.
    • B: crates/zkwid-dev devnet: 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-zkvm guest and host prove fn, Journal, and a dev-mode round-trip test.
  • Wave 2 (3 agents in parallel):
    • E: N createChallenge, createKey (receipt verify), and deleteKey.
    • 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).
  • Wave 3 (2 agents in parallel):
    • H: tests/e2e.rs, the full flow plus negatives.
    • I: docs/future-work.md and the README.
  • Wave 4 (coordinator): /code-review and /simplify passes, 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:

  1. The metadata scope equals atproto <workload.scope> repo:at.zkwid.key.
  2. After consent, N holds a session.
  3. createKey from H puts K in U's repo.
  4. The instance JWT is accepted, and the proxied createRecord at.zkwid.demo.log lands in U's repo.

Negatives, each refused:

  • workload client metadata never contains at.zkwid, and a workload whose scope names at.zkwid.key gets 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 sub is 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 → MeasurementMismatch and 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.createKey with its did:web service auth, passes N's URL and the key to the instance, and calls deleteKey when the run finishes.
    • The run identity is the dev.knotless.check at-uri, used as instance.
  • socialweb-computer-kcp:
    • Accept at.zkwid.key as a signer-binding kind, in place of badgeBlueKeys market_requester. The RFP is then signed by the instance key.
    • Add a policy workflow that checks K → workload → measurements and hosts.
  • 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.at TXT record and com.atproto.lexicon.schema records.