Skip to main content

runmat_package/source/
encrypted_artifact.rs

1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
2use serde::{Deserialize, Serialize};
3
4use crate::{ContentDigest, RegistrySourceId};
5
6pub const ENCRYPTED_ARTIFACT_SCHEMA_VERSION: u32 = 1;
7pub const PACKAGE_KEY_ENVELOPE_SCHEMA_VERSION: u32 = 1;
8pub const P256_PUBLIC_KEY_BYTE_LEN: usize = 65;
9pub const AES_256_GCM_NONCE_BYTE_LEN: usize = 12;
10pub const AES_256_GCM_WRAPPED_KEY_BYTE_LEN: usize = 48;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum ArtifactContentCipher {
15    Aes256Gcm,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum RecipientKeyAlgorithm {
21    P256,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "kebab-case")]
26pub enum KeyEnvelopeAlgorithm {
27    P256HkdfSha256Aes256Gcm,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase", deny_unknown_fields)]
32pub struct RecipientEncryptionKey {
33    pub id: String,
34    pub algorithm: RecipientKeyAlgorithm,
35    pub public_key: String,
36    pub fingerprint: ContentDigest,
37}
38
39impl RecipientEncryptionKey {
40    pub fn validate(&self) -> Result<(), String> {
41        if self.id.trim().is_empty() || self.id.len() > 128 {
42            return Err("recipient encryption key id is invalid".to_string());
43        }
44        let public_key = decode_exact(
45            &self.public_key,
46            P256_PUBLIC_KEY_BYTE_LEN,
47            "recipient encryption public key",
48        )?;
49        if p256::PublicKey::from_sec1_bytes(&public_key).is_err()
50            || self.fingerprint != ContentDigest::sha256(&public_key)
51        {
52            return Err("recipient encryption public key is invalid".to_string());
53        }
54        Ok(())
55    }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60pub struct EncryptedArtifactMetadata {
61    pub schema_version: u32,
62    pub content_cipher: ArtifactContentCipher,
63    pub key_version: u64,
64    pub plaintext_digest: ContentDigest,
65    pub plaintext_byte_len: u64,
66    pub nonce: String,
67    pub aad_digest: ContentDigest,
68}
69
70impl EncryptedArtifactMetadata {
71    pub fn new(
72        key_version: u64,
73        plaintext: &[u8],
74        nonce: [u8; AES_256_GCM_NONCE_BYTE_LEN],
75        source: &RegistrySourceId,
76    ) -> Result<Self, String> {
77        let mut value = Self {
78            schema_version: ENCRYPTED_ARTIFACT_SCHEMA_VERSION,
79            content_cipher: ArtifactContentCipher::Aes256Gcm,
80            key_version,
81            plaintext_digest: ContentDigest::sha256(plaintext),
82            plaintext_byte_len: plaintext.len() as u64,
83            nonce: URL_SAFE_NO_PAD.encode(nonce),
84            aad_digest: ContentDigest::sha256([]),
85        };
86        value.aad_digest = ContentDigest::sha256(value.aad_bytes(source)?);
87        value.validate(source)?;
88        Ok(value)
89    }
90
91    pub fn validate(&self, source: &RegistrySourceId) -> Result<(), String> {
92        if self.schema_version != ENCRYPTED_ARTIFACT_SCHEMA_VERSION
93            || self.key_version == 0
94            || self.plaintext_byte_len == 0
95        {
96            return Err("encrypted artifact metadata is invalid".to_string());
97        }
98        decode_exact(
99            &self.nonce,
100            AES_256_GCM_NONCE_BYTE_LEN,
101            "encrypted artifact nonce",
102        )?;
103        if self.aad_digest != ContentDigest::sha256(self.aad_bytes(source)?) {
104            return Err("encrypted artifact AAD digest is invalid".to_string());
105        }
106        Ok(())
107    }
108
109    pub fn aad_bytes(&self, source: &RegistrySourceId) -> Result<Vec<u8>, String> {
110        #[derive(Serialize)]
111        #[serde(rename_all = "camelCase")]
112        struct Canonical<'a> {
113            format: &'static str,
114            registry: &'a str,
115            namespace: &'a str,
116            name: &'a str,
117            version: String,
118            tree_digest: String,
119            content_cipher: ArtifactContentCipher,
120            key_version: u64,
121            plaintext_digest: String,
122            plaintext_byte_len: u64,
123        }
124        if self.schema_version != ENCRYPTED_ARTIFACT_SCHEMA_VERSION
125            || self.key_version == 0
126            || self.plaintext_byte_len == 0
127        {
128            return Err("encrypted artifact metadata is invalid".to_string());
129        }
130        serde_json::to_vec(&Canonical {
131            format: "runmat-private-artifact-aad-v1",
132            registry: source.registry_origin.as_str(),
133            namespace: source.package.organization(),
134            name: source.package.name(),
135            version: source.version.to_string(),
136            tree_digest: source.tree_digest.to_string(),
137            content_cipher: self.content_cipher,
138            key_version: self.key_version,
139            plaintext_digest: self.plaintext_digest.to_string(),
140            plaintext_byte_len: self.plaintext_byte_len,
141        })
142        .map_err(|error| error.to_string())
143    }
144
145    pub fn decoded_nonce(&self) -> Result<[u8; AES_256_GCM_NONCE_BYTE_LEN], String> {
146        decode_exact(
147            &self.nonce,
148            AES_256_GCM_NONCE_BYTE_LEN,
149            "encrypted artifact nonce",
150        )?
151        .try_into()
152        .map_err(|_| "encrypted artifact nonce is invalid".to_string())
153    }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase", deny_unknown_fields)]
158pub struct PackageKeyEnvelope {
159    pub schema_version: u32,
160    pub algorithm: KeyEnvelopeAlgorithm,
161    pub recipient_key_id: String,
162    pub recipient_key_fingerprint: ContentDigest,
163    pub ephemeral_public_key: String,
164    pub nonce: String,
165    pub wrapped_key: String,
166    pub context_digest: ContentDigest,
167}
168
169impl PackageKeyEnvelope {
170    pub fn validate(
171        &self,
172        recipient: &RecipientEncryptionKey,
173        artifact: &EncryptedArtifactMetadata,
174    ) -> Result<(), String> {
175        if self.schema_version != PACKAGE_KEY_ENVELOPE_SCHEMA_VERSION
176            || self.recipient_key_id != recipient.id
177            || self.recipient_key_fingerprint != recipient.fingerprint
178        {
179            return Err("package key envelope recipient is invalid".to_string());
180        }
181        recipient.validate()?;
182        let ephemeral_public_key = decode_exact(
183            &self.ephemeral_public_key,
184            P256_PUBLIC_KEY_BYTE_LEN,
185            "package key envelope ephemeral public key",
186        )?;
187        if p256::PublicKey::from_sec1_bytes(&ephemeral_public_key).is_err() {
188            return Err("package key envelope ephemeral public key is invalid".to_string());
189        }
190        decode_exact(
191            &self.nonce,
192            AES_256_GCM_NONCE_BYTE_LEN,
193            "package key envelope nonce",
194        )?;
195        decode_exact(
196            &self.wrapped_key,
197            AES_256_GCM_WRAPPED_KEY_BYTE_LEN,
198            "wrapped package content key",
199        )?;
200        if self.context_digest != ContentDigest::sha256(self.context_bytes(artifact)?) {
201            return Err("package key envelope context digest is invalid".to_string());
202        }
203        Ok(())
204    }
205
206    pub fn context_bytes(&self, artifact: &EncryptedArtifactMetadata) -> Result<Vec<u8>, String> {
207        #[derive(Serialize)]
208        #[serde(rename_all = "camelCase")]
209        struct Canonical<'a> {
210            format: &'static str,
211            artifact_aad_digest: String,
212            key_version: u64,
213            recipient_key_id: &'a str,
214            recipient_key_fingerprint: String,
215            ephemeral_public_key: &'a str,
216            algorithm: KeyEnvelopeAlgorithm,
217        }
218        if self.recipient_key_id.trim().is_empty() || self.recipient_key_id.len() > 128 {
219            return Err("package key envelope recipient is invalid".to_string());
220        }
221        serde_json::to_vec(&Canonical {
222            format: "runmat-package-key-envelope-context-v1",
223            artifact_aad_digest: artifact.aad_digest.to_string(),
224            key_version: artifact.key_version,
225            recipient_key_id: &self.recipient_key_id,
226            recipient_key_fingerprint: self.recipient_key_fingerprint.to_string(),
227            ephemeral_public_key: &self.ephemeral_public_key,
228            algorithm: self.algorithm,
229        })
230        .map_err(|error| error.to_string())
231    }
232
233    pub fn decoded_ephemeral_public_key(&self) -> Result<Vec<u8>, String> {
234        decode_exact(
235            &self.ephemeral_public_key,
236            P256_PUBLIC_KEY_BYTE_LEN,
237            "package key envelope ephemeral public key",
238        )
239    }
240
241    pub fn decoded_nonce(&self) -> Result<[u8; AES_256_GCM_NONCE_BYTE_LEN], String> {
242        decode_exact(
243            &self.nonce,
244            AES_256_GCM_NONCE_BYTE_LEN,
245            "package key envelope nonce",
246        )?
247        .try_into()
248        .map_err(|_| "package key envelope nonce is invalid".to_string())
249    }
250
251    pub fn decoded_wrapped_key(&self) -> Result<Vec<u8>, String> {
252        decode_exact(
253            &self.wrapped_key,
254            AES_256_GCM_WRAPPED_KEY_BYTE_LEN,
255            "wrapped package content key",
256        )
257    }
258}
259
260fn decode_exact(value: &str, expected: usize, label: &str) -> Result<Vec<u8>, String> {
261    let decoded = URL_SAFE_NO_PAD
262        .decode(value)
263        .map_err(|_| format!("{label} is not canonical base64url"))?;
264    if decoded.len() != expected || URL_SAFE_NO_PAD.encode(&decoded) != value {
265        return Err(format!("{label} has an invalid length or encoding"));
266    }
267    Ok(decoded)
268}