bzr 0.4.2

A CLI for Bugzilla, inspired by gh
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
use std::path::Path;
use std::sync::Arc;

use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::pki_types::pem::PemObject;
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
use rustls::{DigitallySignedStruct, Error as TlsError, RootCertStore, SignatureScheme};
use sha2::{Digest, Sha256};

use base64::Engine;

use crate::error::{BzrError, Result};
use crate::tls::fingerprint::{compute_fingerprint, parse_pin};

/// A rustls `ServerCertVerifier` that validates the leaf certificate's
/// SHA-256 fingerprint against a pinned value, bypassing CA chain
/// verification entirely.
#[derive(Debug)]
pub(crate) struct PinnedCertVerifier {
    /// The expected SHA-256 hash of the leaf certificate DER bytes.
    pin_hash: [u8; 32],
    /// The full `sha256//<base64>` pin string, kept for error messages.
    pin_str: String,
    /// Raw DER bytes of the expected issuer SEQUENCE for tamper-proof
    /// issuer-change detection.
    pin_issuer_der: Option<Vec<u8>>,
    /// The server name this verifier was created for (for errors).
    server_name: String,
    /// Delegate for cryptographic signature verification.
    sig_verifier: Arc<dyn ServerCertVerifier>,
}

impl PinnedCertVerifier {
    /// Build a pinned certificate verifier.
    ///
    /// `pin_sha256` must be in `sha256//<base64>` format.
    /// `pin_issuer_der_b64` is the base64-encoded raw DER of the issuer
    /// SEQUENCE, used for tamper-proof issuer-change detection. When `None`,
    /// no issuer-change check is performed (only the cert fingerprint pin).
    pub(crate) fn new(
        pin_sha256: &str,
        pin_issuer_der_b64: Option<&str>,
        server_name: &str,
    ) -> Result<Self> {
        let pin_hash = parse_pin(pin_sha256)?;
        let provider = super::default_provider();

        let pin_issuer_der = pin_issuer_der_b64
            .map(|b64| {
                base64::engine::general_purpose::STANDARD
                    .decode(b64)
                    .map_err(|e| {
                        BzrError::config(format!("invalid base64 in tls_pin_issuer_der: {e}"))
                    })
            })
            .transpose()?;

        let mut root_store = RootCertStore::empty();
        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());

        let sig_verifier = rustls::client::WebPkiServerVerifier::builder_with_provider(
            Arc::new(root_store),
            provider,
        )
        .build()
        .map_err(|e| BzrError::config(format!("failed to build signature verifier: {e}")))?;

        Ok(Self {
            pin_hash,
            pin_str: pin_sha256.to_owned(),
            pin_issuer_der,
            server_name: server_name.to_owned(),
            sig_verifier,
        })
    }

    /// Check whether the leaf certificate's issuer matches the pinned issuer
    /// via tamper-proof raw DER comparison. Returns `Err(TlsError::General(..))`
    /// with an `ISSUER_CHANGED` message when the issuer DER differs; `Ok(())`
    /// when it matches or no issuer DER is pinned.
    fn check_issuer_change(&self, leaf_der: &[u8]) -> std::result::Result<(), TlsError> {
        let Some(expected_der) = &self.pin_issuer_der else {
            return Ok(());
        };
        if let Some(actual_der) = extract_issuer_der(leaf_der) {
            if *expected_der != actual_der {
                return Err(TlsError::General(format!(
                    "ISSUER_CHANGED for {}: issuer DER mismatch \
                     (expected {} bytes, got {} bytes)",
                    self.server_name,
                    expected_der.len(),
                    actual_der.len()
                )));
            }
        }
        Ok(())
    }
}

impl ServerCertVerifier for PinnedCertVerifier {
    fn verify_server_cert(
        &self,
        end_entity: &CertificateDer<'_>,
        _intermediates: &[CertificateDer<'_>],
        _server_name: &ServerName<'_>,
        _ocsp_response: &[u8],
        _now: UnixTime,
    ) -> std::result::Result<ServerCertVerified, TlsError> {
        let actual_hash: [u8; 32] = Sha256::digest(end_entity.as_ref()).into();

        if actual_hash == self.pin_hash {
            return Ok(ServerCertVerified::assertion());
        }

        self.check_issuer_change(end_entity.as_ref())?;

        let actual_fp = compute_fingerprint(end_entity.as_ref());
        let actual_issuer = extract_issuer_dn(end_entity.as_ref());
        Err(TlsError::General(format!(
            "PIN_MISMATCH for {}: expected {}, got {}, issuer {}",
            self.server_name, self.pin_str, actual_fp, actual_issuer
        )))
    }

    fn verify_tls12_signature(
        &self,
        message: &[u8],
        cert: &CertificateDer<'_>,
        dss: &DigitallySignedStruct,
    ) -> std::result::Result<HandshakeSignatureValid, TlsError> {
        self.sig_verifier.verify_tls12_signature(message, cert, dss)
    }

    fn verify_tls13_signature(
        &self,
        message: &[u8],
        cert: &CertificateDer<'_>,
        dss: &DigitallySignedStruct,
    ) -> std::result::Result<HandshakeSignatureValid, TlsError> {
        self.sig_verifier.verify_tls13_signature(message, cert, dss)
    }

    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
        self.sig_verifier.supported_verify_schemes()
    }
}

