From 00ca223b5ffcab9aa9034437771043ec43688985 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 22 Jul 2026 23:27:01 +0300 Subject: [PATCH] identity: force did:web signkey check to #atproto verification Lewis: May this revision serve well! --- crates/tranquil-api/src/identity/did.rs | 9 +- crates/tranquil-pds/tests/did_web.rs | 130 ++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/crates/tranquil-api/src/identity/did.rs b/crates/tranquil-api/src/identity/did.rs index b2d2551..3d4e7e8 100644 --- a/crates/tranquil-api/src/identity/did.rs +++ b/crates/tranquil-api/src/identity/did.rs @@ -425,10 +425,15 @@ pub async fn verify_did_web( let expected_multibase = expected_signing_key .strip_prefix("did:key:") .ok_or(DidWebVerifyError::InvalidSigningKey)?; + let did_prefixed_key_id = format!("{}#atproto", did); let has_matching_key = verification_methods.iter().any(|vm| { - vm["publicKeyMultibase"] + let is_atproto_method = vm["id"] .as_str() - .is_some_and(|pk| pk == expected_multibase) + .is_some_and(|id| id == "#atproto" || id == did_prefixed_key_id); + is_atproto_method + && vm["publicKeyMultibase"] + .as_str() + .is_some_and(|pk| pk == expected_multibase) }); if !has_matching_key { return Err(DidWebVerifyError::KeyMismatch( diff --git a/crates/tranquil-pds/tests/did_web.rs b/crates/tranquil-pds/tests/did_web.rs index 8b43518..d3e0286 100644 --- a/crates/tranquil-pds/tests/did_web.rs +++ b/crates/tranquil-pds/tests/did_web.rs @@ -175,6 +175,136 @@ async fn test_external_did_web_no_local_doc() { ); } +async fn reserve_signing_key(client: &reqwest::Client, base: &str, did: &str) -> String { + let res = client + .post(format!("{}/xrpc/com.atproto.server.reserveSigningKey", base)) + .json(&json!({ "did": did })) + .send() + .await + .expect("Failed to reserve signing key"); + assert_eq!(res.status(), StatusCode::OK); + let body: Value = res.json().await.expect("Response wasn't JSON"); + body["signingKey"] + .as_str() + .expect("No signingKey returned") + .to_string() +} + +async fn assert_reserved_key_placement_rejected( + build_methods: impl FnOnce(&str, &str, &str) -> Value, +) { + let client = client(); + let base = base_url().await; + let mock_server = MockServer::start().await; + let mock_uri = mock_server.uri(); + let mock_addr = mock_uri.trim_start_matches("http://"); + let did = format!("did:web:{}", mock_addr.replace(":", "%3A")); + let handle = format!("wm{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); + let pds_endpoint = common::pds_endpoint(); + + let signing_key = reserve_signing_key(&client, base, &did).await; + let signing_multibase = signing_key + .strip_prefix("did:key:") + .expect("signingKey should start with did:key:"); + + let decoy_did = format!( + "did:web:{}.nel.pet", + &uuid::Uuid::new_v4().simple().to_string()[..12] + ); + let decoy_key = reserve_signing_key(&client, base, &decoy_did).await; + let decoy_multibase = decoy_key + .strip_prefix("did:key:") + .expect("decoy signingKey should start with did:key:"); + + let did_doc = json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": did, + "verificationMethod": build_methods(signing_multibase, decoy_multibase, &did), + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": pds_endpoint + }] + }); + Mock::given(method("GET")) + .and(path("/.well-known/did.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(did_doc)) + .mount(&mock_server) + .await; + + let payload = json!({ + "handle": handle, + "email": format!("{}@nel.pet", handle), + "password": "Testpass123!", + "didType": "web-external", + "did": did, + "signingKey": signing_key + }); + let res = client + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + assert_ne!( + res.status(), + StatusCode::OK, + "createAccount must reject a did:web doc whose #atproto method isn't the reserved signing key" + ); + let body: Value = res.json().await.expect("Response was not JSON"); + let message = body["message"] + .as_str() + .or_else(|| body["error"].as_str()) + .unwrap_or(""); + assert!( + message.contains("reserved signing key"), + "error should report signing-key mismatch, got: {:?}", + body + ); +} + +#[tokio::test] +async fn test_external_did_web_signing_key_under_wrong_method_rejected() { + assert_reserved_key_placement_rejected(|signing_multibase, decoy_multibase, did| { + json!([ + { + "id": format!("{}#atproto", did), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": decoy_multibase + }, + { + "id": format!("{}#atproto_reserved", did), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": signing_multibase + } + ]) + }) + .await; +} + +#[tokio::test] +async fn test_external_did_web_signing_key_under_foreign_atproto_fragment_rejected() { + assert_reserved_key_placement_rejected(|signing_multibase, decoy_multibase, did| { + json!([ + { + "id": "did:web:squid.nel.pet#atproto", + "type": "Multikey", + "controller": did, + "publicKeyMultibase": signing_multibase + }, + { + "id": format!("{}#atproto", did), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": decoy_multibase + } + ]) + }) + .await; +} + #[tokio::test] async fn test_plc_operations_blocked_for_did_web() { let client = client();