This repository has no description
0

Configure Feed

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

core / knot2 / crates / knot-atproto / src / identity.rs
11 kB 337 lines
1use std::collections::BTreeMap; 2 3use base64::Engine; 4use base64::engine::general_purpose::URL_SAFE_NO_PAD; 5use knot_runtime::{Entropy, PublicKeyBytes, Signer}; 6use knot_types::{ActorId, KnotId, KnotServiceUrl, OwnerDid, RepoDid, RepoRkey}; 7use serde::Serialize; 8use sha2::{Digest, Sha256}; 9 10const PLC_OP_TYPE: &str = "plc_operation"; 11const ATPROTO_METHOD: &str = "atproto"; 12const KNOT_HOME_SERVICE: &str = "tangled_knot"; 13const KNOT_HOME_TYPE: &str = "TangledKnot"; 14const DID_PLC_PREFIX: &str = "did:plc:"; 15const DID_SUFFIX_LEN: usize = 24; 16const MINT_NONCE_LEN: usize = 16; 17 18#[derive(Debug, thiserror::Error)] 19pub enum IdentityError { 20 #[error("plc operation couldn't be encoded: {0}")] 21 Encode(String), 22 #[error("derived did:plc isn't valid DID: {0}")] 23 Did(#[from] knot_types::ParseError), 24} 25 26#[derive(Serialize)] 27struct PlcService { 28 r#type: &'static str, 29 endpoint: String, 30} 31 32#[derive(Serialize)] 33struct PlcOperation { 34 #[serde(rename = "type")] 35 op_type: &'static str, 36 #[serde(rename = "rotationKeys")] 37 rotation_keys: Vec<String>, 38 #[serde(rename = "verificationMethods")] 39 verification_methods: BTreeMap<&'static str, String>, 40 #[serde(rename = "alsoKnownAs")] 41 also_known_as: Vec<String>, 42 services: BTreeMap<&'static str, PlcService>, 43 prev: Option<String>, 44 #[serde(skip_serializing_if = "Option::is_none")] 45 sig: Option<String>, 46} 47 48pub struct PreparedRepoDid { 49 pub did: RepoDid, 50 operation_json: Vec<u8>, 51} 52 53impl PreparedRepoDid { 54 pub fn operation_json(&self) -> &[u8] { 55 &self.operation_json 56 } 57} 58 59fn did_key(public: &PublicKeyBytes) -> String { 60 format!("did:key:{}", multikey_secp256k1(public)) 61} 62 63fn multikey_secp256k1(public: &PublicKeyBytes) -> ActorId { 64 ActorId::from_secp256k1(public.as_bytes()) 65} 66 67fn encode_cbor(operation: &PlcOperation) -> Result<Vec<u8>, IdentityError> { 68 serde_ipld_dagcbor::to_vec(operation).map_err(|error| IdentityError::Encode(error.to_string())) 69} 70 71pub struct MintNonce([u8; MINT_NONCE_LEN]); 72 73impl MintNonce { 74 pub fn mint(entropy: &dyn Entropy, owner: &OwnerDid, rkey: &RepoRkey) -> Self { 75 let mut bytes = [0u8; MINT_NONCE_LEN]; 76 entropy.derive(mint_label(owner, rkey)).fill(&mut bytes); 77 Self(bytes) 78 } 79 80 fn as_bytes(&self) -> &[u8] { 81 &self.0 82 } 83} 84 85fn mint_label(owner: &OwnerDid, rkey: &RepoRkey) -> u64 { 86 [owner.as_str(), rkey.as_str()] 87 .iter() 88 .flat_map(|part| part.bytes().chain(std::iter::once(0u8))) 89 .fold(0xcbf2_9ce4_8422_2325u64, |hash, byte| { 90 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3) 91 }) 92} 93 94pub fn prepare_repo_did( 95 signer: &dyn Signer, 96 knot_service_url: &KnotServiceUrl, 97 mint_nonce: &MintNonce, 98) -> Result<PreparedRepoDid, IdentityError> { 99 let tag = base32::encode( 100 base32::Alphabet::Rfc4648 { padding: false }, 101 mint_nonce.as_bytes(), 102 ) 103 .to_lowercase(); 104 let base = knot_service_url.as_str(); 105 let mut operation = PlcOperation { 106 op_type: PLC_OP_TYPE, 107 rotation_keys: vec![did_key(&signer.public_key())], 108 verification_methods: BTreeMap::new(), 109 also_known_as: Vec::new(), 110 services: BTreeMap::from([( 111 KNOT_HOME_SERVICE, 112 PlcService { 113 r#type: KNOT_HOME_TYPE, 114 endpoint: format!("{base}/repo/{tag}"), 115 }, 116 )]), 117 prev: None, 118 sig: None, 119 }; 120 121 let unsigned = encode_cbor(&operation)?; 122 operation.sig = Some(URL_SAFE_NO_PAD.encode(signer.sign(&unsigned).as_bytes())); 123 124 let signed = encode_cbor(&operation)?; 125 let did = derive_did_plc(&signed)?; 126 let operation_json = 127 serde_json::to_vec(&operation).map_err(|error| IdentityError::Encode(error.to_string()))?; 128 Ok(PreparedRepoDid { 129 did, 130 operation_json, 131 }) 132} 133 134fn derive_did_plc(signed_cbor: &[u8]) -> Result<RepoDid, IdentityError> { 135 let digest = Sha256::digest(signed_cbor); 136 let encoded = 137 base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &digest).to_lowercase(); 138 let suffix: String = encoded.chars().take(DID_SUFFIX_LEN).collect(); 139 Ok(RepoDid::new(format!("{DID_PLC_PREFIX}{suffix}"))?) 140} 141 142pub fn knot_did_document( 143 knot: &KnotId, 144 signing_key: &PublicKeyBytes, 145 service_url: &KnotServiceUrl, 146) -> serde_json::Value { 147 did_web_document(knot, signing_key, service_url) 148} 149 150fn did_web_document( 151 id: &KnotId, 152 signing_key: &PublicKeyBytes, 153 service_url: &KnotServiceUrl, 154) -> serde_json::Value { 155 serde_json::json!({ 156 "@context": [ 157 "https://www.w3.org/ns/did/v1", 158 "https://w3id.org/security/multikey/v1", 159 "https://w3id.org/security/suites/secp256k1-2019/v1" 160 ], 161 "id": id, 162 "verificationMethod": [{ 163 "id": format!("{id}#{ATPROTO_METHOD}"), 164 "type": "Multikey", 165 "controller": id, 166 "publicKeyMultibase": multikey_secp256k1(signing_key) 167 }], 168 "service": [{ 169 "id": format!("#{KNOT_HOME_SERVICE}"), 170 "type": KNOT_HOME_TYPE, 171 "serviceEndpoint": service_url 172 }] 173 }) 174} 175 176#[cfg(test)] 177mod tests { 178 use super::*; 179 use crate::test_support::*; 180 use knot_runtime::verify; 181 182 #[test] 183 fn did_derivation_is_deterministic_and_nonce_sensitive() { 184 let key = runtime_signer(2); 185 let first = prepare_repo_did( 186 &key, 187 &KnotServiceUrl::new("https://nel.pet").unwrap(), 188 &repo_nonce(101), 189 ) 190 .unwrap(); 191 let again = prepare_repo_did( 192 &key, 193 &KnotServiceUrl::new("https://nel.pet").unwrap(), 194 &repo_nonce(101), 195 ) 196 .unwrap(); 197 assert_eq!(first.did, again.did); 198 assert_eq!(first.operation_json(), again.operation_json()); 199 200 let slashed = prepare_repo_did( 201 &key, 202 &KnotServiceUrl::new("https://nel.pet/").unwrap(), 203 &repo_nonce(101), 204 ) 205 .unwrap(); 206 assert_eq!( 207 first.did, slashed.did, 208 "a trailing slash on the knot url changes neither the endpoint nor the did" 209 ); 210 211 let other_nonce = prepare_repo_did( 212 &key, 213 &KnotServiceUrl::new("https://nel.pet").unwrap(), 214 &repo_nonce(102), 215 ) 216 .unwrap(); 217 assert_ne!( 218 first.did, other_nonce.did, 219 "shared knot key no longer distinguishes repos; mint nonce must" 220 ); 221 } 222 223 #[test] 224 fn the_derived_did_plc_is_pinned_and_well_formed() { 225 let prepared = prepare_repo_did( 226 &runtime_signer(1), 227 &KnotServiceUrl::new("https://knot.oyster.cafe").unwrap(), 228 &repo_nonce(104), 229 ) 230 .unwrap(); 231 let did = prepared.did.as_str(); 232 assert_eq!( 233 did, "did:plc:obafda42ebtgg5thl7bzyjso", 234 "any change to this value means did:plc derivation no longer matches the PLC directory" 235 ); 236 let suffix = did.strip_prefix(DID_PLC_PREFIX).unwrap(); 237 assert_eq!(suffix.len(), DID_SUFFIX_LEN); 238 assert!( 239 suffix 240 .chars() 241 .all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)), 242 "the did:plc suffix is lowercase base32" 243 ); 244 } 245 246 #[test] 247 fn the_operation_signature_verifies_against_the_repo_key_over_the_unsigned_cbor() { 248 let key = runtime_signer(5); 249 let prepared = prepare_repo_did( 250 &key, 251 &KnotServiceUrl::new("https://nel.pet").unwrap(), 252 &repo_nonce(106), 253 ) 254 .unwrap(); 255 let operation: serde_json::Value = 256 serde_json::from_slice(prepared.operation_json()).unwrap(); 257 258 let signature_b64 = operation["sig"].as_str().unwrap(); 259 let signature = 260 knot_runtime::Signature::from_bytes(URL_SAFE_NO_PAD.decode(signature_b64).unwrap()); 261 262 let mut unsigned = operation.clone(); 263 unsigned.as_object_mut().unwrap().remove("sig"); 264 let unsigned_cbor = serde_ipld_dagcbor::to_vec(&unsigned).unwrap(); 265 266 assert!( 267 verify(&key.public_key(), &unsigned_cbor, &signature), 268 "genesis op is self-signed by repo rotation key over its unsigned dag-cbor" 269 ); 270 } 271 272 #[test] 273 fn the_genesis_op_marks_the_home_knot_and_has_no_signing_key() { 274 let key = runtime_signer(6); 275 let prepared = prepare_repo_did( 276 &key, 277 &KnotServiceUrl::new("https://knot.oyster.cafe").unwrap(), 278 &repo_nonce(107), 279 ) 280 .unwrap(); 281 let operation: serde_json::Value = 282 serde_json::from_slice(prepared.operation_json()).unwrap(); 283 284 assert_eq!(operation["type"], "plc_operation"); 285 assert_eq!(operation["prev"], serde_json::Value::Null); 286 assert_eq!(operation["alsoKnownAs"], serde_json::json!([])); 287 assert!( 288 operation["services"][KNOT_HOME_SERVICE]["endpoint"] 289 .as_str() 290 .unwrap() 291 .starts_with("https://knot.oyster.cafe/repo/") 292 ); 293 assert_eq!( 294 operation["services"][KNOT_HOME_SERVICE]["type"], 295 KNOT_HOME_TYPE 296 ); 297 assert!(operation["services"]["atproto_pds"].is_null()); 298 assert_eq!( 299 operation["verificationMethods"], 300 serde_json::json!({}), 301 "repos have no atproto signing key" 302 ); 303 let expected_key = did_key(&key.public_key()); 304 assert_eq!(operation["rotationKeys"][0], expected_key); 305 assert_eq!(operation["rotationKeys"].as_array().unwrap().len(), 1); 306 } 307 308 #[test] 309 fn the_knot_document_declares_its_signing_key_and_tangled_knot_service() { 310 let key = runtime_signer(7); 311 let knot = KnotId::new("did:web:knot.oyster.cafe").unwrap(); 312 let document = knot_did_document( 313 &knot, 314 &key.public_key(), 315 &KnotServiceUrl::new("https://knot.oyster.cafe").unwrap(), 316 ); 317 318 assert_eq!( 319 document["verificationMethod"][0]["id"], "did:web:knot.oyster.cafe#atproto", 320 "knot publishes its signing key under the atproto method" 321 ); 322 assert_eq!( 323 document["verificationMethod"][0]["publicKeyMultibase"], 324 serde_json::json!(multikey_secp256k1(&key.public_key())), 325 "published verification method is the knot's own signing key" 326 ); 327 assert_eq!( 328 document["service"][0], 329 serde_json::json!({ 330 "id": "#tangled_knot", 331 "type": "TangledKnot", 332 "serviceEndpoint": "https://knot.oyster.cafe" 333 }), 334 "knot self-declares its tangled_knot service at the knot root" 335 ); 336 } 337}