August 12, 2026 at 12:00:00 AM UTC
wolfram closes a DPoP sender-constraint bypass and hardens identity resolution
Wolfram
Twelve days (31 July - 12 August) of work on wolfram's protocol-correctness subsystems: identity/DID resolution, PLC operations, OAuth verification, firehose parsing, and DAG-CBOR validation. Several of these were real bugs, not gaps in coverage.
a DPoP sender-constraint bypass
wf_oauth_verify_request had a hole in how it bound DPoP proofs to access tokens. When a token carried no cnf.jkt at all -- meaning it was never bound to any key at issuance -- the resource-server check accepted it anyway whenever a DPoP proof accompanied it, and silently adopted that proof's key as the token's binding (bearer->dpop_jkt = dpop->dpop_jkt), setting dpop_bound = 1. That inverts the whole point of sender-constraining: anyone who obtained an unbound token, however it leaked -- logs, XSS, a compromised proxy -- could mint a DPoP proof with a key of their own choosing and have the verifier treat it as legitimately bound, using it exactly like a stolen plain bearer token. d29d42f fixes it by rejecting an unbound token outright whenever a DPoP proof is presented alongside it, rather than adopting the proof's key; an unbound token can still be used as a plain bearer token through the separate, already-correct Bearer-only path. It was found via adversarial review of the DPoP replay/binding path prompted by tracing how MetalBear's request handling wires into it, and the fix landed with a regression test that fails against the prior code.
A narrower htu bug in the same file predates it: wf_oauth_verify_dpop normalized the server's own computed request URL (normalize_htu, which strips userinfo/query/fragment) but raw-strcmp'd it against the client's htu claim as-is. RFC 9449 defines htu as scheme+authority+path only, but per the reference implementation's compatibility note, some real clients include a query string or fragment in the claim anyway. Because only one side was normalized, a compliant-but-lenient client's otherwise-valid proof would be wrongly rejected. de0e3ea normalizes both sides before comparing.
RFC 7523 client assertions
fffee94 adds wf_oauth_verify_client_assertion, the authorization-server counterpart to the existing wf_oauth_client_assertion_create -- verifying a confidential client's private_key_jwt bearer assertion at the token endpoint. It checks the ES256 signature against a trusted-keys set (matched by the required header kid), and iss/sub == client_id, aud == issuer, jti, iat freshness (WF_OAUTH_CLIENT_ASSERTION_MAX_AGE, 60s, mirroring the reference Client.authenticate), and exp. The signature-verification loop was pulled out of wf_oauth_verify_bearer into a shared wf_jwt_verify_sig, used by both the bearer and assertion paths now. wf_oauth_dpop_jwk_json was also promoted to public API so a server can publish a client's key in its own metadata document's jwks array.
service-token keys for labeler issuers
The service-auth middleware's default key resolver had a narrower bug: it resolved a non-did:key issuer with wf_did_resolve and always used the DID document's #atproto verification key, full stop. A labeler service token (iss carrying a #atproto_labeler fragment, signed with the document's separate #atproto_label key) could never authenticate against it -- the resolver was looking at the wrong key. 460e781 adds wf_did_resolve_verification_key, a getVerificationMaterial-style helper that resolves any named verification method by relative (#fragment) or absolute (did#fragment) id, and updates the default resolver to strip the issuer's service fragment and pick #atproto_label for atproto_labeler issuers, matching upstream verifyServiceJwt. It also validates the document's own id against the requested DID before trusting anything in it.
identity: failing closed, and plugging leaks
Two small, precise bugs in src/identity/identity.c, both fixed the same day. wf_identity_verify_handle initialized its match flag to 1 and only cleared it to 0 once it confirmed alsoKnownAs was present and an array -- so a DID document that omitted or malformed alsoKnownAs entirely was reported as a verified handle. fcf218b flips the default to 0: bidirectional handle verification has to fail closed, since no claim back to the handle from the document means no match, not an assumed one.
Separately, did_fetch_document and wf_handle_resolve_well_known both leaked their HTTP response body on every non-2xx status. wf_http_get's documented contract is that the response body is still transferred into res even on its WF_ERR_HTTP path, and both callers returned early on any non-WF_OK status without freeing it -- a leak on every failed DID document fetch or well-known lookup. d7622f9 frees the body on that path in both places.
racing handle resolution, then caching it
wf_handle_resolve tried DNS TXT first and only fell back to HTTPS well-known on failure, always paying both round trips' worst-case latency in the fallback case. 25ef23e (closing #21) makes the two race instead, matching the reference (packages/identity/src/handle/index.ts): both lookups launch as detached threads sharing a refcounted result struct, the first to succeed wins, and the loser finishes in the background and cleans up its own share of the struct. True cancellation of an in-flight c-ares query or libcurl request isn't safe with pthread_cancel -- both could be left holding state a forced cancellation corrupts -- so the loser is simply let run to completion unobserved. Verified race-free under ThreadSanitizer across several runs.
Layered on top, 702c5f2 (closing #20) adds a process-wide, stale-while-revalidate DID document cache in did_fetch_document -- the single choke point every DID resolution already funnels through, so wf_did_resolve, wf_did_resolve_service, and handle verification all benefit without separate wiring. A document younger than stale_ttl_seconds (default 1h) is served with no network call; between that and max_ttl_seconds (default 1d) a refresh is attempted but the stale copy is still served if it fails, so a transient PLC-directory or did:web outage doesn't fail every dependent request; past max_ttl_seconds a fetch failure is a genuine error.
Both of those make a slow or dead peer more dangerous to hang on, which ba5e0a5 (closing #22) addresses directly: wf_xrpc_perform_cfg's libcurl path set no timeout at all, so any request through wf_http_get/wf_xrpc_* could hang indefinitely against a peer that accepts a connection but never responds. It adds a 10s connect timeout and a low-speed abort (under 1 byte/sec sustained for 30s) rather than a blanket total timeout, so a legitimately slow but progressing large transfer -- a blob upload -- isn't cut off. The c-ares side had the same problem: wf_handle_resolve_cares called ares_queue_wait_empty(channel, -1), an unbounded wait for a DNS server that accepts a query and never replies. Bounded to 5s.
PLC: audit trail and handle updates
Three additions fill in the PLC surface beyond genesis operations. 9aab69d adds wf_plc_get_last_op (fetches an account's currently published operation from the directory and computes its CID via the same canonical-CBOR path wf_plc_operation_compute_did uses -- the value a caller sets as prev on the next operation) and wf_plc_build_handle_update, which wraps it for the common case of changing only alsoKnownAs: it preserves rotationKeys, verificationMethods, and services from the current operation unchanged and signs with the caller's rotation key. Every PLC operation is a full snapshot rather than a diff, so getting that merge wrong corrupts a live account's DID document -- the commit message is explicit that this was previously a hard blocker on a PDS doing real handle updates. 10d5c5d adds wf_plc_get_audit_log, the directory-only counterpart fetching an account's full operation history (GET {plc_directory}/{did}/log/audit) as raw JSON. c817b31 adds wf_did_resolve_raw, a thin wrapper around the same cached did_fetch_document returning the untrimmed document as raw JSON for callers that need alsoKnownAs or other fields wf_did_document doesn't keep.
a firehose frame leak on malformed input
parse_commit and parse_sync in sync_subscribe.c copy blocks (and, for commits, the op array) before validating the remaining fields in the frame. On a malformed frame, the old code returned the parse error directly without freeing what it had already copied -- and the caller only calls event_free on success. 3f92c78 fixes it so every parse-error path calls event_free on the partially built event before returning, and the op-loop failure path uses that instead of hand-unwinding the op array, so blocks gets reclaimed too. Without it, every malformed firehose frame following a valid, large blocks payload leaked until OOM -- a subscriber sitting on a live firehose is exposed to exactly that kind of input continuously. Two smaller sync_publish.c/sync_subscribe.c bugs from earlier in the window were in the same neighborhood: a duplicate cbor_decref in build_ops/build_label_item that over-released a pushed array item and tripped a libcbor assertion, and a duplicate cid variable declaration in parse_repo_op.
all entries