Skip to main content

void_core/collab/
invite.rs

1//! Invite format for contributor onboarding.
2//!
3//! An invite is a lightweight JSON blob that solves the bootstrap problem:
4//! the manifest is encrypted inside commits (via content_key derived from
5//! root key), so you need the root key to read the manifest, but you need
6//! the manifest to get the root key. The invite provides the ECIES-wrapped
7//! key **outside** the commit chain as a one-time bootstrap token.
8//!
9//! Once cloned via invite, subsequent access uses the manifest embedded
10//! in commits.
11
12use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
13use serde::{Deserialize, Serialize};
14
15use super::manifest::{SigningPubKey, WrappedKey};
16
17/// Magic type identifier for invite blob detection.
18pub const INVITE_TYPE_V1: &str = "void-invite/v1";
19
20/// A lightweight invite blob for contributor onboarding.
21///
22/// Stored as JSON, pinned to IPFS as a CID. Contains an ECIES-wrapped
23/// repo key for a specific recipient, signed by the repo owner.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct Invite {
27    /// Magic identifier for detection: "void-invite/v1"
28    #[serde(rename = "type")]
29    pub invite_type: String,
30
31    /// Human-readable repo name.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub repo_name: Option<String>,
34
35    /// Stable repo identifier (UUID).
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub repo_id: Option<String>,
38
39    /// Commit CID to clone from (string-encoded CID).
40    pub head_cid: String,
41
42    /// ECIES-wrapped repo key for the recipient.
43    pub wrapped_key: WrappedKey,
44
45    /// Recipient's signing pubkey (who this invite is for).
46    pub for_recipient: SigningPubKey,
47
48    /// Owner's signing pubkey (who created the invite).
49    pub from_owner: SigningPubKey,
50
51    /// Unix timestamp (seconds since epoch).
52    pub created_at: u64,
53
54    /// Ed25519 signature from owner over signable fields.
55    #[serde(with = "signature_base64")]
56    pub signature: Vec<u8>,
57}
58
59impl Invite {
60    /// Build the canonical byte sequence for signing/verification.
61    ///
62    /// Format: `type || head_cid || wrapped_key || for_recipient || from_owner || created_at`
63    /// Each field is length-prefixed (4-byte little-endian length + bytes).
64    pub fn signable_bytes(&self) -> Vec<u8> {
65        let mut buf = Vec::with_capacity(256);
66
67        // type
68        append_field(&mut buf, self.invite_type.as_bytes());
69        // head_cid
70        append_field(&mut buf, self.head_cid.as_bytes());
71        // wrapped_key
72        append_field(&mut buf, self.wrapped_key.as_bytes());
73        // for_recipient
74        append_field(&mut buf, self.for_recipient.as_bytes());
75        // from_owner
76        append_field(&mut buf, self.from_owner.as_bytes());
77        // created_at
78        buf.extend_from_slice(&self.created_at.to_le_bytes());
79
80        // Optional fields (presence-tagged)
81        match &self.repo_name {
82            Some(name) => {
83                buf.push(1);
84                append_field(&mut buf, name.as_bytes());
85            }
86            None => buf.push(0),
87        }
88        match &self.repo_id {
89            Some(id) => {
90                buf.push(1);
91                append_field(&mut buf, id.as_bytes());
92            }
93            None => buf.push(0),
94        }
95
96        buf
97    }
98
99    /// Sign this invite with the owner's Ed25519 signing key.
100    pub fn sign(&mut self, signing_key: &SigningKey) {
101        let signable = self.signable_bytes();
102        let sig: Signature = signing_key.sign(&signable);
103        self.signature = sig.to_bytes().to_vec();
104    }
105
106    /// Verify the Ed25519 signature against `from_owner`.
107    ///
108    /// Returns `true` if the signature is valid, `false` otherwise.
109    pub fn verify(&self) -> bool {
110        if self.signature.len() != 64 {
111            return false;
112        }
113
114        let Ok(verifying_key) = VerifyingKey::from_bytes(self.from_owner.as_bytes()) else {
115            return false;
116        };
117
118        let mut sig_bytes = [0u8; 64];
119        sig_bytes.copy_from_slice(&self.signature);
120        let signature = Signature::from_bytes(&sig_bytes);
121
122        let signable = self.signable_bytes();
123        verifying_key.verify(&signable, &signature).is_ok()
124    }
125}
126
127/// Try to detect whether a byte slice is a void invite blob.
128///
129/// Attempts JSON parse and checks for the `"void-invite/v1"` type field.
130/// This is intentionally lenient — it only checks the type field, not
131/// full deserialization, to be usable as a fast detection heuristic.
132pub fn is_invite_blob(data: &[u8]) -> bool {
133    // Quick rejection: must start with '{' (JSON object)
134    let trimmed = match data.iter().position(|&b| !b.is_ascii_whitespace()) {
135        Some(pos) => &data[pos..],
136        None => return false,
137    };
138    if !trimmed.starts_with(b"{") {
139        return false;
140    }
141
142    // Try to parse just enough to check the type field
143    let Ok(value) = serde_json::from_slice::<serde_json::Value>(data) else {
144        return false;
145    };
146
147    value
148        .get("type")
149        .and_then(|v| v.as_str())
150        .map_or(false, |t| t == INVITE_TYPE_V1)
151}
152
153/// Parse an invite from JSON bytes, returning `None` if not an invite.
154pub fn parse_invite(data: &[u8]) -> Option<Invite> {
155    if !is_invite_blob(data) {
156        return None;
157    }
158    serde_json::from_slice(data).ok()
159}
160
161/// Append a length-prefixed field to a buffer.
162fn append_field(buf: &mut Vec<u8>, data: &[u8]) {
163    buf.extend_from_slice(&(data.len() as u32).to_le_bytes());
164    buf.extend_from_slice(data);
165}
166
167// ============================================================================
168// Serde helper for signature as base64
169// ============================================================================
170
171mod signature_base64 {
172    use base64::{engine::general_purpose::STANDARD, Engine};
173    use serde::{Deserialize, Deserializer, Serializer};
174
175    pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
176    where
177        S: Serializer,
178    {
179        serializer.serialize_str(&STANDARD.encode(bytes))
180    }
181
182    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
183    where
184        D: Deserializer<'de>,
185    {
186        let s = String::deserialize(deserializer)?;
187        STANDARD.decode(s).map_err(serde::de::Error::custom)
188    }
189}
190
191// ============================================================================
192// Tests
193// ============================================================================
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    fn make_test_invite() -> (Invite, SigningKey) {
200        let signing_key = SigningKey::generate(&mut rand::thread_rng());
201        let owner_pub = SigningPubKey::from_bytes(signing_key.verifying_key().to_bytes());
202        let recipient_pub = SigningPubKey::from_bytes([0xbb; 32]);
203
204        let invite = Invite {
205            invite_type: INVITE_TYPE_V1.to_string(),
206            repo_name: Some("test-repo".to_string()),
207            repo_id: Some("uuid-1234".to_string()),
208            head_cid: "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi".to_string(),
209            wrapped_key: WrappedKey::from_bytes(vec![0xaa; 92]),
210            for_recipient: recipient_pub,
211            from_owner: owner_pub,
212            created_at: 1700000000,
213            signature: vec![],
214        };
215
216        (invite, signing_key)
217    }
218
219    #[test]
220    fn sign_and_verify_roundtrip() {
221        let (mut invite, signing_key) = make_test_invite();
222
223        invite.sign(&signing_key);
224        assert_eq!(invite.signature.len(), 64);
225        assert!(invite.verify());
226    }
227
228    #[test]
229    fn verify_fails_with_wrong_key() {
230        let (mut invite, signing_key) = make_test_invite();
231        invite.sign(&signing_key);
232
233        // Tamper with from_owner (point to different key)
234        invite.from_owner = SigningPubKey::from_bytes([0xcc; 32]);
235        assert!(!invite.verify());
236    }
237
238    #[test]
239    fn verify_fails_with_tampered_data() {
240        let (mut invite, signing_key) = make_test_invite();
241        invite.sign(&signing_key);
242
243        // Tamper with head_cid
244        invite.head_cid = "bafytampered".to_string();
245        assert!(!invite.verify());
246    }
247
248    #[test]
249    fn verify_fails_with_empty_signature() {
250        let (invite, _) = make_test_invite();
251        assert!(!invite.verify());
252    }
253
254    #[test]
255    fn json_roundtrip() {
256        let (mut invite, signing_key) = make_test_invite();
257        invite.sign(&signing_key);
258
259        let json = serde_json::to_string_pretty(&invite).unwrap();
260        let parsed: Invite = serde_json::from_str(&json).unwrap();
261
262        assert_eq!(parsed.invite_type, INVITE_TYPE_V1);
263        assert_eq!(parsed.head_cid, invite.head_cid);
264        assert_eq!(parsed.repo_name, invite.repo_name);
265        assert_eq!(parsed.for_recipient.as_bytes(), invite.for_recipient.as_bytes());
266        assert_eq!(parsed.from_owner.as_bytes(), invite.from_owner.as_bytes());
267        assert!(parsed.verify());
268    }
269
270    #[test]
271    fn is_invite_blob_detects_valid() {
272        let (mut invite, signing_key) = make_test_invite();
273        invite.sign(&signing_key);
274
275        let json = serde_json::to_vec(&invite).unwrap();
276        assert!(is_invite_blob(&json));
277    }
278
279    #[test]
280    fn is_invite_blob_rejects_non_json() {
281        assert!(!is_invite_blob(b"not json"));
282        assert!(!is_invite_blob(b""));
283        assert!(!is_invite_blob(&[0x00, 0x01, 0x02]));
284    }
285
286    #[test]
287    fn is_invite_blob_rejects_wrong_type() {
288        let json = br#"{"type": "something-else"}"#;
289        assert!(!is_invite_blob(json));
290    }
291
292    #[test]
293    fn is_invite_blob_rejects_missing_type() {
294        let json = br#"{"headCid": "bafyabc"}"#;
295        assert!(!is_invite_blob(json));
296    }
297
298    #[test]
299    fn parse_invite_roundtrip() {
300        let (mut invite, signing_key) = make_test_invite();
301        invite.sign(&signing_key);
302
303        let json = serde_json::to_vec(&invite).unwrap();
304        let parsed = parse_invite(&json).unwrap();
305        assert!(parsed.verify());
306        assert_eq!(parsed.head_cid, invite.head_cid);
307    }
308
309    #[test]
310    fn signable_bytes_deterministic() {
311        let (invite, _) = make_test_invite();
312        let a = invite.signable_bytes();
313        let b = invite.signable_bytes();
314        assert_eq!(a, b);
315    }
316
317    #[test]
318    fn signable_bytes_differ_with_different_data() {
319        let (mut invite_a, _) = make_test_invite();
320        let invite_b = invite_a.clone();
321
322        invite_a.head_cid = "bafydifferent".to_string();
323        assert_ne!(invite_a.signable_bytes(), invite_b.signable_bytes());
324    }
325}