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