This repository has no description
0

Configure Feed

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

core / knot2 / crates / knot-cob / src / change.rs
11 kB 361 lines
1use k256::ecdsa::signature::Verifier as _; 2use k256::ecdsa::{Signature as K256Signature, VerifyingKey}; 3use knot_runtime::Signature; 4use knot_types::crypto::PublicKey; 5use knot_types::{ActorId, ChangeId, CobId, KnotId, Oid, RepoDid, TypeName, UnixSeconds}; 6use serde::Serialize; 7use serde::de::DeserializeOwned; 8 9use crate::error::PayloadError; 10 11#[derive(Debug, Clone, PartialEq, Eq)] 12pub enum CobHome { 13 Repo(RepoDid), 14 Knot(KnotId), 15} 16 17impl CobHome { 18 pub fn as_str(&self) -> &str { 19 match self { 20 CobHome::Repo(did) => did.as_str(), 21 CobHome::Knot(knot) => knot.as_str(), 22 } 23 } 24 25 fn kind(&self) -> &'static str { 26 match self { 27 CobHome::Repo(_) => "repo", 28 CobHome::Knot(_) => "knot", 29 } 30 } 31} 32 33impl From<&RepoDid> for CobHome { 34 fn from(did: &RepoDid) -> Self { 35 CobHome::Repo(did.clone()) 36 } 37} 38 39impl From<&KnotId> for CobHome { 40 fn from(knot: &KnotId) -> Self { 41 CobHome::Knot(knot.clone()) 42 } 43} 44 45#[derive(Debug, Clone, PartialEq, Eq)] 46pub struct Payload(Vec<u8>); 47 48impl Payload { 49 pub fn new(bytes: Vec<u8>) -> Self { 50 Self(bytes) 51 } 52 53 pub fn as_bytes(&self) -> &[u8] { 54 &self.0 55 } 56} 57 58pub trait ChangePayload: Serialize + DeserializeOwned + Sized { 59 const TYPE: &'static str; 60 61 fn type_name() -> TypeName { 62 TypeName::new(Self::TYPE).expect("ChangePayload::TYPE must be valid nsid") 63 } 64 65 fn encode(&self) -> Result<Vec<u8>, PayloadError> { 66 serde_ipld_dagcbor::to_vec(self).map_err(|error| PayloadError::Encode(error.to_string())) 67 } 68 69 fn decode(bytes: &[u8]) -> Result<Self, PayloadError> { 70 serde_ipld_dagcbor::from_slice(bytes) 71 .map_err(|error| PayloadError::Decode(error.to_string())) 72 } 73} 74 75const SIGNING_CONTEXT: &str = "sh.tangled.knot.cob.change.v1"; 76 77#[derive(Serialize)] 78struct SignedChange<'a> { 79 context: &'static str, 80 #[serde(rename = "homeKind")] 81 home_kind: &'static str, 82 home: &'a str, 83 revision: Oid, 84 parents: &'a [ChangeId], 85 #[serde(rename = "typeName")] 86 type_name: &'a TypeName, 87 author: &'a ActorId, 88 timestamp: UnixSeconds, 89 #[serde(skip_serializing_if = "Option::is_none")] 90 object: Option<CobId>, 91} 92 93pub(crate) fn signing_bytes( 94 home: &CobHome, 95 revision: Oid, 96 parents: &[ChangeId], 97 type_name: &TypeName, 98 author: &ActorId, 99 timestamp: UnixSeconds, 100 object: Option<CobId>, 101) -> Vec<u8> { 102 let view = SignedChange { 103 context: SIGNING_CONTEXT, 104 home_kind: home.kind(), 105 home: home.as_str(), 106 revision, 107 parents, 108 type_name, 109 author, 110 timestamp, 111 object, 112 }; 113 serde_ipld_dagcbor::to_vec(&view).expect("change signing view always encodes") 114} 115 116pub(crate) fn object_binding(parents: &[ChangeId], object: Option<CobId>) -> Option<CobId> { 117 if parents.is_empty() { None } else { object } 118} 119 120#[allow(clippy::too_many_arguments)] 121pub(crate) fn verify_signature( 122 home: &CobHome, 123 revision: Oid, 124 parents: &[ChangeId], 125 type_name: &TypeName, 126 author: &ActorId, 127 timestamp: UnixSeconds, 128 object: Option<CobId>, 129 signature: &[u8], 130) -> bool { 131 let Ok(public) = PublicKey::decode(author.as_str()) else { 132 return false; 133 }; 134 let Ok(verifying) = public.to_k256() else { 135 return false; 136 }; 137 let Ok(signature) = K256Signature::from_slice(signature) else { 138 return false; 139 }; 140 let message = signing_bytes( 141 home, 142 revision, 143 parents, 144 type_name, 145 author, 146 timestamp, 147 object_binding(parents, object), 148 ); 149 VerifyingKey::from(&verifying) 150 .verify(&message, &signature) 151 .is_ok() 152} 153 154#[derive(Debug, Clone, PartialEq, Eq)] 155pub struct Change { 156 pub id: ChangeId, 157 pub revision: Oid, 158 pub parents: Vec<ChangeId>, 159 pub type_name: TypeName, 160 pub author: ActorId, 161 pub signature: Signature, 162 pub payload: Payload, 163 pub timestamp: UnixSeconds, 164} 165 166impl Change { 167 pub fn payload(&self) -> &[u8] { 168 self.payload.as_bytes() 169 } 170 171 pub fn sort_key(&self) -> (UnixSeconds, ChangeId) { 172 (self.timestamp, self.id) 173 } 174 175 pub fn verify(&self, home: &CobHome, expected_author: &ActorId, object: Option<CobId>) -> bool { 176 &self.author == expected_author 177 && verify_signature( 178 home, 179 self.revision, 180 &self.parents, 181 &self.type_name, 182 &self.author, 183 self.timestamp, 184 object, 185 self.signature.as_bytes(), 186 ) 187 } 188} 189 190#[cfg(test)] 191mod tests { 192 use knot_runtime::{K256Signer, SeededEntropy, Signer}; 193 194 use super::*; 195 196 fn type_name() -> TypeName { 197 TypeName::new("sh.tangled.test.tag").unwrap() 198 } 199 200 fn cob_home() -> CobHome { 201 CobHome::from(&RepoDid::new("did:plc:squid").unwrap()) 202 } 203 204 fn signed_change( 205 signer: &K256Signer, 206 revision: Oid, 207 parents: Vec<ChangeId>, 208 timestamp: i64, 209 ) -> Change { 210 let author = ActorId::from_secp256k1(signer.public_key().as_bytes()); 211 let timestamp = UnixSeconds::new(timestamp); 212 let bytes = signing_bytes( 213 &cob_home(), 214 revision, 215 &parents, 216 &type_name(), 217 &author, 218 timestamp, 219 object_binding(&parents, None), 220 ); 221 Change { 222 id: ChangeId::new(Oid::null()), 223 revision, 224 parents, 225 type_name: type_name(), 226 author, 227 signature: signer.sign(&bytes), 228 payload: Payload::new(Vec::new()), 229 timestamp, 230 } 231 } 232 233 #[test] 234 fn signing_bytes_stay_byte_stable() { 235 let author = { 236 let mut compressed = [0u8; 33]; 237 compressed[0] = 0x02; 238 compressed[1] = 0x09; 239 ActorId::from_secp256k1(&compressed) 240 }; 241 let parents = vec![ChangeId::new( 242 Oid::from_hex("2222222222222222222222222222222222222222").unwrap(), 243 )]; 244 let object = Some(CobId::new( 245 Oid::from_hex("3333333333333333333333333333333333333333").unwrap(), 246 )); 247 let bytes = signing_bytes( 248 &cob_home(), 249 Oid::from_hex("1111111111111111111111111111111111111111").unwrap(), 250 &parents, 251 &type_name(), 252 &author, 253 UnixSeconds::new(1_700_000_000), 254 object_binding(&parents, object), 255 ); 256 assert_eq!( 257 knot_types::lowercase_hex(&bytes), 258 "a964686f6d656d6469643a706c633a737175696466617574686f7278317a513373684e317652664257527847397234564c3251796466474e675955715a5a385836743971535359774b636a644a50666f626a65637478283333333333333333333333333333333333333333333333333333333333333333333333333333333367636f6e74657874781d73682e74616e676c65642e6b6e6f742e636f622e6368616e67652e763167706172656e74738178283232323232323232323232323232323232323232323232323232323232323232323232323232323268686f6d654b696e64647265706f687265766973696f6e78283131313131313131313131313131313131313131313131313131313131313131313131313131313168747970654e616d657373682e74616e676c65642e746573742e7461676974696d657374616d701a6553f100" 259 ); 260 } 261 262 #[test] 263 fn verify_binds_the_author() { 264 let signer = K256Signer::generate(&SeededEntropy::new(13)); 265 let stranger = K256Signer::generate(&SeededEntropy::new(14)); 266 let revision = Oid::from_hex("6666666666666666666666666666666666666666").unwrap(); 267 let change = signed_change(&signer, revision, Vec::new(), 1); 268 let stranger_actor = ActorId::from_secp256k1(stranger.public_key().as_bytes()); 269 assert!(change.verify(&cob_home(), &change.author, None)); 270 assert!( 271 !change.verify(&cob_home(), &stranger_actor, None), 272 "a valid signature under an unexpected expected-author is refused" 273 ); 274 let foreign = Change { 275 author: stranger_actor, 276 ..change 277 }; 278 assert!( 279 !foreign.verify(&cob_home(), &foreign.author, None), 280 "an author swapped to a stranger fails its own signature check" 281 ); 282 } 283 284 #[test] 285 fn verify_rejects_a_tampered_transplanted_or_rehomed_change() { 286 let signer = K256Signer::generate(&SeededEntropy::new(10)); 287 let revision = Oid::from_hex("1111111111111111111111111111111111111111").unwrap(); 288 let genuine = signed_change(&signer, revision, Vec::new(), 1); 289 assert!( 290 genuine.verify(&cob_home(), &genuine.author, None), 291 "genuine root change verifies" 292 ); 293 294 let mutators: Vec<fn(Change) -> Change> = vec![ 295 |change| { 296 let mut bytes = change.signature.as_bytes().to_vec(); 297 bytes[0] ^= 0xff; 298 Change { 299 signature: Signature::from_bytes(bytes), 300 ..change 301 } 302 }, 303 |change| Change { 304 parents: vec![ChangeId::new( 305 Oid::from_hex("3333333333333333333333333333333333333333").unwrap(), 306 )], 307 ..change 308 }, 309 |change| Change { 310 timestamp: UnixSeconds::new(9_999_999), 311 ..change 312 }, 313 ]; 314 mutators.into_iter().for_each(|mutate| { 315 let broken = mutate(genuine.clone()); 316 assert!( 317 !broken.verify(&cob_home(), &broken.author, None), 318 "a tampered or transplanted change fails verification" 319 ); 320 }); 321 322 let other_home = CobHome::from(&RepoDid::new("did:plc:limpet").unwrap()); 323 assert!( 324 !genuine.verify(&other_home, &genuine.author, None), 325 "change signed for one repo mustn't verify under another repo's home" 326 ); 327 328 let author = ActorId::from_secp256k1(signer.public_key().as_bytes()); 329 let parent = 330 ChangeId::new(Oid::from_hex("5555555555555555555555555555555555555555").unwrap()); 331 let home = CobId::new(Oid::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap()); 332 let elsewhere = 333 CobId::new(Oid::from_hex("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap()); 334 let timestamp = UnixSeconds::new(1); 335 let bytes = signing_bytes( 336 &cob_home(), 337 revision, 338 &[parent], 339 &type_name(), 340 &author, 341 timestamp, 342 object_binding(&[parent], Some(home)), 343 ); 344 let bound = Change { 345 id: ChangeId::new(Oid::null()), 346 revision, 347 parents: vec![parent], 348 type_name: type_name(), 349 author, 350 signature: signer.sign(&bytes), 351 payload: Payload::new(Vec::new()), 352 timestamp, 353 }; 354 assert!(bound.verify(&cob_home(), &bound.author, Some(home))); 355 assert!( 356 !bound.verify(&cob_home(), &bound.author, Some(elsewhere)), 357 "a non-root change is bound to its object" 358 ); 359 assert!(!bound.verify(&cob_home(), &bound.author, None)); 360 } 361}