1use base64::Engine;
2use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
3use ring::signature::{ED25519, UnparsedPublicKey};
4use std::collections::BTreeMap;
5
6use super::source_authority::{
7 RegistryManifestSourceAuthority, registry_manifest_source_authority_from_env,
8 registry_manifest_source_key,
9};
10use super::types::{RegistrySignedManifest, TrustTier};
11
12pub const REGISTRY_SIGNED_MANIFEST_SCHEMA: &str = "runx.registry.signed_manifest.v1";
13pub const RUNX_REGISTRY_MANIFEST_TRUST_KEY_ENV: &str = "RUNX_REGISTRY_MANIFEST_TRUST_KEY_BASE64";
14pub const RUNX_REGISTRY_MANIFEST_TRUST_KEY_ID_ENV: &str = "RUNX_REGISTRY_MANIFEST_TRUST_KEY_ID";
15pub const RUNX_REGISTRY_MANIFEST_TRUST_OWNER_ENV: &str = "RUNX_REGISTRY_MANIFEST_TRUST_OWNER";
16
17const RUNX_REGISTRY_MANIFEST_KEY_ID: &str = "runx-registry-ed25519-v1";
18const RUNX_REGISTRY_MANIFEST_PUBLIC_KEY_BASE64: &str =
19 "u770bVZvqF7ULrnxWeTeLTSOsEwlbIkoOQJEAVy98No=";
20const REGISTRY_MANIFEST_SIGNATURE_BASE64_PREFIX: &str = "base64:";
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct TrustedRegistryManifestKey {
24 pub key_id: String,
25 pub public_key: Vec<u8>,
26 pub scope: RegistryManifestTrustScope,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum RegistryManifestTrustScope {
31 OfficialRunx,
32 ThirdParty {
33 allowed_owner: String,
34 allowed_source: String,
35 },
36}
37
38impl TrustedRegistryManifestKey {
39 pub fn from_base64(
40 key_id: String,
41 public_key: &str,
42 allowed_owner: String,
43 allowed_source: String,
44 ) -> Result<Self, RegistryManifestKeyError> {
45 let allowed_owner = validate_owner_namespace(allowed_owner)?;
46 let allowed_source = validate_registry_source(allowed_source)?;
47 Self::from_base64_with_scope(
48 key_id,
49 public_key,
50 RegistryManifestTrustScope::ThirdParty {
51 allowed_owner,
52 allowed_source,
53 },
54 )
55 }
56
57 pub fn official_from_base64(
58 key_id: String,
59 public_key: &str,
60 ) -> Result<Self, RegistryManifestKeyError> {
61 Self::from_base64_with_scope(key_id, public_key, RegistryManifestTrustScope::OfficialRunx)
62 }
63
64 fn from_base64_with_scope(
65 key_id: String,
66 public_key: &str,
67 scope: RegistryManifestTrustScope,
68 ) -> Result<Self, RegistryManifestKeyError> {
69 let public_key = decode_base64(public_key).map_err(|_| RegistryManifestKeyError)?;
70 if public_key.len() != 32 {
71 return Err(RegistryManifestKeyError);
72 }
73 Ok(Self {
74 key_id,
75 public_key,
76 scope,
77 })
78 }
79
80 #[must_use]
81 pub fn public_key_base64(&self) -> String {
82 STANDARD.encode(&self.public_key)
83 }
84}
85
86#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
87#[error("registry manifest key is invalid")]
88pub struct RegistryManifestKeyError;
89
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub enum RegistryManifestVerificationFailure {
92 UnsupportedSchema,
93 UnsupportedAlgorithm,
94 MalformedPayload,
95 UnknownKey,
96 MalformedKey,
97 MalformedSignature,
98 SignatureMismatch,
99}
100
101pub fn verify_registry_signed_manifest<'a>(
102 manifest: &RegistrySignedManifest,
103 trusted_keys: &'a [TrustedRegistryManifestKey],
104) -> Result<&'a TrustedRegistryManifestKey, RegistryManifestVerificationFailure> {
105 if manifest.schema != REGISTRY_SIGNED_MANIFEST_SCHEMA {
106 return Err(RegistryManifestVerificationFailure::UnsupportedSchema);
107 }
108 if manifest.signature.alg != "ed25519" {
109 return Err(RegistryManifestVerificationFailure::UnsupportedAlgorithm);
110 }
111 validate_registry_manifest_payload_terms(manifest)?;
112 let key = trusted_keys
113 .iter()
114 .find(|key| key.key_id == manifest.signer.key_id)
115 .ok_or(RegistryManifestVerificationFailure::UnknownKey)?;
116 if key.public_key.len() != 32 {
117 return Err(RegistryManifestVerificationFailure::MalformedKey);
118 }
119 let signature = decode_signature(&manifest.signature.value)?;
120 if signature.len() != 64 {
121 return Err(RegistryManifestVerificationFailure::MalformedSignature);
122 }
123 let payload = registry_manifest_payload(
124 &manifest.skill_id,
125 &manifest.version,
126 &manifest.digest,
127 manifest.profile_digest.as_deref(),
128 manifest.package_digest.as_deref(),
129 &manifest.signer.id,
130 &manifest.signer.key_id,
131 );
132 UnparsedPublicKey::new(&ED25519, &key.public_key)
133 .verify(payload.as_bytes(), &signature)
134 .map_err(|_| RegistryManifestVerificationFailure::SignatureMismatch)?;
135 Ok(key)
136}
137
138pub fn default_trusted_registry_manifest_keys()
139-> Result<Vec<TrustedRegistryManifestKey>, RegistryManifestKeyError> {
140 Ok(vec![TrustedRegistryManifestKey::official_from_base64(
141 RUNX_REGISTRY_MANIFEST_KEY_ID.to_owned(),
142 RUNX_REGISTRY_MANIFEST_PUBLIC_KEY_BASE64,
143 )?])
144}
145
146#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
147pub enum RegistryManifestTrustEnvError {
148 #[error("registry manifest trust key is invalid")]
149 InvalidKey,
150 #[error("registry manifest trust key id is required")]
151 MissingKeyId,
152 #[error("registry manifest trust owner is required")]
153 MissingOwner,
154 #[error("registry manifest trust source is required")]
155 MissingSource,
156}
157
158pub fn trusted_registry_manifest_keys_from_env(
159 env: &BTreeMap<String, String>,
160) -> Result<Vec<TrustedRegistryManifestKey>, RegistryManifestTrustEnvError> {
161 trusted_registry_manifest_keys_from_env_with_source(
162 env,
163 registry_manifest_source_authority_from_env(env),
164 )
165}
166
167pub fn trusted_registry_manifest_keys_from_env_with_source(
168 env: &BTreeMap<String, String>,
169 source_authority: Option<RegistryManifestSourceAuthority>,
170) -> Result<Vec<TrustedRegistryManifestKey>, RegistryManifestTrustEnvError> {
171 let mut trusted_keys = default_trusted_registry_manifest_keys()
172 .map_err(|_| RegistryManifestTrustEnvError::InvalidKey)?;
173 let Some(public_key) = env.get(RUNX_REGISTRY_MANIFEST_TRUST_KEY_ENV) else {
174 return Ok(trusted_keys);
175 };
176 let key_id = env
177 .get(RUNX_REGISTRY_MANIFEST_TRUST_KEY_ID_ENV)
178 .cloned()
179 .ok_or(RegistryManifestTrustEnvError::MissingKeyId)?;
180 if matches!(
181 source_authority,
182 Some(RegistryManifestSourceAuthority::OfficialRunx)
183 ) {
184 return Err(RegistryManifestTrustEnvError::InvalidKey);
185 }
186 let allowed_owner = env
187 .get(RUNX_REGISTRY_MANIFEST_TRUST_OWNER_ENV)
188 .cloned()
189 .ok_or(RegistryManifestTrustEnvError::MissingOwner)?;
190 let allowed_source = source_authority
191 .as_ref()
192 .map(registry_manifest_source_key)
193 .ok_or(RegistryManifestTrustEnvError::MissingSource)?;
194 let key =
195 TrustedRegistryManifestKey::from_base64(key_id, public_key, allowed_owner, allowed_source)
196 .map_err(|_| RegistryManifestTrustEnvError::InvalidKey)?;
197 trusted_keys.push(key);
198 Ok(trusted_keys)
199}
200
201pub fn registry_manifest_key_allows(
202 key: &TrustedRegistryManifestKey,
203 skill_id: &str,
204 trust_tier: &TrustTier,
205 source_authority: Option<&RegistryManifestSourceAuthority>,
206) -> Result<(), String> {
207 match &key.scope {
208 RegistryManifestTrustScope::OfficialRunx => {
209 if !matches!(
210 source_authority,
211 Some(RegistryManifestSourceAuthority::OfficialRunx)
212 ) {
213 return Err(
214 "official key may only grant trust for the official runx registry source"
215 .to_owned(),
216 );
217 }
218 if matches!(trust_tier, TrustTier::FirstParty) && !skill_id.starts_with("runx/") {
219 return Err(
220 "official key may only grant first_party trust to runx/* skills".to_owned(),
221 );
222 }
223 Ok(())
224 }
225 RegistryManifestTrustScope::ThirdParty {
226 allowed_owner,
227 allowed_source,
228 } => {
229 if matches!(trust_tier, TrustTier::FirstParty) {
230 return Err("third-party keys may not grant first_party trust".to_owned());
231 }
232 let actual_source = source_authority
233 .map(registry_manifest_source_key)
234 .ok_or_else(|| "third-party key requires a registry source".to_owned())?;
235 if actual_source != *allowed_source {
236 return Err(format!(
237 "third-party key may only sign from registry source {allowed_source}"
238 ));
239 }
240 let Some((owner, _name)) = skill_id.split_once('/') else {
241 return Err("skill id must include an owner namespace".to_owned());
242 };
243 if owner == "runx" {
244 return Err("third-party keys may not sign runx/* skills".to_owned());
245 }
246 if owner != allowed_owner {
247 return Err(format!(
248 "third-party key may only sign {allowed_owner}/* skills"
249 ));
250 }
251 Ok(())
252 }
253 }
254}
255
256fn validate_owner_namespace(value: String) -> Result<String, RegistryManifestKeyError> {
257 let owner = value.trim();
258 if owner.is_empty()
259 || owner == "runx"
260 || owner.contains('/')
261 || owner
262 .bytes()
263 .any(|byte| matches!(byte, b'\n' | b'\r' | b'=' | 0))
264 {
265 return Err(RegistryManifestKeyError);
266 }
267 Ok(owner.to_owned())
268}
269
270fn validate_registry_source(value: String) -> Result<String, RegistryManifestKeyError> {
271 let source = value.trim();
272 if source.is_empty()
273 || source
274 .bytes()
275 .any(|byte| matches!(byte, b'\n' | b'\r' | b'=' | 0))
276 {
277 return Err(RegistryManifestKeyError);
278 }
279 Ok(source.to_owned())
280}
281
282fn registry_manifest_payload(
283 skill_id: &str,
284 version: &str,
285 digest: &str,
286 profile_digest: Option<&str>,
287 package_digest: Option<&str>,
288 signer_id: &str,
289 key_id: &str,
290) -> String {
291 format!(
292 "{REGISTRY_SIGNED_MANIFEST_SCHEMA}\nskill_id={skill_id}\nversion={version}\ndigest={digest}\nprofile_digest={}\npackage_digest={}\nsigner_id={signer_id}\nkey_id={key_id}\n",
293 profile_digest.unwrap_or(""),
294 package_digest.unwrap_or("")
295 )
296}
297
298fn validate_registry_manifest_payload_terms(
299 manifest: &RegistrySignedManifest,
300) -> Result<(), RegistryManifestVerificationFailure> {
301 validate_registry_manifest_payload_term(&manifest.skill_id)?;
302 validate_registry_manifest_payload_term(&manifest.version)?;
303 validate_registry_manifest_payload_term(&manifest.digest)?;
304 if let Some(profile_digest) = &manifest.profile_digest {
305 validate_registry_manifest_payload_term(profile_digest)?;
306 }
307 if let Some(package_digest) = &manifest.package_digest {
308 validate_registry_manifest_payload_term(package_digest)?;
309 }
310 validate_registry_manifest_payload_term(&manifest.signer.id)?;
311 validate_registry_manifest_payload_term(&manifest.signer.key_id)
312}
313
314fn validate_registry_manifest_payload_term(
315 value: &str,
316) -> Result<(), RegistryManifestVerificationFailure> {
317 if value.is_empty()
318 || value
319 .bytes()
320 .any(|byte| matches!(byte, b'\n' | b'\r' | b'=' | 0))
321 {
322 return Err(RegistryManifestVerificationFailure::MalformedPayload);
323 }
324 Ok(())
325}
326
327fn decode_signature(value: &str) -> Result<Vec<u8>, RegistryManifestVerificationFailure> {
328 let Some(encoded) = value.strip_prefix(REGISTRY_MANIFEST_SIGNATURE_BASE64_PREFIX) else {
329 return Err(RegistryManifestVerificationFailure::MalformedSignature);
330 };
331 decode_base64(encoded).map_err(|_| RegistryManifestVerificationFailure::MalformedSignature)
332}
333
334fn decode_base64(value: &str) -> Result<Vec<u8>, base64::DecodeError> {
335 URL_SAFE_NO_PAD
336 .decode(value)
337 .or_else(|_| STANDARD.decode(value))
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 fn trust_env() -> BTreeMap<String, String> {
345 BTreeMap::from([
346 (
347 RUNX_REGISTRY_MANIFEST_TRUST_KEY_ID_ENV.to_owned(),
348 "test-key".to_owned(),
349 ),
350 (
351 RUNX_REGISTRY_MANIFEST_TRUST_KEY_ENV.to_owned(),
352 "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=".to_owned(),
353 ),
354 ])
355 }
356
357 #[test]
358 fn env_trust_key_cannot_promote_itself_to_official_source() {
359 let result = trusted_registry_manifest_keys_from_env_with_source(
360 &trust_env(),
361 Some(RegistryManifestSourceAuthority::OfficialRunx),
362 );
363 assert_eq!(result, Err(RegistryManifestTrustEnvError::InvalidKey));
364 }
365
366 #[test]
367 fn third_party_source_trust_key_still_requires_owner() {
368 let result = trusted_registry_manifest_keys_from_env_with_source(
369 &trust_env(),
370 Some(RegistryManifestSourceAuthority::RegistrySource(
371 "local:/tmp/runx-registry".to_owned(),
372 )),
373 );
374
375 assert_eq!(result, Err(RegistryManifestTrustEnvError::MissingOwner));
376 }
377}