1use base64::Engine;
22use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
23use serde::{Deserialize, Serialize};
24use std::path::PathBuf;
25
26pub const SCHEMA: u32 = 1;
27
28pub const DEFAULT_URL: &str = "https://darthdemono.github.io/EnvVault/catalogue/catalogue.json";
31
32pub const PINNED_KEY_HEX: &str = "12d80743bd61b75e2a355ff299e64a8cfb46da855ad1be3f669156d6cd89ba9a";
35
36fn pinned_key() -> [u8; 32] {
37 let mut k = [0u8; 32];
38 hex::decode_to_slice(PINNED_KEY_HEX, &mut k).expect("PINNED_KEY_HEX is 64 hex digits");
39 k
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43pub struct Provider {
44 pub prefix: String,
45 pub issuer: String,
46 pub secret_type: String,
47 pub icon: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub api_url: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub environment: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub acts_as: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub exposure: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub console_url: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub docs_url: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub rotate_url: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub revoke_url: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub verified_on: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub source_url: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct Catalogue {
77 pub schema: u32,
78 pub generated_at: String,
80 pub providers: Vec<Provider>,
81}
82
83#[derive(Serialize, Deserialize)]
86struct Signed {
87 payload: String,
88 signature: String,
89}
90
91fn https(u: &Option<String>) -> bool {
92 u.as_deref()
93 .is_none_or(|s| s.starts_with("https://") && s.len() < 300)
94}
95
96fn validate(c: &Catalogue) -> Result<(), String> {
97 if c.schema != SCHEMA {
98 return Err(format!("unsupported catalogue schema {}", c.schema));
99 }
100 if c.generated_at.len() != 20 || !c.generated_at.ends_with('Z') {
101 return Err("generated_at must be YYYY-MM-DDTHH:MM:SSZ".into());
102 }
103 if c.providers.is_empty() || c.providers.len() > 20_000 {
104 return Err("provider count out of range".into());
105 }
106 for p in &c.providers {
107 let ok_prefix = p.prefix.len() >= 3
108 && p.prefix.len() <= 64
109 && p.prefix.bytes().all(|b| b.is_ascii_graphic());
110 if !ok_prefix {
111 return Err(format!("unusable prefix `{}`", p.prefix.escape_debug()));
112 }
113 if p.issuer.is_empty() || p.issuer.len() > 80 || p.icon.len() > 64 {
114 return Err(format!("bad issuer/icon for `{}`", p.prefix));
115 }
116 if crate::secret_types::find(&p.secret_type).is_none() {
117 return Err(format!("unknown secret type `{}`", p.secret_type));
118 }
119 if !(https(&p.api_url)
120 && https(&p.docs_url)
121 && https(&p.rotate_url)
122 && https(&p.revoke_url))
123 {
124 return Err(format!("non-https URL for `{}`", p.prefix));
125 }
126 if !(https(&p.console_url)) {
127 return Err(format!("non-https console URL for `{}`", p.prefix));
128 }
129 const ACTS: [&str; 6] = [
130 "anonymous",
131 "user",
132 "service",
133 "bot",
134 "installation",
135 "admin",
136 ];
137 const EXPO: [&str; 3] = ["publishable", "server_only", "verify_only"];
138 if p.acts_as.as_deref().is_some_and(|v| !ACTS.contains(&v))
139 || p.exposure.as_deref().is_some_and(|v| !EXPO.contains(&v))
140 {
141 return Err(format!("unknown axis value for `{}`", p.prefix));
142 }
143 if p.environment.as_deref().is_some_and(|e| e.len() > 24) {
144 return Err(format!("bad environment for `{}`", p.prefix));
145 }
146 }
147 Ok(())
148}
149
150pub fn verify(raw: &[u8], key: &[u8; 32]) -> Result<Catalogue, String> {
152 let signed: Signed =
153 serde_json::from_slice(raw).map_err(|e| format!("not a catalogue: {e}"))?;
154 let sig = base64::engine::general_purpose::STANDARD
155 .decode(signed.signature.trim())
156 .map_err(|_| "signature is not base64".to_string())?;
157 let sig =
158 Signature::from_slice(&sig).map_err(|_| "signature has the wrong length".to_string())?;
159 let vk = VerifyingKey::from_bytes(key).map_err(|e| e.to_string())?;
160 vk.verify(signed.payload.as_bytes(), &sig)
161 .map_err(|_| "signature does not match the pinned key".to_string())?;
162 let c: Catalogue = serde_json::from_str(&signed.payload).map_err(|e| e.to_string())?;
163 validate(&c)?;
164 Ok(c)
165}
166
167pub fn sign(c: &Catalogue, seed: &[u8; 32]) -> Result<String, String> {
170 validate(c)?;
171 let payload = serde_json::to_string(c).map_err(|e| e.to_string())?;
172 let sig = SigningKey::from_bytes(seed).sign(payload.as_bytes());
173 let signature = base64::engine::general_purpose::STANDARD.encode(sig.to_bytes());
174 serde_json::to_string_pretty(&Signed { payload, signature }).map_err(|e| e.to_string())
175}
176
177pub fn cache_path() -> Option<PathBuf> {
178 if let Some(p) = std::env::var_os("UNV_CATALOGUE_FILE") {
179 return Some(PathBuf::from(p));
180 }
181 dirs::data_dir().map(|d| d.join("io.unenverse").join("catalogue.json"))
182}
183
184pub fn load_cached() -> Option<Catalogue> {
187 let raw = std::fs::read(cache_path()?).ok()?;
188 verify(&raw, &pinned_key()).ok()
189}
190
191pub fn store(raw: &[u8]) -> Result<Catalogue, String> {
194 store_with(raw, &pinned_key())
195}
196
197fn store_with(raw: &[u8], key: &[u8; 32]) -> Result<Catalogue, String> {
198 let c = verify(raw, key)?;
199 if let Some(old) = load_cached_with(key) {
200 if c.generated_at < old.generated_at {
201 return Err(format!(
202 "refusing to roll back: cached catalogue is {}, offered one is {}",
203 old.generated_at, c.generated_at
204 ));
205 }
206 }
207 let path = cache_path().ok_or("no data directory")?;
208 if let Some(dir) = path.parent() {
209 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
210 }
211 let tmp = path.with_extension("tmp");
212 std::fs::write(&tmp, raw).map_err(|e| e.to_string())?;
213 #[cfg(unix)]
214 {
215 use std::os::unix::fs::PermissionsExt;
216 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))
217 .map_err(|e| e.to_string())?;
218 }
219 std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
220 Ok(c)
221}
222
223fn load_cached_with(key: &[u8; 32]) -> Option<Catalogue> {
224 verify(&std::fs::read(cache_path()?).ok()?, key).ok()
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 const SEED: [u8; 32] = [7; 32];
232
233 fn key() -> [u8; 32] {
234 SigningKey::from_bytes(&SEED).verifying_key().to_bytes()
235 }
236
237 fn cat(at: &str) -> Catalogue {
238 Catalogue {
239 schema: 1,
240 generated_at: at.into(),
241 providers: vec![Provider {
242 prefix: "zz_".into(),
243 issuer: "Zed".into(),
244 secret_type: "api_key".into(),
245 icon: "zed".into(),
246 api_url: Some("https://api.zed.example".into()),
247 environment: None,
248 acts_as: None,
249 exposure: None,
250 console_url: None,
251 docs_url: None,
252 rotate_url: None,
253 revoke_url: None,
254 verified_on: None,
255 source_url: None,
256 }],
257 }
258 }
259
260 #[test]
261 fn the_pinned_key_is_a_valid_point() {
262 assert!(VerifyingKey::from_bytes(&pinned_key()).is_ok());
263 }
264
265 #[test]
266 fn signed_catalogue_verifies_and_tampering_does_not() {
267 let good = sign(&cat("2026-10-08T00:00:00Z"), &SEED).unwrap();
268 assert!(verify(good.as_bytes(), &key()).is_ok());
269 let bad = good.replace("Zed", "Zex");
270 assert!(verify(bad.as_bytes(), &key()).is_err());
271 assert!(verify(good.as_bytes(), &pinned_key()).is_err(), "wrong key");
272 }
273
274 #[test]
275 fn a_signed_but_overbroad_prefix_is_refused() {
276 let mut c = cat("2026-10-08T00:00:00Z");
277 c.providers[0].prefix = "s".into();
278 assert!(sign(&c, &SEED).is_err());
279 let mut c = cat("2026-10-08T00:00:00Z");
280 c.providers[0].api_url = Some("http://evil.example".into());
281 assert!(sign(&c, &SEED).is_err());
282 }
283
284 #[test]
285 fn an_unknown_axis_value_is_refused() {
286 let mut c = cat("2026-10-08T00:00:00Z");
287 c.providers[0].exposure = Some("public_ish".into());
288 assert!(sign(&c, &SEED).is_err());
289 c.providers[0].exposure = Some("publishable".into());
290 c.providers[0].acts_as = Some("bot".into());
291 assert!(sign(&c, &SEED).is_ok());
292 }
293
294 #[test]
295 fn rollback_is_refused() {
296 let dir = std::env::temp_dir().join(format!("unv-cat-{}", std::process::id()));
297 std::fs::create_dir_all(&dir).unwrap();
298 std::env::set_var("UNV_CATALOGUE_FILE", dir.join("c.json"));
299 let new = sign(&cat("2026-10-08T00:00:00Z"), &SEED).unwrap();
300 let old = sign(&cat("2026-10-07T00:00:00Z"), &SEED).unwrap();
301 store_with(new.as_bytes(), &key()).unwrap();
302 assert!(store_with(old.as_bytes(), &key())
303 .unwrap_err()
304 .contains("roll back"));
305 store_with(new.as_bytes(), &key()).unwrap();
306 std::env::remove_var("UNV_CATALOGUE_FILE");
307 std::fs::remove_dir_all(dir).ok();
308 }
309 #[test]
310 fn the_shipped_catalogue_file_parses_and_every_sourced_entry_is_complete() {
311 let list: Vec<Provider> =
312 serde_json::from_str(include_str!("../../catalogue/providers.json")).unwrap();
313 let mut seen = std::collections::HashSet::new();
314 for p in &list {
315 assert!(
316 seen.insert(p.prefix.clone()),
317 "duplicate prefix {}",
318 p.prefix
319 );
320 assert_eq!(
321 p.verified_on.is_some(),
322 p.source_url.is_some(),
323 "{}: verified_on and source_url go together",
324 p.prefix
325 );
326 if let Some(u) = &p.source_url {
327 assert!(
328 u.starts_with("https://"),
329 "{}: source must be https",
330 p.prefix
331 );
332 assert!(p.prefix.len() >= 4, "{}: too short to be safe", p.prefix);
333 }
334 }
335 }
336}