+1
Cargo.lock
+1
Cargo.lock
+1
Cargo.toml
+1
Cargo.toml
+15
-54
src/api/repo/record/utils.rs
+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
+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
+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
+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
+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))