/// Build a `rustls::ClientConfig` that trusts system roots plus any
/// additional CA certificates from a PEM file on disk.
pub(crate) fn build_ca_cert_config(ca_pem_path: &Path) -> Result<rustls::ClientConfig> {
    let pem_data = std::fs::read(ca_pem_path).map_err(|e| {
        BzrError::config(format!(
            "failed to read CA certificate file {}: {e}",
            ca_pem_path.display()
        ))
    })?;

    let mut root_store = RootCertStore::empty();

    // Add system roots.
    let native_certs = rustls_native_certs::load_native_certs();
    for cert in native_certs.certs {
        let _ = root_store.add(cert);
    }

    // Parse and add custom CA certs from the PEM file.
    let custom_certs: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(&pem_data)
        .collect::<std::result::Result<Vec<_>, _>>()
        .map_err(|e| {
            BzrError::config(format!(
                "failed to parse PEM certificates from {}: {e}",
                ca_pem_path.display()
            ))
        })?;

    if custom_certs.is_empty() {
        return Err(BzrError::config(format!(
            "no valid PEM certificates found in {}",
            ca_pem_path.display()
        )));
    }

    for cert in custom_certs {
        root_store.add(cert).map_err(|e| {
            BzrError::config(format!(
                "failed to add CA certificate from {}: {e}",
                ca_pem_path.display()
            ))
        })?;
    }

    let config = super::base_tls_builder("protocol versions")?
        .with_root_certificates(root_store)
        .with_no_client_auth();

    Ok(config)
}

/// Build a `rustls::ClientConfig` that uses a `PinnedCertVerifier`
/// for certificate pinning instead of CA chain validation.
pub(crate) fn build_pinned_config(
    pin_sha256: &str,
    pin_issuer_der: Option<&str>,
    server_name: &str,
) -> Result<rustls::ClientConfig> {
    let verifier = PinnedCertVerifier::new(pin_sha256, pin_issuer_der, server_name)?;

    let config = super::base_tls_builder("protocol versions")?
        .dangerous()
        .with_custom_certificate_verifier(Arc::new(verifier))
        .with_no_client_auth();

    Ok(config)
}

/// Navigate to the start of the issuer field within a DER-encoded
/// X.509 certificate, returning the remaining bytes starting at the
/// issuer SEQUENCE. Shared by both `extract_issuer_der` and
/// `extract_issuer_dn`.
fn navigate_to_issuer(cert_der: &[u8]) -> Option<&[u8]> {
    let (_, content) = parse_der_sequence(cert_der)?;
    let (_, tbs) = parse_der_sequence(content)?;
    let mut pos = tbs;
    // Skip optional version [0] EXPLICIT
    if pos.first()? & 0xe0 == 0xa0 {
        let (rest, _) = skip_der_element(pos)?;
        pos = rest;
    }
    // Skip serialNumber INTEGER
    let (rest, _) = skip_der_element(pos)?;
    pos = rest;
    // Skip signature AlgorithmIdentifier SEQUENCE
    let (rest, _) = skip_der_element(pos)?;
    Some(rest)
}

/// Extract the raw DER bytes of the issuer SEQUENCE (tag + length +
/// content) from a DER-encoded X.509 certificate.
pub(crate) fn extract_issuer_der(cert_der: &[u8]) -> Option<Vec<u8>> {
    let pos = navigate_to_issuer(cert_der)?;
    let (rest_after_issuer, _) = skip_der_element(pos)?;
    let issuer_len = pos.len() - rest_after_issuer.len();
    Some(pos[..issuer_len].to_vec())
}

/// Best-effort extraction of issuer information from DER-encoded
/// certificate bytes. Returns a fallback string if parsing fails.
pub(crate) fn extract_issuer_dn(der: &[u8]) -> String {
    // X.509 DER structure (simplified):
    // SEQUENCE {
    //   SEQUENCE {                    -- TBSCertificate
    //     [0] EXPLICIT version       -- optional
    //     INTEGER serialNumber
    //     SEQUENCE signature
    //     SEQUENCE issuer            -- what we want
    //     ...
    //   }
    //   ...
    // }
    //
    // This is a best-effort parser that walks the outer SEQUENCE,
    // the TBSCertificate SEQUENCE, skips version/serial/signature,
    // and returns the raw bytes of the issuer field as a hex string.
    // A proper ASN.1 parser will replace this later.
    parse_issuer_from_tbs(der).unwrap_or_else(|| format!("<raw DER, {} bytes>", der.len()))
}

