Skip to main content

vyn_core/
wrapping.rs

1use std::fs::File;
2use std::io::{BufReader, Read, Write};
3use std::path::Path;
4
5use age::Decryptor;
6use secrecy::ExposeSecret;
7use thiserror::Error;
8
9use crate::crypto::{SecretBytes, secret_bytes};
10
11/// Structured invite payload produced by `unwrap_invite_with_ssh_identity_file`.
12pub struct InvitePayload {
13    pub vault_id: String,
14    pub relay_url: Option<String>,
15    pub key: SecretBytes,
16}
17
18#[derive(Debug, Error)]
19pub enum WrappingError {
20    #[error("invalid recipient public key: {0}")]
21    InvalidRecipient(String),
22    #[error("encryption setup failed")]
23    EncryptSetup,
24    #[error("failed to write encrypted payload")]
25    EncryptWrite,
26    #[error("failed to finalize encrypted payload")]
27    EncryptFinish,
28    #[error("invalid encrypted invite format")]
29    InvalidEncryptedInvite,
30    #[error("failed to open SSH private key file: {0}")]
31    IdentityOpen(#[from] std::io::Error),
32    #[error("failed to parse SSH private key identity")]
33    IdentityParse,
34    #[error("failed to decrypt invite with SSH identity")]
35    DecryptFailure,
36    #[error("decrypted project key has invalid size")]
37    InvalidProjectKeySize,
38}
39
40pub fn wrap_project_key_for_ssh_recipient(
41    project_key: &SecretBytes,
42    recipient_public_key: &str,
43) -> Result<Vec<u8>, WrappingError> {
44    let recipient: age::ssh::Recipient =
45        recipient_public_key
46            .trim()
47            .parse()
48            .map_err(|e: age::ssh::ParseRecipientKeyError| {
49                WrappingError::InvalidRecipient(format!("{e:?}"))
50            })?;
51
52    let recipients = [recipient];
53    let encryptor =
54        age::Encryptor::with_recipients(recipients.iter().map(|r| r as &dyn age::Recipient))
55            .map_err(|_| WrappingError::EncryptSetup)?;
56
57    let mut output = Vec::new();
58    let mut writer = encryptor
59        .wrap_output(&mut output)
60        .map_err(|_| WrappingError::EncryptSetup)?;
61    writer
62        .write_all(project_key.expose_secret())
63        .map_err(|_| WrappingError::EncryptWrite)?;
64    writer.finish().map_err(|_| WrappingError::EncryptFinish)?;
65
66    Ok(output)
67}
68
69pub fn unwrap_project_key_with_ssh_identity_file(
70    encrypted_invite: &[u8],
71    identity_file: &Path,
72) -> Result<SecretBytes, WrappingError> {
73    let decryptor =
74        Decryptor::new(encrypted_invite).map_err(|_| WrappingError::InvalidEncryptedInvite)?;
75
76    let file = File::open(identity_file)?;
77    let mut reader = BufReader::new(file);
78    let identity = age::ssh::Identity::from_buffer(&mut reader, None)
79        .map_err(|_| WrappingError::IdentityParse)?;
80
81    let identities = vec![&identity as &dyn age::Identity];
82    let mut reader = decryptor
83        .decrypt(identities.into_iter())
84        .map_err(|_| WrappingError::DecryptFailure)?;
85
86    let mut plaintext = Vec::new();
87    reader
88        .read_to_end(&mut plaintext)
89        .map_err(|_| WrappingError::DecryptFailure)?;
90
91    if plaintext.len() != 32 {
92        return Err(WrappingError::InvalidProjectKeySize);
93    }
94
95    Ok(secret_bytes(plaintext))
96}
97
98/// Wraps a project key into an age-encrypted invite that carries vault metadata.
99///
100/// The inner plaintext is a JSON object:
101/// `{"vault_id":"…","relay_url":"…","key":"<hex>"}` (relay_url omitted when None).
102pub fn wrap_invite_for_ssh_recipient(
103    project_key: &SecretBytes,
104    vault_id: &str,
105    relay_url: Option<&str>,
106    recipient_public_key: &str,
107) -> Result<Vec<u8>, WrappingError> {
108    let key_hex = hex::encode(project_key.expose_secret());
109    let json = match relay_url {
110        Some(url) => {
111            format!(r#"{{"vault_id":"{vault_id}","relay_url":"{url}","key":"{key_hex}"}}"#)
112        }
113        None => format!(r#"{{"vault_id":"{vault_id}","key":"{key_hex}"}}"#),
114    };
115
116    let recipient: age::ssh::Recipient =
117        recipient_public_key
118            .trim()
119            .parse()
120            .map_err(|e: age::ssh::ParseRecipientKeyError| {
121                WrappingError::InvalidRecipient(format!("{e:?}"))
122            })?;
123
124    let encryptor =
125        age::Encryptor::with_recipients([&recipient as &dyn age::Recipient].into_iter())
126            .map_err(|_| WrappingError::EncryptSetup)?;
127
128    let mut output = Vec::new();
129    let mut writer = encryptor
130        .wrap_output(&mut output)
131        .map_err(|_| WrappingError::EncryptSetup)?;
132    writer
133        .write_all(json.as_bytes())
134        .map_err(|_| WrappingError::EncryptWrite)?;
135    writer.finish().map_err(|_| WrappingError::EncryptFinish)?;
136
137    Ok(output)
138}
139
140/// Decrypts an invite created by `wrap_invite_for_ssh_recipient`.
141///
142/// Supports both the new JSON format and the legacy raw-32-byte format.
143pub fn unwrap_invite_with_ssh_identity_file(
144    encrypted_invite: &[u8],
145    identity_file: &Path,
146) -> Result<InvitePayload, WrappingError> {
147    let decryptor =
148        Decryptor::new(encrypted_invite).map_err(|_| WrappingError::InvalidEncryptedInvite)?;
149
150    let file = File::open(identity_file)?;
151    let mut reader = BufReader::new(file);
152    let identity = age::ssh::Identity::from_buffer(&mut reader, None)
153        .map_err(|_| WrappingError::IdentityParse)?;
154
155    let identities = vec![&identity as &dyn age::Identity];
156    let mut decrypted_reader = decryptor
157        .decrypt(identities.into_iter())
158        .map_err(|_| WrappingError::DecryptFailure)?;
159
160    let mut plaintext = Vec::new();
161    decrypted_reader
162        .read_to_end(&mut plaintext)
163        .map_err(|_| WrappingError::DecryptFailure)?;
164
165    // Try JSON format first.
166    if let Ok(text) = std::str::from_utf8(&plaintext)
167        && let Ok(v) = serde_json::from_str::<serde_json::Value>(text)
168        && let Some(key_hex) = v.get("key").and_then(|k| k.as_str())
169        && let Ok(key_bytes) = hex::decode(key_hex)
170        && key_bytes.len() == 32
171    {
172        let vault_id = v
173            .get("vault_id")
174            .and_then(|k| k.as_str())
175            .unwrap_or("")
176            .to_string();
177        let relay_url = v
178            .get("relay_url")
179            .and_then(|k| k.as_str())
180            .map(str::to_string);
181        return Ok(InvitePayload {
182            vault_id,
183            relay_url,
184            key: secret_bytes(key_bytes),
185        });
186    }
187
188    // Legacy format: raw 32-byte key with no embedded metadata.
189    if plaintext.len() == 32 {
190        return Ok(InvitePayload {
191            vault_id: String::new(),
192            relay_url: None,
193            key: secret_bytes(plaintext),
194        });
195    }
196
197    Err(WrappingError::InvalidProjectKeySize)
198}
199
200#[cfg(test)]
201mod tests {
202    use super::{unwrap_project_key_with_ssh_identity_file, wrap_project_key_for_ssh_recipient};
203    use crate::crypto::secret_bytes;
204    use secrecy::ExposeSecret;
205    use std::fs;
206    use uuid::Uuid;
207
208    const ED25519_PRIVATE: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\n\
209b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n\
210QyNTUxOQAAACC08MnmfkXbvDUS6ZCCLP+IVNuHmnR6xmfIm3grO/i8eAAAAKC9VdgAvVXY\n\
211AAAAAAtzc2gtZWQyNTUxOQAAACC08MnmfkXbvDUS6ZCCLP+IVNuHmnR6xmfIm3grO/i8eA\n\
212AAAEAe+MvbyIgxPxS9Q0z17bjL4zmDhgTgal6UxuwRHkGYSLTwyeZ+Rdu8NRLpkIIs/4hU\n\
21324eadHrGZ8ibeCs7+Lx4AAAAFmlja2RldkBMQVBUT1AtSThRVUQxN1YBAgMEBQYH\n\
214-----END OPENSSH PRIVATE KEY-----\n";
215
216    const ED25519_PUBLIC: &str =
217        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILTwyeZ+Rdu8NRLpkIIs/4hU24eadHrGZ8ibeCs7+Lx4";
218
219    const RSA_PRIVATE: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\n\
220b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\n\
221NhAAAAAwEAAQAAAQEA2d2TQ47WLUaiHx3eDA/jAj2OJp9dBMQUnwkZJ9PwzCK4z/oLSgVO\n\
222yEdH1sy0XlfCfsDrc0FLjA2quDaC6tp137eI5qQJKIW/nvY+zHTKKdyRAAJ7AoeSq9niDn\n\
223lOCmnjsTJpDwDhezL+G7WK4FXsNd2qX6nsd4ZePwd5GjRILQnzwHEAwAb+0u9M+kqk99Nf\n\
224btum350ATqBNErxmMHf7qYphG+cwjhqaaJpOx7NPPSRcntw9vM79CXU/uhCTd2OKj6bkuR\n\
225IxZZqMqyrQwwaYCfzv5c5ReGvmD/xt8xq9vJbMAYOJg7Pn6ewVm4RxcES3TwkTGqnec+yL\n\
226eoJ7ZHHizQAAA9DVhT/p1YU/6QAAAAdzc2gtcnNhAAABAQDZ3ZNDjtYtRqIfHd4MD+MCPY\n\
2274mn10ExBSfCRkn0/DMIrjP+gtKBU7IR0fWzLReV8J+wOtzQUuMDaq4NoLq2nXft4jmpAko\n\
228hb+e9j7MdMop3JEAAnsCh5Kr2eIOeU4KaeOxMmkPAOF7Mv4btYrgVew13apfqex3hl4/B3\n\
229kaNEgtCfPAcQDABv7S70z6SqT3019u26bfnQBOoE0SvGYwd/upimEb5zCOGppomk7Hs089\n\
230JFye3D28zv0JdT+6EJN3Y4qPpuS5EjFlmoyrKtDDBpgJ/O/lzlF4a+YP/G3zGr28lswBg4\n\
231mDs+fp7BWbhHFwRLdPCRMaqd5z7It6gntkceLNAAAAAwEAAQAAAQAZzu04hg2qHGFtJTke\n\
232Ha2rIMabnapDu8SjmEzSEoHGdOCGxpyavqk4AXWppONC/8tq/4iExTnhU+ci3lZA4vMuts\n\
233uxYsIw+jMabho/VyBxuA63PRP8VzoRQITOaSFNC4EtBwc5/0U2tnIyrx1N+O+768/YeEUq\n\
234XZEBj22RpJreNsUi740JpAGTC66GvGxWYDNv3GLCmTn6f9/kZPzxqyn7f9Q9W4VL4Tz7v5\n\
235t5tClFT2JiGKP9V88rzoMCVWxdsbQ32eh00otIX94ll3zROPN8C+zweIe2Dln949gynwX/\n\
236xsGF1TUAF0okooJG/5OwIlcqH42utCzM7Xt9rKSZ3BIzAAAAgQCEdYN8bVWtyn8PKyIR5f\n\
237PIxzjDymFi6oKeTstfDX4hW3KMlx9Ssw12beT94A9F8HxZ3n94lenmMMWZTRK5GHWlr+Bc\n\
238rQDJ0OCMWgNX/9yvIV7CABcJEq1HN0zAw2r3SEKykvavrZYdteNOv6xUgeIG31lMc1415B\n\
239GwUdRe5cg36QAAAIEA8BNs3rPmZKiwhgPMchMwvbPy3j3A5ueKszJSfSrmXH9CDaVeeegr\n\
240RRXQ+bvg08ddnylez1sXmi8BnpNEBbLloF77mnJfydyjw680+GmLhApSh9cL/DQRhb3fVZ\n\
241S5s8lkQC2NZz2Gng+kopUJHjoGT2K6DjGorgqcXtA60VNsGNcAAACBAOhRAp313i8PpICM\n\
242P/Cez1Gdoi4+o5Pt/Tx7qcE7gxdizr1wHKIoWWKDhS6vq2hvPgDtEoYj/n7xrSj/FUXtk3\n\
243/tTCGT39tUKpZ8ECVBoZHDxRVkqNZo+DExGELwtskxrSL/g3xmpcVQF9Yp83I3vkGGbP/s\n\
244KI+W/Y8KVMd63bj7AAAAFmlja2RldkBMQVBUT1AtSThRVUQxN1YBAgME\n\
245-----END OPENSSH PRIVATE KEY-----\n";
246
247    const RSA_PUBLIC: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDZ3ZNDjtYtRqIfHd4MD+MCPY4mn10ExBSfCRkn0/DMIrjP+gtKBU7IR0fWzLReV8J+wOtzQUuMDaq4NoLq2nXft4jmpAkohb+e9j7MdMop3JEAAnsCh5Kr2eIOeU4KaeOxMmkPAOF7Mv4btYrgVew13apfqex3hl4/B3kaNEgtCfPAcQDABv7S70z6SqT3019u26bfnQBOoE0SvGYwd/upimEb5zCOGppomk7Hs089JFye3D28zv0JdT+6EJN3Y4qPpuS5EjFlmoyrKtDDBpgJ/O/lzlF4a+YP/G3zGr28lswBg4mDs+fp7BWbhHFwRLdPCRMaqd5z7It6gntkceLN";
248
249    #[test]
250    fn ssh_key_wrapping_ed25519() {
251        run_roundtrip(ED25519_PRIVATE, ED25519_PUBLIC);
252    }
253
254    #[test]
255    fn ssh_key_wrapping_rsa() {
256        run_roundtrip(RSA_PRIVATE, RSA_PUBLIC);
257    }
258
259    fn run_roundtrip(private: &str, public: &str) {
260        let tmp = std::env::temp_dir().join(format!("vyn-wrap-fixture-{}", Uuid::new_v4()));
261        fs::create_dir_all(&tmp).expect("temp directory should be created");
262        let private_key = tmp.join("id_key");
263        fs::write(&private_key, private).expect("private key fixture should be written");
264        let key = secret_bytes(vec![11u8; 32]);
265
266        let encrypted =
267            wrap_project_key_for_ssh_recipient(&key, public).expect("wrapping should succeed");
268        let unwrapped = unwrap_project_key_with_ssh_identity_file(&encrypted, &private_key)
269            .expect("unwrapping should succeed");
270
271        assert_eq!(key.expose_secret(), unwrapped.expose_secret());
272
273        fs::remove_dir_all(tmp).expect("temp directory should be removed");
274    }
275}