this repo has no description

Use jacquard commit signing

lewis f95bdcae ed3d8331

+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))