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`, `origin`, and `capability` are optional and
21/// serde-defaulted in both directions: an old client's frames parse on a
22/// new daemon and vice versa. `capability` is the WBS-504/505 presented
23/// installation-capability secret (e.g. the native host's); origin remains
24/// provenance-only and never authorizes (WBS-505 negative test holds:
25/// claiming NativeHost without the capability material is denied).
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct IpcEnvelope {
28    pub token: String,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub client_token: Option<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub origin: Option<Origin>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub capability: Option<String>,
35    pub message: IpcMessage,
36}
37
38impl IpcEnvelope {
39    pub fn new(token: String, message: IpcMessage) -> Self {
40        Self {
41            token,
42            client_token: None,
43            origin: None,
44            capability: None,
45            message,
46        }
47    }
48
49    pub fn with_client_token(mut self, client_token: Option<String>) -> Self {
50        self.client_token = client_token;
51        self
52    }
53
54    pub fn with_origin(mut self, origin: Origin) -> Self {
55        self.origin = Some(origin);
56        self
57    }
58
59    pub fn with_capability(mut self, capability: Option<String>) -> Self {
60        self.capability = capability;
61        self
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn test_ipc_envelope_serialization() {
71        let envelope = IpcEnvelope {
72            token: "test_token_12345".to_string(),
73            client_token: None,
74            origin: None,
75            capability: None,
76            message: IpcMessage::GetCredential {
77                domain: "example.com".to_string(),
78                page_url: None,
79                username: None,
80            },
81        };
82
83        let serialized = serde_json::to_string(&envelope).unwrap();
84        let deserialized: IpcEnvelope = serde_json::from_str(&serialized).unwrap();
85
86        assert_eq!(deserialized.token, envelope.token);
87        assert!(deserialized.client_token.is_none());
88        assert!(deserialized.origin.is_none());
89        match deserialized.message {
90            IpcMessage::GetCredential { domain, .. } => {
91                assert_eq!(domain, "example.com");
92            }
93            _ => panic!("Wrong message type"),
94        }
95    }
96
97    #[test]
98    fn legacy_envelope_without_new_fields_parses() {
99        // Frames written by <= 0.7 clients carry only token + message.
100        let legacy = r#"{"token":"tok","message":"CheckVault"}"#;
101        let parsed: IpcEnvelope = serde_json::from_str(legacy).unwrap();
102        assert_eq!(parsed.token, "tok");
103        assert!(parsed.client_token.is_none());
104        assert!(parsed.origin.is_none());
105    }
106
107    #[test]
108    fn envelope_with_origin_and_client_token_round_trips() {
109        let envelope = IpcEnvelope::new("tok".to_string(), IpcMessage::CheckVault)
110            .with_client_token(Some("spt_abc".to_string()))
111            .with_origin(Origin::NativeHost);
112
113        let serialized = serde_json::to_string(&envelope).unwrap();
114        assert!(serialized.contains("\"origin\":\"native_host\""));
115        let parsed: IpcEnvelope = serde_json::from_str(&serialized).unwrap();
116        assert_eq!(parsed.origin, Some(Origin::NativeHost));
117        assert_eq!(parsed.client_token.as_deref(), Some("spt_abc"));
118    }
119}