Skip to main content

age_plugin/
identity.rs

1//! Identity plugin helpers.
2
3use age_core::{
4    format::{FileKey, Stanza},
5    plugin::{self, BidirSend, Connection},
6    primitives::bech32_decode,
7    secrecy::{ExposeSecret, SecretString},
8};
9use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
10
11use std::collections::HashMap;
12use std::convert::Infallible;
13use std::io;
14
15use crate::{Callbacks, PLUGIN_IDENTITY_PREFIX};
16
17const ADD_IDENTITY: &str = "add-identity";
18const RECIPIENT_STANZA: &str = "recipient-stanza";
19
20/// The interface that age implementations will use to interact with an age plugin.
21///
22/// Implementations of this trait will be used within the [`identity-v1`] state machine.
23///
24/// [`identity-v1`]: https://c2sp.org/age-plugin#unwrapping-with-identity-v1
25pub trait IdentityPluginV1 {
26    /// Stores an identity that the user would like to use for decrypting age files.
27    ///
28    /// `plugin_name` is the name of the binary that resolved to this plugin.
29    ///
30    /// Returns an error if the identity is unknown or invalid.
31    fn add_identity(&mut self, index: usize, plugin_name: &str, bytes: &[u8]) -> Result<(), Error>;
32
33    /// Attempts to unwrap the file keys contained within the given age recipient stanzas,
34    /// using identities previously stored via [`add_identity`].
35    ///
36    /// Returns a `HashMap` containing the unwrapping results for each file:
37    ///
38    /// - A list of errors, if any stanzas for a file cannot be unwrapped that detectably
39    ///   should be unwrappable.
40    ///
41    /// - A [`FileKey`], if any stanza for a file can be successfully unwrapped.
42    ///
43    /// Note that if all known and valid stanzas for a given file cannot be unwrapped, and
44    /// none are expected to be unwrappable, that file has no entry in the `HashMap`. That
45    /// is, file keys that cannot be unwrapped are implicit.
46    ///
47    /// `callbacks` can be used to interact with the user, to have them take some physical
48    /// action or request a secret value.
49    ///
50    /// [`add_identity`]: IdentityPluginV1::add_identity
51    fn unwrap_file_keys(
52        &mut self,
53        files: Vec<Vec<Stanza>>,
54        callbacks: impl Callbacks<Error>,
55    ) -> io::Result<HashMap<usize, Result<FileKey, Vec<Error>>>>;
56}
57
58impl IdentityPluginV1 for Infallible {
59    fn add_identity(&mut self, _: usize, _: &str, _: &[u8]) -> Result<(), Error> {
60        // This is never executed.
61        Ok(())
62    }
63
64    fn unwrap_file_keys(
65        &mut self,
66        _: Vec<Vec<Stanza>>,
67        _: impl Callbacks<Error>,
68    ) -> io::Result<HashMap<usize, Result<FileKey, Vec<Error>>>> {
69        // This is never executed.
70        Ok(HashMap::new())
71    }
72}
73
74/// The interface that age plugins can use to interact with an age implementation.
75struct BidirCallbacks<'a, 'b, R: io::Read, W: io::Write>(&'b mut BidirSend<'a, R, W>);
76
77impl<'a, 'b, R: io::Read, W: io::Write> Callbacks<Error> for BidirCallbacks<'a, 'b, R, W> {
78    /// Shows a message to the user.
79    ///
80    /// This can be used to prompt the user to take some physical action, such as
81    /// inserting a hardware key.
82    fn message(&mut self, message: &str) -> plugin::Result<()> {
83        self.0
84            .send("msg", &[], message.as_bytes())
85            .map(|res| res.map(|_| ()))
86    }
87
88    fn confirm(
89        &mut self,
90        message: &str,
91        yes_string: &str,
92        no_string: Option<&str>,
93    ) -> age_core::plugin::Result<bool> {
94        let metadata: Vec<_> = Some(yes_string)
95            .into_iter()
96            .chain(no_string)
97            .map(|s| BASE64_STANDARD_NO_PAD.encode(s))
98            .collect();
99        let metadata: Vec<_> = metadata.iter().map(|s| s.as_str()).collect();
100
101        self.0
102            .send("confirm", &metadata, message.as_bytes())
103            .and_then(|res| match res {
104                Ok(s) => match &s.args[..] {
105                    [x] if x == "yes" => Ok(Ok(true)),
106                    [x] if x == "no" => Ok(Ok(false)),
107                    _ => Err(io::Error::new(
108                        io::ErrorKind::InvalidData,
109                        "Invalid response to confirm command",
110                    )),
111                },
112                Err(e) => Ok(Err(e)),
113            })
114    }
115
116    fn request_public(&mut self, message: &str) -> plugin::Result<String> {
117        self.0
118            .send("request-public", &[], message.as_bytes())
119            .and_then(|res| match res {
120                Ok(s) => String::from_utf8(s.body)
121                    .map_err(|_| {
122                        io::Error::new(io::ErrorKind::InvalidData, "response is not UTF-8")
123                    })
124                    .map(Ok),
125                Err(e) => Ok(Err(e)),
126            })
127    }
128
129    /// Requests a secret value from the user, such as a passphrase.
130    ///
131    /// `message` will be displayed to the user, providing context for the request.
132    fn request_secret(&mut self, message: &str) -> plugin::Result<SecretString> {
133        self.0
134            .send("request-secret", &[], message.as_bytes())
135            .and_then(|res| match res {
136                Ok(s) => String::from_utf8(s.body)
137                    .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "secret is not UTF-8"))
138                    .map(|s| Ok(SecretString::from(s))),
139                Err(e) => Ok(Err(e)),
140            })
141    }
142
143    fn error(&mut self, error: Error) -> plugin::Result<()> {
144        error.send(self.0).map(|()| Ok(()))
145    }
146}
147
148/// The kinds of errors that can occur within the identity plugin state machine.
149pub enum Error {
150    /// An error caused by a specific identity.
151    Identity {
152        /// The index of the identity.
153        index: usize,
154        /// The error message.
155        message: String,
156    },
157    /// A general error that occured inside the state machine.
158    Internal {
159        /// The error message.
160        message: String,
161    },
162    /// An error caused by a specific stanza.
163    ///
164    /// Note that unknown stanzas MUST be ignored by plugins; this error is only for
165    /// stanzas that have a supported tag but are otherwise invalid (indicating an invalid
166    /// age file).
167    Stanza {
168        /// The index of the file containing the stanza.
169        file_index: usize,
170        /// The index of the stanza within the file.
171        stanza_index: usize,
172        /// The error message.
173        message: String,
174    },
175}
176
177impl Error {
178    fn kind(&self) -> &str {
179        match self {
180            Error::Identity { .. } => "identity",
181            Error::Internal { .. } => "internal",
182            Error::Stanza { .. } => "stanza",
183        }
184    }
185
186    fn message(&self) -> &str {
187        match self {
188            Error::Identity { message, .. } => message,
189            Error::Internal { message } => message,
190            Error::Stanza { message, .. } => message,
191        }
192    }
193
194    fn send<R: io::Read, W: io::Write>(self, phase: &mut BidirSend<R, W>) -> io::Result<()> {
195        let index = match self {
196            Error::Identity { index, .. } => Some((index.to_string(), None)),
197            Error::Internal { .. } => None,
198            Error::Stanza {
199                file_index,
200                stanza_index,
201                ..
202            } => Some((file_index.to_string(), Some(stanza_index.to_string()))),
203        };
204
205        let metadata = match &index {
206            Some((file_index, Some(stanza_index))) => vec![self.kind(), file_index, stanza_index],
207            Some((index, None)) => vec![self.kind(), index],
208            None => vec![self.kind()],
209        };
210
211        phase
212            .send("error", &metadata, self.message().as_bytes())?
213            .unwrap();
214
215        Ok(())
216    }
217}
218
219/// Runs the identity plugin v1 protocol.
220pub(crate) fn run_v1<P: IdentityPluginV1>(mut plugin: P) -> io::Result<()> {
221    let mut conn = Connection::accept();
222
223    // Phase 1: receive identities and stanzas
224    let (identities, recipient_stanzas) = {
225        let (identities, stanzas, _, _) = conn.unidir_receive(
226            (ADD_IDENTITY, |s| match (&s.args[..], &s.body[..]) {
227                ([identity], []) => Ok(identity.clone()),
228                _ => Err(Error::Internal {
229                    message: format!(
230                        "{} command must have exactly one metadata argument and no data",
231                        ADD_IDENTITY
232                    ),
233                }),
234            }),
235            (RECIPIENT_STANZA, |mut s| {
236                if s.args.len() >= 2 {
237                    let file_index = s.args.remove(0);
238                    s.tag = s.args.remove(0);
239                    file_index
240                        .parse::<usize>()
241                        .map(|i| (i, s))
242                        .map_err(|_| Error::Internal {
243                            message: format!(
244                                "first metadata argument to {} must be an integer",
245                                RECIPIENT_STANZA
246                            ),
247                        })
248                } else {
249                    Err(Error::Internal {
250                        message: format!(
251                            "{} command must have at least two metadata arguments",
252                            RECIPIENT_STANZA
253                        ),
254                    })
255                }
256            }),
257            (None, |_| Ok(())),
258            (None, |_| Ok(())),
259        )?;
260
261        // Now that we have the full list of identities, parse them as Bech32 and add them
262        // to the plugin.
263        let identities = identities.and_then(|items| {
264            let errors: Vec<_> = items
265                .into_iter()
266                .enumerate()
267                .map(|(index, item)| {
268                    bech32_decode(
269                        &item,
270                        |_| "invalid Bech32 encoding",
271                        |hrp| {
272                            (hrp.len() > PLUGIN_IDENTITY_PREFIX.len()
273                                && hrp.as_str().starts_with(PLUGIN_IDENTITY_PREFIX)
274                                && hrp.as_str().ends_with('-'))
275                            .then_some(())
276                            .ok_or("invalid HRP")
277                        },
278                        |hrp, bytes| Ok((hrp, bytes.collect::<Vec<_>>())),
279                    )
280                    .map_err(|message| Error::Identity {
281                        index,
282                        message: message.to_owned(),
283                    })
284                    .and_then(|(hrp, bytes)| {
285                        // TODO: Decide whether to allow plugin names to end in -
286                        let name = hrp
287                            .as_str()
288                            .split_at(PLUGIN_IDENTITY_PREFIX.len())
289                            .1
290                            .trim_end_matches('-')
291                            .to_lowercase();
292                        plugin.add_identity(index, &name, &bytes)
293                    })
294                })
295                .filter_map(|res| res.err())
296                .collect();
297
298            if errors.is_empty() {
299                Ok(())
300            } else {
301                Err(errors)
302            }
303        });
304
305        let stanzas = stanzas.and_then(|recipient_stanzas| {
306            let mut stanzas: Vec<Vec<Stanza>> = Vec::new();
307            let mut errors: Vec<Error> = vec![];
308            for (file_index, stanza) in recipient_stanzas {
309                if let Some(file) = stanzas.get_mut(file_index) {
310                    file.push(stanza);
311                } else if stanzas.len() == file_index {
312                    stanzas.push(vec![stanza]);
313                } else {
314                    errors.push(Error::Internal {
315                        message: format!(
316                            "{} file indices are not ordered and monotonically increasing",
317                            RECIPIENT_STANZA
318                        ),
319                    });
320                }
321            }
322            if errors.is_empty() {
323                Ok(stanzas)
324            } else {
325                Err(errors)
326            }
327        });
328
329        (identities, stanzas)
330    };
331
332    // Phase 2: interactively unwrap
333    conn.bidir_send(|mut phase| {
334        let stanzas = match (identities, recipient_stanzas) {
335            (Ok(()), Ok(stanzas)) => stanzas,
336            (Err(errors1), Err(errors2)) => {
337                for error in errors1.into_iter().chain(errors2.into_iter()) {
338                    error.send(&mut phase)?;
339                }
340                return Ok(());
341            }
342            (Err(errors), _) | (_, Err(errors)) => {
343                for error in errors {
344                    error.send(&mut phase)?;
345                }
346                return Ok(());
347            }
348        };
349
350        let unwrapped = plugin.unwrap_file_keys(stanzas, BidirCallbacks(&mut phase))?;
351
352        for (file_index, file_key) in unwrapped {
353            match file_key {
354                Ok(file_key) => {
355                    phase
356                        .send(
357                            "file-key",
358                            &[&format!("{}", file_index)],
359                            file_key.expose_secret(),
360                        )?
361                        .unwrap();
362                }
363                Err(errors) => {
364                    for error in errors {
365                        error.send(&mut phase)?;
366                    }
367                }
368            }
369        }
370
371        Ok(())
372    })
373}