diff --git a/src/oauth/verify.c b/src/oauth/verify.c index 0c44b0c..09dc7e8 100644 --- a/src/oauth/verify.c +++ b/src/oauth/verify.c @@ -778,16 +778,19 @@ wf_status wf_oauth_verify_request(const char *authorization, wf_oauth_verified_token_free(bearer); return status; } - /* Confirmation: if the access token carried a cnf.jkt, it must match - * the DPoP proof key thumbprint. */ - if (bearer->dpop_jkt && strcmp(bearer->dpop_jkt, dpop->dpop_jkt) != 0) { + /* Confirmation: the access token's cnf.jkt must match the DPoP + * proof key thumbprint. A token with no cnf.jkt at all was never + * bound to any key at issuance, so it is rejected here rather than + * silently bound to whatever key this proof happens to carry -- + * accepting that would let anyone who obtains an unbound token + * (however it leaked) "claim" DPoP binding to a key of their own + * choosing, defeating the entire point of sender-constraining it. */ + if (!bearer->dpop_jkt || + strcmp(bearer->dpop_jkt, dpop->dpop_jkt) != 0) { wf_oauth_verified_token_free(bearer); wf_oauth_verified_token_free(dpop); return WF_ERR_INVALID_ARG; } - free(bearer->dpop_jkt); - bearer->dpop_jkt = dpop->dpop_jkt; - dpop->dpop_jkt = NULL; bearer->dpop_bound = 1; wf_oauth_verified_token_free(dpop); *out = bearer; diff --git a/test/test_oauth_verify.c b/test/test_oauth_verify.c index 6e20b97..f214710 100644 --- a/test/test_oauth_verify.c +++ b/test/test_oauth_verify.c @@ -407,6 +407,42 @@ int main(void) { tok = NULL; } + /* --- Negative: unbound access token (no cnf.jkt) + a DPoP proof must + * not silently bind on first use --------------------------------- */ + { + /* Minted with cnf_jkt = NULL: nothing was ever bound to this token + * at issuance. A caller presenting it alongside ANY freshly-minted + * DPoP proof must be rejected outright, never treated as "bind it + * to whatever key this proof happens to carry" -- that would let + * anyone who obtains an unbound token, however it leaked, claim + * DPoP binding to a key of their own choosing and use it exactly + * like a stolen plain bearer token, defeating DPoP's entire + * purpose. */ + char *unbound_token = make_access_token( + at_ec, did, "https://op.example.com", "https://api.example.com", + "atproto repo", NULL, now, now + 3600); + char *unbound_auth = malloc(strlen(unbound_token) + 8); + sprintf(unbound_auth, "DPoP %s", unbound_token); + unsigned char digest3[32]; + char *ath3; + SHA256((const unsigned char *)unbound_token, strlen(unbound_token), + digest3); + ath3 = b64url(digest3, 32); + cJSON *jwk3 = cJSON_Parse(dp_jwk); + char *dpop_proof3 = make_dpop_proof(dp_ec, jwk3, "POST", uri, ath3, + "jti-unbound-1", now); + free(ath3); + wf_status st = wf_oauth_verify_request(unbound_auth, dpop_proof3, + "POST", uri, keys, replay, &tok); + WF_CHECK(st != WF_OK); + WF_CHECK(tok == NULL); + free(unbound_token); + free(unbound_auth); + free(dpop_proof3); + wf_oauth_verified_token_free(tok); + tok = NULL; + } + /* --- Negative: tampered DPoP proof signature --- */ { char *bad = str_dup(dpop_proof);