Skip to content

Commit ae26140

Browse files
refactor(agent): rewrite read_cert_chain with picky read_pem (#1771)
1 parent fda18f8 commit ae26140

1 file changed

Lines changed: 102 additions & 21 deletions

File tree

crates/agent-tunnel/src/cert.rs

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::time::Duration;
88

99
use anyhow::{Context as _, bail};
1010
use camino::{Utf8Path, Utf8PathBuf};
11-
use picky::pem::parse_pem;
11+
use picky::pem::{PemError, parse_pem, read_pem};
1212
use picky::x509::Cert;
1313
use picky_asn1_x509::{ExtensionView, GeneralName};
1414
use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType};
@@ -32,29 +32,36 @@ fn cert_pem_to_der(pem_str: &str) -> anyhow::Result<Vec<u8>> {
3232
/// Parse one or more PEM-encoded certificates into `rustls` certificate types.
3333
///
3434
/// A PEM file can carry multiple concatenated CERTIFICATE blocks (chain). We
35-
/// iterate block-by-block with [`parse_pem`], check each label, and wrap the
36-
/// DER bytes in [`rustls_pki_types::CertificateDer`] — the only type the
37-
/// rustls/quinn TLS builders accept.
35+
/// use [`read_pem`] in a loop — each call consumes one block; `HeaderNotFound`
36+
/// signals "no more blocks left", which is the termination condition. Each
37+
/// block's label is verified, then the DER bytes are wrapped in
38+
/// [`rustls_pki_types::CertificateDer`] — the type the rustls/quinn TLS
39+
/// builders accept.
3840
fn read_cert_chain(pem_str: &str) -> anyhow::Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
41+
use std::io::BufReader;
42+
43+
let mut reader = BufReader::new(pem_str.as_bytes());
3944
let mut chain = Vec::new();
40-
let mut remaining = pem_str;
41-
while let Some(start) = remaining.find("-----BEGIN ") {
42-
let block_end = remaining[start..]
43-
.find("-----END ")
44-
.and_then(|e| {
45-
remaining[start + e..]
46-
.find("-----\n")
47-
.map(|n| start + e + n + "-----\n".len())
48-
})
49-
.context("malformed PEM block (no END tag)")?;
50-
51-
let block = &remaining[start..block_end];
52-
let pem = parse_pem(block).context("parse PEM block")?;
53-
if pem.label() != PEM_LABEL_CERTIFICATE {
54-
bail!("expected {PEM_LABEL_CERTIFICATE} PEM, got {}", pem.label());
45+
46+
loop {
47+
match read_pem(&mut reader) {
48+
Ok(pem) => {
49+
if pem.label() != PEM_LABEL_CERTIFICATE {
50+
bail!(
51+
"expected {PEM_LABEL_CERTIFICATE} PEM at index {}, got {}",
52+
chain.len(),
53+
pem.label()
54+
);
55+
}
56+
// `into_data().into_owned()` consumes the `Pem` and avoids the
57+
// copy that `pem.data().to_vec()` would force.
58+
chain.push(rustls::pki_types::CertificateDer::from(pem.into_data().into_owned()));
59+
}
60+
Err(PemError::HeaderNotFound) => break,
61+
Err(e) => {
62+
return Err(anyhow::Error::new(e).context(format!("parse PEM block at index {}", chain.len())));
63+
}
5564
}
56-
chain.push(rustls::pki_types::CertificateDer::from(pem.data().to_vec()));
57-
remaining = &remaining[block_end..];
5865
}
5966

6067
if chain.is_empty() {
@@ -605,4 +612,78 @@ mod tests {
605612

606613
let _ = std::fs::remove_dir_all(&temp_dir);
607614
}
615+
616+
// ---------------------------------------------------------------------
617+
// read_cert_chain — regression coverage for PEM whitespace / multi-block
618+
// ---------------------------------------------------------------------
619+
620+
/// Mint a real CA + signed end-entity cert and return (ca_pem, leaf_pem).
621+
/// Uses `CaManager::load_or_generate` so we don't reimplement PEM emission
622+
/// in the test — whatever rcgen produces here is what the runtime sees.
623+
fn make_cert_pair() -> (String, String) {
624+
let temp_dir = std::env::temp_dir().join(format!("dgw-cert-chain-test-{}", Uuid::new_v4()));
625+
let data_dir = Utf8PathBuf::from_path_buf(temp_dir.clone()).expect("temp path UTF-8");
626+
let ca = CaManager::load_or_generate(&data_dir).expect("CA generation");
627+
let (_key_pair, csr_pem) = generate_test_csr("chain-test-agent");
628+
let signed = ca
629+
.sign_agent_csr(Uuid::new_v4(), "chain-test-agent", &csr_pem, None)
630+
.expect("sign CSR");
631+
let ca_pem = ca.ca_cert_pem().to_owned();
632+
let _ = std::fs::remove_dir_all(&temp_dir);
633+
(ca_pem, signed.client_cert_pem)
634+
}
635+
636+
#[test]
637+
fn read_cert_chain_single_block() {
638+
let (ca_pem, _) = make_cert_pair();
639+
let chain = read_cert_chain(&ca_pem).expect("parse single-block chain");
640+
assert_eq!(chain.len(), 1);
641+
}
642+
643+
#[test]
644+
fn read_cert_chain_multi_block() {
645+
let (ca_pem, leaf_pem) = make_cert_pair();
646+
let combined = format!("{leaf_pem}{ca_pem}");
647+
let chain = read_cert_chain(&combined).expect("parse multi-block chain");
648+
assert_eq!(chain.len(), 2, "leaf + CA should both be parsed");
649+
}
650+
651+
#[test]
652+
fn read_cert_chain_handles_crlf_line_endings() {
653+
let (ca_pem, _) = make_cert_pair();
654+
let crlf = ca_pem.replace('\n', "\r\n");
655+
let chain = read_cert_chain(&crlf).expect("parse CRLF chain");
656+
assert_eq!(chain.len(), 1);
657+
}
658+
659+
#[test]
660+
fn read_cert_chain_handles_trailing_whitespace_between_blocks() {
661+
let (ca_pem, leaf_pem) = make_cert_pair();
662+
// Insert blank lines and trailing spaces between concatenated blocks —
663+
// the kind of noise hand-edited or shell-concatenated PEM files end up
664+
// with. The original hand-rolled scanner choked on this; `read_pem`
665+
// handles it.
666+
let combined = format!("{leaf_pem}\n \n\t\n{ca_pem}\n\n");
667+
let chain = read_cert_chain(&combined).expect("parse whitespace-padded chain");
668+
assert_eq!(chain.len(), 2);
669+
}
670+
671+
#[test]
672+
fn read_cert_chain_rejects_empty_input() {
673+
let err = read_cert_chain("").expect_err("empty input should fail");
674+
let msg = format!("{err:#}");
675+
assert!(msg.contains("no CERTIFICATE blocks"), "got: {msg}");
676+
}
677+
678+
#[test]
679+
fn read_cert_chain_rejects_wrong_label() {
680+
let (_, leaf_pem) = make_cert_pair();
681+
// Build a 2-block input where the second block has the wrong label.
682+
let private_key_block = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg\n-----END PRIVATE KEY-----\n";
683+
let combined = format!("{leaf_pem}{private_key_block}");
684+
let err = read_cert_chain(&combined).expect_err("wrong label should fail");
685+
let msg = format!("{err:#}");
686+
// The error context must point at the failing block index, not just say "parse PEM".
687+
assert!(msg.contains("index 1"), "error should locate the bad block: {msg}");
688+
}
608689
}

0 commit comments

Comments
 (0)