pray_core/client_trust/
feed.rs1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::Path;
4
5use crate::{PrayError, PrayResult};
6use serde::Deserialize;
7
8pub const DEFAULT_COMPROMISED_KEYS_SOURCE: &str =
9 "https://raw.githubusercontent.com/bmx-rs/trust-lists/main/compromised-keys.toml";
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct CompromisedKeyEntry {
13 pub key: String,
14 pub reason: Option<String>,
15 pub reference: Option<String>,
16 pub reported_at: Option<String>,
17}
18
19#[derive(Debug, Deserialize, Default)]
20struct CompromisedTomlFeed {
21 #[serde(default)]
22 keys: Vec<CompromisedTomlEntry>,
23}
24
25#[derive(Debug, Deserialize, Default)]
26struct CompromisedTomlEntry {
27 #[serde(default)]
28 value: String,
29 #[serde(default)]
30 reason: Option<String>,
31 #[serde(default)]
32 reference: Option<String>,
33 #[serde(default)]
34 reported_at: Option<String>,
35}
36
37pub fn parse_compromised_toml(body: &str) -> Vec<CompromisedKeyEntry> {
38 let feed: CompromisedTomlFeed = toml::from_str(body).unwrap_or_default();
39 feed.keys
40 .into_iter()
41 .filter_map(|entry| {
42 let key = normalize_key(&entry.value);
43 if key.is_empty() {
44 return None;
45 }
46 Some(CompromisedKeyEntry {
47 key,
48 reason: entry.reason,
49 reference: entry.reference,
50 reported_at: entry.reported_at,
51 })
52 })
53 .collect()
54}
55
56pub fn parse_compromised_txt(body: &str) -> Vec<CompromisedKeyEntry> {
57 let mut entries = Vec::new();
58 for raw_line in body.lines() {
59 let line = raw_line.trim();
60 if line.is_empty() || line.starts_with('#') {
61 continue;
62 }
63 let mut parts = line.splitn(2, '#');
64 let head = parts.next().unwrap_or_default().trim();
65 if head.is_empty() {
66 continue;
67 }
68 let key = normalize_key(head.split_whitespace().next().unwrap_or_default());
69 if key.is_empty() {
70 continue;
71 }
72 let reason = parts
73 .next()
74 .map(str::trim)
75 .filter(|value| !value.is_empty())
76 .map(str::to_string);
77 entries.push(CompromisedKeyEntry {
78 key,
79 reason,
80 reference: None,
81 reported_at: None,
82 });
83 }
84 entries
85}
86
87pub fn parse_compromised_feed(body: &str, source_hint: &str) -> Vec<CompromisedKeyEntry> {
88 let lower = source_hint.to_ascii_lowercase();
89 if lower.ends_with(".txt") {
90 parse_compromised_txt(body)
91 } else {
92 parse_compromised_toml(body)
93 }
94}
95
96pub fn trusted_keys_by_scope(home: &Path) -> PrayResult<BTreeMap<String, BTreeSet<String>>> {
97 let path = super::policy::trust_policy_path(home);
98 if !path.is_file() {
99 return Ok(BTreeMap::new());
100 }
101 let text = fs::read_to_string(&path)?;
102 let policy: super::policy::ClientTrustPolicy =
103 toml::from_str(&text).map_err(|error| PrayError::Parse {
104 kind: "client trust policy",
105 message: error.to_string(),
106 })?;
107
108 let mut output: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
109 for key in policy.default.allowed_signing_keys {
110 let normalized = normalize_key(&key);
111 if !normalized.is_empty() {
112 output
113 .entry(normalized)
114 .or_default()
115 .insert("global/default".to_string());
116 }
117 }
118 for rule in policy.rules {
119 let scope = format!(
120 "local:{}",
121 rule.match_prefix.unwrap_or_else(|| "-".to_string())
122 );
123 for key in rule.allowed_signing_keys {
124 let normalized = normalize_key(&key);
125 if !normalized.is_empty() {
126 output.entry(normalized).or_default().insert(scope.clone());
127 }
128 }
129 }
130 Ok(output)
131}
132
133#[allow(clippy::type_complexity)]
134pub fn check_compromised_keys(
135 home: &Path,
136 entries: &[CompromisedKeyEntry],
137) -> PrayResult<Vec<(String, BTreeSet<String>, Vec<CompromisedKeyEntry>)>> {
138 let trusted = trusted_keys_by_scope(home)?;
139 if trusted.is_empty() {
140 return Ok(Vec::new());
141 }
142
143 let mut compromised_by_key: BTreeMap<String, Vec<CompromisedKeyEntry>> = BTreeMap::new();
144 for entry in entries {
145 compromised_by_key
146 .entry(entry.key.clone())
147 .or_default()
148 .push(entry.clone());
149 }
150
151 let mut hits = Vec::new();
152 for (key, scopes) in trusted {
153 let Some(matches) = compromised_by_key.get(&key) else {
154 continue;
155 };
156 hits.push((key, scopes, matches.clone()));
157 }
158 Ok(hits)
159}
160
161fn normalize_key(value: &str) -> String {
162 value.trim().to_ascii_uppercase()
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn parses_toml_and_txt_feeds() {
171 let toml_body = r#"
172[[keys]]
173value = "sha256:abc"
174reason = "exposure"
175"#;
176 let entries = parse_compromised_toml(toml_body);
177 assert_eq!(entries.len(), 1);
178 assert_eq!(entries[0].key, "SHA256:ABC");
179
180 let txt_body = "sha256:def # leaked\n";
181 let txt_entries = parse_compromised_txt(txt_body);
182 assert_eq!(txt_entries.len(), 1);
183 assert_eq!(txt_entries[0].key, "SHA256:DEF");
184 }
185}