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 (schema v5 shipped registry ownership groundwork; the
97    /// delete decision itself stays deliberately rejected per ADR-001 D1).
98    DeleteSecret {
99        client_id: String,
100        domain: String,
101    },
102    DeleteSecretResponse {
103        deleted: bool,
104        #[serde(default, skip_serializing_if = "Option::is_none")]
105        locked: Option<bool>,
106        error: Option<String>,
107    },
108    UnlockVault {
109        master_password: String,
110    },
111    UnlockVaultBiometric {
112        prompt_reason: Option<String>,
113    },
114    UnlockVaultResponse {
115        success: bool,
116        error: Option<String>,
117    },
118    CheckVault,
119    VaultStatusResponse {
120        unlocked: bool,
121        /// Master-password rotation generation of the vault (ADR-002).
122        /// serde default keeps pre-epoch clients deserializing; 0 means
123        /// the responder could not read metadata.
124        #[serde(default)]
125        key_epoch: i64,
126    },
127    LockVault,
128    Shutdown,
129
130    // --- Sync messages ---
131    /// Trigger a sync cycle now (push + pull).
132    SyncNow,
133    /// Response to SyncNow.
134    SyncNowResponse {
135        success: bool,
136        pushed: u64,
137        pulled: u64,
138        error: Option<String>,
139    },
140    /// Get sync status.
141    SyncStatus,
142    /// Sync status response.
143    SyncStatusResponse {
144        enabled: bool,
145        device_id: Option<String>,
146        device_name: Option<String>,
147        relay_url: Option<String>,
148        last_sync_at: Option<i64>,
149        pending_changes: u64,
150    },
151}
152
153/// Summary of a credential for listing (excludes password for bulk operations)
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct CredentialSummary {
156    pub username: String,
157    pub title: Option<String>,
158    pub domain: String,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_ipc_envelope_round_trip_is_in_envelope_tests() {
167        // Envelope tests live in envelope.rs; this placeholder documents that.
168    }
169
170    #[test]
171    fn test_credential_summary_serialization() {
172        let summary = CredentialSummary {
173            username: "user@example.com".to_string(),
174            title: Some("Example Account".to_string()),
175            domain: "example.com".to_string(),
176        };
177
178        let serialized = serde_json::to_string(&summary).unwrap();
179        let deserialized: CredentialSummary = serde_json::from_str(&serialized).unwrap();
180
181        assert_eq!(deserialized.username, summary.username);
182        assert_eq!(deserialized.title, summary.title);
183        assert_eq!(deserialized.domain, summary.domain);
184    }
185
186    #[test]
187    fn test_credential_summary_without_title() {
188        let summary = CredentialSummary {
189            username: "user@example.com".to_string(),
190            title: None,
191            domain: "example.com".to_string(),
192        };
193
194        let serialized = serde_json::to_string(&summary).unwrap();
195        let deserialized: CredentialSummary = serde_json::from_str(&serialized).unwrap();
196
197        assert_eq!(deserialized.username, summary.username);
198        assert_eq!(deserialized.title, None);
199        assert_eq!(deserialized.domain, summary.domain);
200    }
201
202    #[test]
203    fn test_message_types_serialize_correctly() {
204        let messages = vec![
205            IpcMessage::GetCredential {
206                domain: "example.com".to_string(),
207            },
208            IpcMessage::GetExternalSecret {
209                client_id: "victor".to_string(),
210                domain: "anthropic".to_string(),
211                field: ExternalSecretField::Password,
212                purpose: Some("victor-auth".to_string()),
213            },
214            IpcMessage::CheckVault,
215            IpcMessage::LockVault,
216            IpcMessage::Shutdown,
217            IpcMessage::ListDomainCredentials {
218                base_domain: "example.com".to_string(),
219            },
220        ];
221
222        for msg in messages {
223            let serialized = serde_json::to_string(&msg).unwrap();
224            let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
225
226            // Verify round-trip
227            match (&msg, &deserialized) {
228                (
229                    IpcMessage::GetCredential { domain: d1 },
230                    IpcMessage::GetCredential { domain: d2 },
231                ) => {
232                    assert_eq!(d1, d2);
233                }
234                (
235                    IpcMessage::GetExternalSecret {
236                        client_id: c1,
237                        domain: d1,
238                        field: f1,
239                        purpose: p1,
240                    },
241                    IpcMessage::GetExternalSecret {
242                        client_id: c2,
243                        domain: d2,
244                        field: f2,
245                        purpose: p2,
246                    },
247                ) => {
248                    assert_eq!(c1, c2);
249                    assert_eq!(d1, d2);
250                    assert_eq!(f1, f2);
251                    assert_eq!(p1, p2);
252                }
253                (
254                    IpcMessage::ListDomainCredentials { base_domain: b1 },
255                    IpcMessage::ListDomainCredentials { base_domain: b2 },
256                ) => {
257                    assert_eq!(b1, b2);
258                }
259                (IpcMessage::CheckVault, IpcMessage::CheckVault) => {}
260                (IpcMessage::LockVault, IpcMessage::LockVault) => {}
261                (IpcMessage::Shutdown, IpcMessage::Shutdown) => {}
262                _ => panic!("Message type mismatch during round-trip"),
263            }
264        }
265    }
266
267    #[test]
268    fn test_get_credential_response_serialization() {
269        let response = IpcMessage::GetCredentialResponse {
270            username: Some("user@example.com".to_string()),
271            password: Some("password123".to_string()),
272            title: Some("Example".to_string()),
273            locked: None,
274        };
275
276        let serialized = serde_json::to_string(&response).unwrap();
277        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
278
279        match deserialized {
280            IpcMessage::GetCredentialResponse {
281                username,
282                password,
283                title,
284                locked: None,
285            } => {
286                assert_eq!(username, Some("user@example.com".to_string()));
287                assert_eq!(password, Some("password123".to_string()));
288                assert_eq!(title, Some("Example".to_string()));
289            }
290            _ => panic!("Wrong response type"),
291        }
292    }
293
294    #[test]
295    fn test_get_external_secret_response_serialization() {
296        let response = IpcMessage::GetExternalSecretResponse {
297            value: Some("secret-value".to_string()),
298            authorized: true,
299            error: None,
300            locked: None,
301        };
302
303        let serialized = serde_json::to_string(&response).unwrap();
304        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
305
306        match deserialized {
307            IpcMessage::GetExternalSecretResponse {
308                value,
309                authorized,
310                error,
311                locked: None,
312            } => {
313                assert_eq!(value, Some("secret-value".to_string()));
314                assert!(authorized);
315                assert_eq!(error, None);
316            }
317            _ => panic!("Wrong response type"),
318        }
319    }
320
321    #[test]
322    fn test_list_domain_credentials_response_serialization() {
323        let credentials = vec![
324            CredentialSummary {
325                username: "user1@example.com".to_string(),
326                title: Some("Account 1".to_string()),
327                domain: "example.com".to_string(),
328            },
329            CredentialSummary {
330                username: "user2@example.com".to_string(),
331                title: Some("Account 2".to_string()),
332                domain: "example.com".to_string(),
333            },
334        ];
335
336        let response = IpcMessage::ListDomainCredentialsResponse {
337            credentials: credentials.clone(),
338            locked: None,
339        };
340
341        let serialized = serde_json::to_string(&response).unwrap();
342        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
343
344        match deserialized {
345            IpcMessage::ListDomainCredentialsResponse {
346                credentials: decoded,
347                locked: None,
348            } => {
349                assert_eq!(decoded.len(), 2);
350                assert_eq!(decoded[0].username, "user1@example.com");
351                assert_eq!(decoded[1].username, "user2@example.com");
352            }
353            _ => panic!("Wrong response type"),
354        }
355    }
356
357    #[test]
358    fn test_save_credential_response_serialization() {
359        let response = IpcMessage::SaveCredentialResponse {
360            success: true,
361            error: None,
362            locked: None,
363        };
364
365        let serialized = serde_json::to_string(&response).unwrap();
366        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
367
368        match deserialized {
369            IpcMessage::SaveCredentialResponse {
370                success,
371                error,
372                locked: None,
373            } => {
374                assert!(success);
375                assert!(error.is_none());
376            }
377            _ => panic!("Wrong response type"),
378        }
379    }
380
381    #[test]
382    fn test_save_credential_error_response_serialization() {
383        let response = IpcMessage::SaveCredentialResponse {
384            success: false,
385            error: Some("Vault is locked".to_string()),
386            locked: None,
387        };
388
389        let serialized = serde_json::to_string(&response).unwrap();
390        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
391
392        match deserialized {
393            IpcMessage::SaveCredentialResponse {
394                success,
395                error,
396                locked,
397            } => {
398                assert!(!success);
399                assert_eq!(error, Some("Vault is locked".to_string()));
400                assert_eq!(locked, None);
401            }
402            _ => panic!("Wrong response type"),
403        }
404    }
405
406    #[test]
407    fn test_unlock_vault_response_serialization() {
408        let response = IpcMessage::UnlockVaultResponse {
409            success: true,
410            error: None,
411        };
412
413        let serialized = serde_json::to_string(&response).unwrap();
414        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
415
416        match deserialized {
417            IpcMessage::UnlockVaultResponse { success, error } => {
418                assert!(success);
419                assert!(error.is_none());
420            }
421            _ => panic!("Wrong response type"),
422        }
423    }
424
425    #[test]
426    fn test_vault_status_response_serialization() {
427        let response = IpcMessage::VaultStatusResponse {
428            unlocked: true,
429            key_epoch: 1,
430        };
431
432        let serialized = serde_json::to_string(&response).unwrap();
433        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
434
435        match deserialized {
436            IpcMessage::VaultStatusResponse {
437                unlocked,
438                key_epoch,
439            } => {
440                assert!(unlocked);
441                assert_eq!(key_epoch, 1);
442            }
443            _ => panic!("Wrong response type"),
444        }
445    }
446
447    #[test]
448    fn test_totp_response_serialization() {
449        let response = IpcMessage::GetTotpCodeResponse {
450            code: Some("123456".to_string()),
451            seconds_remaining: Some(30),
452            locked: None,
453        };
454
455        let serialized = serde_json::to_string(&response).unwrap();
456        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
457
458        match deserialized {
459            IpcMessage::GetTotpCodeResponse {
460                code,
461                seconds_remaining,
462                locked: None,
463            } => {
464                assert_eq!(code, Some("123456".to_string()));
465                assert_eq!(seconds_remaining, Some(30));
466            }
467            _ => panic!("Wrong response type"),
468        }
469    }
470
471    #[test]
472    fn test_save_credential_message_serialization() {
473        let msg = IpcMessage::SaveCredential {
474            domain: "example.com".to_string(),
475            username: "user@example.com".to_string(),
476            password: "secure_password".to_string(),
477            url: Some("https://example.com".to_string()),
478        };
479
480        let serialized = serde_json::to_string(&msg).unwrap();
481        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
482
483        match deserialized {
484            IpcMessage::SaveCredential {
485                domain,
486                username,
487                password,
488                url,
489            } => {
490                assert_eq!(domain, "example.com");
491                assert_eq!(username, "user@example.com");
492                assert_eq!(password, "secure_password");
493                assert_eq!(url, Some("https://example.com".to_string()));
494            }
495            _ => panic!("Wrong message type"),
496        }
497    }
498
499    #[test]
500    fn test_unlock_vault_message_serialization() {
501        let msg = IpcMessage::UnlockVault {
502            master_password: "test_password".to_string(),
503        };
504
505        let serialized = serde_json::to_string(&msg).unwrap();
506        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
507
508        match deserialized {
509            IpcMessage::UnlockVault { master_password } => {
510                assert_eq!(master_password, "test_password");
511            }
512            _ => panic!("Wrong message type"),
513        }
514    }
515
516    #[test]
517    fn test_unlock_vault_biometric_message_serialization() {
518        let msg = IpcMessage::UnlockVaultBiometric {
519            prompt_reason: Some("Authenticate to unlock".to_string()),
520        };
521
522        let serialized = serde_json::to_string(&msg).unwrap();
523        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
524
525        match deserialized {
526            IpcMessage::UnlockVaultBiometric { prompt_reason } => {
527                assert_eq!(prompt_reason, Some("Authenticate to unlock".to_string()));
528            }
529            _ => panic!("Wrong response type"),
530        }
531    }
532
533    #[test]
534    fn test_empty_credential_list_serialization() {
535        let response = IpcMessage::ListDomainCredentialsResponse {
536            credentials: vec![],
537            locked: None,
538        };
539
540        let serialized = serde_json::to_string(&response).unwrap();
541        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
542
543        match deserialized {
544            IpcMessage::ListDomainCredentialsResponse {
545                credentials,
546                locked: None,
547            } => {
548                assert!(credentials.is_empty());
549            }
550            _ => panic!("Wrong response type"),
551        }
552    }
553
554    #[test]
555    fn test_get_totp_code_message_serialization() {
556        let msg = IpcMessage::GetTotpCode {
557            domain: "example.com".to_string(),
558        };
559
560        let serialized = serde_json::to_string(&msg).unwrap();
561        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
562
563        match deserialized {
564            IpcMessage::GetTotpCode { domain } => {
565                assert_eq!(domain, "example.com");
566            }
567            _ => panic!("Wrong message type"),
568        }
569    }
570
571    #[test]
572    fn test_sync_status_message_serialization() {
573        let msg = IpcMessage::SyncStatus;
574
575        let serialized = serde_json::to_string(&msg).unwrap();
576        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
577
578        match deserialized {
579            IpcMessage::SyncStatus => {}
580            _ => panic!("Wrong message type"),
581        }
582    }
583
584    #[test]
585    fn test_sync_status_response_serialization() {
586        let response = IpcMessage::SyncStatusResponse {
587            enabled: true,
588            device_id: Some("device-123".to_string()),
589            device_name: Some("Test Device".to_string()),
590            relay_url: Some("https://relay.example.com".to_string()),
591            last_sync_at: Some(1700000000),
592            pending_changes: 5,
593        };
594
595        let serialized = serde_json::to_string(&response).unwrap();
596        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
597
598        match deserialized {
599            IpcMessage::SyncStatusResponse {
600                enabled,
601                device_id,
602                device_name,
603                relay_url,
604                last_sync_at,
605                pending_changes,
606            } => {
607                assert!(enabled);
608                assert_eq!(device_id, Some("device-123".to_string()));
609                assert_eq!(device_name, Some("Test Device".to_string()));
610                assert_eq!(relay_url, Some("https://relay.example.com".to_string()));
611                assert_eq!(last_sync_at, Some(1700000000));
612                assert_eq!(pending_changes, 5);
613            }
614            _ => panic!("Wrong response type"),
615        }
616    }
617}