Skip to main content

igc_net/
group.rs

1//! Group record types and signed-record helpers.
2//!
3//! Implements the group and social schemas described in `specs/75-groups-and-social.md`.
4
5use serde::{Deserialize, Serialize};
6
7use crate::id::{Blake3Hex, GroupId, PilotId};
8use crate::util::{canonical_utc_now, is_canonical_utc_timestamp};
9
10// ── Schema constants ──────────────────────────────────────────────────────────
11
12const 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// ── GroupType ─────────────────────────────────────────────────────────────────
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum GroupType {
30    Private,
31    Public,
32}
33
34// ── Error ─────────────────────────────────────────────────────────────────────
35
36#[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// ── GroupCreationRecord ───────────────────────────────────────────────────────
67
68#[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// ── PrivateGroupMemberAddRecord ───────────────────────────────────────────────
201
202#[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// ── PrivateGroupMemberRemoveRecord ────────────────────────────────────────────
310
311#[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// ── PublicGroupInviteRecord ───────────────────────────────────────────────────
419
420#[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// ── PublicGroupAcceptRecord ───────────────────────────────────────────────────
528
529#[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// ── PublicGroupLeaveRecord ────────────────────────────────────────────────────
628
629#[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
727// ── Shared helpers ────────────────────────────────────────────────────────────
728
729fn 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}