Skip to main content

ap_client/
types.rs

1//! Types for the remote client protocol
2
3use std::fmt;
4
5use ap_noise::Psk;
6use ap_proxy_protocol::IdentityFingerprint;
7use serde::{Deserialize, Serialize};
8use zeroize::Zeroizing;
9
10/// A stable identifier for a PSK, derived from `hex(SHA256(psk)[0..8])`.
11///
12/// Used as a lookup key to match incoming handshakes to the correct pending
13/// pairing. Today this maps to in-memory pending pairings; in the future it
14/// could index persistent/reusable PSKs from a `PskStore`.
15pub type PskId = String;
16
17/// What kind of credential to look up.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub enum CredentialQuery {
21    /// Look up by domain / URL.
22    Domain(String),
23    /// Look up by vault item ID.
24    Id(String),
25    /// Free-text search.
26    Search(String),
27}
28
29impl CredentialQuery {
30    /// Extract the inner search string from any query variant.
31    pub fn search_string(&self) -> &str {
32        match self {
33            Self::Domain(d) => d.as_str(),
34            Self::Id(id) => id.as_str(),
35            Self::Search(s) => s.as_str(),
36        }
37    }
38}
39
40impl fmt::Display for CredentialQuery {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            CredentialQuery::Domain(d) => write!(f, "domain: {d}"),
44            CredentialQuery::Id(id) => write!(f, "id: {id}"),
45            CredentialQuery::Search(s) => write!(f, "search: {s}"),
46        }
47    }
48}
49
50/// A parsed PSK token (`<64-hex-psk>_<64-hex-fingerprint>`, 129 chars).
51///
52/// Tokens are generated by [`UserClient::get_psk_token`] and parsed by
53/// consumers that need to connect via PSK mode.
54///
55/// # Examples
56///
57/// ```
58/// use ap_client::PskToken;
59///
60/// let token_str = format!("{}_{}", "ab".repeat(32), "cd".repeat(32));
61/// let token = PskToken::parse(&token_str).unwrap();
62/// assert_eq!(token.to_string(), token_str);
63/// ```
64#[derive(Debug, Clone)]
65pub struct PskToken {
66    psk: Psk,
67    fingerprint: IdentityFingerprint,
68}
69
70impl PskToken {
71    /// Create a new `PskToken` from its components.
72    pub fn new(psk: Psk, fingerprint: IdentityFingerprint) -> Self {
73        Self { psk, fingerprint }
74    }
75
76    /// Parse a PSK token string (`<64-hex-psk>_<64-hex-fingerprint>`).
77    ///
78    /// # Errors
79    ///
80    /// Returns [`ClientError::InvalidPairingCode`] if the format is invalid.
81    pub fn parse(token: &str) -> Result<Self, crate::error::ClientError> {
82        if token.len() != 129 {
83            return Err(crate::error::ClientError::InvalidPairingCode(format!(
84                "PSK token must be exactly 129 characters, got {}",
85                token.len()
86            )));
87        }
88
89        let (psk_hex, rest) = token.split_at(64);
90        let (sep, fp_hex) = rest.split_at(1);
91        if sep != "_" {
92            return Err(crate::error::ClientError::InvalidPairingCode(
93                "PSK token must have '_' separator at position 64".to_string(),
94            ));
95        }
96
97        let psk = Psk::from_hex(psk_hex).map_err(|e| {
98            crate::error::ClientError::InvalidPairingCode(format!("Invalid PSK: {e}"))
99        })?;
100        let fingerprint = IdentityFingerprint::from_hex(fp_hex).map_err(|e| {
101            crate::error::ClientError::InvalidPairingCode(format!("Invalid fingerprint: {e}"))
102        })?;
103
104        Ok(Self { psk, fingerprint })
105    }
106
107    /// Cheap heuristic: returns `true` if the string looks like a PSK token.
108    pub fn looks_like_psk_token(s: &str) -> bool {
109        s.len() == 129 && s.as_bytes()[64] == b'_'
110    }
111
112    /// The pre-shared key.
113    pub fn psk(&self) -> &Psk {
114        &self.psk
115    }
116
117    /// The remote peer's identity fingerprint.
118    pub fn fingerprint(&self) -> &IdentityFingerprint {
119        &self.fingerprint
120    }
121
122    /// Consume the token into its parts.
123    pub fn into_parts(self) -> (Psk, IdentityFingerprint) {
124        (self.psk, self.fingerprint)
125    }
126
127    /// Convert into the corresponding [`ConnectionMode`].
128    pub fn into_connection_mode(self) -> ConnectionMode {
129        ConnectionMode::NewPsk {
130            psk: self.psk,
131            remote_fingerprint: self.fingerprint,
132        }
133    }
134}
135
136impl fmt::Display for PskToken {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        write!(f, "{}_{}", self.psk.to_hex(), self.fingerprint.to_hex())
139    }
140}
141
142/// Connection mode for establishing a connection
143#[derive(Debug, Clone)]
144pub enum ConnectionMode {
145    /// New connection requiring rendezvous code pairing
146    New { rendezvous_code: String },
147    /// New connection using PSK authentication
148    NewPsk {
149        psk: Psk,
150        remote_fingerprint: IdentityFingerprint,
151    },
152    /// Existing connection using cached remote fingerprint
153    Existing {
154        remote_fingerprint: IdentityFingerprint,
155    },
156}
157
158/// Credential data returned from a request
159#[derive(Clone, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct CredentialData {
162    /// Username for the credential
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub username: Option<String>,
165    /// Password for the credential
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub password: Option<Zeroizing<String>>,
168    /// TOTP code if available
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub totp: Option<String>,
171    /// URI associated with the credential
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub uri: Option<String>,
174    /// Additional notes
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub notes: Option<String>,
177    /// Vault item ID
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub credential_id: Option<String>,
180    /// Domain associated with this credential
181    #[serde(default)]
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub domain: Option<String>,
184}
185
186impl std::fmt::Debug for CredentialData {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        f.debug_struct("CredentialData")
189            .field("domain", &self.domain)
190            .field("username", &self.username)
191            .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
192            .field("totp", &self.totp.as_ref().map(|_| "[REDACTED]"))
193            .field("notes", &self.notes.as_ref().map(|_| "[REDACTED]"))
194            .field("credential_id", &self.credential_id)
195            .finish()
196    }
197}
198
199/// Internal protocol messages sent over WebSocket
200#[derive(Debug, Clone, Serialize, Deserialize)]
201#[serde(tag = "type", rename_all = "kebab-case")]
202pub(crate) enum ProtocolMessage {
203    /// Noise handshake init (initiator -> responder)
204    #[serde(rename = "handshake-init")]
205    HandshakeInit {
206        data: String,
207        ciphersuite: String,
208        /// PSK identifier — `Some(id)` for PSK mode, `None` for rendezvous mode.
209        /// Backward-compatible: old clients omit this field (deserialized as `None`).
210        #[serde(default, skip_serializing_if = "Option::is_none")]
211        psk_id: Option<PskId>,
212    },
213    /// Noise handshake response (responder -> initiator)
214    #[serde(rename = "handshake-response")]
215    HandshakeResponse { data: String, ciphersuite: String },
216    /// Encrypted credential request
217    CredentialRequest { encrypted: String },
218    /// Encrypted credential response
219    CredentialResponse { encrypted: String },
220}
221
222/// Internal credential request structure (encrypted in transit)
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub(crate) struct CredentialRequestPayload {
225    #[serde(rename = "type")]
226    pub request_type: String,
227    pub query: CredentialQuery,
228    pub timestamp: u64,
229    #[serde(rename = "requestId")]
230    pub request_id: String,
231}
232
233/// Internal credential response structure (encrypted in transit)
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub(crate) struct CredentialResponsePayload {
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub credential: Option<CredentialData>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub error: Option<String>,
240    #[serde(rename = "requestId")]
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub request_id: Option<String>,
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn psk_token_roundtrip() {
251        let psk = Psk::generate();
252        let fp = IdentityFingerprint([0xcd; 32]);
253        let token = PskToken::new(psk.clone(), fp);
254        let s = token.to_string();
255        assert_eq!(s.len(), 129);
256        assert_eq!(s.as_bytes()[64], b'_');
257
258        let parsed = PskToken::parse(&s).expect("should parse");
259        assert_eq!(parsed.fingerprint(), &fp);
260        assert_eq!(parsed.psk().to_hex(), psk.to_hex());
261    }
262
263    #[test]
264    fn psk_token_parse_wrong_length() {
265        assert!(PskToken::parse("too_short").is_err());
266    }
267
268    #[test]
269    fn psk_token_looks_like() {
270        let valid = format!("{}_{}", "ab".repeat(32), "cd".repeat(32));
271        assert!(PskToken::looks_like_psk_token(&valid));
272        assert!(!PskToken::looks_like_psk_token("ABC-DEF-GHI"));
273    }
274
275    #[test]
276    fn psk_token_into_connection_mode() {
277        let psk = Psk::generate();
278        let fp = IdentityFingerprint([0x01; 32]);
279        let token = PskToken::new(psk, fp);
280        let mode = token.into_connection_mode();
281        assert!(matches!(mode, ConnectionMode::NewPsk { .. }));
282    }
283}