Skip to main content

sentinelpass_protocol/
message.rs

1//! IPC message types — the daemon request/response vocabulary.
2
3use serde::{Deserialize, Serialize};
4
5/// Secret field that a local tool may request.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum ExternalSecretField {
9    Username,
10    Password,
11    Title,
12}
13
14impl ExternalSecretField {
15    pub fn as_str(self) -> &'static str {
16        match self {
17            Self::Username => "username",
18            Self::Password => "password",
19            Self::Title => "title",
20        }
21    }
22}
23
24/// IPC message types
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub enum IpcMessage {
27    GetCredential {
28        domain: String,
29    },
30    GetExternalSecret {
31        client_id: String,
32        domain: String,
33        field: ExternalSecretField,
34        purpose: Option<String>,
35    },
36    GetExternalSecretResponse {
37        value: Option<String>,
38        authorized: bool,
39        error: Option<String>,
40        /// Some(true) = vault locked (distinct from not-found).
41        #[serde(default, skip_serializing_if = "Option::is_none")]
42        locked: Option<bool>,
43    },
44    GetCredentialResponse {
45        username: Option<String>,
46        password: Option<String>,
47        title: Option<String>,
48        #[serde(default, skip_serializing_if = "Option::is_none")]
49        locked: Option<bool>,
50    },
51    ListDomainCredentials {
52        base_domain: String,
53    },
54    ListDomainCredentialsResponse {
55        credentials: Vec<CredentialSummary>,
56        #[serde(default, skip_serializing_if = "Option::is_none")]
57        locked: Option<bool>,
58    },
59    GetTotpCode {
60        domain: String,
61    },
62    GetTotpCodeResponse {
63        code: Option<String>,
64        seconds_remaining: Option<u32>,
65        #[serde(default, skip_serializing_if = "Option::is_none")]
66        locked: Option<bool>,
67    },
68    SaveCredential {
69        domain: String,
70        username: String,
71        password: String,
72        url: Option<String>,
73    },
74    SaveCredentialResponse {
75        success: bool,
76        error: Option<String>,
77        #[serde(default, skip_serializing_if = "Option::is_none")]
78        locked: Option<bool>,
79    },
80    /// External tool writes (upserts) one secret value for one scope.
81    /// Requires a token-enforced grant with allow_write.
82    SaveSecret {
83        client_id: String,
84        domain: String,
85        value: String,
86        purpose: Option<String>,
87    },
88    SaveSecretResponse {
89        success: bool,
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        locked: Option<bool>,
92        error: Option<String>,
93    },
94    /// Defined for protocol completeness; the daemon currently rejects
95    /// deletion because third-party-created entries have no ownership
96    /// tracking yet (planned for schema v5).
97    DeleteSecret {
98        client_id: String,
99        domain: String,
100    },
101    DeleteSecretResponse {
102        deleted: bool,
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        locked: Option<bool>,
105        error: Option<String>,
106    },
107    UnlockVault {
108        master_password: String,
109    },
110    UnlockVaultBiometric {
111        prompt_reason: Option<String>,
112    },
113    UnlockVaultResponse {
114        success: bool,
115        error: Option<String>,
116    },
117    CheckVault,
118    VaultStatusResponse {
119        unlocked: bool,
120    },
121    LockVault,
122    Shutdown,
123
124    // --- Sync messages ---
125    /// Trigger a sync cycle now (push + pull).
126    SyncNow,
127    /// Response to SyncNow.
128    SyncNowResponse {
129        success: bool,
130        pushed: u64,
131        pulled: u64,
132        error: Option<String>,
133    },
134    /// Get sync status.
135    SyncStatus,
136    /// Sync status response.
137    SyncStatusResponse {
138        enabled: bool,
139        device_id: Option<String>,
140        device_name: Option<String>,
141        relay_url: Option<String>,
142        last_sync_at: Option<i64>,
143        pending_changes: u64,
144    },
145}
146
147/// Summary of a credential for listing (excludes password for bulk operations)
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct CredentialSummary {
150    pub username: String,
151    pub title: Option<String>,
152    pub domain: String,
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_ipc_envelope_round_trip_is_in_envelope_tests() {
161        // Envelope tests live in envelope.rs; this placeholder documents that.
162    }
163
164    #[test]
165    fn test_credential_summary_serialization() {
166        let summary = CredentialSummary {
167            username: "user@example.com".to_string(),
168            title: Some("Example Account".to_string()),
169            domain: "example.com".to_string(),
170        };
171
172        let serialized = serde_json::to_string(&summary).unwrap();
173        let deserialized: CredentialSummary = serde_json::from_str(&serialized).unwrap();
174
175        assert_eq!(deserialized.username, summary.username);
176        assert_eq!(deserialized.title, summary.title);
177        assert_eq!(deserialized.domain, summary.domain);
178    }
179
180    #[test]
181    fn test_credential_summary_without_title() {
182        let summary = CredentialSummary {
183            username: "user@example.com".to_string(),
184            title: None,
185            domain: "example.com".to_string(),
186        };
187
188        let serialized = serde_json::to_string(&summary).unwrap();
189        let deserialized: CredentialSummary = serde_json::from_str(&serialized).unwrap();
190
191        assert_eq!(deserialized.username, summary.username);
192        assert_eq!(deserialized.title, None);
193        assert_eq!(deserialized.domain, summary.domain);
194    }
195
196    #[test]
197    fn test_message_types_serialize_correctly() {
198        let messages = vec![
199            IpcMessage::GetCredential {
200                domain: "example.com".to_string(),
201            },
202            IpcMessage::GetExternalSecret {
203                client_id: "victor".to_string(),
204                domain: "anthropic".to_string(),
205                field: ExternalSecretField::Password,
206                purpose: Some("victor-auth".to_string()),
207            },
208            IpcMessage::CheckVault,
209            IpcMessage::LockVault,
210            IpcMessage::Shutdown,
211            IpcMessage::ListDomainCredentials {
212                base_domain: "example.com".to_string(),
213            },
214        ];
215
216        for msg in messages {
217            let serialized = serde_json::to_string(&msg).unwrap();
218            let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
219
220            // Verify round-trip
221            match (&msg, &deserialized) {
222                (
223                    IpcMessage::GetCredential { domain: d1 },
224                    IpcMessage::GetCredential { domain: d2 },
225                ) => {
226                    assert_eq!(d1, d2);
227                }
228                (
229                    IpcMessage::GetExternalSecret {
230                        client_id: c1,
231                        domain: d1,
232                        field: f1,
233                        purpose: p1,
234                    },
235                    IpcMessage::GetExternalSecret {
236                        client_id: c2,
237                        domain: d2,
238                        field: f2,
239                        purpose: p2,
240                    },
241                ) => {
242                    assert_eq!(c1, c2);
243                    assert_eq!(d1, d2);
244                    assert_eq!(f1, f2);
245                    assert_eq!(p1, p2);
246                }
247                (
248                    IpcMessage::ListDomainCredentials { base_domain: b1 },
249                    IpcMessage::ListDomainCredentials { base_domain: b2 },
250                ) => {
251                    assert_eq!(b1, b2);
252                }
253                (IpcMessage::CheckVault, IpcMessage::CheckVault) => {}
254                (IpcMessage::LockVault, IpcMessage::LockVault) => {}
255                (IpcMessage::Shutdown, IpcMessage::Shutdown) => {}
256                _ => panic!("Message type mismatch during round-trip"),
257            }
258        }
259    }
260
261    #[test]
262    fn test_get_credential_response_serialization() {
263        let response = IpcMessage::GetCredentialResponse {
264            username: Some("user@example.com".to_string()),
265            password: Some("password123".to_string()),
266            title: Some("Example".to_string()),
267            locked: None,
268        };
269
270        let serialized = serde_json::to_string(&response).unwrap();
271        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
272
273        match deserialized {
274            IpcMessage::GetCredentialResponse {
275                username,
276                password,
277                title,
278                locked: None,
279            } => {
280                assert_eq!(username, Some("user@example.com".to_string()));
281                assert_eq!(password, Some("password123".to_string()));
282                assert_eq!(title, Some("Example".to_string()));
283            }
284            _ => panic!("Wrong response type"),
285        }
286    }
287
288    #[test]
289    fn test_get_external_secret_response_serialization() {
290        let response = IpcMessage::GetExternalSecretResponse {
291            value: Some("secret-value".to_string()),
292            authorized: true,
293            error: None,
294            locked: None,
295        };
296
297        let serialized = serde_json::to_string(&response).unwrap();
298        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
299
300        match deserialized {
301            IpcMessage::GetExternalSecretResponse {
302                value,
303                authorized,
304                error,
305                locked: None,
306            } => {
307                assert_eq!(value, Some("secret-value".to_string()));
308                assert!(authorized);
309                assert_eq!(error, None);
310            }
311            _ => panic!("Wrong response type"),
312        }
313    }
314
315    #[test]
316    fn test_list_domain_credentials_response_serialization() {
317        let credentials = vec![
318            CredentialSummary {
319                username: "user1@example.com".to_string(),
320                title: Some("Account 1".to_string()),
321                domain: "example.com".to_string(),
322            },
323            CredentialSummary {
324                username: "user2@example.com".to_string(),
325                title: Some("Account 2".to_string()),
326                domain: "example.com".to_string(),
327            },
328        ];
329
330        let response = IpcMessage::ListDomainCredentialsResponse {
331            credentials: credentials.clone(),
332            locked: None,
333        };
334
335        let serialized = serde_json::to_string(&response).unwrap();
336        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
337
338        match deserialized {
339            IpcMessage::ListDomainCredentialsResponse {
340                credentials: decoded,
341                locked: None,
342            } => {
343                assert_eq!(decoded.len(), 2);
344                assert_eq!(decoded[0].username, "user1@example.com");
345                assert_eq!(decoded[1].username, "user2@example.com");
346            }
347            _ => panic!("Wrong response type"),
348        }
349    }
350
351    #[test]
352    fn test_save_credential_response_serialization() {
353        let response = IpcMessage::SaveCredentialResponse {
354            success: true,
355            error: None,
356            locked: None,
357        };
358
359        let serialized = serde_json::to_string(&response).unwrap();
360        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
361
362        match deserialized {
363            IpcMessage::SaveCredentialResponse {
364                success,
365                error,
366                locked: None,
367            } => {
368                assert!(success);
369                assert!(error.is_none());
370            }
371            _ => panic!("Wrong response type"),
372        }
373    }
374
375    #[test]
376    fn test_save_credential_error_response_serialization() {
377        let response = IpcMessage::SaveCredentialResponse {
378            success: false,
379            error: Some("Vault is locked".to_string()),
380            locked: None,
381        };
382
383        let serialized = serde_json::to_string(&response).unwrap();
384        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
385
386        match deserialized {
387            IpcMessage::SaveCredentialResponse {
388                success,
389                error,
390                locked,
391            } => {
392                assert!(!success);
393                assert_eq!(error, Some("Vault is locked".to_string()));
394                assert_eq!(locked, None);
395            }
396            _ => panic!("Wrong response type"),
397        }
398    }
399
400    #[test]
401    fn test_unlock_vault_response_serialization() {
402        let response = IpcMessage::UnlockVaultResponse {
403            success: true,
404            error: None,
405        };
406
407        let serialized = serde_json::to_string(&response).unwrap();
408        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
409
410        match deserialized {
411            IpcMessage::UnlockVaultResponse { success, error } => {
412                assert!(success);
413                assert!(error.is_none());
414            }
415            _ => panic!("Wrong response type"),
416        }
417    }
418
419    #[test]
420    fn test_vault_status_response_serialization() {
421        let response = IpcMessage::VaultStatusResponse { unlocked: true };
422
423        let serialized = serde_json::to_string(&response).unwrap();
424        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
425
426        match deserialized {
427            IpcMessage::VaultStatusResponse { unlocked } => {
428                assert!(unlocked);
429            }
430            _ => panic!("Wrong response type"),
431        }
432    }
433
434    #[test]
435    fn test_totp_response_serialization() {
436        let response = IpcMessage::GetTotpCodeResponse {
437            code: Some("123456".to_string()),
438            seconds_remaining: Some(30),
439            locked: None,
440        };
441
442        let serialized = serde_json::to_string(&response).unwrap();
443        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
444
445        match deserialized {
446            IpcMessage::GetTotpCodeResponse {
447                code,
448                seconds_remaining,
449                locked: None,
450            } => {
451                assert_eq!(code, Some("123456".to_string()));
452                assert_eq!(seconds_remaining, Some(30));
453            }
454            _ => panic!("Wrong response type"),
455        }
456    }
457
458    #[test]
459    fn test_save_credential_message_serialization() {
460        let msg = IpcMessage::SaveCredential {
461            domain: "example.com".to_string(),
462            username: "user@example.com".to_string(),
463            password: "secure_password".to_string(),
464            url: Some("https://example.com".to_string()),
465        };
466
467        let serialized = serde_json::to_string(&msg).unwrap();
468        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
469
470        match deserialized {
471            IpcMessage::SaveCredential {
472                domain,
473                username,
474                password,
475                url,
476            } => {
477                assert_eq!(domain, "example.com");
478                assert_eq!(username, "user@example.com");
479                assert_eq!(password, "secure_password");
480                assert_eq!(url, Some("https://example.com".to_string()));
481            }
482            _ => panic!("Wrong message type"),
483        }
484    }
485
486    #[test]
487    fn test_unlock_vault_message_serialization() {
488        let msg = IpcMessage::UnlockVault {
489            master_password: "test_password".to_string(),
490        };
491
492        let serialized = serde_json::to_string(&msg).unwrap();
493        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
494
495        match deserialized {
496            IpcMessage::UnlockVault { master_password } => {
497                assert_eq!(master_password, "test_password");
498            }
499            _ => panic!("Wrong message type"),
500        }
501    }
502
503    #[test]
504    fn test_unlock_vault_biometric_message_serialization() {
505        let msg = IpcMessage::UnlockVaultBiometric {
506            prompt_reason: Some("Authenticate to unlock".to_string()),
507        };
508
509        let serialized = serde_json::to_string(&msg).unwrap();
510        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
511
512        match deserialized {
513            IpcMessage::UnlockVaultBiometric { prompt_reason } => {
514                assert_eq!(prompt_reason, Some("Authenticate to unlock".to_string()));
515            }
516            _ => panic!("Wrong response type"),
517        }
518    }
519
520    #[test]
521    fn test_empty_credential_list_serialization() {
522        let response = IpcMessage::ListDomainCredentialsResponse {
523            credentials: vec![],
524            locked: None,
525        };
526
527        let serialized = serde_json::to_string(&response).unwrap();
528        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
529
530        match deserialized {
531            IpcMessage::ListDomainCredentialsResponse {
532                credentials,
533                locked: None,
534            } => {
535                assert!(credentials.is_empty());
536            }
537            _ => panic!("Wrong response type"),
538        }
539    }
540
541    #[test]
542    fn test_get_totp_code_message_serialization() {
543        let msg = IpcMessage::GetTotpCode {
544            domain: "example.com".to_string(),
545        };
546
547        let serialized = serde_json::to_string(&msg).unwrap();
548        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
549
550        match deserialized {
551            IpcMessage::GetTotpCode { domain } => {
552                assert_eq!(domain, "example.com");
553            }
554            _ => panic!("Wrong message type"),
555        }
556    }
557
558    #[test]
559    fn test_sync_status_message_serialization() {
560        let msg = IpcMessage::SyncStatus;
561
562        let serialized = serde_json::to_string(&msg).unwrap();
563        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
564
565        match deserialized {
566            IpcMessage::SyncStatus => {}
567            _ => panic!("Wrong message type"),
568        }
569    }
570
571    #[test]
572    fn test_sync_status_response_serialization() {
573        let response = IpcMessage::SyncStatusResponse {
574            enabled: true,
575            device_id: Some("device-123".to_string()),
576            device_name: Some("Test Device".to_string()),
577            relay_url: Some("https://relay.example.com".to_string()),
578            last_sync_at: Some(1700000000),
579            pending_changes: 5,
580        };
581
582        let serialized = serde_json::to_string(&response).unwrap();
583        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
584
585        match deserialized {
586            IpcMessage::SyncStatusResponse {
587                enabled,
588                device_id,
589                device_name,
590                relay_url,
591                last_sync_at,
592                pending_changes,
593            } => {
594                assert!(enabled);
595                assert_eq!(device_id, Some("device-123".to_string()));
596                assert_eq!(device_name, Some("Test Device".to_string()));
597                assert_eq!(relay_url, Some("https://relay.example.com".to_string()));
598                assert_eq!(last_sync_at, Some(1700000000));
599                assert_eq!(pending_changes, 5);
600            }
601            _ => panic!("Wrong response type"),
602        }
603    }
604}