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#[derive(Debug, Error)]
12pub enum WrappingError {
13    #[error("invalid recipient public key: {0}")]
14    InvalidRecipient(String),
15    #[error("encryption setup failed")]
16    EncryptSetup,
17    #[error("failed to write encrypted payload")]
18    EncryptWrite,
19    #[error("failed to finalize encrypted payload")]
20    EncryptFinish,
21    #[error("invalid encrypted invite format")]
22    InvalidEncryptedInvite,
23    #[error("failed to open SSH private key file: {0}")]
24    IdentityOpen(#[from] std::io::Error),
25    #[error("failed to parse SSH private key identity")]
26    IdentityParse,
27    #[error("failed to decrypt invite with SSH identity")]
28    DecryptFailure,
29    #[error("decrypted project key has invalid size")]
30    InvalidProjectKeySize,
31}
32
33pub fn wrap_project_key_for_ssh_recipient(
34    project_key: &SecretBytes,
35    recipient_public_key: &str,
36) -> Result<Vec<u8>, WrappingError> {
37    let recipient: age::ssh::Recipient =
38        recipient_public_key
39            .trim()
40            .parse()
41            .map_err(|e: age::ssh::ParseRecipientKeyError| {
42                WrappingError::InvalidRecipient(format!("{e:?}"))
43            })?;
44
45    let recipients = [recipient];
46    let encryptor =
47        age::Encryptor::with_recipients(recipients.iter().map(|r| r as &dyn age::Recipient))
48            .map_err(|_| WrappingError::EncryptSetup)?;
49
50    let mut output = Vec::new();
51    let mut writer = encryptor
52        .wrap_output(&mut output)
53        .map_err(|_| WrappingError::EncryptSetup)?;
54    writer
55        .write_all(project_key.expose_secret())
56        .map_err(|_| WrappingError::EncryptWrite)?;
57    writer.finish().map_err(|_| WrappingError::EncryptFinish)?;
58
59    Ok(output)
60}
61
62pub fn unwrap_project_key_with_ssh_identity_file(
63    encrypted_invite: &[u8],
64    identity_file: &Path,
65) -> Result<SecretBytes, WrappingError> {
66    let decryptor =
67        Decryptor::new(encrypted_invite).map_err(|_| WrappingError::InvalidEncryptedInvite)?;
68
69    let file = File::open(identity_file)?;
70    let mut reader = BufReader::new(file);
71    let identity = age::ssh::Identity::from_buffer(&mut reader, None)
72        .map_err(|_| WrappingError::IdentityParse)?;
73
74    let identities = vec![&identity as &dyn age::Identity];
75    let mut reader = decryptor
76        .decrypt(identities.into_iter())
77        .map_err(|_| WrappingError::DecryptFailure)?;
78
79    let mut plaintext = Vec::new();
80    reader
81        .read_to_end(&mut plaintext)
82        .map_err(|_| WrappingError::DecryptFailure)?;
83
84    if plaintext.len() != 32 {
85        return Err(WrappingError::InvalidProjectKeySize);
86    }
87
88    Ok(secret_bytes(plaintext))
89}
90
91#[cfg(test)]
92mod tests {
93    use super::{unwrap_project_key_with_ssh_identity_file, wrap_project_key_for_ssh_recipient};
94    use crate::crypto::secret_bytes;
95    use secrecy::ExposeSecret;
96    use std::fs;
97    use uuid::Uuid;
98
99    const ED25519_PRIVATE: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\n\
100b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n\
101QyNTUxOQAAACC08MnmfkXbvDUS6ZCCLP+IVNuHmnR6xmfIm3grO/i8eAAAAKC9VdgAvVXY\n\
102AAAAAAtzc2gtZWQyNTUxOQAAACC08MnmfkXbvDUS6ZCCLP+IVNuHmnR6xmfIm3grO/i8eA\n\
103AAAEAe+MvbyIgxPxS9Q0z17bjL4zmDhgTgal6UxuwRHkGYSLTwyeZ+Rdu8NRLpkIIs/4hU\n\
10424eadHrGZ8ibeCs7+Lx4AAAAFmlja2RldkBMQVBUT1AtSThRVUQxN1YBAgMEBQYH\n\
105-----END OPENSSH PRIVATE KEY-----\n";
106
107    const ED25519_PUBLIC: &str =
108        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILTwyeZ+Rdu8NRLpkIIs/4hU24eadHrGZ8ibeCs7+Lx4";
109
110    const RSA_PRIVATE: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\n\
111b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABFwAAAAdzc2gtcn\n\
112NhAAAAAwEAAQAAAQEA2d2TQ47WLUaiHx3eDA/jAj2OJp9dBMQUnwkZJ9PwzCK4z/oLSgVO\n\
113yEdH1sy0XlfCfsDrc0FLjA2quDaC6tp137eI5qQJKIW/nvY+zHTKKdyRAAJ7AoeSq9niDn\n\
114lOCmnjsTJpDwDhezL+G7WK4FXsNd2qX6nsd4ZePwd5GjRILQnzwHEAwAb+0u9M+kqk99Nf\n\
115btum350ATqBNErxmMHf7qYphG+cwjhqaaJpOx7NPPSRcntw9vM79CXU/uhCTd2OKj6bkuR\n\
116IxZZqMqyrQwwaYCfzv5c5ReGvmD/xt8xq9vJbMAYOJg7Pn6ewVm4RxcES3TwkTGqnec+yL\n\
117eoJ7ZHHizQAAA9DVhT/p1YU/6QAAAAdzc2gtcnNhAAABAQDZ3ZNDjtYtRqIfHd4MD+MCPY\n\
1184mn10ExBSfCRkn0/DMIrjP+gtKBU7IR0fWzLReV8J+wOtzQUuMDaq4NoLq2nXft4jmpAko\n\
119hb+e9j7MdMop3JEAAnsCh5Kr2eIOeU4KaeOxMmkPAOF7Mv4btYrgVew13apfqex3hl4/B3\n\
120kaNEgtCfPAcQDABv7S70z6SqT3019u26bfnQBOoE0SvGYwd/upimEb5zCOGppomk7Hs089\n\
121JFye3D28zv0JdT+6EJN3Y4qPpuS5EjFlmoyrKtDDBpgJ/O/lzlF4a+YP/G3zGr28lswBg4\n\
122mDs+fp7BWbhHFwRLdPCRMaqd5z7It6gntkceLNAAAAAwEAAQAAAQAZzu04hg2qHGFtJTke\n\
123Ha2rIMabnapDu8SjmEzSEoHGdOCGxpyavqk4AXWppONC/8tq/4iExTnhU+ci3lZA4vMuts\n\
124uxYsIw+jMabho/VyBxuA63PRP8VzoRQITOaSFNC4EtBwc5/0U2tnIyrx1N+O+768/YeEUq\n\
125XZEBj22RpJreNsUi740JpAGTC66GvGxWYDNv3GLCmTn6f9/kZPzxqyn7f9Q9W4VL4Tz7v5\n\
126t5tClFT2JiGKP9V88rzoMCVWxdsbQ32eh00otIX94ll3zROPN8C+zweIe2Dln949gynwX/\n\
127xsGF1TUAF0okooJG/5OwIlcqH42utCzM7Xt9rKSZ3BIzAAAAgQCEdYN8bVWtyn8PKyIR5f\n\
128PIxzjDymFi6oKeTstfDX4hW3KMlx9Ssw12beT94A9F8HxZ3n94lenmMMWZTRK5GHWlr+Bc\n\
129rQDJ0OCMWgNX/9yvIV7CABcJEq1HN0zAw2r3SEKykvavrZYdteNOv6xUgeIG31lMc1415B\n\
130GwUdRe5cg36QAAAIEA8BNs3rPmZKiwhgPMchMwvbPy3j3A5ueKszJSfSrmXH9CDaVeeegr\n\
131RRXQ+bvg08ddnylez1sXmi8BnpNEBbLloF77mnJfydyjw680+GmLhApSh9cL/DQRhb3fVZ\n\
132S5s8lkQC2NZz2Gng+kopUJHjoGT2K6DjGorgqcXtA60VNsGNcAAACBAOhRAp313i8PpICM\n\
133P/Cez1Gdoi4+o5Pt/Tx7qcE7gxdizr1wHKIoWWKDhS6vq2hvPgDtEoYj/n7xrSj/FUXtk3\n\
134/tTCGT39tUKpZ8ECVBoZHDxRVkqNZo+DExGELwtskxrSL/g3xmpcVQF9Yp83I3vkGGbP/s\n\
135KI+W/Y8KVMd63bj7AAAAFmlja2RldkBMQVBUT1AtSThRVUQxN1YBAgME\n\
136-----END OPENSSH PRIVATE KEY-----\n";
137
138    const RSA_PUBLIC: &str = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDZ3ZNDjtYtRqIfHd4MD+MCPY4mn10ExBSfCRkn0/DMIrjP+gtKBU7IR0fWzLReV8J+wOtzQUuMDaq4NoLq2nXft4jmpAkohb+e9j7MdMop3JEAAnsCh5Kr2eIOeU4KaeOxMmkPAOF7Mv4btYrgVew13apfqex3hl4/B3kaNEgtCfPAcQDABv7S70z6SqT3019u26bfnQBOoE0SvGYwd/upimEb5zCOGppomk7Hs089JFye3D28zv0JdT+6EJN3Y4qPpuS5EjFlmoyrKtDDBpgJ/O/lzlF4a+YP/G3zGr28lswBg4mDs+fp7BWbhHFwRLdPCRMaqd5z7It6gntkceLN";
139
140    #[test]
141    fn ssh_key_wrapping_ed25519() {
142        run_roundtrip(ED25519_PRIVATE, ED25519_PUBLIC);
143    }
144
145    #[test]
146    fn ssh_key_wrapping_rsa() {
147        run_roundtrip(RSA_PRIVATE, RSA_PUBLIC);
148    }
149
150    fn run_roundtrip(private: &str, public: &str) {
151        let tmp = std::env::temp_dir().join(format!("vyn-wrap-fixture-{}", Uuid::new_v4()));
152        fs::create_dir_all(&tmp).expect("temp directory should be created");
153        let private_key = tmp.join("id_key");
154        fs::write(&private_key, private).expect("private key fixture should be written");
155        let key = secret_bytes(vec![11u8; 32]);
156
157        let encrypted =
158            wrap_project_key_for_ssh_recipient(&key, public).expect("wrapping should succeed");
159        let unwrapped = unwrap_project_key_with_ssh_identity_file(&encrypted, &private_key)
160            .expect("unwrapping should succeed");
161
162        assert_eq!(key.expose_secret(), unwrapped.expose_secret());
163
164        fs::remove_dir_all(tmp).expect("temp directory should be removed");
165    }
166}