Skip to main content

pray_core/client_trust/
feed.rs

1use 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}