Skip to content

Commit 73bef71

Browse files
committed
feat: support multiple keys per email address
Parse combined/concatenated certificates from single files using CertParser and merge keys from separate files for the same email instead of overwriting. Serve all keys as concatenated binary output.
1 parent 90a2625 commit 73bef71

2 files changed

Lines changed: 89 additions & 49 deletions

File tree

src/keys/db.rs

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub struct CertEntry {
2727
pub path: OsString,
2828
}
2929

30-
type Cache = HashMap<CertKey, CertEntry>;
30+
type Cache = HashMap<CertKey, Vec<CertEntry>>;
3131

3232
pub struct KeyDb {
3333
_watcher: RecommendedWatcher,
@@ -136,29 +136,32 @@ impl KeyDb {
136136
path: &Path,
137137
split_keys: bool,
138138
) -> Result<()> {
139-
// first, we remove all files that might be in here still because of this path
139+
// first, we remove all entries that came from this path
140140
Self::remove_file_from_cache(cache, path)?;
141141

142142
let entries = read_key_file(path, split_keys).context("Reading file")?;
143143
if entries.is_empty() {
144144
info!("Ignoring file {}, no entries found", path.to_string_lossy());
145145
return Ok(());
146146
}
147-
entries.into_iter().for_each(|(entry, content)| {
147+
entries.into_iter().for_each(|(key, entry)| {
148148
info!(
149149
"Adding key '{}@{}' from file {} to db",
150-
content.username,
151-
entry.domain,
150+
entry.username,
151+
key.domain,
152152
path.to_string_lossy()
153153
);
154-
cache.insert(entry, content);
154+
cache.entry(key).or_default().push(entry);
155155
});
156156
Ok(())
157157
}
158158

159159
fn remove_file_from_cache(cache: &mut RwLockWriteGuard<Cache>, path: &Path) -> Result<()> {
160-
// we remove all items that were inserted into the map because of this file.
161-
cache.retain(|_, v| v.path != path.as_os_str());
160+
// Remove entries from this path, and clean up empty keys
161+
for entries in cache.values_mut() {
162+
entries.retain(|e| e.path != path.as_os_str());
163+
}
164+
cache.retain(|_, v| !v.is_empty());
162165
Ok(())
163166
}
164167

@@ -168,7 +171,7 @@ impl KeyDb {
168171
domain: &str,
169172
username: Option<&String>,
170173
) -> Result<Option<Vec<u8>>> {
171-
let value = self
174+
let entries = self
172175
.keys
173176
.read()
174177
.await
@@ -178,14 +181,35 @@ impl KeyDb {
178181
})
179182
.cloned();
180183

181-
match (username, value) {
182-
(Some(requested), Some(CertEntry { username, .. })) if requested != &username => {
184+
let Some(entries) = entries else {
185+
return Ok(None);
186+
};
187+
188+
// Filter by username if provided (for advanced method)
189+
let filtered: Vec<_> = if let Some(requested) = username {
190+
entries
191+
.into_iter()
192+
.filter(|e| &e.username == requested)
193+
.collect()
194+
} else {
195+
entries
196+
};
197+
198+
if filtered.is_empty() {
199+
if let Some(requested) = username {
183200
info!(
184-
"hash matched for '{username}@{domain}', but requested local part '{requested}' did not match. Ignoring."
201+
"hash matched for domain '{domain}', but requested local part '{requested}' did not match any entries. Ignoring."
185202
);
186-
Ok(None)
187203
}
188-
(_, value) => Ok(value.map(|entry| entry.cert.to_vec().unwrap())),
204+
return Ok(None);
205+
}
206+
207+
// Concatenate all certs into a single binary response
208+
let mut result = Vec::new();
209+
for entry in filtered {
210+
result.extend(entry.cert.to_vec()?);
189211
}
212+
213+
Ok(Some(result))
190214
}
191215
}

src/keys/fs.rs

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,57 +4,67 @@ use anyhow::{Context, Result, bail};
44
use openpgp::armor::{Kind, Reader, ReaderMode};
55
use sequoia_openpgp as openpgp;
66
use sequoia_openpgp::Cert;
7+
use sequoia_openpgp::cert::CertParser;
78
use sequoia_openpgp::parse::Parse;
89
use sequoia_openpgp::policy::StandardPolicy;
910
use std::io::BufReader;
1011
use std::path::Path;
1112
use tracing::warn;
1213

1314
pub fn read_key_file(path: &Path, split_keys: bool) -> Result<Vec<(CertKey, CertEntry)>> {
14-
let Some(cert) = read_cert(path)? else {
15+
let certs = read_certs(path)?;
16+
if certs.is_empty() {
1517
return Ok(vec![]);
16-
};
18+
}
1719

1820
let p = StandardPolicy::new();
19-
let cert = cert.with_policy(&p, None).context("invalid certificate")?;
20-
21-
let mut certs = Vec::new();
21+
let mut results = Vec::new();
2222

23-
for userid in cert.userids() {
24-
let Some(email) = userid
25-
.userid()
26-
.email()
27-
.context("user id does not have a valid email")?
28-
else {
29-
warn!(
30-
"user id {} does not have an email, skipping",
31-
userid.userid()
32-
);
33-
continue;
23+
for cert in certs {
24+
let validated_cert = match cert.with_policy(&p, None) {
25+
Ok(c) => c,
26+
Err(e) => {
27+
warn!("Skipping invalid certificate in {}: {}", path.to_string_lossy(), e);
28+
continue;
29+
}
3430
};
3531

36-
let Some((username, cert_key)) = hash::mail_to_key_entry(email)? else {
37-
bail!("could not hash {email}");
38-
};
32+
for userid in validated_cert.userids() {
33+
let Some(email) = userid
34+
.userid()
35+
.email()
36+
.context("user id does not have a valid email")?
37+
else {
38+
warn!(
39+
"user id {} does not have an email, skipping",
40+
userid.userid()
41+
);
42+
continue;
43+
};
3944

40-
let mut cert = userid.cert().clone().strip_secret_key_material();
41-
if split_keys {
42-
cert = cert.retain_userids(|uid| uid.userid() == userid.userid());
43-
}
45+
let Some((username, cert_key)) = hash::mail_to_key_entry(email)? else {
46+
bail!("could not hash {email}");
47+
};
4448

45-
let cert_entry = CertEntry {
46-
username,
47-
cert,
48-
path: path.as_os_str().into(),
49-
};
49+
let mut cert = userid.cert().clone().strip_secret_key_material();
50+
if split_keys {
51+
cert = cert.retain_userids(|uid| uid.userid() == userid.userid());
52+
}
53+
54+
let cert_entry = CertEntry {
55+
username,
56+
cert,
57+
path: path.as_os_str().into(),
58+
};
5059

51-
certs.push((cert_key, cert_entry));
60+
results.push((cert_key, cert_entry));
61+
}
5262
}
5363

54-
Ok(certs)
64+
Ok(results)
5565
}
5666

57-
fn read_cert(path: &Path) -> Result<Option<Cert>> {
67+
fn read_certs(path: &Path) -> Result<Vec<Cert>> {
5868
if !path.exists() || !path.is_file() {
5969
bail!("File {} not found or not a file", path.to_string_lossy());
6070
}
@@ -67,9 +77,15 @@ fn read_cert(path: &Path) -> Result<Option<Cert>> {
6777
&content,
6878
ReaderMode::Tolerant(Some(Kind::PublicKey)),
6979
));
70-
if let Ok(cert) = Cert::from_reader(reader) {
71-
Ok(Some(cert))
72-
} else {
73-
Ok(None)
80+
81+
// Use CertParser to handle multiple concatenated certificates
82+
let mut certs = Vec::new();
83+
for cert_result in CertParser::from_reader(reader)? {
84+
match cert_result {
85+
Ok(cert) => certs.push(cert),
86+
Err(e) => warn!("Skipping malformed certificate in {}: {}", path.to_string_lossy(), e),
87+
}
7488
}
89+
90+
Ok(certs)
7591
}

0 commit comments

Comments
 (0)