Skip to main content

runmat_package/source/
supply_chain.rs

1use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
2use chrono::{DateTime, Utc};
3use ed25519_dalek::{Signature, Verifier as _, VerifyingKey};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest as _, Sha256};
6
7pub const RELEASE_SUPPLY_CHAIN_SCHEMA_VERSION: u32 = 1;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum PackageTrustTier {
12    Official,
13    VerifiedWrapper,
14    Community,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum SbomFormat {
20    CycloneDxJson,
21    SpdxJson,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct SbomReference {
27    pub format: SbomFormat,
28    pub digest: String,
29    pub media_type: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34pub struct WrapperProvenance {
35    pub upstream_repository: String,
36    pub upstream_version: String,
37    pub upstream_digest: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct BuildProvenance {
43    pub source_repository: String,
44    pub source_commit: String,
45    pub builder_id: String,
46    pub workflow_ref: Option<String>,
47    pub invocation_id: String,
48    pub inventory_digest: String,
49    pub release_manifest_digest: String,
50    pub sbom: Option<SbomReference>,
51    pub license: Option<String>,
52    pub wrapper: Option<WrapperProvenance>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57pub struct RegistryReleaseSupplyChain {
58    pub schema_version: u32,
59    pub publication_id: String,
60    pub publisher_id: String,
61    pub publisher_name: String,
62    pub public_key: String,
63    pub key_fingerprint: String,
64    pub payload_digest: String,
65    pub sequence: u64,
66    pub signed_at: DateTime<Utc>,
67    pub trust_tier: PackageTrustTier,
68    pub provenance: BuildProvenance,
69    pub signature: String,
70}
71
72impl RegistryReleaseSupplyChain {
73    pub fn verify(&self, package_id: &str) -> Result<(), String> {
74        self.validate()?;
75        let key_bytes = STANDARD_NO_PAD
76            .decode(&self.public_key)
77            .map_err(|_| "trusted publisher public key is invalid".to_string())?;
78        let key_bytes: [u8; 32] = key_bytes
79            .try_into()
80            .map_err(|_| "trusted publisher public key is invalid".to_string())?;
81        let key = VerifyingKey::from_bytes(&key_bytes)
82            .map_err(|_| "trusted publisher public key is invalid".to_string())?;
83        let fingerprint = format!("sha256:{:x}", Sha256::digest(key.as_bytes()));
84        if fingerprint != self.key_fingerprint {
85            return Err("trusted publisher key fingerprint differs from its key".to_string());
86        }
87        let signature_bytes = STANDARD_NO_PAD
88            .decode(&self.signature)
89            .map_err(|_| "publication signature is invalid".to_string())?;
90        let signature = Signature::from_slice(&signature_bytes)
91            .map_err(|_| "publication signature is invalid".to_string())?;
92        key.verify(&self.canonical_signing_bytes(package_id)?, &signature)
93            .map_err(|_| "publication signature is invalid".to_string())
94    }
95
96    pub fn canonical_signing_bytes(&self, package_id: &str) -> Result<Vec<u8>, String> {
97        #[derive(Serialize)]
98        #[serde(rename_all = "camelCase")]
99        struct Canonical<'a> {
100            format: &'static str,
101            publication_id: &'a str,
102            package_id: &'a str,
103            publisher_id: &'a str,
104            payload_digest: &'a str,
105            sequence: u64,
106            issued_at_micros: i64,
107            provenance: &'a BuildProvenance,
108        }
109        serde_json::to_vec(&Canonical {
110            format: "runmat-publication-attestation-v1",
111            publication_id: &self.publication_id,
112            package_id,
113            publisher_id: &self.publisher_id,
114            payload_digest: &self.payload_digest,
115            sequence: self.sequence,
116            issued_at_micros: self.signed_at.timestamp_micros(),
117            provenance: &self.provenance,
118        })
119        .map_err(|error| error.to_string())
120    }
121
122    fn validate(&self) -> Result<(), String> {
123        if self.schema_version != RELEASE_SUPPLY_CHAIN_SCHEMA_VERSION
124            || self.publication_id.is_empty()
125            || self.publisher_id.is_empty()
126            || self.publisher_name.trim().is_empty()
127            || self.publisher_name.len() > 128
128            || self.sequence == 0
129            || !valid_sha256(&self.key_fingerprint)
130            || !valid_sha256(&self.payload_digest)
131            || self.public_key.is_empty()
132            || self.public_key.len() > 256
133            || self.signature.is_empty()
134            || self.signature.len() > 256
135            || (matches!(self.trust_tier, PackageTrustTier::VerifiedWrapper)
136                && self.provenance.wrapper.is_none())
137        {
138            return Err("release supply-chain metadata is invalid".to_string());
139        }
140        self.provenance.validate()
141    }
142}
143
144impl BuildProvenance {
145    fn validate(&self) -> Result<(), String> {
146        if !valid_https_url(&self.source_repository)
147            || !matches!(self.source_commit.len(), 40 | 64)
148            || !self
149                .source_commit
150                .bytes()
151                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
152            || self.builder_id.trim().is_empty()
153            || self.builder_id.len() > 512
154            || self
155                .workflow_ref
156                .as_deref()
157                .is_some_and(|value| value.trim().is_empty() || value.len() > 512)
158            || self.invocation_id.trim().is_empty()
159            || self.invocation_id.len() > 512
160            || !valid_sha256(&self.inventory_digest)
161            || !valid_sha256(&self.release_manifest_digest)
162            || self.sbom.as_ref().is_some_and(|value| {
163                !valid_sha256(&value.digest)
164                    || value.media_type.trim().is_empty()
165                    || value.media_type.len() > 128
166            })
167            || self
168                .license
169                .as_deref()
170                .is_some_and(|value| value.trim().is_empty() || value.len() > 256)
171            || self.wrapper.as_ref().is_some_and(|value| {
172                !valid_https_url(&value.upstream_repository)
173                    || value.upstream_version.trim().is_empty()
174                    || value.upstream_version.len() > 256
175                    || !valid_sha256(&value.upstream_digest)
176            })
177        {
178            return Err("build provenance is invalid".to_string());
179        }
180        Ok(())
181    }
182}
183
184fn valid_https_url(value: &str) -> bool {
185    url::Url::parse(value).ok().is_some_and(|url| {
186        url.scheme() == "https"
187            && url.username().is_empty()
188            && url.password().is_none()
189            && url.query().is_none()
190            && url.fragment().is_none()
191            && value.len() <= 2048
192    })
193}
194
195fn valid_sha256(value: &str) -> bool {
196    value.strip_prefix("sha256:").is_some_and(|hex| {
197        hex.len() == 64
198            && hex
199                .bytes()
200                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
201    })
202}