Skip to main content

age_plugin_argon2/
recipient.rs

1use std::collections::HashSet;
2
3use age::EncryptError;
4use age_core::format::{FileKey, Stanza};
5use argon2::{Algorithm, Argon2, Version};
6use base64::engine::general_purpose::STANDARD_NO_PAD;
7use base64::Engine;
8use rand::rngs::OsRng;
9use rand::RngCore;
10use secrecy::ExposeSecret;
11use uuid::Uuid;
12
13use crate::params::Argon2Params;
14
15const STANZA_TAG: &str = "thesis.co/argon2";
16
17/// Argon2id recipient — full KDF encryption path.
18///
19/// Used during `kin init` and when no cached material is available.
20/// Generates a random salt, derives a wrapping key via Argon2id,
21/// and wraps the FileKey using ChaCha20-Poly1305 (via age's AEAD primitives).
22pub struct Argon2idRecipient {
23    passphrase: Vec<u8>,
24    params: Argon2Params,
25}
26
27impl Argon2idRecipient {
28    /// Create a new recipient from a passphrase and Argon2id parameters.
29    pub fn new(passphrase: &[u8], params: Argon2Params) -> Self {
30        Self {
31            passphrase: passphrase.to_vec(),
32            params,
33        }
34    }
35}
36
37impl age::Recipient for Argon2idRecipient {
38    fn wrap_file_key(
39        &self,
40        file_key: &FileKey,
41    ) -> Result<(Vec<Stanza>, HashSet<String>), EncryptError> {
42        // 1. Generate 16-byte random salt
43        let mut salt = [0u8; 16];
44        OsRng.fill_bytes(&mut salt);
45
46        // 2. Derive 32-byte wrapping key via Argon2id
47        let wrapping_key = derive_wrapping_key(&self.passphrase, &salt, &self.params)?;
48
49        // 3. Wrap FileKey using age's AEAD
50        let body = age_core::primitives::aead_encrypt(&wrapping_key, file_key.expose_secret());
51
52        // 4. Build stanza
53        let stanza = Stanza {
54            tag: STANZA_TAG.to_string(),
55            args: vec![
56                STANDARD_NO_PAD.encode(salt),
57                self.params.m_cost().to_string(),
58                self.params.t_cost().to_string(),
59                self.params.p_cost().to_string(),
60            ],
61            body,
62        };
63
64        // 5. Random UUID label — enforces "must be only recipient"
65        let mut labels = HashSet::new();
66        labels.insert(Uuid::new_v4().to_string());
67
68        Ok((vec![stanza], labels))
69    }
70}
71
72/// Derive a 32-byte wrapping key from a passphrase and salt using Argon2id.
73///
74/// Returns `Err` if the Argon2 parameters are invalid or hashing fails.
75pub(crate) fn derive_wrapping_key(
76    passphrase: &[u8],
77    salt: &[u8],
78    params: &Argon2Params,
79) -> Result<[u8; 32], age::EncryptError> {
80    let argon2_params =
81        argon2::Params::new(params.m_cost(), params.t_cost(), params.p_cost(), Some(32)).map_err(
82            |e| age::EncryptError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e)),
83        )?;
84
85    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
86
87    let mut key = [0u8; 32];
88    argon2
89        .hash_password_into(passphrase, salt, &mut key)
90        .map_err(|e| {
91            age::EncryptError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e))
92        })?;
93
94    Ok(key)
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use age::Recipient;
101
102    #[test]
103    fn test_derive_wrapping_key_deterministic() {
104        let passphrase = b"test-password";
105        let salt = [1u8; 16];
106        let params = Argon2Params::new(256, 1, 1).unwrap();
107
108        let key1 = derive_wrapping_key(passphrase, &salt, &params).unwrap();
109        let key2 = derive_wrapping_key(passphrase, &salt, &params).unwrap();
110        assert_eq!(key1, key2);
111    }
112
113    #[test]
114    fn test_wrap_file_key_produces_valid_stanza() {
115        let recipient = Argon2idRecipient::new(b"test", Argon2Params::new(256, 1, 1).unwrap());
116
117        let file_key = FileKey::new(Box::new([42u8; 16]));
118        let (stanzas, labels) = recipient.wrap_file_key(&file_key).unwrap();
119
120        assert_eq!(stanzas.len(), 1);
121        assert_eq!(stanzas[0].tag, "thesis.co/argon2");
122        assert_eq!(stanzas[0].args.len(), 4);
123        // body = 16 bytes file key + 16 bytes poly1305 tag = 32 bytes
124        assert_eq!(stanzas[0].body.len(), 32);
125        // Exactly one label (UUID)
126        assert_eq!(labels.len(), 1);
127    }
128}