ff_carl/
lib.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5//! A trivially simple library to automate creation of Firefox' mTLS host:certificate assignment
6//! `ClientAuthRememberList.bin` file.
7//!
8//! For a properly seamless mTLS experience, Firefox obviously needs to be aware of (and have access to) the
9//! configured client certificate(s).  This is *typically* achieved by way of a [policies.json][policy-templates]
10//! file, and specifically through a [Certificates -> Install][certificates-install] stanza (for filesystem resident
11//! certs) and/or a [SecurityDevices][security-devices] stanza (for PKCS#11 resident certs).
12//!
13//! FF-CARL currently requires client x509 certificate \[u8\] to be in **DER** format.  The library will issue an
14//! io::Error if not DER, if the certificate is corrupt, or due to other unanticipated i/o issues.
15//!
16//! [policy-templates]: https://mozilla.github.io/policy-templates/
17//! [certificates-install]: https://mozilla.github.io/policy-templates/#certificates--install
18//! [security-devices]: https://mozilla.github.io/policy-templates/#securitydevices
19//!
20//! #### Example
21//!
22//! This (fictitious file paths) example shows a single host:certificate configuration.
23//! ```rust,no_run
24//! use ff_carl::write_entry;
25//! use ff_carl::EntryArgs;
26//! use std::path::PathBuf;
27//!
28//! fn main() -> Result<(), std::io::Error> {
29//!     let der_cert = std::fs::read("/path/to/cert.der").expect("Failed to read DER certificate.");
30//!     let entry_args = EntryArgs::new(
31//!         "https", // scheme
32//!         "mtls.cert-demo.com", // ascii_host
33//!         443, // port
34//!         "cert-demo.com", // base_domain
35//!         der_cert.as_ref(), // DER cert byte array
36//!     )?;
37//!
38//!     let backing_path = PathBuf::from("/path/to/firefox/profile/ClientAuthRememberList.bin");
39//!
40//!     write_entry(entry_args, backing_path)
41//! }
42//! ```
43//! To configure *multiple* host:certificate assignments, use the [`write_entries()`] function.
44//!
45//! Please refer to inlined source documentation for more details on *ClientAuthRememberList.bin*'s
46//! internal format and contents.
47//!
48
49use base64::prelude::*;
50use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
51use std::fs::OpenOptions;
52use std::io::{Error, ErrorKind::InvalidInput, Result, Seek, SeekFrom, Write};
53use std::path::PathBuf;
54use std::time::{Duration, SystemTime, UNIX_EPOCH};
55use x509_parser::{nom::AsBytes, prelude::*};
56
57/// The unambiguous, requisite host and DER certificate details used for creating ClientAuthRememberList *Entry* values.
58pub struct EntryArgs<'a> {
59    /// Scheme; for example: "https".
60    scheme: &'a [u8],
61    /// ASCII host; for example: "my.example.com".
62    ascii_host: &'a [u8],
63    /// port; for example: Some(8443.to_string())
64    port: Option<String>,
65    /// Base domain; for example (assuming `ascii_host` is `my.example.com`): "example.com".
66    base_domain: &'a [u8],
67    /// X509 certificate to associate for mTLS with the above host.
68    cert: X509Certificate<'a>,
69}
70
71impl<'a> EntryArgs<'a> {
72    /// Construct a new EntryArgs.  This will issue an io::Error if the `der_cert`
73    /// is not of DER format or if there are any certificate parsing issues.
74    /// #### Example
75    /// ```rust,ignore
76    /// let entry_args = ff_carl::EntryArgs::new(
77    ///    "https",
78    ///    "mtls.cert-demo.com",
79    ///    443,
80    ///    "cert-demo.com",
81    ///    der_cert_bytes,
82    /// );
83    /// ```
84    pub fn new(
85        scheme: &'a str,
86        ascii_host: &'a str,
87        port: u32,
88        base_domain: &'a str,
89        der_cert: &'a [u8],
90    ) -> Result<Self> {
91        // DER is very simple to parse; we've got a composition of:
92        // * tags that distinguish types
93        // * data length
94        // * data of that respective length
95
96        // let's encrypt has a really nice summary:
97        // https://letsencrypt.org/docs/a-warm-welcome-to-asn1-and-der/
98
99        // However, we can simply make use of the x509-parser crate:
100        let res = X509Certificate::from_der(der_cert);
101
102        match res {
103            Ok((_rem, cert)) => Ok(EntryArgs {
104                scheme: scheme.as_bytes(),
105                ascii_host: ascii_host.as_bytes(),
106                port: match port {
107                    // Firefox will default to 443 for https and 80 for http:
108                    80 | 443 => None,
109                    p => Some(p.to_string()),
110                },
111                base_domain: base_domain.as_bytes(),
112                cert,
113            }),
114            _ => Err(Error::new(
115                InvalidInput,
116                format!("x509 parsing failed: {:?}", res),
117            )),
118        }
119    }
120}
121
122/// Write a single ClientAuthRememberList *Entry* value to the given PathBuf.
123pub fn write_entry(entry_args: EntryArgs, backing_path: PathBuf) -> Result<()> {
124    write_entries(vec![entry_args], backing_path)
125}
126
127/// Write *multiple* ClientAuthRememberList *Entry* values to the given PathBuf.
128pub fn write_entries(entry_inputs: Vec<EntryArgs>, backing_path: PathBuf) -> Result<()> {
129    // NB: majority of this code was copied from Gecko source security/manager/ssl/data_storage/src/lib.rs.
130
131    const KEY_LENGTH: usize = 256;
132    const SLOT_LENGTH: usize = 1286;
133
134    let mut backing_file = OpenOptions::new()
135        .write(true)
136        .truncate(true)
137        .open(backing_path)?;
138
139    let necessary_len = (entry_inputs.len() * SLOT_LENGTH) as u64;
140    if backing_file.metadata()?.len() < necessary_len {
141        backing_file.set_len(necessary_len)?;
142    }
143
144    let mut buf = vec![0u8; SLOT_LENGTH];
145
146    for (slot_index, entry_input) in entry_inputs.iter().enumerate() {
147        let mut buf_writer = buf.as_mut_slice();
148        buf_writer.write_u16::<BigEndian>(0)?; // set checksum to 0 for now
149        let mut checksum: u16 = 1; // the "score" defaults to a value of 1
150        buf_writer.write_u16::<BigEndian>(1)?; // actually write out the score
151        let last_accessed = now_in_days();
152        checksum ^= last_accessed;
153        buf_writer.write_u16::<BigEndian>(last_accessed)?;
154
155        // --------------------------------------------------------------------------
156        // =========================== ENTRY KEY DETAILS ============================
157        // --------------------------------------------------------------------------
158        // Entry key c++ reference code is at nsClientAuthRemember::GetEntryKey;
159        // its contents consist of:
160        // * The ascii host.
161        // * ",,".
162        // * An `OriginAttributes` suffix (c++ reference code is at OriginAttributes::CreateSuffix).
163        //   The OriginAttributes suffix is a set of key/value pairs with '^' character separator
164        //   between pairs.  It seems we only use the "partitionKey" key and its encoded value:
165        //   * "^partitionKey="
166        //   * "(<scheme>,<baseDomain>,[port])" (NOTE: the '(', ',', ')' characters get "percent
167        //      encoded" treatment; please refer to https://en.wikipedia.org/wiki/Percent-encoding).
168        //      Please note that the port is optional for standardized ports such as 80 and 443.
169        // * Any remaining bytes (of the 256) get 0 padded.
170        // ==========================================================================
171        let entry_key = get_entry_key(entry_input).unwrap();
172
173        for mut chunk in entry_key.chunks(2) {
174            if chunk.len() == 1 {
175                checksum ^= (chunk[0] as u16) << 8;
176            } else {
177                checksum ^= chunk.read_u16::<BigEndian>()?;
178            }
179        }
180
181        buf_writer.write_all(&entry_key)?;
182
183        let (key_remainder, mut buf_writer) = buf_writer.split_at_mut(KEY_LENGTH - entry_key.len());
184        key_remainder.fill(0);
185
186        // --------------------------------------------------------------------------
187        // ======================== ENTRY VALUE DETAILS =============================
188        // --------------------------------------------------------------------------
189        // The entry value is effectively a key used in an internal certificate database,
190        // the "certdb" (c reference code is at certdb.[c|h]).  Entry value c++ reference
191        // code is at nsNSSCertificate::GetDbKey.  The entry value consists of:
192        // * base64 encoded "dbkey" consisting of:
193        //   * empty 4 bytes (this was intended to be the module ID, but it was never implemented)
194        //   * empty 4 bytes (this was intended to be the slot ID, but it was never implemented)
195        //   * 4 bytes <serial number length in big-endian order>
196        //   * 4 bytes <DER-encoded issuer distinguished name length in big-endian order>
197        //   * n bytes <bytes of serial number>
198        //   * m bytes <DER-encoded issuer distinguished name>
199        // * Any remaining bytes (of the 1,024) get 0 padded.
200        // ==========================================================================
201        let db_key = get_dbkey(entry_input).unwrap();
202
203        for mut chunk in db_key.chunks(2) {
204            if chunk.len() == 1 {
205                checksum ^= (chunk[0] as u16) << 8;
206            } else {
207                checksum ^= chunk.read_u16::<BigEndian>()?;
208            }
209        }
210        buf_writer.write_all(&db_key)?;
211        buf_writer.fill(0);
212
213        backing_file.seek(SeekFrom::Start((slot_index * SLOT_LENGTH) as u64))?;
214        backing_file.write_all(&buf)?;
215        backing_file.flush()?;
216        backing_file.seek(SeekFrom::Start((slot_index * SLOT_LENGTH) as u64))?;
217        backing_file.write_u16::<BigEndian>(checksum)?;
218    }
219
220    Ok(())
221}
222
223/// Returns the current day in days since the unix epoch, to a maximum of
224/// u16::MAX days.
225fn now_in_days() -> u16 {
226    // NB: copied from security/manager/ssl/data_storage/src/lib.rs
227    const SECONDS_PER_DAY: u64 = 60 * 60 * 24;
228    let now = SystemTime::now()
229        .duration_since(UNIX_EPOCH)
230        .unwrap_or(Duration::ZERO);
231    (now.as_secs() / SECONDS_PER_DAY)
232        .try_into()
233        .unwrap_or(u16::MAX)
234}
235
236// We are assuming the usecase here to be mTLS, thus the `partitionKey=` treatment.
237fn get_entry_key(entry_input: &EntryArgs) -> Result<Vec<u8>> {
238    const COMMA_COMMA_CARET: &[u8] = b",,^";
239    const PARTITION_KEY_EQUALS: &[u8] = b"partitionKey=";
240    const PERCENT_ENCODED_LEFT_PAREN: &[u8] = b"%28";
241    const PERCENT_ENCODED_COMMA: &[u8] = b"%2C";
242    const PERCENT_ENCODED_RIGHT_PAREN: &[u8] = b"%29";
243
244    let buf_length = entry_input.ascii_host.len()
245        + COMMA_COMMA_CARET.len()
246        + PARTITION_KEY_EQUALS.len()
247        + PERCENT_ENCODED_LEFT_PAREN.len()
248        + entry_input.scheme.len()
249        + PERCENT_ENCODED_COMMA.len()
250        + entry_input.base_domain.len()
251        + match &entry_input.port {
252            Some(p) => PERCENT_ENCODED_COMMA.len() + p.as_bytes().len(),
253            None => 0,
254        }
255        + PERCENT_ENCODED_RIGHT_PAREN.len();
256
257    let mut buf = vec![0u8; buf_length];
258    let mut buf_writer = buf.as_mut_slice();
259    buf_writer.write_all(entry_input.ascii_host.as_bytes())?;
260    buf_writer.write_all(COMMA_COMMA_CARET)?;
261    buf_writer.write_all(PARTITION_KEY_EQUALS)?;
262    buf_writer.write_all(PERCENT_ENCODED_LEFT_PAREN)?;
263    buf_writer.write_all(entry_input.scheme.as_bytes())?;
264    buf_writer.write_all(PERCENT_ENCODED_COMMA)?;
265    buf_writer.write_all(entry_input.base_domain.as_bytes())?;
266    if entry_input.port.is_some() {
267        buf_writer.write_all(PERCENT_ENCODED_COMMA)?;
268        buf_writer.write_all(entry_input.port.as_ref().unwrap().as_bytes())?;
269    }
270    buf_writer.write_all(PERCENT_ENCODED_RIGHT_PAREN)?;
271
272    Ok(buf)
273}
274
275// "dbkey" is the "entry value", which is effectively the meat of the slot's value.
276fn get_dbkey(entry_input: &EntryArgs) -> Result<Vec<u8>> {
277    let serial_bytes = entry_input.cert.raw_serial();
278    let serial_bytes_len = serial_bytes.len();
279
280    let issuer_raw = entry_input.cert.issuer.as_raw();
281    let issuer_raw_len = issuer_raw.len();
282
283    let buf_length = 4 // empty module ID
284        + 4 // empty slot ID
285        + 4 // serial number length
286        + 4 // DER-encoded issuer distinguished name length
287        + serial_bytes_len // length of raw serial number bytes
288        + issuer_raw_len; // DER-encoded issuer distinguished name bytes
289
290    let mut buf = vec![0u8; buf_length];
291    let mut buf_writer = buf.as_mut_slice();
292
293    buf_writer.write_u32::<BigEndian>(0)?; // module ID
294    buf_writer.write_u32::<BigEndian>(0)?; // slot ID
295    buf_writer.write_u32::<BigEndian>(serial_bytes_len as u32)?; // serial number length
296    buf_writer.write_u32::<BigEndian>(issuer_raw_len as u32)?; // DER-encoded issuer distinguished name length
297    buf_writer.write_all(serial_bytes)?; // raw serial number bytes
298    buf_writer.write_all(issuer_raw)?; // raw DER-encoded issuer distinguished name bytes
299
300    Ok(BASE64_STANDARD.encode(buf).into_bytes())
301}