Skip to main content

agentknock/
secrets.rs

1use std::{collections::BTreeMap, future::Future, io};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use ulid::Ulid;
6
7use crate::{
8    Client, RequestError, RequestProgress,
9    config::{ConfigurationError, clear_rotation_key, read_pairing_from},
10    crypto::Session,
11    protocol::{self, Method, Response},
12    websocket::RelayExchange,
13};
14
15/// Metadata for a secret available from the paired device.
16///
17/// This type never contains secret values.
18#[derive(Debug, Eq, PartialEq)]
19#[non_exhaustive]
20pub enum Secret {
21    /// A secret that provides environment variables.
22    #[non_exhaustive]
23    Environment {
24        /// An optional human-readable description.
25        description: Option<String>,
26        /// The environment variable names provided by the secret.
27        ///
28        /// Names are sorted and contain no duplicates.
29        variables: Vec<String>,
30    },
31
32    /// A secret that provides SSH operations without exposing its private key.
33    #[non_exhaustive]
34    Ssh {
35        /// An optional human-readable description.
36        description: Option<String>,
37        /// The public key in OpenSSH format.
38        public_key: String,
39    },
40
41    /// A secret type that this library version doesn't recognize.
42    #[non_exhaustive]
43    Unknown {
44        /// An optional human-readable description.
45        description: Option<String>,
46        /// The type name reported by the device.
47        secret_type: String,
48    },
49}
50
51/// Secret metadata keyed by secret name.
52///
53/// Iteration yields secret names in lexicographic order.
54pub type Secrets = BTreeMap<String, Secret>;
55
56/// A secret to upload to the device.
57#[derive(Eq, PartialEq)]
58#[non_exhaustive]
59pub enum SecretUpload {
60    /// An environment-variable secret.
61    Environment {
62        /// The name of the new or existing secret.
63        name: String,
64        /// The proposed description.
65        description: Option<String>,
66        /// Environment variable values keyed by variable name.
67        variables: BTreeMap<String, String>,
68    },
69
70    /// An SSH private-key secret.
71    Ssh {
72        /// The name of the new or existing secret.
73        name: String,
74        /// The proposed description.
75        description: Option<String>,
76        /// A passphrase-free private key in OpenSSH format.
77        ///
78        /// The client-device protocol encrypts the upload. A passphrase used to decrypt the source
79        /// key is not sent to the device.
80        private_key: String,
81    },
82}
83
84impl SecretUpload {
85    /// Returns the proposed or existing secret name.
86    pub fn name(&self) -> &str {
87        match self {
88            Self::Environment { name, .. } | Self::Ssh { name, .. } => name,
89        }
90    }
91}
92
93/// How an uploaded secret changes device state after user acceptance.
94#[derive(Clone, Copy, Debug, Eq, PartialEq)]
95pub enum SecretUploadMode {
96    /// Proposes a new secret whose final name the user can choose on the device.
97    Create,
98
99    /// Replaces an existing secret and removes content that isn't supplied.
100    Replace,
101
102    /// Updates supplied fields while retaining content that isn't supplied.
103    Update,
104}
105
106/// An error uploading a secret.
107#[derive(Debug, Error)]
108#[non_exhaustive]
109pub enum SecretUploadError {
110    /// The authenticated exchange failed.
111    #[error(transparent)]
112    Request(#[from] RequestError),
113
114    /// The device rejected the upload instead of storing it for user review.
115    #[error("the device rejected the secret upload: {message}")]
116    Rejected {
117        /// Human-readable context supplied by the device.
118        message: String,
119    },
120}
121
122impl From<ConfigurationError> for SecretUploadError {
123    fn from(error: ConfigurationError) -> Self {
124        Self::Request(error.into())
125    }
126}
127
128impl From<crate::websocket::Error> for SecretUploadError {
129    fn from(error: crate::websocket::Error) -> Self {
130        Self::Request(error.into())
131    }
132}
133
134impl Client {
135    /// Lists metadata for secrets available from the paired device.
136    ///
137    /// The returned [`Secrets`] includes names, types, descriptions, and the
138    /// names of values each secret provides. It never includes secret values.
139    ///
140    /// The `progress` callback receives lifecycle updates synchronously and
141    /// should return promptly. Cancellation before an authenticated response
142    /// returns [`RequestError::Interrupted`]. After a response is
143    /// authenticated, cancellation only shortens the best-effort completion
144    /// handoff and the method still returns the decoded list. Pass
145    /// [`std::future::pending()`] when the operation doesn't need cancellation.
146    ///
147    /// # Errors
148    ///
149    /// Returns [`RequestError`] if local pairing state isn't active, the relay
150    /// exchange fails, the response is invalid, or the operation is canceled
151    /// before a response is authenticated.
152    pub async fn list_secrets<P>(
153        &self,
154        cancellation: impl Future<Output = ()>,
155        mut progress: P,
156    ) -> Result<Secrets, RequestError>
157    where
158        P: FnMut(RequestProgress),
159    {
160        tokio::pin!(cancellation);
161        progress(RequestProgress::Preparing);
162        self.maybe_rotate_psk()?;
163        let pairing_path = self.pairing_path();
164        let pairing = read_pairing_from(&pairing_path)?;
165        let request_id = Ulid::generate();
166        let plaintext = self
167            .encode(&ListRequest {
168                method: Method::SecretList,
169            })
170            .map_err(RequestError::other)?;
171        let mut session = Session::new(&pairing, &request_id).map_err(RequestError::other)?;
172        let request = session
173            .seal_request(&plaintext)
174            .map_err(RequestError::other)?;
175        let mut relay = RelayExchange::authenticated(self, &pairing, &request_id.to_string())?;
176
177        progress(RequestProgress::WaitingForDelivery);
178        let response = tokio::select! {
179            biased;
180            _ = cancellation.as_mut() => return Err(RequestError::Interrupted),
181            response = relay.request(&request, || {
182                progress(RequestProgress::WaitingForResponse);
183            }) => response?,
184        };
185        progress(RequestProgress::Completing);
186        let plaintext = session
187            .open_response(response)
188            .map_err(RequestError::other)?;
189        if let Some(rotation_key) = pairing.rotation_key() {
190            clear_rotation_key(&pairing_path, rotation_key)?;
191        }
192        let response: ListResponse =
193            match protocol::decode_response(&plaintext).map_err(RequestError::other)? {
194                Response::Message(response) => response,
195                Response::Error(error) => {
196                    if let Some(completion) =
197                        protocol::seal_error_completion(self, &mut session, &error)
198                    {
199                        let _ = relay.complete_briefly(&completion).await;
200                    }
201                    return Err(RequestError::DeviceRejected {
202                        code: error.code,
203                        message: error.message,
204                    });
205                }
206            };
207        let plaintext = self.encode(&EmptyMessage {}).map_err(RequestError::other)?;
208        let completion = session
209            .seal_completion(&plaintext)
210            .map_err(RequestError::other)?;
211        let interrupted = tokio::select! {
212            biased;
213            _ = cancellation.as_mut() => true,
214            result = relay.complete(&completion) => {
215                result?;
216                false
217            }
218        };
219        if interrupted {
220            let _ = relay.complete_briefly(&completion).await;
221        }
222        progress(RequestProgress::Completed);
223
224        response
225            .secrets
226            .into_iter()
227            .map(|(name, secret)| Ok((name, secret.try_into()?)))
228            .collect::<io::Result<_>>()
229            .map_err(RequestError::other)
230    }
231
232    /// Uploads a secret for review on the device.
233    ///
234    /// Success means that the device received and stored the upload proposal.
235    /// It doesn't mean that the user accepted the proposal or that the secret
236    /// is available for use. Upload mode controls how a later acceptance would
237    /// change device state.
238    ///
239    /// The `progress` callback receives lifecycle updates synchronously and
240    /// should return promptly. Cancellation before an authenticated response
241    /// returns [`RequestError::Interrupted`] through [`SecretUploadError`].
242    /// After a response is authenticated, cancellation only shortens the
243    /// best-effort completion handoff and the method still returns the device's
244    /// result. Pass [`std::future::pending()`] when the operation doesn't need
245    /// cancellation.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`SecretUploadError`] if local pairing state isn't active, the
250    /// relay exchange fails, the device rejects the proposal, the response is
251    /// invalid, or the operation is canceled before a response is
252    /// authenticated.
253    pub async fn upload_secret<P>(
254        &self,
255        secret: &SecretUpload,
256        mode: SecretUploadMode,
257        cancellation: impl Future<Output = ()>,
258        mut progress: P,
259    ) -> Result<(), SecretUploadError>
260    where
261        P: FnMut(RequestProgress),
262    {
263        tokio::pin!(cancellation);
264        progress(RequestProgress::Preparing);
265        self.maybe_rotate_psk()?;
266        let pairing_path = self.pairing_path();
267        let pairing = read_pairing_from(&pairing_path)?;
268        let request_id = Ulid::generate();
269        let request_payload = UploadRequest {
270            method: Method::SecretUpload,
271            mode: mode.into(),
272            secret: UploadSecretMessage::from(secret),
273        };
274        let plaintext = self.encode(&request_payload).map_err(RequestError::other)?;
275        let mut session = Session::new(&pairing, &request_id).map_err(RequestError::other)?;
276        let request = session
277            .seal_request(&plaintext)
278            .map_err(RequestError::other)?;
279        let mut relay = RelayExchange::authenticated(self, &pairing, &request_id.to_string())?;
280
281        progress(RequestProgress::WaitingForDelivery);
282        let response = tokio::select! {
283            biased;
284            _ = cancellation.as_mut() => {
285                return Err(RequestError::Interrupted.into());
286            }
287            response = relay.request(&request, || {
288                progress(RequestProgress::WaitingForResponse);
289            }) => response?,
290        };
291        progress(RequestProgress::Completing);
292        let plaintext = session
293            .open_response(response)
294            .map_err(RequestError::other)?;
295        if let Some(rotation_key) = pairing.rotation_key() {
296            clear_rotation_key(&pairing_path, rotation_key)?;
297        }
298        let response: UploadResult =
299            match protocol::decode_response(&plaintext).map_err(RequestError::other)? {
300                Response::Message(response) => response,
301                Response::Error(error) => {
302                    if let Some(completion) =
303                        protocol::seal_error_completion(self, &mut session, &error)
304                    {
305                        let _ = relay.complete_briefly(&completion).await;
306                    }
307                    return Err(RequestError::DeviceRejected {
308                        code: error.code,
309                        message: error.message,
310                    }
311                    .into());
312                }
313            };
314        let completion = self.encode(&response).map_err(RequestError::other)?;
315        let completion = session
316            .seal_completion(&completion)
317            .map_err(RequestError::other)?;
318        tokio::select! {
319            biased;
320            _ = cancellation.as_mut() => {},
321            _ = relay.complete_briefly(&completion) => {},
322        }
323        progress(RequestProgress::Completed);
324
325        match response {
326            UploadResult::Received => Ok(()),
327            UploadResult::Rejected { message } => Err(SecretUploadError::Rejected { message }),
328        }
329    }
330}
331
332#[derive(Serialize)]
333struct ListRequest {
334    method: Method,
335}
336
337#[derive(Serialize)]
338struct EmptyMessage {}
339
340#[derive(Deserialize)]
341struct ListResponse {
342    secrets: BTreeMap<String, ListedSecretMessage>,
343}
344
345#[derive(Serialize)]
346struct UploadRequest<'a> {
347    method: Method,
348    mode: SecretUploadModeMessage,
349    secret: UploadSecretMessage<'a>,
350}
351
352#[derive(Serialize)]
353#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
354enum SecretUploadModeMessage {
355    Create,
356    Replace,
357    Update,
358}
359
360impl From<SecretUploadMode> for SecretUploadModeMessage {
361    fn from(mode: SecretUploadMode) -> Self {
362        match mode {
363            SecretUploadMode::Create => Self::Create,
364            SecretUploadMode::Replace => Self::Replace,
365            SecretUploadMode::Update => Self::Update,
366        }
367    }
368}
369
370#[derive(Deserialize, Serialize)]
371#[serde(tag = "result", rename_all = "SCREAMING_SNAKE_CASE")]
372enum UploadResult {
373    Received,
374    Rejected { message: String },
375}
376
377#[derive(Deserialize, Serialize)]
378pub(crate) struct SecretMessage<T> {
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub(crate) description: Option<String>,
381    #[serde(flatten)]
382    pub(crate) contents: SecretContentsMessage<T>,
383}
384
385#[derive(Deserialize, Serialize)]
386#[serde(tag = "type", rename_all = "snake_case")]
387pub(crate) enum SecretContentsMessage<T> {
388    Environment { variables: T },
389    Ssh { public_key: String },
390}
391
392#[derive(Deserialize, Serialize)]
393pub(crate) struct EnvironmentVariableMessage {
394    pub(crate) value: String,
395}
396
397#[derive(Deserialize)]
398struct ListedSecretMessage {
399    #[serde(default)]
400    description: Option<String>,
401    #[serde(rename = "type")]
402    secret_type: String,
403    #[serde(flatten)]
404    metadata: BTreeMap<String, serde_json::Value>,
405}
406
407#[derive(Serialize)]
408#[serde(tag = "type", rename_all = "snake_case")]
409enum UploadSecretMessage<'a> {
410    Environment {
411        name: &'a str,
412        #[serde(skip_serializing_if = "Option::is_none")]
413        description: Option<&'a str>,
414        variables: BTreeMap<&'a str, UploadEnvironmentVariableMessage<'a>>,
415    },
416    Ssh {
417        name: &'a str,
418        #[serde(skip_serializing_if = "Option::is_none")]
419        description: Option<&'a str>,
420        private_key: &'a str,
421    },
422}
423
424#[derive(Serialize)]
425struct UploadEnvironmentVariableMessage<'a> {
426    value: &'a str,
427}
428
429impl<'a> From<&'a SecretUpload> for UploadSecretMessage<'a> {
430    fn from(secret: &'a SecretUpload) -> Self {
431        match secret {
432            SecretUpload::Environment {
433                name,
434                description,
435                variables,
436            } => Self::Environment {
437                name,
438                description: description.as_deref(),
439                variables: variables
440                    .iter()
441                    .map(|(name, value)| {
442                        (name.as_str(), UploadEnvironmentVariableMessage { value })
443                    })
444                    .collect(),
445            },
446            SecretUpload::Ssh {
447                name,
448                description,
449                private_key,
450            } => Self::Ssh {
451                name,
452                description: description.as_deref(),
453                private_key,
454            },
455        }
456    }
457}
458
459impl TryFrom<ListedSecretMessage> for Secret {
460    type Error = io::Error;
461
462    fn try_from(mut secret: ListedSecretMessage) -> Result<Self, Self::Error> {
463        match secret.secret_type.as_str() {
464            "environment" => {
465                let variables = secret.metadata.remove("variables").ok_or_else(|| {
466                    io::Error::other("environment secret metadata has no variables")
467                })?;
468                let mut variables: Vec<String> =
469                    serde_json::from_value(variables).map_err(io::Error::other)?;
470                variables.sort();
471                variables.dedup();
472                Ok(Self::Environment {
473                    description: secret.description,
474                    variables,
475                })
476            }
477            "ssh" => Ok(Self::Ssh {
478                description: secret.description,
479                public_key: serde_json::from_value(
480                    secret
481                        .metadata
482                        .remove("public_key")
483                        .ok_or_else(|| io::Error::other("SSH secret metadata has no public key"))?,
484                )
485                .map_err(io::Error::other)?,
486            }),
487            _ => Ok(Self::Unknown {
488                description: secret.description,
489                secret_type: secret.secret_type,
490            }),
491        }
492    }
493}