mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-30 05:55:33 +00:00
425 lines
16 KiB
Rust
425 lines
16 KiB
Rust
mod common;
|
|
use cid::Cid;
|
|
use common::*;
|
|
use ipld_core::ipld::Ipld;
|
|
use jacquard::types::{integer::LimitedU32, string::Tid};
|
|
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
|
|
use reqwest::StatusCode;
|
|
use serde_json::json;
|
|
use sha2::{Digest, Sha256};
|
|
use sqlx::PgPool;
|
|
use std::collections::BTreeMap;
|
|
use wiremock::matchers::{method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
fn make_cid(data: &[u8]) -> Cid {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(data);
|
|
let hash = hasher.finalize();
|
|
let multihash = multihash::Multihash::wrap(0x12, &hash).unwrap();
|
|
Cid::new_v1(0x71, multihash)
|
|
}
|
|
|
|
fn write_varint(buf: &mut Vec<u8>, mut value: u64) {
|
|
loop {
|
|
let mut byte = (value & 0x7F) as u8;
|
|
value >>= 7;
|
|
if value != 0 {
|
|
byte |= 0x80;
|
|
}
|
|
buf.push(byte);
|
|
if value == 0 {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn encode_car_block(cid: &Cid, data: &[u8]) -> Vec<u8> {
|
|
let cid_bytes = cid.to_bytes();
|
|
let mut result = Vec::new();
|
|
write_varint(&mut result, (cid_bytes.len() + data.len()) as u64);
|
|
result.extend_from_slice(&cid_bytes);
|
|
result.extend_from_slice(data);
|
|
result
|
|
}
|
|
|
|
fn get_multikey_from_signing_key(signing_key: &SigningKey) -> String {
|
|
let public_key = signing_key.verifying_key();
|
|
let compressed = public_key.to_sec1_bytes();
|
|
fn encode_uvarint(mut x: u64) -> Vec<u8> {
|
|
let mut out = Vec::new();
|
|
while x >= 0x80 {
|
|
out.push(((x as u8) & 0x7F) | 0x80);
|
|
x >>= 7;
|
|
}
|
|
out.push(x as u8);
|
|
out
|
|
}
|
|
let mut buf = encode_uvarint(0xE7);
|
|
buf.extend_from_slice(&compressed);
|
|
multibase::encode(multibase::Base::Base58Btc, buf)
|
|
}
|
|
|
|
fn create_did_document(
|
|
did: &str,
|
|
handle: &str,
|
|
signing_key: &SigningKey,
|
|
pds_endpoint: &str,
|
|
) -> serde_json::Value {
|
|
let multikey = get_multikey_from_signing_key(signing_key);
|
|
json!({
|
|
"@context": [
|
|
"https://www.w3.org/ns/did/v1",
|
|
"https://w3id.org/security/multikey/v1"
|
|
],
|
|
"id": did,
|
|
"alsoKnownAs": [format!("at://{}", handle)],
|
|
"verificationMethod": [{
|
|
"id": format!("{}#atproto", did),
|
|
"type": "Multikey",
|
|
"controller": did,
|
|
"publicKeyMultibase": multikey
|
|
}],
|
|
"service": [{
|
|
"id": "#atproto_pds",
|
|
"type": "AtprotoPersonalDataServer",
|
|
"serviceEndpoint": pds_endpoint
|
|
}]
|
|
})
|
|
}
|
|
|
|
fn create_signed_commit(did: &str, data_cid: &Cid, signing_key: &SigningKey) -> (Vec<u8>, Cid) {
|
|
let rev = Tid::now(LimitedU32::MIN).to_string();
|
|
let unsigned = Ipld::Map(BTreeMap::from([
|
|
("data".to_string(), Ipld::Link(*data_cid)),
|
|
("did".to_string(), Ipld::String(did.to_string())),
|
|
("prev".to_string(), Ipld::Null),
|
|
("rev".to_string(), Ipld::String(rev.clone())),
|
|
("sig".to_string(), Ipld::Bytes(vec![])),
|
|
("version".to_string(), Ipld::Integer(3)),
|
|
]));
|
|
let unsigned_bytes = serde_ipld_dagcbor::to_vec(&unsigned).unwrap();
|
|
let signature: Signature = signing_key.sign(&unsigned_bytes);
|
|
let sig_bytes = signature.to_bytes().to_vec();
|
|
let signed = Ipld::Map(BTreeMap::from([
|
|
("data".to_string(), Ipld::Link(*data_cid)),
|
|
("did".to_string(), Ipld::String(did.to_string())),
|
|
("prev".to_string(), Ipld::Null),
|
|
("rev".to_string(), Ipld::String(rev)),
|
|
("sig".to_string(), Ipld::Bytes(sig_bytes)),
|
|
("version".to_string(), Ipld::Integer(3)),
|
|
]));
|
|
let signed_bytes = serde_ipld_dagcbor::to_vec(&signed).unwrap();
|
|
let cid = make_cid(&signed_bytes);
|
|
(signed_bytes, cid)
|
|
}
|
|
|
|
fn create_mst_node(entries: Vec<(String, Cid)>) -> (Vec<u8>, Cid) {
|
|
let ipld_entries: Vec<Ipld> = entries
|
|
.into_iter()
|
|
.map(|(key, value_cid)| {
|
|
Ipld::Map(BTreeMap::from([
|
|
("k".to_string(), Ipld::Bytes(key.into_bytes())),
|
|
("v".to_string(), Ipld::Link(value_cid)),
|
|
("p".to_string(), Ipld::Integer(0)),
|
|
]))
|
|
})
|
|
.collect();
|
|
let node = Ipld::Map(BTreeMap::from([(
|
|
"e".to_string(),
|
|
Ipld::List(ipld_entries),
|
|
)]));
|
|
let bytes = serde_ipld_dagcbor::to_vec(&node).unwrap();
|
|
let cid = make_cid(&bytes);
|
|
(bytes, cid)
|
|
}
|
|
|
|
fn create_record() -> (Vec<u8>, Cid) {
|
|
let record = Ipld::Map(BTreeMap::from([
|
|
(
|
|
"$type".to_string(),
|
|
Ipld::String("app.bsky.feed.post".to_string()),
|
|
),
|
|
(
|
|
"text".to_string(),
|
|
Ipld::String("Test post for verification".to_string()),
|
|
),
|
|
(
|
|
"createdAt".to_string(),
|
|
Ipld::String("2024-01-01T00:00:00Z".to_string()),
|
|
),
|
|
]));
|
|
let bytes = serde_ipld_dagcbor::to_vec(&record).unwrap();
|
|
let cid = make_cid(&bytes);
|
|
(bytes, cid)
|
|
}
|
|
fn build_car_with_signature(did: &str, signing_key: &SigningKey) -> (Vec<u8>, Cid) {
|
|
let (record_bytes, record_cid) = create_record();
|
|
let (mst_bytes, mst_cid) =
|
|
create_mst_node(vec![("app.bsky.feed.post/test123".to_string(), record_cid)]);
|
|
let (commit_bytes, commit_cid) = create_signed_commit(did, &mst_cid, signing_key);
|
|
let header = iroh_car::CarHeader::new_v1(vec![commit_cid]);
|
|
let header_bytes = header.encode().unwrap();
|
|
let mut car = Vec::new();
|
|
write_varint(&mut car, header_bytes.len() as u64);
|
|
car.extend_from_slice(&header_bytes);
|
|
car.extend(encode_car_block(&commit_cid, &commit_bytes));
|
|
car.extend(encode_car_block(&mst_cid, &mst_bytes));
|
|
car.extend(encode_car_block(&record_cid, &record_bytes));
|
|
(car, commit_cid)
|
|
}
|
|
async fn setup_mock_plc_directory(did: &str, did_doc: serde_json::Value) -> MockServer {
|
|
let mock_server = MockServer::start().await;
|
|
let did_encoded = urlencoding::encode(did);
|
|
let did_path = format!("/{}", did_encoded);
|
|
Mock::given(method("GET"))
|
|
.and(path(did_path))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(did_doc))
|
|
.mount(&mock_server)
|
|
.await;
|
|
mock_server
|
|
}
|
|
async fn get_user_signing_key(did: &str) -> Option<Vec<u8>> {
|
|
let db_url = get_db_connection_string().await;
|
|
let pool = PgPool::connect(&db_url).await.ok()?;
|
|
let row = sqlx::query!(
|
|
r#"
|
|
SELECT k.key_bytes, k.encryption_version
|
|
FROM user_keys k
|
|
JOIN users u ON k.user_id = u.id
|
|
WHERE u.did = $1
|
|
"#,
|
|
did
|
|
)
|
|
.fetch_optional(&pool)
|
|
.await
|
|
.ok()??;
|
|
tranquil_pds::config::decrypt_key(&row.key_bytes, row.encryption_version).ok()
|
|
}
|
|
#[tokio::test]
|
|
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_valid_signature_and_mock_plc -- --ignored --test-threads=1"]
|
|
async fn test_import_with_valid_signature_and_mock_plc() {
|
|
let client = client();
|
|
let (token, did) = create_account_and_login(&client).await;
|
|
let key_bytes = get_user_signing_key(&did)
|
|
.await
|
|
.expect("Failed to get user signing key");
|
|
let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key");
|
|
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
|
let pds_endpoint = format!("https://{}", hostname);
|
|
let handle = did.split(':').last().unwrap_or("user");
|
|
let did_doc = create_did_document(&did, handle, &signing_key, &pds_endpoint);
|
|
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
|
|
unsafe {
|
|
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
|
|
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
|
}
|
|
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
|
|
let import_res = client
|
|
.post(format!(
|
|
"{}/xrpc/com.atproto.repo.importRepo",
|
|
base_url().await
|
|
))
|
|
.bearer_auth(&token)
|
|
.header("Content-Type", "application/vnd.ipld.car")
|
|
.body(car_bytes)
|
|
.send()
|
|
.await
|
|
.expect("Import request failed");
|
|
let status = import_res.status();
|
|
let body: serde_json::Value = import_res.json().await.unwrap_or(json!({}));
|
|
unsafe {
|
|
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
|
|
}
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::OK,
|
|
"Import with valid signature should succeed. Response: {:?}",
|
|
body
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_wrong_signing_key_fails -- --ignored --test-threads=1"]
|
|
async fn test_import_with_wrong_signing_key_fails() {
|
|
let client = client();
|
|
let (token, did) = create_account_and_login(&client).await;
|
|
let wrong_signing_key = SigningKey::random(&mut rand::thread_rng());
|
|
let key_bytes = get_user_signing_key(&did)
|
|
.await
|
|
.expect("Failed to get user signing key");
|
|
let correct_signing_key =
|
|
SigningKey::from_slice(&key_bytes).expect("Failed to create signing key");
|
|
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
|
let pds_endpoint = format!("https://{}", hostname);
|
|
let handle = did.split(':').last().unwrap_or("user");
|
|
let did_doc = create_did_document(&did, handle, &correct_signing_key, &pds_endpoint);
|
|
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
|
|
unsafe {
|
|
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
|
|
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
|
}
|
|
let (car_bytes, _root_cid) = build_car_with_signature(&did, &wrong_signing_key);
|
|
let import_res = client
|
|
.post(format!(
|
|
"{}/xrpc/com.atproto.repo.importRepo",
|
|
base_url().await
|
|
))
|
|
.bearer_auth(&token)
|
|
.header("Content-Type", "application/vnd.ipld.car")
|
|
.body(car_bytes)
|
|
.send()
|
|
.await
|
|
.expect("Import request failed");
|
|
let status = import_res.status();
|
|
let body: serde_json::Value = import_res.json().await.unwrap_or(json!({}));
|
|
unsafe {
|
|
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
|
|
}
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::BAD_REQUEST,
|
|
"Import with wrong signature should fail. Response: {:?}",
|
|
body
|
|
);
|
|
assert!(
|
|
body["error"] == "InvalidSignature"
|
|
|| body["message"].as_str().unwrap_or("").contains("signature"),
|
|
"Error should mention signature: {:?}",
|
|
body
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_did_mismatch_fails -- --ignored --test-threads=1"]
|
|
async fn test_import_with_did_mismatch_fails() {
|
|
let client = client();
|
|
let (token, did) = create_account_and_login(&client).await;
|
|
let key_bytes = get_user_signing_key(&did)
|
|
.await
|
|
.expect("Failed to get user signing key");
|
|
let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key");
|
|
let wrong_did = "did:plc:wrongdidthatdoesnotmatch";
|
|
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
|
let pds_endpoint = format!("https://{}", hostname);
|
|
let handle = did.split(':').last().unwrap_or("user");
|
|
let did_doc = create_did_document(&did, handle, &signing_key, &pds_endpoint);
|
|
let mock_plc = setup_mock_plc_directory(&did, did_doc).await;
|
|
unsafe {
|
|
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
|
|
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
|
}
|
|
let (car_bytes, _root_cid) = build_car_with_signature(wrong_did, &signing_key);
|
|
let import_res = client
|
|
.post(format!(
|
|
"{}/xrpc/com.atproto.repo.importRepo",
|
|
base_url().await
|
|
))
|
|
.bearer_auth(&token)
|
|
.header("Content-Type", "application/vnd.ipld.car")
|
|
.body(car_bytes)
|
|
.send()
|
|
.await
|
|
.expect("Import request failed");
|
|
let status = import_res.status();
|
|
let body: serde_json::Value = import_res.json().await.unwrap_or(json!({}));
|
|
unsafe {
|
|
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
|
|
}
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::FORBIDDEN,
|
|
"Import with DID mismatch should be forbidden. Response: {:?}",
|
|
body
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_plc_resolution_failure -- --ignored --test-threads=1"]
|
|
async fn test_import_with_plc_resolution_failure() {
|
|
let client = client();
|
|
let (token, did) = create_account_and_login(&client).await;
|
|
let key_bytes = get_user_signing_key(&did)
|
|
.await
|
|
.expect("Failed to get user signing key");
|
|
let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key");
|
|
let mock_plc = MockServer::start().await;
|
|
let did_encoded = urlencoding::encode(&did);
|
|
let did_path = format!("/{}", did_encoded);
|
|
Mock::given(method("GET"))
|
|
.and(path(did_path))
|
|
.respond_with(ResponseTemplate::new(404))
|
|
.mount(&mock_plc)
|
|
.await;
|
|
unsafe {
|
|
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
|
|
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
|
}
|
|
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
|
|
let import_res = client
|
|
.post(format!(
|
|
"{}/xrpc/com.atproto.repo.importRepo",
|
|
base_url().await
|
|
))
|
|
.bearer_auth(&token)
|
|
.header("Content-Type", "application/vnd.ipld.car")
|
|
.body(car_bytes)
|
|
.send()
|
|
.await
|
|
.expect("Import request failed");
|
|
let status = import_res.status();
|
|
let body: serde_json::Value = import_res.json().await.unwrap_or(json!({}));
|
|
unsafe {
|
|
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
|
|
}
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::BAD_REQUEST,
|
|
"Import with PLC resolution failure should fail. Response: {:?}",
|
|
body
|
|
);
|
|
}
|
|
#[tokio::test]
|
|
#[ignore = "requires exclusive env var access; run with: cargo test test_import_with_no_signing_key_in_did_doc -- --ignored --test-threads=1"]
|
|
async fn test_import_with_no_signing_key_in_did_doc() {
|
|
let client = client();
|
|
let (token, did) = create_account_and_login(&client).await;
|
|
let key_bytes = get_user_signing_key(&did)
|
|
.await
|
|
.expect("Failed to get user signing key");
|
|
let signing_key = SigningKey::from_slice(&key_bytes).expect("Failed to create signing key");
|
|
let handle = did.split(':').last().unwrap_or("user");
|
|
let did_doc_without_key = json!({
|
|
"@context": ["https://www.w3.org/ns/did/v1"],
|
|
"id": did,
|
|
"alsoKnownAs": [format!("at://{}", handle)],
|
|
"verificationMethod": [],
|
|
"service": []
|
|
});
|
|
let mock_plc = setup_mock_plc_directory(&did, did_doc_without_key).await;
|
|
unsafe {
|
|
std::env::set_var("PLC_DIRECTORY_URL", mock_plc.uri());
|
|
std::env::remove_var("SKIP_IMPORT_VERIFICATION");
|
|
}
|
|
let (car_bytes, _root_cid) = build_car_with_signature(&did, &signing_key);
|
|
let import_res = client
|
|
.post(format!(
|
|
"{}/xrpc/com.atproto.repo.importRepo",
|
|
base_url().await
|
|
))
|
|
.bearer_auth(&token)
|
|
.header("Content-Type", "application/vnd.ipld.car")
|
|
.body(car_bytes)
|
|
.send()
|
|
.await
|
|
.expect("Import request failed");
|
|
let status = import_res.status();
|
|
let body: serde_json::Value = import_res.json().await.unwrap_or(json!({}));
|
|
unsafe {
|
|
std::env::set_var("SKIP_IMPORT_VERIFICATION", "true");
|
|
}
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::BAD_REQUEST,
|
|
"Import with missing signing key should fail. Response: {:?}",
|
|
body
|
|
);
|
|
}
|