age_plugin/
identity.rs

1//! Identity plugin helpers.
2
3use age_core::{
4    format::{FileKey, Stanza},
5    plugin::{self, BidirSend, Connection},
6    secrecy::{ExposeSecret, SecretString},
7};
8use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine};
9use bech32::FromBase32;
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(&item)
269                        .ok()
270                        .and_then(|(hrp, data, variant)| {
271                            if hrp.starts_with(PLUGIN_IDENTITY_PREFIX)
272                                && hrp.ends_with('-')
273                                && variant == bech32::Variant::Bech32
274                            {
275                                Vec::from_base32(&data).ok().map(|data| (hrp, data))
276                            } else {
277                                None
278                            }
279                        })
280                        .ok_or_else(|| Error::Identity {
281                            index,
282                            message: "Invalid identity encoding".to_owned(),
283                        })
284                        .and_then(|(hrp, bytes)| {
285                            plugin.add_identity(
286                                index,
287                                &hrp[PLUGIN_IDENTITY_PREFIX.len()..hrp.len() - 1],
288                                &bytes,
289                            )
290                        })
291                })
292                .filter_map(|res| res.err())
293                .collect();
294
295            if errors.is_empty() {
296                Ok(())
297            } else {
298                Err(errors)
299            }
300        });
301
302        let stanzas = stanzas.and_then(|recipient_stanzas| {
303            let mut stanzas: Vec<Vec<Stanza>> = Vec::new();
304            let mut errors: Vec<Error> = vec![];
305            for (file_index, stanza) in recipient_stanzas {
306                if let Some(file) = stanzas.get_mut(file_index) {
307                    file.push(stanza);
308                } else if stanzas.len() == file_index {
309                    stanzas.push(vec![stanza]);
310                } else {
311                    errors.push(Error::Internal {
312                        message: format!(
313                            "{} file indices are not ordered and monotonically increasing",
314                            RECIPIENT_STANZA
315                        ),
316                    });
317                }
318            }
319            if errors.is_empty() {
320                Ok(stanzas)
321            } else {
322                Err(errors)
323            }
324        });
325
326        (identities, stanzas)
327    };
328
329    // Phase 2: interactively unwrap
330    conn.bidir_send(|mut phase| {
331        let stanzas = match (identities, recipient_stanzas) {
332            (Ok(()), Ok(stanzas)) => stanzas,
333            (Err(errors1), Err(errors2)) => {
334                for error in errors1.into_iter().chain(errors2.into_iter()) {
335                    error.send(&mut phase)?;
336                }
337                return Ok(());
338            }
339            (Err(errors), _) | (_, Err(errors)) => {
340                for error in errors {
341                    error.send(&mut phase)?;
342                }
343                return Ok(());
344            }
345        };
346
347        let unwrapped = plugin.unwrap_file_keys(stanzas, BidirCallbacks(&mut phase))?;
348
349        for (file_index, file_key) in unwrapped {
350            match file_key {
351                Ok(file_key) => {
352                    phase
353                        .send(
354                            "file-key",
355                            &[&format!("{}", file_index)],
356                            file_key.expose_secret(),
357                        )?
358                        .unwrap();
359                }
360                Err(errors) => {
361                    for error in errors {
362                        error.send(&mut phase)?;
363                    }
364                }
365            }
366        }
367
368        Ok(())
369    })
370}