Skip to main content

sentinelpass_protocol/
envelope.rs

1//! Authentication envelope for IPC frames.
2
3use crate::message::IpcMessage;
4use serde::{Deserialize, Serialize};
5
6/// Where a request originated. This is provenance labeling for deprecation
7/// gating and logging — NOT authentication. The security boundary for
8/// external tools is the daemon token plus per-client grant tokens.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum Origin {
12    /// The browser native-messaging host process.
13    NativeHost,
14    /// A CLI invocation acting on behalf of a user or local tool.
15    Cli,
16}
17
18/// Every IPC frame carries the daemon auth token alongside the message.
19///
20/// `client_token` and `origin` are optional and serde-defaulted in both
21/// directions: an old client's frames parse on a new daemon and vice versa.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct IpcEnvelope {
24    pub token: String,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub client_token: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub origin: Option<Origin>,
29    pub message: IpcMessage,
30}
31
32impl IpcEnvelope {
33    pub fn new(token: String, message: IpcMessage) -> Self {
34        Self {
35            token,
36            client_token: None,
37            origin: None,
38            message,
39        }
40    }
41
42    pub fn with_client_token(mut self, client_token: Option<String>) -> Self {
43        self.client_token = client_token;
44        self
45    }
46
47    pub fn with_origin(mut self, origin: Origin) -> Self {
48        self.origin = Some(origin);
49        self
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn test_ipc_envelope_serialization() {
59        let envelope = IpcEnvelope {
60            token: "test_token_12345".to_string(),
61            client_token: None,
62            origin: None,
63            message: IpcMessage::GetCredential {
64                domain: "example.com".to_string(),
65            },
66        };
67
68        let serialized = serde_json::to_string(&envelope).unwrap();
69        let deserialized: IpcEnvelope = serde_json::from_str(&serialized).unwrap();
70
71        assert_eq!(deserialized.token, envelope.token);
72        assert!(deserialized.client_token.is_none());
73        assert!(deserialized.origin.is_none());
74        match deserialized.message {
75            IpcMessage::GetCredential { domain } => {
76                assert_eq!(domain, "example.com");
77            }
78            _ => panic!("Wrong message type"),
79        }
80    }
81
82    #[test]
83    fn legacy_envelope_without_new_fields_parses() {
84        // Frames written by <= 0.7 clients carry only token + message.
85        let legacy = r#"{"token":"tok","message":"CheckVault"}"#;
86        let parsed: IpcEnvelope = serde_json::from_str(legacy).unwrap();
87        assert_eq!(parsed.token, "tok");
88        assert!(parsed.client_token.is_none());
89        assert!(parsed.origin.is_none());
90    }
91
92    #[test]
93    fn envelope_with_origin_and_client_token_round_trips() {
94        let envelope = IpcEnvelope::new("tok".to_string(), IpcMessage::CheckVault)
95            .with_client_token(Some("spt_abc".to_string()))
96            .with_origin(Origin::NativeHost);
97
98        let serialized = serde_json::to_string(&envelope).unwrap();
99        assert!(serialized.contains("\"origin\":\"native_host\""));
100        let parsed: IpcEnvelope = serde_json::from_str(&serialized).unwrap();
101        assert_eq!(parsed.origin, Some(Origin::NativeHost));
102        assert_eq!(parsed.client_token.as_deref(), Some("spt_abc"));
103    }
104}