1use serde::{Deserialize, Serialize};
6
7use crate::id::{Blake3Hex, PilotId};
8use crate::util::{canonical_utc_now, is_canonical_utc_timestamp};
9
10const FOLLOW_SCHEMA: &str = "igc-net/follow";
11const FOLLOW_VERSION: u8 = 1;
12const UNFOLLOW_SCHEMA: &str = "igc-net/unfollow";
13const UNFOLLOW_VERSION: u8 = 1;
14
15#[derive(Debug, thiserror::Error)]
18pub enum FollowRecordError {
19 #[error("JSON: {0}")]
20 Json(#[from] serde_json::Error),
21 #[error("identifier: {0}")]
22 Identifier(#[from] crate::id::IdentifierError),
23 #[error("schema must be {expected:?}, got {found:?}")]
24 Schema { expected: &'static str, found: String },
25 #[error("schema_version must be {expected}, got {found}")]
26 SchemaVersion { expected: u8, found: u8 },
27 #[error("created_at is not canonical UTC RFC3339 seconds format: {0:?}")]
28 CreatedAt(String),
29 #[error("signature must be 128 lowercase hex chars")]
30 SignatureEncoding,
31 #[error("pilot_id does not contain a valid Ed25519 public key: {0}")]
32 PilotIdPublicKey(String),
33 #[error("record_id mismatch: expected {expected}, found {found}")]
34 RecordIdMismatch {
35 expected: Blake3Hex,
36 found: Blake3Hex,
37 },
38 #[error("signature verification failed")]
39 SignatureVerification,
40 #[error("follower and followee must be distinct pilot IDs")]
41 SelfFollow,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct FollowRecord {
48 pub schema: String,
49 pub schema_version: u8,
50 pub record_id: Blake3Hex,
51 pub follower_pilot_id: PilotId,
52 pub followee_pilot_id: PilotId,
53 pub created_at: String,
54 pub signature: String,
55}
56
57#[derive(Serialize)]
58struct FollowPayload<'a> {
59 schema: &'static str,
60 schema_version: u8,
61 follower_pilot_id: &'a PilotId,
62 followee_pilot_id: &'a PilotId,
63 created_at: &'a str,
64}
65
66#[derive(Serialize)]
67struct FollowSignPayload<'a> {
68 schema: &'static str,
69 schema_version: u8,
70 record_id: &'a Blake3Hex,
71 follower_pilot_id: &'a PilotId,
72 followee_pilot_id: &'a PilotId,
73 created_at: &'a str,
74}
75
76impl FollowRecord {
77 pub fn issue(
78 follower_secret_key: &iroh::SecretKey,
79 followee_pilot_id: PilotId,
80 ) -> Result<Self, FollowRecordError> {
81 let created_at = canonical_utc_now();
82 let follower_pilot_id = PilotId::from_public_key(follower_secret_key.public());
83 if follower_pilot_id == followee_pilot_id {
84 return Err(FollowRecordError::SelfFollow);
85 }
86
87 let id_payload = FollowPayload {
88 schema: FOLLOW_SCHEMA,
89 schema_version: FOLLOW_VERSION,
90 follower_pilot_id: &follower_pilot_id,
91 followee_pilot_id: &followee_pilot_id,
92 created_at: &created_at,
93 };
94 let record_id = blake3_record_id(&id_payload)?;
95 let sign_payload = FollowSignPayload {
96 schema: FOLLOW_SCHEMA,
97 schema_version: FOLLOW_VERSION,
98 record_id: &record_id,
99 follower_pilot_id: &follower_pilot_id,
100 followee_pilot_id: &followee_pilot_id,
101 created_at: &created_at,
102 };
103 let signature = sign_payload_hex(follower_secret_key, &sign_payload)?;
104
105 let record = Self {
106 schema: FOLLOW_SCHEMA.to_string(),
107 schema_version: FOLLOW_VERSION,
108 record_id,
109 follower_pilot_id,
110 followee_pilot_id,
111 created_at,
112 signature,
113 };
114 record.validate()?;
115 Ok(record)
116 }
117
118 pub fn validate(&self) -> Result<(), FollowRecordError> {
119 check_schema(&self.schema, FOLLOW_SCHEMA)?;
120 check_schema_version(self.schema_version, FOLLOW_VERSION)?;
121 check_created_at(&self.created_at)?;
122 if self.follower_pilot_id == self.followee_pilot_id {
123 return Err(FollowRecordError::SelfFollow);
124 }
125
126 let id_payload = FollowPayload {
127 schema: FOLLOW_SCHEMA,
128 schema_version: FOLLOW_VERSION,
129 follower_pilot_id: &self.follower_pilot_id,
130 followee_pilot_id: &self.followee_pilot_id,
131 created_at: &self.created_at,
132 };
133 let expected = blake3_record_id(&id_payload)?;
134 if self.record_id != expected {
135 return Err(FollowRecordError::RecordIdMismatch { expected, found: self.record_id.clone() });
136 }
137 let sign_payload = FollowSignPayload {
138 schema: FOLLOW_SCHEMA,
139 schema_version: FOLLOW_VERSION,
140 record_id: &self.record_id,
141 follower_pilot_id: &self.follower_pilot_id,
142 followee_pilot_id: &self.followee_pilot_id,
143 created_at: &self.created_at,
144 };
145 verify_signature(&self.follower_pilot_id, &self.signature, &sign_payload)?;
146 Ok(())
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153pub struct UnfollowRecord {
154 pub schema: String,
155 pub schema_version: u8,
156 pub record_id: Blake3Hex,
157 pub follower_pilot_id: PilotId,
158 pub followee_pilot_id: PilotId,
159 pub created_at: String,
160 pub signature: String,
161}
162
163#[derive(Serialize)]
164struct UnfollowPayload<'a> {
165 schema: &'static str,
166 schema_version: u8,
167 follower_pilot_id: &'a PilotId,
168 followee_pilot_id: &'a PilotId,
169 created_at: &'a str,
170}
171
172#[derive(Serialize)]
173struct UnfollowSignPayload<'a> {
174 schema: &'static str,
175 schema_version: u8,
176 record_id: &'a Blake3Hex,
177 follower_pilot_id: &'a PilotId,
178 followee_pilot_id: &'a PilotId,
179 created_at: &'a str,
180}
181
182impl UnfollowRecord {
183 pub fn issue(
184 follower_secret_key: &iroh::SecretKey,
185 followee_pilot_id: PilotId,
186 ) -> Result<Self, FollowRecordError> {
187 let created_at = canonical_utc_now();
188 let follower_pilot_id = PilotId::from_public_key(follower_secret_key.public());
189 if follower_pilot_id == followee_pilot_id {
190 return Err(FollowRecordError::SelfFollow);
191 }
192
193 let id_payload = UnfollowPayload {
194 schema: UNFOLLOW_SCHEMA,
195 schema_version: UNFOLLOW_VERSION,
196 follower_pilot_id: &follower_pilot_id,
197 followee_pilot_id: &followee_pilot_id,
198 created_at: &created_at,
199 };
200 let record_id = blake3_record_id(&id_payload)?;
201 let sign_payload = UnfollowSignPayload {
202 schema: UNFOLLOW_SCHEMA,
203 schema_version: UNFOLLOW_VERSION,
204 record_id: &record_id,
205 follower_pilot_id: &follower_pilot_id,
206 followee_pilot_id: &followee_pilot_id,
207 created_at: &created_at,
208 };
209 let signature = sign_payload_hex(follower_secret_key, &sign_payload)?;
210
211 let record = Self {
212 schema: UNFOLLOW_SCHEMA.to_string(),
213 schema_version: UNFOLLOW_VERSION,
214 record_id,
215 follower_pilot_id,
216 followee_pilot_id,
217 created_at,
218 signature,
219 };
220 record.validate()?;
221 Ok(record)
222 }
223
224 pub fn validate(&self) -> Result<(), FollowRecordError> {
225 check_schema(&self.schema, UNFOLLOW_SCHEMA)?;
226 check_schema_version(self.schema_version, UNFOLLOW_VERSION)?;
227 check_created_at(&self.created_at)?;
228 if self.follower_pilot_id == self.followee_pilot_id {
229 return Err(FollowRecordError::SelfFollow);
230 }
231
232 let id_payload = UnfollowPayload {
233 schema: UNFOLLOW_SCHEMA,
234 schema_version: UNFOLLOW_VERSION,
235 follower_pilot_id: &self.follower_pilot_id,
236 followee_pilot_id: &self.followee_pilot_id,
237 created_at: &self.created_at,
238 };
239 let expected = blake3_record_id(&id_payload)?;
240 if self.record_id != expected {
241 return Err(FollowRecordError::RecordIdMismatch { expected, found: self.record_id.clone() });
242 }
243 let sign_payload = UnfollowSignPayload {
244 schema: UNFOLLOW_SCHEMA,
245 schema_version: UNFOLLOW_VERSION,
246 record_id: &self.record_id,
247 follower_pilot_id: &self.follower_pilot_id,
248 followee_pilot_id: &self.followee_pilot_id,
249 created_at: &self.created_at,
250 };
251 verify_signature(&self.follower_pilot_id, &self.signature, &sign_payload)?;
252 Ok(())
253 }
254}
255
256fn blake3_record_id<T: serde::Serialize>(payload: &T) -> Result<Blake3Hex, FollowRecordError> {
259 let bytes = json_canon::to_vec(payload)?;
260 Ok(Blake3Hex::from_hash(blake3::hash(&bytes)))
261}
262
263fn sign_payload_hex<T: serde::Serialize>(
264 secret_key: &iroh::SecretKey,
265 payload: &T,
266) -> Result<String, FollowRecordError> {
267 let bytes = json_canon::to_vec(payload)?;
268 Ok(hex::encode(secret_key.sign(&bytes).to_bytes()))
269}
270
271fn pilot_id_public_key(pilot_id: &PilotId) -> Result<iroh::PublicKey, FollowRecordError> {
272 let bytes = hex::decode(pilot_id.public_key_hex())
273 .ok()
274 .and_then(|b| <[u8; 32]>::try_from(b).ok())
275 .ok_or_else(|| FollowRecordError::PilotIdPublicKey(pilot_id.to_string()))?;
276 iroh::PublicKey::from_bytes(&bytes)
277 .map_err(|_| FollowRecordError::PilotIdPublicKey(pilot_id.to_string()))
278}
279
280fn decode_signature_hex(value: &str) -> Result<iroh::Signature, FollowRecordError> {
281 if value.len() != 128 || !value.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
282 return Err(FollowRecordError::SignatureEncoding);
283 }
284 let bytes = hex::decode(value).map_err(|_| FollowRecordError::SignatureEncoding)?;
285 let sig_bytes: [u8; 64] = bytes
286 .try_into()
287 .map_err(|_| FollowRecordError::SignatureEncoding)?;
288 Ok(iroh::Signature::from_bytes(&sig_bytes))
289}
290
291fn verify_signature<T: serde::Serialize>(
292 signer: &PilotId,
293 signature_hex: &str,
294 payload: &T,
295) -> Result<(), FollowRecordError> {
296 let pubkey = pilot_id_public_key(signer)?;
297 let signature = decode_signature_hex(signature_hex)?;
298 let bytes = json_canon::to_vec(payload)?;
299 pubkey
300 .verify(&bytes, &signature)
301 .map_err(|_| FollowRecordError::SignatureVerification)
302}
303
304fn check_schema(found: &str, expected: &'static str) -> Result<(), FollowRecordError> {
305 if found != expected {
306 return Err(FollowRecordError::Schema { expected, found: found.to_string() });
307 }
308 Ok(())
309}
310
311fn check_schema_version(found: u8, expected: u8) -> Result<(), FollowRecordError> {
312 if found != expected {
313 return Err(FollowRecordError::SchemaVersion { expected, found });
314 }
315 Ok(())
316}
317
318fn check_created_at(value: &str) -> Result<(), FollowRecordError> {
319 if !is_canonical_utc_timestamp(value) {
320 return Err(FollowRecordError::CreatedAt(value.to_string()));
321 }
322 Ok(())
323}