/// Try to extract a human-readable issuer string from DER bytes.
/// Returns `None` if parsing fails at any point.
fn parse_issuer_from_tbs(der: &[u8]) -> Option<String> {
    let pos = navigate_to_issuer(der)?;
    let (_, issuer_bytes) = parse_der_sequence(pos)?;

    // Walk the RDN SEQUENCEs and extract OID=value pairs
    extract_rdns(issuer_bytes)
}

/// Parse a DER SEQUENCE tag+length, returning (rest after, content).
fn parse_der_sequence(data: &[u8]) -> Option<(&[u8], &[u8])> {
    if data.first()? != &0x30 {
        return None;
    }
    let (rest, content_len) = parse_der_length(&data[1..])?;
    if rest.len() < content_len {
        return None;
    }
    Some((&rest[content_len..], &rest[..content_len]))
}

/// Skip one DER element (tag + length + value), returning the
/// remainder of the slice.
fn skip_der_element(data: &[u8]) -> Option<(&[u8], &[u8])> {
    if data.is_empty() {
        return None;
    }
    let (rest, content_len) = parse_der_length(&data[1..])?;
    if rest.len() < content_len {
        return None;
    }
    Some((&rest[content_len..], &rest[..content_len]))
}

/// Parse a DER length encoding, returning (rest, length value).
fn parse_der_length(data: &[u8]) -> Option<(&[u8], usize)> {
    let first = *data.first()?;
    if first < 0x80 {
        Some((&data[1..], first as usize))
    } else {
        let num_bytes = (first & 0x7f) as usize;
        if num_bytes == 0 || num_bytes > 4 || data.len() < 1 + num_bytes {
            return None;
        }
        let mut len: usize = 0;
        for &b in &data[1..=num_bytes] {
            len = len.checked_shl(8)?.checked_add(b as usize)?;
        }
        Some((&data[1 + num_bytes..], len))
    }
}

/// Walk RDN SET/SEQUENCE structures and produce "CN=foo, O=bar" style
/// output. Falls back to hex if UTF-8 decoding fails.
fn extract_rdns(mut data: &[u8]) -> Option<String> {
    let mut parts = Vec::new();

    while !data.is_empty() {
        // Each RDN is a SET
        let set_tag = *data.first()?;
        if set_tag != 0x31 {
            break;
        }
        let (rest, set_content) = skip_der_element(data)?;
        data = rest;

        // Inside the SET is a SEQUENCE of OID + value
        if let Some((_, seq_content)) = parse_der_sequence(set_content) {
            if let Some(part) = parse_attribute_type_and_value(seq_content) {
                parts.push(part);
            }
        }
    }

    if parts.is_empty() {
        None
    } else {
        Some(parts.join(", "))
    }
}

/// Parse an `AttributeTypeAndValue` (OID + string value).
fn parse_attribute_type_and_value(data: &[u8]) -> Option<String> {
    // OID tag = 0x06
    if data.first()? != &0x06 {
        return None;
    }
    let (rest, oid_bytes) = skip_der_element(data)?;
    let oid_name = oid_short_name(oid_bytes);

    // Value is a string type (UTF8String 0x0C, PrintableString 0x13,
    // IA5String 0x16, etc.)
    let (_, value_bytes) = skip_der_element(rest)?;
    let value =
        String::from_utf8(value_bytes.to_vec()).unwrap_or_else(|_| hex::encode(value_bytes));

    Some(format!("{oid_name}={value}"))
}

/// Map common X.500 OID byte sequences to short names.
fn oid_short_name(oid: &[u8]) -> &'static str {
    match oid {
        // 2.5.4.3 — CN
        [0x55, 0x04, 0x03] => "CN",
        // 2.5.4.6 — C
        [0x55, 0x04, 0x06] => "C",
        // 2.5.4.7 — L
        [0x55, 0x04, 0x07] => "L",
        // 2.5.4.8 — ST
        [0x55, 0x04, 0x08] => "ST",
        // 2.5.4.10 — O
        [0x55, 0x04, 0x0a] => "O",
        // 2.5.4.11 — OU
        [0x55, 0x04, 0x0b] => "OU",
        _ => "OID",
    }
}

/// Simple hex encoder to avoid adding a dependency just for error
/// messages in the DER parser fallback path.
mod hex {
    use std::fmt::Write as _;

    pub(super) fn encode(data: &[u8]) -> String {
        let mut s = String::with_capacity(data.len() * 2);
        for b in data {
            let _ = write!(s, "{b:02x}");
        }
        s
    }
}

#[cfg(test)]
#[path = "verifier_tests.rs"]
mod tests;