1use serde::{Deserialize, Serialize};
6
7use crate::id::{Blake3Hex, GroupId, PilotId};
8use crate::util::{canonical_utc_now, is_canonical_utc_timestamp};
9
10const GROUP_CREATION_SCHEMA: &str = "igc-net/group-creation";
13const GROUP_CREATION_VERSION: u8 = 1;
14const PRIVATE_MEMBER_ADD_SCHEMA: &str = "igc-net/private-group-member-add";
15const PRIVATE_MEMBER_ADD_VERSION: u8 = 1;
16const PRIVATE_MEMBER_REMOVE_SCHEMA: &str = "igc-net/private-group-member-remove";
17const PRIVATE_MEMBER_REMOVE_VERSION: u8 = 1;
18const PUBLIC_GROUP_INVITE_SCHEMA: &str = "igc-net/public-group-invite";
19const PUBLIC_GROUP_INVITE_VERSION: u8 = 1;
20const PUBLIC_GROUP_ACCEPT_SCHEMA: &str = "igc-net/public-group-accept";
21const PUBLIC_GROUP_ACCEPT_VERSION: u8 = 1;
22const PUBLIC_GROUP_LEAVE_SCHEMA: &str = "igc-net/public-group-leave";
23const PUBLIC_GROUP_LEAVE_VERSION: u8 = 1;
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum GroupType {
30 Private,
31 Public,
32}
33
34#[derive(Debug, thiserror::Error)]
37pub enum GroupRecordError {
38 #[error("JSON: {0}")]
39 Json(#[from] serde_json::Error),
40 #[error("identifier: {0}")]
41 Identifier(#[from] crate::id::IdentifierError),
42 #[error("schema must be {expected:?}, got {found:?}")]
43 Schema { expected: &'static str, found: String },
44 #[error("schema_version must be {expected}, got {found}")]
45 SchemaVersion { expected: u8, found: u8 },
46 #[error("created_at is not canonical UTC RFC3339 seconds format: {0:?}")]
47 CreatedAt(String),
48 #[error("signature must be 128 lowercase hex chars")]
49 SignatureEncoding,
50 #[error("pilot_id does not contain a valid Ed25519 public key: {0}")]
51 PilotIdPublicKey(String),
52 #[error("record_id mismatch: expected {expected}, found {found}")]
53 RecordIdMismatch {
54 expected: Blake3Hex,
55 found: Blake3Hex,
56 },
57 #[error("group_id mismatch: expected {expected}, found {found}")]
58 GroupIdMismatch {
59 expected: GroupId,
60 found: GroupId,
61 },
62 #[error("signature verification failed")]
63 SignatureVerification,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct GroupCreationRecord {
70 pub schema: String,
71 pub schema_version: u8,
72 pub record_id: Blake3Hex,
73 pub group_id: GroupId,
74 pub group_type: GroupType,
75 pub creator_pilot_id: PilotId,
76 pub name: Option<String>,
77 pub created_at: String,
78 pub signature: String,
79}
80
81#[derive(Serialize)]
82struct GroupCreationPayload<'a> {
83 schema: &'static str,
84 schema_version: u8,
85 group_id: &'a GroupId,
86 group_type: &'a GroupType,
87 creator_pilot_id: &'a PilotId,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 name: &'a Option<String>,
90 created_at: &'a str,
91}
92
93#[derive(Serialize)]
94struct GroupCreationSignPayload<'a> {
95 schema: &'static str,
96 schema_version: u8,
97 record_id: &'a Blake3Hex,
98 group_id: &'a GroupId,
99 group_type: &'a GroupType,
100 creator_pilot_id: &'a PilotId,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 name: &'a Option<String>,
103 created_at: &'a str,
104}
105
106impl GroupCreationRecord {
107 pub fn issue(
108 pilot_id_secret_key: &iroh::SecretKey,
109 group_type: GroupType,
110 name: Option<String>,
111 ) -> Result<Self, GroupRecordError> {
112 let created_at = canonical_utc_now();
113 let creator_pilot_id = PilotId::from_public_key(pilot_id_secret_key.public());
114 let group_id = GroupId::derive(&group_type, &creator_pilot_id, &name, &created_at)?;
115
116 let id_payload = GroupCreationPayload {
117 schema: GROUP_CREATION_SCHEMA,
118 schema_version: GROUP_CREATION_VERSION,
119 group_id: &group_id,
120 group_type: &group_type,
121 creator_pilot_id: &creator_pilot_id,
122 name: &name,
123 created_at: &created_at,
124 };
125 let record_id = blake3_record_id(&id_payload)?;
126
127 let sign_payload = GroupCreationSignPayload {
128 schema: GROUP_CREATION_SCHEMA,
129 schema_version: GROUP_CREATION_VERSION,
130 record_id: &record_id,
131 group_id: &group_id,
132 group_type: &group_type,
133 creator_pilot_id: &creator_pilot_id,
134 name: &name,
135 created_at: &created_at,
136 };
137 let signature = sign_payload_hex(pilot_id_secret_key, &sign_payload)?;
138
139 let record = Self {
140 schema: GROUP_CREATION_SCHEMA.to_string(),
141 schema_version: GROUP_CREATION_VERSION,
142 record_id,
143 group_id,
144 group_type,
145 creator_pilot_id,
146 name,
147 created_at,
148 signature,
149 };
150 record.validate()?;
151 Ok(record)
152 }
153
154 pub fn validate(&self) -> Result<(), GroupRecordError> {
155 check_schema(&self.schema, GROUP_CREATION_SCHEMA)?;
156 check_schema_version(self.schema_version, GROUP_CREATION_VERSION)?;
157 check_created_at(&self.created_at)?;
158
159 let expected_group_id =
160 GroupId::derive(&self.group_type, &self.creator_pilot_id, &self.name, &self.created_at)?;
161 if self.group_id != expected_group_id {
162 return Err(GroupRecordError::GroupIdMismatch {
163 expected: expected_group_id,
164 found: self.group_id.clone(),
165 });
166 }
167
168 let id_payload = GroupCreationPayload {
169 schema: GROUP_CREATION_SCHEMA,
170 schema_version: GROUP_CREATION_VERSION,
171 group_id: &self.group_id,
172 group_type: &self.group_type,
173 creator_pilot_id: &self.creator_pilot_id,
174 name: &self.name,
175 created_at: &self.created_at,
176 };
177 let expected_record_id = blake3_record_id(&id_payload)?;
178 if self.record_id != expected_record_id {
179 return Err(GroupRecordError::RecordIdMismatch {
180 expected: expected_record_id,
181 found: self.record_id.clone(),
182 });
183 }
184
185 let sign_payload = GroupCreationSignPayload {
186 schema: GROUP_CREATION_SCHEMA,
187 schema_version: GROUP_CREATION_VERSION,
188 record_id: &self.record_id,
189 group_id: &self.group_id,
190 group_type: &self.group_type,
191 creator_pilot_id: &self.creator_pilot_id,
192 name: &self.name,
193 created_at: &self.created_at,
194 };
195 verify_signature(&self.creator_pilot_id, &self.signature, &sign_payload)?;
196 Ok(())
197 }
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203pub struct PrivateGroupMemberAddRecord {
204 pub schema: String,
205 pub schema_version: u8,
206 pub record_id: Blake3Hex,
207 pub group_id: GroupId,
208 pub member_pilot_id: PilotId,
209 pub added_by_pilot_id: PilotId,
210 pub created_at: String,
211 pub signature: String,
212}
213
214#[derive(Serialize)]
215struct PrivateMemberAddPayload<'a> {
216 schema: &'static str,
217 schema_version: u8,
218 group_id: &'a GroupId,
219 member_pilot_id: &'a PilotId,
220 added_by_pilot_id: &'a PilotId,
221 created_at: &'a str,
222}
223
224#[derive(Serialize)]
225struct PrivateMemberAddSignPayload<'a> {
226 schema: &'static str,
227 schema_version: u8,
228 record_id: &'a Blake3Hex,
229 group_id: &'a GroupId,
230 member_pilot_id: &'a PilotId,
231 added_by_pilot_id: &'a PilotId,
232 created_at: &'a str,
233}
234
235impl PrivateGroupMemberAddRecord {
236 pub fn issue(
237 owner_secret_key: &iroh::SecretKey,
238 group_id: GroupId,
239 member_pilot_id: PilotId,
240 ) -> Result<Self, GroupRecordError> {
241 let created_at = canonical_utc_now();
242 let added_by_pilot_id = PilotId::from_public_key(owner_secret_key.public());
243
244 let id_payload = PrivateMemberAddPayload {
245 schema: PRIVATE_MEMBER_ADD_SCHEMA,
246 schema_version: PRIVATE_MEMBER_ADD_VERSION,
247 group_id: &group_id,
248 member_pilot_id: &member_pilot_id,
249 added_by_pilot_id: &added_by_pilot_id,
250 created_at: &created_at,
251 };
252 let record_id = blake3_record_id(&id_payload)?;
253 let sign_payload = PrivateMemberAddSignPayload {
254 schema: PRIVATE_MEMBER_ADD_SCHEMA,
255 schema_version: PRIVATE_MEMBER_ADD_VERSION,
256 record_id: &record_id,
257 group_id: &group_id,
258 member_pilot_id: &member_pilot_id,
259 added_by_pilot_id: &added_by_pilot_id,
260 created_at: &created_at,
261 };
262 let signature = sign_payload_hex(owner_secret_key, &sign_payload)?;
263
264 let record = Self {
265 schema: PRIVATE_MEMBER_ADD_SCHEMA.to_string(),
266 schema_version: PRIVATE_MEMBER_ADD_VERSION,
267 record_id,
268 group_id,
269 member_pilot_id,
270 added_by_pilot_id,
271 created_at,
272 signature,
273 };
274 record.validate()?;
275 Ok(record)
276 }
277
278 pub fn validate(&self) -> Result<(), GroupRecordError> {
279 check_schema(&self.schema, PRIVATE_MEMBER_ADD_SCHEMA)?;
280 check_schema_version(self.schema_version, PRIVATE_MEMBER_ADD_VERSION)?;
281 check_created_at(&self.created_at)?;
282
283 let id_payload = PrivateMemberAddPayload {
284 schema: PRIVATE_MEMBER_ADD_SCHEMA,
285 schema_version: PRIVATE_MEMBER_ADD_VERSION,
286 group_id: &self.group_id,
287 member_pilot_id: &self.member_pilot_id,
288 added_by_pilot_id: &self.added_by_pilot_id,
289 created_at: &self.created_at,
290 };
291 let expected = blake3_record_id(&id_payload)?;
292 if self.record_id != expected {
293 return Err(GroupRecordError::RecordIdMismatch { expected, found: self.record_id.clone() });
294 }
295 let sign_payload = PrivateMemberAddSignPayload {
296 schema: PRIVATE_MEMBER_ADD_SCHEMA,
297 schema_version: PRIVATE_MEMBER_ADD_VERSION,
298 record_id: &self.record_id,
299 group_id: &self.group_id,
300 member_pilot_id: &self.member_pilot_id,
301 added_by_pilot_id: &self.added_by_pilot_id,
302 created_at: &self.created_at,
303 };
304 verify_signature(&self.added_by_pilot_id, &self.signature, &sign_payload)?;
305 Ok(())
306 }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
312pub struct PrivateGroupMemberRemoveRecord {
313 pub schema: String,
314 pub schema_version: u8,
315 pub record_id: Blake3Hex,
316 pub group_id: GroupId,
317 pub member_pilot_id: PilotId,
318 pub removed_by_pilot_id: PilotId,
319 pub created_at: String,
320 pub signature: String,
321}
322
323#[derive(Serialize)]
324struct PrivateMemberRemovePayload<'a> {
325 schema: &'static str,
326 schema_version: u8,
327 group_id: &'a GroupId,
328 member_pilot_id: &'a PilotId,
329 removed_by_pilot_id: &'a PilotId,
330 created_at: &'a str,
331}
332
333#[derive(Serialize)]
334struct PrivateMemberRemoveSignPayload<'a> {
335 schema: &'static str,
336 schema_version: u8,
337 record_id: &'a Blake3Hex,
338 group_id: &'a GroupId,
339 member_pilot_id: &'a PilotId,
340 removed_by_pilot_id: &'a PilotId,
341 created_at: &'a str,
342}
343
344impl PrivateGroupMemberRemoveRecord {
345 pub fn issue(
346 owner_secret_key: &iroh::SecretKey,
347 group_id: GroupId,
348 member_pilot_id: PilotId,
349 ) -> Result<Self, GroupRecordError> {
350 let created_at = canonical_utc_now();
351 let removed_by_pilot_id = PilotId::from_public_key(owner_secret_key.public());
352
353 let id_payload = PrivateMemberRemovePayload {
354 schema: PRIVATE_MEMBER_REMOVE_SCHEMA,
355 schema_version: PRIVATE_MEMBER_REMOVE_VERSION,
356 group_id: &group_id,
357 member_pilot_id: &member_pilot_id,
358 removed_by_pilot_id: &removed_by_pilot_id,
359 created_at: &created_at,
360 };
361 let record_id = blake3_record_id(&id_payload)?;
362 let sign_payload = PrivateMemberRemoveSignPayload {
363 schema: PRIVATE_MEMBER_REMOVE_SCHEMA,
364 schema_version: PRIVATE_MEMBER_REMOVE_VERSION,
365 record_id: &record_id,
366 group_id: &group_id,
367 member_pilot_id: &member_pilot_id,
368 removed_by_pilot_id: &removed_by_pilot_id,
369 created_at: &created_at,
370 };
371 let signature = sign_payload_hex(owner_secret_key, &sign_payload)?;
372
373 let record = Self {
374 schema: PRIVATE_MEMBER_REMOVE_SCHEMA.to_string(),
375 schema_version: PRIVATE_MEMBER_REMOVE_VERSION,
376 record_id,
377 group_id,
378 member_pilot_id,
379 removed_by_pilot_id,
380 created_at,
381 signature,
382 };
383 record.validate()?;
384 Ok(record)
385 }
386
387 pub fn validate(&self) -> Result<(), GroupRecordError> {
388 check_schema(&self.schema, PRIVATE_MEMBER_REMOVE_SCHEMA)?;
389 check_schema_version(self.schema_version, PRIVATE_MEMBER_REMOVE_VERSION)?;
390 check_created_at(&self.created_at)?;
391
392 let id_payload = PrivateMemberRemovePayload {
393 schema: PRIVATE_MEMBER_REMOVE_SCHEMA,
394 schema_version: PRIVATE_MEMBER_REMOVE_VERSION,
395 group_id: &self.group_id,
396 member_pilot_id: &self.member_pilot_id,
397 removed_by_pilot_id: &self.removed_by_pilot_id,
398 created_at: &self.created_at,
399 };
400 let expected = blake3_record_id(&id_payload)?;
401 if self.record_id != expected {
402 return Err(GroupRecordError::RecordIdMismatch { expected, found: self.record_id.clone() });
403 }
404 let sign_payload = PrivateMemberRemoveSignPayload {
405 schema: PRIVATE_MEMBER_REMOVE_SCHEMA,
406 schema_version: PRIVATE_MEMBER_REMOVE_VERSION,
407 record_id: &self.record_id,
408 group_id: &self.group_id,
409 member_pilot_id: &self.member_pilot_id,
410 removed_by_pilot_id: &self.removed_by_pilot_id,
411 created_at: &self.created_at,
412 };
413 verify_signature(&self.removed_by_pilot_id, &self.signature, &sign_payload)?;
414 Ok(())
415 }
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
421pub struct PublicGroupInviteRecord {
422 pub schema: String,
423 pub schema_version: u8,
424 pub record_id: Blake3Hex,
425 pub group_id: GroupId,
426 pub invited_pilot_id: PilotId,
427 pub invited_by_pilot_id: PilotId,
428 pub created_at: String,
429 pub signature: String,
430}
431
432#[derive(Serialize)]
433struct PublicInvitePayload<'a> {
434 schema: &'static str,
435 schema_version: u8,
436 group_id: &'a GroupId,
437 invited_pilot_id: &'a PilotId,
438 invited_by_pilot_id: &'a PilotId,
439 created_at: &'a str,
440}
441
442#[derive(Serialize)]
443struct PublicInviteSignPayload<'a> {
444 schema: &'static str,
445 schema_version: u8,
446 record_id: &'a Blake3Hex,
447 group_id: &'a GroupId,
448 invited_pilot_id: &'a PilotId,
449 invited_by_pilot_id: &'a PilotId,
450 created_at: &'a str,
451}
452
453impl PublicGroupInviteRecord {
454 pub fn issue(
455 inviter_secret_key: &iroh::SecretKey,
456 group_id: GroupId,
457 invited_pilot_id: PilotId,
458 ) -> Result<Self, GroupRecordError> {
459 let created_at = canonical_utc_now();
460 let invited_by_pilot_id = PilotId::from_public_key(inviter_secret_key.public());
461
462 let id_payload = PublicInvitePayload {
463 schema: PUBLIC_GROUP_INVITE_SCHEMA,
464 schema_version: PUBLIC_GROUP_INVITE_VERSION,
465 group_id: &group_id,
466 invited_pilot_id: &invited_pilot_id,
467 invited_by_pilot_id: &invited_by_pilot_id,
468 created_at: &created_at,
469 };
470 let record_id = blake3_record_id(&id_payload)?;
471 let sign_payload = PublicInviteSignPayload {
472 schema: PUBLIC_GROUP_INVITE_SCHEMA,
473 schema_version: PUBLIC_GROUP_INVITE_VERSION,
474 record_id: &record_id,
475 group_id: &group_id,
476 invited_pilot_id: &invited_pilot_id,
477 invited_by_pilot_id: &invited_by_pilot_id,
478 created_at: &created_at,
479 };
480 let signature = sign_payload_hex(inviter_secret_key, &sign_payload)?;
481
482 let record = Self {
483 schema: PUBLIC_GROUP_INVITE_SCHEMA.to_string(),
484 schema_version: PUBLIC_GROUP_INVITE_VERSION,
485 record_id,
486 group_id,
487 invited_pilot_id,
488 invited_by_pilot_id,
489 created_at,
490 signature,
491 };
492 record.validate()?;
493 Ok(record)
494 }
495
496 pub fn validate(&self) -> Result<(), GroupRecordError> {
497 check_schema(&self.schema, PUBLIC_GROUP_INVITE_SCHEMA)?;
498 check_schema_version(self.schema_version, PUBLIC_GROUP_INVITE_VERSION)?;
499 check_created_at(&self.created_at)?;
500
501 let id_payload = PublicInvitePayload {
502 schema: PUBLIC_GROUP_INVITE_SCHEMA,
503 schema_version: PUBLIC_GROUP_INVITE_VERSION,
504 group_id: &self.group_id,
505 invited_pilot_id: &self.invited_pilot_id,
506 invited_by_pilot_id: &self.invited_by_pilot_id,
507 created_at: &self.created_at,
508 };
509 let expected = blake3_record_id(&id_payload)?;
510 if self.record_id != expected {
511 return Err(GroupRecordError::RecordIdMismatch { expected, found: self.record_id.clone() });
512 }
513 let sign_payload = PublicInviteSignPayload {
514 schema: PUBLIC_GROUP_INVITE_SCHEMA,
515 schema_version: PUBLIC_GROUP_INVITE_VERSION,
516 record_id: &self.record_id,
517 group_id: &self.group_id,
518 invited_pilot_id: &self.invited_pilot_id,
519 invited_by_pilot_id: &self.invited_by_pilot_id,
520 created_at: &self.created_at,
521 };
522 verify_signature(&self.invited_by_pilot_id, &self.signature, &sign_payload)?;
523 Ok(())
524 }
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
530pub struct PublicGroupAcceptRecord {
531 pub schema: String,
532 pub schema_version: u8,
533 pub record_id: Blake3Hex,
534 pub group_id: GroupId,
535 pub member_pilot_id: PilotId,
536 pub created_at: String,
537 pub signature: String,
538}
539
540#[derive(Serialize)]
541struct PublicAcceptPayload<'a> {
542 schema: &'static str,
543 schema_version: u8,
544 group_id: &'a GroupId,
545 member_pilot_id: &'a PilotId,
546 created_at: &'a str,
547}
548
549#[derive(Serialize)]
550struct PublicAcceptSignPayload<'a> {
551 schema: &'static str,
552 schema_version: u8,
553 record_id: &'a Blake3Hex,
554 group_id: &'a GroupId,
555 member_pilot_id: &'a PilotId,
556 created_at: &'a str,
557}
558
559impl PublicGroupAcceptRecord {
560 pub fn issue(
561 member_secret_key: &iroh::SecretKey,
562 group_id: GroupId,
563 ) -> Result<Self, GroupRecordError> {
564 let created_at = canonical_utc_now();
565 let member_pilot_id = PilotId::from_public_key(member_secret_key.public());
566
567 let id_payload = PublicAcceptPayload {
568 schema: PUBLIC_GROUP_ACCEPT_SCHEMA,
569 schema_version: PUBLIC_GROUP_ACCEPT_VERSION,
570 group_id: &group_id,
571 member_pilot_id: &member_pilot_id,
572 created_at: &created_at,
573 };
574 let record_id = blake3_record_id(&id_payload)?;
575 let sign_payload = PublicAcceptSignPayload {
576 schema: PUBLIC_GROUP_ACCEPT_SCHEMA,
577 schema_version: PUBLIC_GROUP_ACCEPT_VERSION,
578 record_id: &record_id,
579 group_id: &group_id,
580 member_pilot_id: &member_pilot_id,
581 created_at: &created_at,
582 };
583 let signature = sign_payload_hex(member_secret_key, &sign_payload)?;
584
585 let record = Self {
586 schema: PUBLIC_GROUP_ACCEPT_SCHEMA.to_string(),
587 schema_version: PUBLIC_GROUP_ACCEPT_VERSION,
588 record_id,
589 group_id,
590 member_pilot_id,
591 created_at,
592 signature,
593 };
594 record.validate()?;
595 Ok(record)
596 }
597
598 pub fn validate(&self) -> Result<(), GroupRecordError> {
599 check_schema(&self.schema, PUBLIC_GROUP_ACCEPT_SCHEMA)?;
600 check_schema_version(self.schema_version, PUBLIC_GROUP_ACCEPT_VERSION)?;
601 check_created_at(&self.created_at)?;
602
603 let id_payload = PublicAcceptPayload {
604 schema: PUBLIC_GROUP_ACCEPT_SCHEMA,
605 schema_version: PUBLIC_GROUP_ACCEPT_VERSION,
606 group_id: &self.group_id,
607 member_pilot_id: &self.member_pilot_id,
608 created_at: &self.created_at,
609 };
610 let expected = blake3_record_id(&id_payload)?;
611 if self.record_id != expected {
612 return Err(GroupRecordError::RecordIdMismatch { expected, found: self.record_id.clone() });
613 }
614 let sign_payload = PublicAcceptSignPayload {
615 schema: PUBLIC_GROUP_ACCEPT_SCHEMA,
616 schema_version: PUBLIC_GROUP_ACCEPT_VERSION,
617 record_id: &self.record_id,
618 group_id: &self.group_id,
619 member_pilot_id: &self.member_pilot_id,
620 created_at: &self.created_at,
621 };
622 verify_signature(&self.member_pilot_id, &self.signature, &sign_payload)?;
623 Ok(())
624 }
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
630pub struct PublicGroupLeaveRecord {
631 pub schema: String,
632 pub schema_version: u8,
633 pub record_id: Blake3Hex,
634 pub group_id: GroupId,
635 pub member_pilot_id: PilotId,
636 pub created_at: String,
637 pub signature: String,
638}
639
640#[derive(Serialize)]
641struct PublicLeavePayload<'a> {
642 schema: &'static str,
643 schema_version: u8,
644 group_id: &'a GroupId,
645 member_pilot_id: &'a PilotId,
646 created_at: &'a str,
647}
648
649#[derive(Serialize)]
650struct PublicLeaveSignPayload<'a> {
651 schema: &'static str,
652 schema_version: u8,
653 record_id: &'a Blake3Hex,
654 group_id: &'a GroupId,
655 member_pilot_id: &'a PilotId,
656 created_at: &'a str,
657}
658
659impl PublicGroupLeaveRecord {
660 pub fn issue(
661 member_secret_key: &iroh::SecretKey,
662 group_id: GroupId,
663 ) -> Result<Self, GroupRecordError> {
664 let created_at = canonical_utc_now();
665 let member_pilot_id = PilotId::from_public_key(member_secret_key.public());
666
667 let id_payload = PublicLeavePayload {
668 schema: PUBLIC_GROUP_LEAVE_SCHEMA,
669 schema_version: PUBLIC_GROUP_LEAVE_VERSION,
670 group_id: &group_id,
671 member_pilot_id: &member_pilot_id,
672 created_at: &created_at,
673 };
674 let record_id = blake3_record_id(&id_payload)?;
675 let sign_payload = PublicLeaveSignPayload {
676 schema: PUBLIC_GROUP_LEAVE_SCHEMA,
677 schema_version: PUBLIC_GROUP_LEAVE_VERSION,
678 record_id: &record_id,
679 group_id: &group_id,
680 member_pilot_id: &member_pilot_id,
681 created_at: &created_at,
682 };
683 let signature = sign_payload_hex(member_secret_key, &sign_payload)?;
684
685 let record = Self {
686 schema: PUBLIC_GROUP_LEAVE_SCHEMA.to_string(),
687 schema_version: PUBLIC_GROUP_LEAVE_VERSION,
688 record_id,
689 group_id,
690 member_pilot_id,
691 created_at,
692 signature,
693 };
694 record.validate()?;
695 Ok(record)
696 }
697
698 pub fn validate(&self) -> Result<(), GroupRecordError> {
699 check_schema(&self.schema, PUBLIC_GROUP_LEAVE_SCHEMA)?;
700 check_schema_version(self.schema_version, PUBLIC_GROUP_LEAVE_VERSION)?;
701 check_created_at(&self.created_at)?;
702
703 let id_payload = PublicLeavePayload {
704 schema: PUBLIC_GROUP_LEAVE_SCHEMA,
705 schema_version: PUBLIC_GROUP_LEAVE_VERSION,
706 group_id: &self.group_id,
707 member_pilot_id: &self.member_pilot_id,
708 created_at: &self.created_at,
709 };
710 let expected = blake3_record_id(&id_payload)?;
711 if self.record_id != expected {
712 return Err(GroupRecordError::RecordIdMismatch { expected, found: self.record_id.clone() });
713 }
714 let sign_payload = PublicLeaveSignPayload {
715 schema: PUBLIC_GROUP_LEAVE_SCHEMA,
716 schema_version: PUBLIC_GROUP_LEAVE_VERSION,
717 record_id: &self.record_id,
718 group_id: &self.group_id,
719 member_pilot_id: &self.member_pilot_id,
720 created_at: &self.created_at,
721 };
722 verify_signature(&self.member_pilot_id, &self.signature, &sign_payload)?;
723 Ok(())
724 }
725}
726
727fn blake3_record_id<T: serde::Serialize>(payload: &T) -> Result<Blake3Hex, GroupRecordError> {
730 let bytes = json_canon::to_vec(payload)?;
731 Ok(Blake3Hex::from_hash(blake3::hash(&bytes)))
732}
733
734fn sign_payload_hex<T: serde::Serialize>(
735 secret_key: &iroh::SecretKey,
736 payload: &T,
737) -> Result<String, GroupRecordError> {
738 let bytes = json_canon::to_vec(payload)?;
739 Ok(hex::encode(secret_key.sign(&bytes).to_bytes()))
740}
741
742fn pilot_id_public_key(pilot_id: &PilotId) -> Result<iroh::PublicKey, GroupRecordError> {
743 let bytes = hex::decode(pilot_id.public_key_hex())
744 .ok()
745 .and_then(|b| <[u8; 32]>::try_from(b).ok())
746 .ok_or_else(|| GroupRecordError::PilotIdPublicKey(pilot_id.to_string()))?;
747 iroh::PublicKey::from_bytes(&bytes)
748 .map_err(|_| GroupRecordError::PilotIdPublicKey(pilot_id.to_string()))
749}
750
751fn decode_signature_hex(value: &str) -> Result<iroh::Signature, GroupRecordError> {
752 if value.len() != 128 || !value.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) {
753 return Err(GroupRecordError::SignatureEncoding);
754 }
755 let bytes = hex::decode(value).map_err(|_| GroupRecordError::SignatureEncoding)?;
756 let sig_bytes: [u8; 64] = bytes
757 .try_into()
758 .map_err(|_| GroupRecordError::SignatureEncoding)?;
759 Ok(iroh::Signature::from_bytes(&sig_bytes))
760}
761
762fn verify_signature<T: serde::Serialize>(
763 signer: &PilotId,
764 signature_hex: &str,
765 payload: &T,
766) -> Result<(), GroupRecordError> {
767 let pubkey = pilot_id_public_key(signer)?;
768 let signature = decode_signature_hex(signature_hex)?;
769 let bytes = json_canon::to_vec(payload)?;
770 pubkey
771 .verify(&bytes, &signature)
772 .map_err(|_| GroupRecordError::SignatureVerification)
773}
774
775fn check_schema(found: &str, expected: &'static str) -> Result<(), GroupRecordError> {
776 if found != expected {
777 return Err(GroupRecordError::Schema {
778 expected,
779 found: found.to_string(),
780 });
781 }
782 Ok(())
783}
784
785fn check_schema_version(found: u8, expected: u8) -> Result<(), GroupRecordError> {
786 if found != expected {
787 return Err(GroupRecordError::SchemaVersion { expected, found });
788 }
789 Ok(())
790}
791
792fn check_created_at(value: &str) -> Result<(), GroupRecordError> {
793 if !is_canonical_utc_timestamp(value) {
794 return Err(GroupRecordError::CreatedAt(value.to_string()));
795 }
796 Ok(())
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802
803 fn secret_key(byte: u8) -> iroh::SecretKey {
804 iroh::SecretKey::from_bytes(&[byte; 32])
805 }
806
807 #[test]
808 fn group_creation_record_roundtrip() {
809 let key = secret_key(1);
810 let record = GroupCreationRecord::issue(&key, GroupType::Private, None).unwrap();
811 assert!(record.group_id.as_str().starts_with("igcnet:group:"));
812 assert_eq!(record.group_id.id_hex().len(), 32);
813 record.validate().unwrap();
814 }
815
816 #[test]
817 fn group_creation_record_named_roundtrip() {
818 let key = secret_key(2);
819 let record =
820 GroupCreationRecord::issue(&key, GroupType::Private, Some("Test Club".to_string()))
821 .unwrap();
822 record.validate().unwrap();
823 }
824
825 #[test]
826 fn group_creation_record_validate_catches_group_id_mismatch() {
827 let key = secret_key(3);
828 let mut record = GroupCreationRecord::issue(&key, GroupType::Private, None).unwrap();
829 let other_record =
830 GroupCreationRecord::issue(&secret_key(4), GroupType::Private, None).unwrap();
831 record.group_id = other_record.group_id;
832 let err = record.validate().unwrap_err();
833 assert!(matches!(err, GroupRecordError::GroupIdMismatch { .. }));
834 }
835}