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