runmat_execution/security/
mod.rs1use std::collections::BTreeSet;
7
8use ed25519_dalek::{Signature, Signer as _, SigningKey, Verifier as _, VerifyingKey};
9use minicbor::Encoder;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest as _, Sha256};
12
13use crate::ContractError;
14
15#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17#[repr(u8)]
18pub enum ExecutionTrustTier {
19 CustomerTrusted,
20 HostedOrdinary,
21 AttestedConfidential,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
25#[serde(deny_unknown_fields)]
26pub struct EndpointRecipientKey {
27 pub suite: String,
28 pub public_key: Vec<u8>,
29 pub fingerprint: String,
30 pub valid_after_unix_millis: u64,
31 pub valid_before_unix_millis: u64,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct DirectQuicEndpoint {
37 pub authority: String,
38 pub server_name: String,
39 pub certificate_der: Vec<u8>,
40 pub certificate_sha256: String,
41}
42
43#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct EndpointIdentityEvidence {
46 pub schema_version: u16,
47 pub org_id: String,
48 pub cluster_id: String,
49 pub node_id: String,
50 pub allocation_lease_id: String,
51 pub fencing_token: u64,
52 pub run_identity: String,
53 pub identity_public_key: Vec<u8>,
54 pub identity_fingerprint: String,
55 pub recipient: EndpointRecipientKey,
56 pub direct_quic_endpoints: Vec<DirectQuicEndpoint>,
57 pub trust_tier: ExecutionTrustTier,
58 pub attestation_class: Option<String>,
59 pub attestation_evidence: Option<Vec<u8>>,
60 pub issued_at_unix_millis: u64,
61 pub expires_at_unix_millis: u64,
62 pub signature: Vec<u8>,
63}
64
65#[derive(Clone)]
66pub struct EndpointIdentitySigner(SigningKey);
67
68impl EndpointIdentitySigner {
69 pub fn from_secret(secret: [u8; 32]) -> Result<Self, ContractError> {
70 if secret.iter().all(|byte| *byte == 0) {
71 return Err(invalid("identity signing secret is all zero"));
72 }
73 Ok(Self(SigningKey::from_bytes(&secret)))
74 }
75
76 pub fn public_key(&self) -> [u8; 32] {
77 self.0.verifying_key().to_bytes()
78 }
79
80 pub fn fingerprint(&self) -> String {
81 identity_fingerprint(&self.public_key())
82 }
83
84 pub fn sign(&self, evidence: &mut EndpointIdentityEvidence) -> Result<(), ContractError> {
85 if evidence.identity_public_key != self.public_key()
86 || evidence.identity_fingerprint != self.fingerprint()
87 {
88 return Err(invalid(
89 "signer does not own the declared endpoint identity",
90 ));
91 }
92 evidence.signature = vec![0; 64];
93 evidence.validate_shape()?;
94 evidence.signature = self.0.sign(&evidence.signing_bytes()?).to_bytes().to_vec();
95 Ok(())
96 }
97}
98
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct EndpointTrustPolicy {
101 pub permitted_tiers: BTreeSet<ExecutionTrustTier>,
102 pub trusted_identity_fingerprints: BTreeSet<String>,
103 pub allowed_attestation_classes: BTreeSet<String>,
104 pub require_pinned_identity: bool,
105 pub now_unix_millis: u64,
106 pub maximum_clock_skew_millis: u64,
107}
108
109impl EndpointTrustPolicy {
110 pub fn verify(&self, evidence: &EndpointIdentityEvidence) -> Result<(), ContractError> {
111 evidence.validate_shape()?;
112 if !self.permitted_tiers.contains(&evidence.trust_tier)
113 || (self.require_pinned_identity
114 && !self
115 .trusted_identity_fingerprints
116 .contains(&evidence.identity_fingerprint))
117 || evidence.issued_at_unix_millis
118 > self
119 .now_unix_millis
120 .saturating_add(self.maximum_clock_skew_millis)
121 || evidence.expires_at_unix_millis
122 <= self
123 .now_unix_millis
124 .saturating_sub(self.maximum_clock_skew_millis)
125 {
126 return Err(invalid(
127 "endpoint evidence is not authorized by the local trust policy",
128 ));
129 }
130 if evidence.trust_tier == ExecutionTrustTier::AttestedConfidential
131 && !evidence.attestation_class.as_ref().is_some_and(|class| {
132 self.allowed_attestation_classes.is_empty()
133 || self.allowed_attestation_classes.contains(class)
134 })
135 {
136 return Err(invalid("attestation class is not locally trusted"));
137 }
138 let public_key: [u8; 32] = evidence
139 .identity_public_key
140 .as_slice()
141 .try_into()
142 .map_err(|_| invalid("endpoint signing key has an invalid length"))?;
143 let verifying_key =
144 VerifyingKey::from_bytes(&public_key).map_err(|_| invalid("invalid signing key"))?;
145 let signature = Signature::from_slice(&evidence.signature)
146 .map_err(|_| invalid("invalid endpoint evidence signature"))?;
147 verifying_key
148 .verify(&evidence.signing_bytes()?, &signature)
149 .map_err(|_| invalid("endpoint evidence signature verification failed"))
150 }
151}
152
153impl EndpointIdentityEvidence {
154 pub fn validate_shape(&self) -> Result<(), ContractError> {
155 let identifiers = [
156 self.org_id.as_str(),
157 self.cluster_id.as_str(),
158 self.node_id.as_str(),
159 self.allocation_lease_id.as_str(),
160 self.run_identity.as_str(),
161 ];
162 if self.schema_version != 1
163 || identifiers.iter().any(|value| !bounded_ascii(value, 256))
164 || self.fencing_token == 0
165 || self.identity_public_key.len() != 32
166 || self.identity_fingerprint
167 != fingerprint(
168 "runmat-execution-endpoint-identity-v1",
169 &self.identity_public_key,
170 )
171 || self.recipient.suite != "x25519-hkdf-sha256-aes128gcm-v1"
172 || self.recipient.public_key.len() != 32
173 || self.recipient.fingerprint
174 != fingerprint(
175 "runmat-execution-recipient-key-v1",
176 &self.recipient.public_key,
177 )
178 || self.recipient.valid_after_unix_millis >= self.recipient.valid_before_unix_millis
179 || self.issued_at_unix_millis >= self.expires_at_unix_millis
180 || self.recipient.valid_after_unix_millis > self.issued_at_unix_millis
181 || self.recipient.valid_before_unix_millis < self.expires_at_unix_millis
182 || self.direct_quic_endpoints.len() > 8
183 || self
184 .direct_quic_endpoints
185 .iter()
186 .any(|endpoint| !endpoint.validate())
187 || self.signature.len() != 64
188 || self
189 .attestation_class
190 .as_deref()
191 .is_some_and(|value| !bounded_ascii(value, 128))
192 || self
193 .attestation_evidence
194 .as_ref()
195 .is_some_and(|value| value.len() > 64 * 1024)
196 || (self.attestation_class.is_some() != self.attestation_evidence.is_some())
197 || (self.trust_tier == ExecutionTrustTier::AttestedConfidential
198 && self.attestation_class.is_none())
199 {
200 return Err(ContractError::Invalid {
201 field: "endpoint_identity_evidence",
202 reason: "evidence is malformed".into(),
203 });
204 }
205 Ok(())
206 }
207
208 pub fn signing_bytes(&self) -> Result<Vec<u8>, ContractError> {
209 let mut bytes = b"runmat-execution-endpoint-evidence-v1\0".to_vec();
210 let mut encoder = Encoder::new(&mut bytes);
211 encoder
212 .array(20)
213 .and_then(|encoder| encoder.u16(self.schema_version))
214 .and_then(|encoder| encoder.str(&self.org_id))
215 .and_then(|encoder| encoder.str(&self.cluster_id))
216 .and_then(|encoder| encoder.str(&self.node_id))
217 .and_then(|encoder| encoder.str(&self.allocation_lease_id))
218 .and_then(|encoder| encoder.u64(self.fencing_token))
219 .and_then(|encoder| encoder.str(&self.run_identity))
220 .and_then(|encoder| encoder.bytes(&self.identity_public_key))
221 .and_then(|encoder| encoder.str(&self.identity_fingerprint))
222 .and_then(|encoder| encoder.str(&self.recipient.suite))
223 .and_then(|encoder| encoder.bytes(&self.recipient.public_key))
224 .and_then(|encoder| encoder.str(&self.recipient.fingerprint))
225 .and_then(|encoder| encoder.u64(self.recipient.valid_after_unix_millis))
226 .and_then(|encoder| encoder.u64(self.recipient.valid_before_unix_millis))
227 .map_err(encoding)?;
228 encoder
229 .array(self.direct_quic_endpoints.len() as u64)
230 .map_err(encoding)?;
231 for endpoint in &self.direct_quic_endpoints {
232 encoder
233 .array(4)
234 .and_then(|encoder| encoder.str(&endpoint.authority))
235 .and_then(|encoder| encoder.str(&endpoint.server_name))
236 .and_then(|encoder| encoder.bytes(&endpoint.certificate_der))
237 .and_then(|encoder| encoder.str(&endpoint.certificate_sha256))
238 .map_err(encoding)?;
239 }
240 encoder.u8(self.trust_tier as u8).map_err(encoding)?;
241 encode_optional_text(&mut encoder, self.attestation_class.as_deref())?;
242 encode_optional_bytes(&mut encoder, self.attestation_evidence.as_deref())?;
243 encoder
244 .u64(self.issued_at_unix_millis)
245 .and_then(|encoder| encoder.u64(self.expires_at_unix_millis))
246 .map_err(encoding)?;
247 Ok(bytes)
248 }
249}
250
251impl DirectQuicEndpoint {
252 fn validate(&self) -> bool {
253 bounded_ascii(&self.authority, 512)
254 && bounded_ascii(&self.server_name, 253)
255 && !self.certificate_der.is_empty()
256 && self.certificate_der.len() <= 16 * 1024
257 && self.certificate_sha256 == format!("{:x}", Sha256::digest(&self.certificate_der))
258 }
259}
260
261pub fn identity_fingerprint(public_key: &[u8]) -> String {
262 fingerprint("runmat-execution-endpoint-identity-v1", public_key)
263}
264
265pub fn recipient_fingerprint(public_key: &[u8]) -> String {
266 fingerprint("runmat-execution-recipient-key-v1", public_key)
267}
268
269fn fingerprint(domain: &str, public_key: &[u8]) -> String {
270 let mut digest = Sha256::new();
271 digest.update(domain.as_bytes());
272 digest.update([0]);
273 digest.update(public_key);
274 format!("{:x}", digest.finalize())
275}
276
277fn bounded_ascii(value: &str, maximum: usize) -> bool {
278 !value.is_empty()
279 && value.len() <= maximum
280 && value.is_ascii()
281 && !value.chars().any(char::is_control)
282}
283
284fn encode_optional_text(
285 encoder: &mut Encoder<&mut Vec<u8>>,
286 value: Option<&str>,
287) -> Result<(), ContractError> {
288 match value {
289 Some(value) => encoder.str(value),
290 None => encoder.null(),
291 }
292 .map(|_| ())
293 .map_err(encoding)
294}
295
296fn encode_optional_bytes(
297 encoder: &mut Encoder<&mut Vec<u8>>,
298 value: Option<&[u8]>,
299) -> Result<(), ContractError> {
300 match value {
301 Some(value) => encoder.bytes(value),
302 None => encoder.null(),
303 }
304 .map(|_| ())
305 .map_err(encoding)
306}
307
308fn encoding(error: minicbor::encode::Error<std::convert::Infallible>) -> ContractError {
309 ContractError::MalformedProtocol(error.to_string())
310}
311
312fn invalid(reason: impl Into<String>) -> ContractError {
313 ContractError::Invalid {
314 field: "endpoint_identity_evidence",
315 reason: reason.into(),
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 fn evidence() -> EndpointIdentityEvidence {
324 let signer = EndpointIdentitySigner::from_secret([7; 32]).unwrap();
325 let recipient_public = [9; 32];
326 let mut evidence = EndpointIdentityEvidence {
327 schema_version: 1,
328 org_id: "org_a".into(),
329 cluster_id: "cluster_a".into(),
330 node_id: "node_a".into(),
331 allocation_lease_id: "lease_a".into(),
332 fencing_token: 3,
333 run_identity: "run_a".into(),
334 identity_public_key: signer.public_key().to_vec(),
335 identity_fingerprint: signer.fingerprint(),
336 recipient: EndpointRecipientKey {
337 suite: "x25519-hkdf-sha256-aes128gcm-v1".into(),
338 public_key: recipient_public.to_vec(),
339 fingerprint: recipient_fingerprint(&recipient_public),
340 valid_after_unix_millis: 900,
341 valid_before_unix_millis: 2_000,
342 },
343 direct_quic_endpoints: vec![DirectQuicEndpoint {
344 authority: "127.0.0.1:4433".into(),
345 server_name: "runmat.execution".into(),
346 certificate_der: vec![1, 2, 3],
347 certificate_sha256: format!("{:x}", Sha256::digest([1, 2, 3])),
348 }],
349 trust_tier: ExecutionTrustTier::CustomerTrusted,
350 attestation_class: None,
351 attestation_evidence: None,
352 issued_at_unix_millis: 1_000,
353 expires_at_unix_millis: 1_500,
354 signature: vec![0; 64],
355 };
356 signer.sign(&mut evidence).unwrap();
357 evidence
358 }
359
360 fn policy(evidence: &EndpointIdentityEvidence) -> EndpointTrustPolicy {
361 EndpointTrustPolicy {
362 permitted_tiers: [ExecutionTrustTier::CustomerTrusted].into_iter().collect(),
363 trusted_identity_fingerprints: [evidence.identity_fingerprint.clone()]
364 .into_iter()
365 .collect(),
366 allowed_attestation_classes: BTreeSet::new(),
367 require_pinned_identity: true,
368 now_unix_millis: 1_100,
369 maximum_clock_skew_millis: 10,
370 }
371 }
372
373 #[test]
374 fn verifies_pinned_evidence_and_rejects_substitution_replay_and_downgrade() {
375 let evidence = evidence();
376 policy(&evidence).verify(&evidence).unwrap();
377
378 let mut substitution = evidence.clone();
379 substitution.run_identity = "run_b".into();
380 assert!(policy(&evidence).verify(&substitution).is_err());
381
382 let mut replay = policy(&evidence);
383 replay.now_unix_millis = 2_000;
384 assert!(replay.verify(&evidence).is_err());
385
386 let mut downgrade = evidence.clone();
387 downgrade.trust_tier = ExecutionTrustTier::HostedOrdinary;
388 assert!(policy(&evidence).verify(&downgrade).is_err());
389
390 let mut transport_substitution = evidence.clone();
391 transport_substitution.direct_quic_endpoints[0].authority = "attacker:4433".into();
392 assert!(policy(&evidence).verify(&transport_substitution).is_err());
393
394 let mut certificate_substitution = evidence.clone();
395 certificate_substitution.direct_quic_endpoints[0].certificate_der[0] ^= 1;
396 assert!(policy(&evidence).verify(&certificate_substitution).is_err());
397 }
398}