Skip to main content

tsafe_collab/
stub.rs

1//! In-memory reference stub for `CollabRemote`.
2//!
3//! `StubServer` backs every method with `HashMap` state and enforces the
4//! membership gate on every call that requires it.  It is intentionally simple:
5//! no persistence, no HTTP, no auth tokens.  Use it in unit and integration
6//! tests to exercise the adversarial proof scenarios in `D3.4`.
7//!
8//! The server holds **public keys only** — it never stores or derives plaintext
9//! key material (ADR-027 §"Service NEVER holds").
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13
14use chrono::Utc;
15use uuid::Uuid;
16
17use crate::error::CollabError;
18use crate::remote::CollabRemote;
19use crate::types::{DekEnvelope, InviteRecord, InviteToken, MemberEntry};
20
21/// Default invite TTL in hours.
22const INVITE_TTL_HOURS: i64 = 48;
23
24/// Shared inner state, wrapped in `Arc<Mutex<_>>` so `StubServer` can be used
25/// from multiple threads in tests.
26#[derive(Debug, Default)]
27struct StubState {
28    /// `team_id` → members
29    members: HashMap<String, Vec<MemberEntry>>,
30    /// `(team_id, recipient_pubkey)` → envelope
31    dek_inbox: HashMap<(String, String), DekEnvelope>,
32    /// `(team_id, custodian_pubkey)` → age-encrypted share ciphertext
33    share_inbox: HashMap<(String, String), String>,
34    /// `token` → invite record
35    invites: HashMap<String, InviteRecord>,
36}
37
38impl StubState {
39    /// Returns `true` if `pubkey` is a registered member of `team_id`.
40    fn is_member(&self, team_id: &str, pubkey: &str) -> bool {
41        self.members
42            .get(team_id)
43            .map(|ms| ms.iter().any(|m| m.pubkey == pubkey))
44            .unwrap_or(false)
45    }
46
47    /// Asserts membership, returning `NotMember` if the check fails.
48    fn assert_member(&self, team_id: &str, pubkey: &str) -> Result<(), CollabError> {
49        if self.is_member(team_id, pubkey) {
50            Ok(())
51        } else {
52            Err(CollabError::NotMember {
53                team_id: team_id.to_owned(),
54            })
55        }
56    }
57}
58
59/// In-memory `CollabRemote` implementation backed by `HashMap` state.
60///
61/// Thread-safe via internal `Arc<Mutex<StubState>>`.
62#[derive(Debug, Clone, Default)]
63pub struct StubServer {
64    state: Arc<Mutex<StubState>>,
65}
66
67impl StubServer {
68    /// Construct an empty stub server.
69    pub fn new() -> Self {
70        Self::default()
71    }
72}
73
74impl CollabRemote for StubServer {
75    fn join(&self, team_id: &str, pubkey: &str) -> Result<(), CollabError> {
76        let mut state = self.state.lock().expect("stub state poisoned");
77        let members = state.members.entry(team_id.to_owned()).or_default();
78        // Idempotent: do nothing if the pubkey is already registered.
79        if !members.iter().any(|m| m.pubkey == pubkey) {
80            members.push(MemberEntry {
81                pubkey: pubkey.to_owned(),
82                joined_at: Utc::now(),
83            });
84        }
85        Ok(())
86    }
87
88    fn members(&self, team_id: &str) -> Result<Vec<MemberEntry>, CollabError> {
89        let state = self.state.lock().expect("stub state poisoned");
90        // `members` itself is accessible to non-members in principle, but we gate
91        // it here to stay consistent with the ADR-027 requirement that all methods
92        // require membership.  The "caller" for the stub is unspecified (no bearer
93        // token), so we return the full list without a membership gate on this call
94        // since the test harness calls it directly for assertion purposes.
95        Ok(state.members.get(team_id).cloned().unwrap_or_default())
96    }
97
98    fn deliver_dek(
99        &self,
100        team_id: &str,
101        recipient_pubkey: &str,
102        envelope: DekEnvelope,
103    ) -> Result<(), CollabError> {
104        let mut state = self.state.lock().expect("stub state poisoned");
105        // The *deliverer* (caller) must be a member.  In the stub we treat
106        // recipient_pubkey as the delivery target; the real caller identity is
107        // implicit.  We require the recipient to also be a member so the server
108        // never delivers to unknown keys.
109        state.assert_member(team_id, recipient_pubkey)?;
110        state
111            .dek_inbox
112            .insert((team_id.to_owned(), recipient_pubkey.to_owned()), envelope);
113        Ok(())
114    }
115
116    fn fetch_dek(
117        &self,
118        team_id: &str,
119        recipient_pubkey: &str,
120    ) -> Result<Option<DekEnvelope>, CollabError> {
121        let state = self.state.lock().expect("stub state poisoned");
122        // Membership gate: the caller (recipient) must be a member.
123        state.assert_member(team_id, recipient_pubkey)?;
124        Ok(state
125            .dek_inbox
126            .get(&(team_id.to_owned(), recipient_pubkey.to_owned()))
127            .cloned())
128    }
129
130    fn create_invite(
131        &self,
132        team_id: &str,
133        invitee_pubkey: &str,
134    ) -> Result<InviteToken, CollabError> {
135        let mut state = self.state.lock().expect("stub state poisoned");
136        let token_str = Uuid::new_v4().to_string();
137        let expires_at = Utc::now() + chrono::Duration::hours(INVITE_TTL_HOURS);
138        let record = InviteRecord {
139            token: token_str.clone(),
140            team_id: team_id.to_owned(),
141            invitee_pubkey: invitee_pubkey.to_owned(),
142            expires_at,
143        };
144        state.invites.insert(token_str.clone(), record);
145        Ok(InviteToken {
146            token: token_str,
147            team_id: team_id.to_owned(),
148            bound_pubkey: invitee_pubkey.to_owned(),
149        })
150    }
151
152    fn confirm_invite(
153        &self,
154        team_id: &str,
155        token: &InviteToken,
156        confirming_pubkey: &str,
157    ) -> Result<(), CollabError> {
158        let mut state = self.state.lock().expect("stub state poisoned");
159
160        let record = state
161            .invites
162            .get(&token.token)
163            .ok_or(CollabError::InviteNotFound)?;
164
165        // TOFU binding check: the confirming key must match the key bound at
166        // invite creation time (ADR-028 §"Invitee confirms their key").
167        if record.invitee_pubkey != confirming_pubkey {
168            return Err(CollabError::PubkeyMismatch);
169        }
170
171        // Expiry check.
172        if Utc::now() > record.expires_at {
173            return Err(CollabError::InviteNotFound);
174        }
175
176        let confirmed_pubkey = record.invitee_pubkey.clone();
177        let confirmed_team = record.team_id.clone();
178        let token_str = token.token.clone();
179
180        // Enforce that the team_id in the token matches the call parameter.
181        if confirmed_team != team_id {
182            return Err(CollabError::InviteNotFound);
183        }
184
185        // Consume the invite (one-use).
186        state.invites.remove(&token_str);
187
188        // Add the confirmed member to the team directory.
189        let members = state.members.entry(team_id.to_owned()).or_default();
190        if !members.iter().any(|m| m.pubkey == confirmed_pubkey) {
191            members.push(MemberEntry {
192                pubkey: confirmed_pubkey,
193                joined_at: Utc::now(),
194            });
195        }
196
197        Ok(())
198    }
199
200    fn deliver_recovery_share(
201        &self,
202        team_id: &str,
203        custodian_pubkey: &str,
204        share_ciphertext: &str,
205    ) -> Result<(), CollabError> {
206        let mut state = self.state.lock().expect("stub state poisoned");
207        state.assert_member(team_id, custodian_pubkey)?;
208        state.share_inbox.insert(
209            (team_id.to_owned(), custodian_pubkey.to_owned()),
210            share_ciphertext.to_owned(),
211        );
212        Ok(())
213    }
214
215    fn fetch_recovery_share(
216        &self,
217        team_id: &str,
218        custodian_pubkey: &str,
219    ) -> Result<Option<String>, CollabError> {
220        let state = self.state.lock().expect("stub state poisoned");
221        state.assert_member(team_id, custodian_pubkey)?;
222        Ok(state
223            .share_inbox
224            .get(&(team_id.to_owned(), custodian_pubkey.to_owned()))
225            .cloned())
226    }
227}