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}