Skip to main content

runmat_execution/security/
mod.rs

1//! Portable, content-free endpoint identity evidence.
2//!
3//! This module defines canonical signed bytes and bounded wire models. Native
4//! and browser hosts supply the platform crypto and local trust policy.
5
6use 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}