Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

Use jacquard commit signing

+187 -127
+1
Cargo.lock
··· 6279 6279 "bs58", 6280 6280 "bytes", 6281 6281 "chrono", 6282 + "ciborium", 6282 6283 "cid", 6283 6284 "ctor", 6284 6285 "dotenvy",
+1
Cargo.toml
··· 62 62 [features] 63 63 external-infra = [] 64 64 [dev-dependencies] 65 + ciborium = "0.2" 65 66 ctor = "0.6.3" 66 67 testcontainers = "0.26.2" 67 68 testcontainers-modules = { version = "0.14.0", features = ["postgres"] }
+15 -54
src/api/repo/record/utils.rs
··· 2 2 use bytes::Bytes; 3 3 use cid::Cid; 4 4 use jacquard::types::{integer::LimitedU32, string::Tid}; 5 + use jacquard_repo::commit::Commit; 5 6 use jacquard_repo::storage::BlockStore; 6 - use k256::ecdsa::{Signature, SigningKey, signature::Signer}; 7 - use serde::Serialize; 7 + use k256::ecdsa::SigningKey; 8 8 use serde_json::json; 9 + use std::str::FromStr; 9 10 use uuid::Uuid; 10 11 11 - /* 12 - * Why custom commit signing instead of jacquard's Commit::sign()? 13 - * 14 - * Jacquard previously had a bug in how it created unsigned bytes for signing: 15 - * it set sig to empty bytes and serialized (6-field CBOR map), while the 16 - * ATProto spec creates a struct *without* the sig field (5-field CBOR map). 17 - * These produce different CBOR bytes, so signatures didn't verify with relays. 18 - * 19 - * The bug has been fixed in jacquard, but the fix is untested here. 20 - * TODO: Switch back to jacquard's Commit::sign() and verify it works. 21 - */ 22 - 23 - #[derive(Serialize)] 24 - struct UnsignedCommit<'a> { 25 - data: Cid, 26 - did: &'a str, 27 - prev: Option<Cid>, 28 - rev: &'a str, 29 - version: i64, 30 - } 31 - 32 12 pub fn create_signed_commit( 33 13 did: &str, 34 14 data: Cid, ··· 36 16 prev: Option<Cid>, 37 17 signing_key: &SigningKey, 38 18 ) -> Result<(Vec<u8>, Bytes), String> { 39 - let unsigned = UnsignedCommit { 40 - data, 41 - did, 42 - prev, 43 - rev, 44 - version: 3, 45 - }; 46 - let unsigned_bytes = serde_ipld_dagcbor::to_vec(&unsigned) 47 - .map_err(|e| format!("Failed to serialize unsigned commit: {:?}", e))?; 48 - let sig: Signature = signing_key.sign(&unsigned_bytes); 49 - let sig_bytes = Bytes::copy_from_slice(&sig.to_bytes()); 50 - #[derive(Serialize)] 51 - struct SignedCommit<'a> { 52 - data: Cid, 53 - did: &'a str, 54 - prev: Option<Cid>, 55 - rev: &'a str, 56 - #[serde(with = "serde_bytes")] 57 - sig: &'a [u8], 58 - version: i64, 59 - } 60 - let signed = SignedCommit { 61 - data, 62 - did, 63 - prev, 64 - rev, 65 - sig: &sig_bytes, 66 - version: 3, 67 - }; 68 - let signed_bytes = serde_ipld_dagcbor::to_vec(&signed) 19 + let did = jacquard::types::string::Did::new(did) 20 + .map_err(|e| format!("Invalid DID: {:?}", e))?; 21 + let rev = jacquard::types::string::Tid::from_str(rev) 22 + .map_err(|e| format!("Invalid TID: {:?}", e))?; 23 + let unsigned = Commit::new_unsigned(did, data, rev, prev); 24 + let signed = unsigned 25 + .sign(signing_key) 26 + .map_err(|e| format!("Failed to sign commit: {:?}", e))?; 27 + let sig_bytes = signed.sig().clone(); 28 + let signed_bytes = signed 29 + .to_cbor() 69 30 .map_err(|e| format!("Failed to serialize signed commit: {:?}", e))?; 70 31 Ok((signed_bytes, sig_bytes)) 71 32 } ··· 423 384 let uri = format!("at://{}/{}/{}", did, collection, rkey); 424 385 Ok((uri, result.commit_cid)) 425 386 } 426 - use std::str::FromStr; 387 + 427 388 pub async fn sequence_identity_event( 428 389 state: &AppState, 429 390 did: &str,
+121
tests/commit_signing.rs
··· 1 + use cid::Cid; 2 + use jacquard::types::{integer::LimitedU32, string::Tid}; 3 + use jacquard_repo::commit::Commit; 4 + use k256::ecdsa::SigningKey; 5 + use std::str::FromStr; 6 + 7 + #[test] 8 + fn test_commit_signing_produces_valid_signature() { 9 + let signing_key = SigningKey::random(&mut rand::thread_rng()); 10 + 11 + let did = "did:plc:testuser123456789abcdef"; 12 + let data_cid = 13 + Cid::from_str("bafyreib2rxk3ryblouj3fxza5jvx6psmwewwessc4m6g6e7pqhhkwqomfi").unwrap(); 14 + let rev = Tid::now(LimitedU32::MIN); 15 + 16 + let did_typed = jacquard::types::string::Did::new(did).unwrap(); 17 + let unsigned = Commit::new_unsigned(did_typed, data_cid, rev, None); 18 + let signed = unsigned.sign(&signing_key).unwrap(); 19 + 20 + let pubkey_bytes = signing_key.verifying_key().to_encoded_point(true); 21 + let pubkey = jacquard::types::crypto::PublicKey { 22 + codec: jacquard::types::crypto::KeyCodec::Secp256k1, 23 + bytes: std::borrow::Cow::Owned(pubkey_bytes.as_bytes().to_vec()), 24 + }; 25 + 26 + signed.verify(&pubkey).expect("signature should verify"); 27 + } 28 + 29 + #[test] 30 + fn test_commit_signing_with_prev() { 31 + let signing_key = SigningKey::random(&mut rand::thread_rng()); 32 + 33 + let did = "did:plc:testuser123456789abcdef"; 34 + let data_cid = 35 + Cid::from_str("bafyreib2rxk3ryblouj3fxza5jvx6psmwewwessc4m6g6e7pqhhkwqomfi").unwrap(); 36 + let prev_cid = 37 + Cid::from_str("bafyreigxmvutyl3k5m4guzwxv3xf34gfxjlykgfdqkjmf32vwb5vcjxlui").unwrap(); 38 + let rev = Tid::now(LimitedU32::MIN); 39 + 40 + let did_typed = jacquard::types::string::Did::new(did).unwrap(); 41 + let unsigned = Commit::new_unsigned(did_typed, data_cid, rev, Some(prev_cid)); 42 + let signed = unsigned.sign(&signing_key).unwrap(); 43 + 44 + let pubkey_bytes = signing_key.verifying_key().to_encoded_point(true); 45 + let pubkey = jacquard::types::crypto::PublicKey { 46 + codec: jacquard::types::crypto::KeyCodec::Secp256k1, 47 + bytes: std::borrow::Cow::Owned(pubkey_bytes.as_bytes().to_vec()), 48 + }; 49 + 50 + signed.verify(&pubkey).expect("signature should verify"); 51 + } 52 + 53 + #[test] 54 + fn test_unsigned_commit_has_5_fields() { 55 + let did = "did:plc:test"; 56 + let data_cid = 57 + Cid::from_str("bafyreib2rxk3ryblouj3fxza5jvx6psmwewwessc4m6g6e7pqhhkwqomfi").unwrap(); 58 + let rev = Tid::from_str("3masrxv55po22").unwrap(); 59 + 60 + let did_typed = jacquard::types::string::Did::new(did).unwrap(); 61 + let unsigned = Commit::new_unsigned(did_typed, data_cid, rev, None); 62 + 63 + let unsigned_bytes = serde_ipld_dagcbor::to_vec(&unsigned).unwrap(); 64 + 65 + let decoded: ciborium::Value = ciborium::from_reader(&unsigned_bytes[..]).unwrap(); 66 + if let ciborium::Value::Map(map) = decoded { 67 + assert_eq!( 68 + map.len(), 69 + 5, 70 + "Unsigned commit must have exactly 5 fields (data, did, prev, rev, version) - no sig field" 71 + ); 72 + let keys: Vec<String> = map 73 + .iter() 74 + .filter_map(|(k, _)| { 75 + if let ciborium::Value::Text(s) = k { 76 + Some(s.clone()) 77 + } else { 78 + None 79 + } 80 + }) 81 + .collect(); 82 + assert!(keys.contains(&"data".to_string())); 83 + assert!(keys.contains(&"did".to_string())); 84 + assert!(keys.contains(&"prev".to_string())); 85 + assert!(keys.contains(&"rev".to_string())); 86 + assert!(keys.contains(&"version".to_string())); 87 + assert!( 88 + !keys.contains(&"sig".to_string()), 89 + "Unsigned commit must NOT contain sig field" 90 + ); 91 + } else { 92 + panic!("Expected CBOR map"); 93 + } 94 + } 95 + 96 + #[test] 97 + fn test_create_signed_commit_helper() { 98 + use tranquil_pds::api::repo::record::utils::create_signed_commit; 99 + 100 + let signing_key = SigningKey::random(&mut rand::thread_rng()); 101 + let did = "did:plc:testuser123456789abcdef"; 102 + let data_cid = 103 + Cid::from_str("bafyreib2rxk3ryblouj3fxza5jvx6psmwewwessc4m6g6e7pqhhkwqomfi").unwrap(); 104 + let rev = Tid::now(LimitedU32::MIN).to_string(); 105 + 106 + let (signed_bytes, sig) = create_signed_commit(did, data_cid, &rev, None, &signing_key) 107 + .expect("signing should succeed"); 108 + 109 + assert!(!signed_bytes.is_empty()); 110 + assert_eq!(sig.len(), 64); 111 + 112 + let commit = Commit::from_cbor(&signed_bytes).expect("should parse as valid commit"); 113 + 114 + let pubkey_bytes = signing_key.verifying_key().to_encoded_point(true); 115 + let pubkey = jacquard::types::crypto::PublicKey { 116 + codec: jacquard::types::crypto::KeyCodec::Secp256k1, 117 + bytes: std::borrow::Cow::Owned(pubkey_bytes.as_bytes().to_vec()), 118 + }; 119 + 120 + commit.verify(&pubkey).expect("signature should verify"); 121 + }
+26 -34
tests/common/mod.rs
··· 305 305 .await 306 306 .expect("Failed to get verification code"); 307 307 308 - let verification_code = body_text 309 - .lines() 310 - .find(|line| line.contains("verification code:") || line.contains("code is:")) 311 - .and_then(|line| { 312 - if line.contains("verification code:") { 313 - line.split("verification code:") 314 - .nth(1) 315 - .map(|s| s.trim().to_string()) 316 - } else { 317 - line.split("code is:").nth(1).map(|s| s.trim().to_string()) 318 - } 308 + let lines: Vec<&str> = body_text.lines().collect(); 309 + let verification_code = lines 310 + .iter() 311 + .enumerate() 312 + .find(|(_, line)| { 313 + line.contains("verification code is:") || line.contains("code is:") 319 314 }) 320 - .unwrap_or_else(|| { 315 + .and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string())) 316 + .or_else(|| { 321 317 body_text 322 - .lines() 323 - .find(|line| line.trim().starts_with("MX") && line.contains('-')) 324 - .map(|s| s.trim().to_string()) 325 - .unwrap_or_default() 326 - }); 318 + .split_whitespace() 319 + .find(|word| { 320 + word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3 321 + }) 322 + .map(|s| s.to_string()) 323 + }) 324 + .unwrap_or_else(|| body_text.clone()); 327 325 328 326 let confirm_payload = json!({ 329 327 "did": did, ··· 480 478 .fetch_one(&pool) 481 479 .await 482 480 .expect("Failed to get verification from comms_queue"); 483 - let verification_code = body_text 484 - .lines() 485 - .find(|line| line.contains("verification code:") || line.contains("code is:")) 486 - .and_then(|line| { 487 - if line.contains("verification code:") { 488 - line.split("verification code:") 489 - .nth(1) 490 - .map(|s| s.trim().to_string()) 491 - } else if line.contains("code is:") { 492 - line.split("code is:").nth(1).map(|s| s.trim().to_string()) 493 - } else { 494 - None 495 - } 481 + let lines: Vec<&str> = body_text.lines().collect(); 482 + let verification_code = lines 483 + .iter() 484 + .enumerate() 485 + .find(|(_, line)| { 486 + line.contains("verification code is:") || line.contains("code is:") 496 487 }) 497 - .unwrap_or_else(|| { 488 + .and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string())) 489 + .or_else(|| { 498 490 body_text 499 491 .split_whitespace() 500 492 .find(|word| { 501 493 word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3 502 494 }) 503 - .unwrap_or(&body_text) 504 - .to_string() 505 - }); 495 + .map(|s| s.to_string()) 496 + }) 497 + .unwrap_or_else(|| body_text.clone()); 506 498 507 499 let confirm_payload = json!({ 508 500 "did": did,
+8 -22
tests/import_with_verification.rs
··· 3 3 use common::*; 4 4 use ipld_core::ipld::Ipld; 5 5 use jacquard::types::{integer::LimitedU32, string::Tid}; 6 - use k256::ecdsa::{Signature, SigningKey, signature::Signer}; 6 + use jacquard_repo::commit::Commit; 7 + use k256::ecdsa::SigningKey; 7 8 use reqwest::StatusCode; 8 9 use serde_json::json; 9 10 use sha2::{Digest, Sha256}; 10 11 use sqlx::PgPool; 11 12 use std::collections::BTreeMap; 13 + use std::str::FromStr; 12 14 use wiremock::matchers::{method, path}; 13 15 use wiremock::{Mock, MockServer, ResponseTemplate}; 14 16 ··· 89 91 } 90 92 91 93 fn create_signed_commit(did: &str, data_cid: &Cid, signing_key: &SigningKey) -> (Vec<u8>, Cid) { 92 - let rev = Tid::now(LimitedU32::MIN).to_string(); 93 - let unsigned = Ipld::Map(BTreeMap::from([ 94 - ("data".to_string(), Ipld::Link(*data_cid)), 95 - ("did".to_string(), Ipld::String(did.to_string())), 96 - ("prev".to_string(), Ipld::Null), 97 - ("rev".to_string(), Ipld::String(rev.clone())), 98 - ("sig".to_string(), Ipld::Bytes(vec![])), 99 - ("version".to_string(), Ipld::Integer(3)), 100 - ])); 101 - let unsigned_bytes = serde_ipld_dagcbor::to_vec(&unsigned).unwrap(); 102 - let signature: Signature = signing_key.sign(&unsigned_bytes); 103 - let sig_bytes = signature.to_bytes().to_vec(); 104 - let signed = Ipld::Map(BTreeMap::from([ 105 - ("data".to_string(), Ipld::Link(*data_cid)), 106 - ("did".to_string(), Ipld::String(did.to_string())), 107 - ("prev".to_string(), Ipld::Null), 108 - ("rev".to_string(), Ipld::String(rev)), 109 - ("sig".to_string(), Ipld::Bytes(sig_bytes)), 110 - ("version".to_string(), Ipld::Integer(3)), 111 - ])); 112 - let signed_bytes = serde_ipld_dagcbor::to_vec(&signed).unwrap(); 94 + let rev = Tid::now(LimitedU32::MIN); 95 + let did = jacquard::types::string::Did::new(did).expect("valid DID"); 96 + let unsigned = Commit::new_unsigned(did, *data_cid, rev, None); 97 + let signed = unsigned.sign(signing_key).expect("signing failed"); 98 + let signed_bytes = signed.to_cbor().expect("serialization failed"); 113 99 let cid = make_cid(&signed_bytes); 114 100 (signed_bytes, cid) 115 101 }
+15 -17
tests/jwt_security.rs
··· 692 692 "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", 693 693 did 694 694 ).fetch_one(&pool).await.unwrap(); 695 - let code = body_text 696 - .lines() 697 - .find(|line| line.contains("verification code:") || line.contains("code is:")) 698 - .and_then(|line| { 699 - if line.contains("verification code:") { 700 - line.split("verification code:") 701 - .nth(1) 702 - .map(|s| s.trim().to_string()) 703 - } else { 704 - line.split("code is:").nth(1).map(|s| s.trim().to_string()) 705 - } 695 + let lines: Vec<&str> = body_text.lines().collect(); 696 + let code = lines 697 + .iter() 698 + .enumerate() 699 + .find(|(_, line)| { 700 + line.contains("verification code is:") || line.contains("code is:") 706 701 }) 707 - .unwrap_or_else(|| { 702 + .and_then(|(i, _)| lines.get(i + 1).map(|s| s.trim().to_string())) 703 + .or_else(|| { 708 704 body_text 709 - .lines() 710 - .find(|line| line.trim().starts_with("MX") && line.contains('-')) 711 - .map(|s| s.trim().to_string()) 712 - .unwrap_or_default() 713 - }); 705 + .split_whitespace() 706 + .find(|word| { 707 + word.contains('-') && word.chars().filter(|c| *c == '-').count() >= 3 708 + }) 709 + .map(|s| s.to_string()) 710 + }) 711 + .unwrap_or_else(|| body_text.clone()); 714 712 715 713 let confirm = http_client 716 714 .post(format!("{}/xrpc/com.atproto.server.confirmSignup", url))