loading...
Cover image for Decode a certificate

Decode a certificate

wayofthepie profile image Stephen O'Brien ・19 min read

TDD and Rust: A CLI for decoding certs (3 Part Series)

1) A Rust CLI for decoding certs 2) Rust and GitHub Actions 3) Decode a certificate

Introduction

From this post on I will leave a note at the end of some sections linking to the latest code up to that point. It will look like this:

🔗 wayofthepie/cert-decoder@a9d761e

That link points to the latest code from the last post.

Table of Contents

Some missing things

After publishing the first post I realized I missed a positive test, a test which checks that everything went ok. Let's write that.

#[test]
fn should_succeed() {
    let args = vec!["a-file".to_owned()];
    let validator = FakeValidator { is_file: true };
    let result = execute(validator, args);
    assert!(result.is_ok());
}

It's a good idea to see it fail first. Let's change a behaviour it expects by making it return an error if the file does exist:

- if !validator.is_file(path) {
+ if validator.is_file(path) {
   return Err(
...

Now it will fail:

✦ ➜ cargo -q test should_succeed

running 1 test
F
failures:

---- test::should_succeed stdout ----
thread 'test::should_succeed' panicked at 'assertion failed: result.is_ok()', src/main.rs:98:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    test::should_succeed

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out

error: test failed, to rerun pass '--bin cert-decode'

And if we revert our change:

- if validator.is_file(path) {
+ if !validator.is_file(path) {
...    

It will pass:

✦ ➜ cargo -q test should_succeed

running 1 test
.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2 filtered out

Great! Now for some new things.

🔗 wayofthepie/cert-decoder@66da25a

Read a certificate

Time to read a certificate. The x509-parser crate will allow us to do this. Add as a dependency to Cargo.toml.

[package]
name = "cert-decode"
version = "0.1.0"
authors = ["Stephen OBrien <wayofthepie@users.noreply.github.com>"]
edition = "2018"

[dependencies]
x509-parser = "0.7.0"

📝 Note

I use cargo-edit to update Cargo.toml. You can install it by running:

✦ ➜ cargo install cargo-edit

Then to add a dependency you just need to run:

✦ ➜ cargo add x509-parser
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding x509-parser v0.7.0 to dependencies

See the README for more details. The code blocks in this note have
incorrect formatting, they are centered. I raised a bug about this, see thepracticaldev/dev.to#8767.

It's a good idea to rebuild when adding a new dependency.

✦ ➜ cargo build
    Updating crates.io index
   Compiling autocfg v1.0.0
   Compiling bitflags v1.2.1
   Compiling ryu v1.0.5
   Compiling lexical-core v0.7.4
   Compiling memchr v2.3.3
   Compiling version_check v0.9.2
   Compiling arrayvec v0.5.1
   Compiling static_assertions v1.1.0
   Compiling cfg-if v0.1.10
   Compiling libc v0.2.71
   Compiling base64 v0.11.0
   Compiling nom v5.1.2
   Compiling num-traits v0.2.12
   Compiling num-integer v0.1.43
   Compiling num-bigint v0.2.6
   Compiling time v01.3
   Compiling rusticata-macros v2.1.0
   Compiling der-parser v3.0.4
   Compiling x509-parser v0.7.0
   Compiling cert-decode v0.1.0 (/home/chaospie/repos/blog-cert-decode/cert-decode)
    Finished dev [unoptimize + debuginfo] target(s) in 8.70s

The x509-parser crate has pulled in a bunch of transitive dependencies. Not too many though. When working on a larger project you may want to view the overall dependency hierarchy. You can do this with cargo tree. For example:

✦ ➜ cargo tree
cert-decode v0.1.0 (/home/chaospie/repos/blog-cert-decode/cert-decode)
└── x509-parser v0.7.0
    ├── base64 v0.11.0
    ├── der-parser v3.0.4
    │   ├── nom v5.1.2
    │   │   ├── lexical-core v0.7.4
    │   │   │   ├── arrayvec v0.5.1
    │   │   │   ├── bitflags v1.2.1
    │   │   │   ├── cfg-if v0.1.10
    │   │   │   ├── ryu v1.0.5
    │   │   │   └── static_assertions v1.1.0
    │   │   └── memchr v2.3.3
    │   │   [build-dependencies]
    │   │   └── version_check v0.9.2
    │   ├── num-bigint v0.2.6
    │   │   ├── num-integer v0.1.43
    │   │   │   └── num-traits v0.2.12
    │   │   │       [build-dependencies]
    │   │   │       └── autocfg v1.0.0
    │   │   │   [build-dependencies]
    │   │   │   └── autocfg v1.0.0
    │   │   └── num-traits v0.2.12 (*)
    │   │   [build-dependencies]
    │   │   └── autocfg v1.0.0
    │   └── rusticata-macros v2.1.0
    │       └── nom v5.1.2 (*)
    ├── nom v5.1.2 (*)
    ├── num-bigint v0.2.6 (*)
    ├── rusticata-macros v2.1.0 (*)
    └── time v0.1.43
        └── libc v0.2.71

📝 Note

As of Rust 1.44.0 cargo tree is part of cargo if you are using a version before that you will need to install cargo-tree. You should update to the latest Rust if there is no reason to be on a version less than 1.44.0.

I've used the x509-parser crate in the past so I know a bit about it's API. But let's do some exploration anyway. First, let's set a goal and make a tiny test list. Our goal is simply to be able to print certificate details. Up to now, we have verified the argument we pass is a file, so I think what we should do next is:


Read and print certificate

  • ☐ Validate file is a certificate
  • ☐ Print the certificate

Let's dive into the docs for x509-parser.

x509-parser API

All crates published to crates.io should have docs on docs.rs. These docs will contain the public API of the crate and whatever further documentation the author added. We're using version 0.7.0 of the x509-parser crate, the docs for this are here. The very first example in these docs does almost what we need:

use x509_parser::parse_x509_der;

static IGCA_DER: &'static [u8] = include_bytes!("../assets/IGC_A.der");

let res = parse_x509_der(IGCA_DER);
match res {
    Ok((rem, cert)) => {
        assert!(rem.is_empty());
        //
        assert_eq!(cert.tbs_certificate.version, 2);
    },
    _ => panic!("x509 parsing failed: {:?}", res),
}

It seems we could use the parse_x509_der function to parse our certificate. Our certificate should be in PEM format however, that was a constraint we set in the initial post. Is there anything that can deal directly with PEM certificates in this API?

There is! The x509_parser::pem module has functionality for doing just this. The second example in that modules docs does just what we want, it uses the pem_to_der function to convert a PEM encoded certificate into DER (Distinguished Encoding Rules) and then calls parse_x509_der on that DER to build a X509Certificate. Here is the example:

use x509_parser::pem::pem_to_der;
use x509_parser::parse_x509_der;

static IGCA_PEM: &'static [u8] = include_bytes!("../assets/IGC_A.pem");

let res = pem_to_der(IGCA_PEM);
match res {
    Ok((rem, pem)) => {
        assert!(rem.is_empty());
        //
        assert_eq!(pem.label, String::from("CERTIFICATE"));
        //
        let res_x509 = parse_x509_der(&pem.contents);
        assert!(res_x509.is_ok());
    },
    _ => panic!("PEM parsing failed: {:?}", res),
}

Don't worry if you don't understand everything in this example, we will cover a lot of this syntax in the next few posts. Let's look closer at pem_to_der.

pub fn pem_to_der<'a>(i: &'a [u8]) -> IResult<&'a [u8], Pem, PEMError>

It takes a &'a [u8], a slice of bytes with a lifetime1 of 'a, and returns IResult<&'a [u8], Pem, PEMError>. I will go into more detail on lifetimes in a future post, in short they tell the compiler how long a reference lives. In this specific case, the slice i which the function takes as an argument must live as long as the slice returned in the IResult return type, as both have a lifetime of 'a. This tells us the slice in the return type must either be i or a subslice of i. For more information see Generic Types, Traits, and Lifetimes.

Glossing over some other details which are outside the scope of this post, this IResult is effectively just a Result type which we saw in the first post. It can return Ok with some value or Err with an error. In this case, the type of the value in Err will be PEMError.

Validate the file is a certificate

Now we have enough knowledge to write a test in the case our cert is not PEM encoded. First, let's get a cert and save it so we can use that in our tests.

➜ openssl s_client -connect google.com:443 2>/dev/null < /dev/null \
>     | sed -n '/BEGIN CERTIFICATE/,/END CERTIFICATE/p' > google.com.crt

➜ cat google.com.crt
-----BEGIN CERTIFICATE-----
MIIJTzCCCDegAwIBAgIQVvrczQ6+8BwIAAAAAENV5zANBgkqhkiG9w0BAQsFADBC
MQswCQYDVQQGEwJVUzEeMBwGA1UEChMVR29vZ2xlIFRydXN0IFNlcnZpY2VzMRMw
...
d5JOd+lJOypPGs0/p5OrR8B84Y7wyKFD/EXaKYVMZ4RUXnoAi5DF5RLKNAmnt7R9
V6z8Kz2boaY5oZ0gvrA49R6T+u3yrstte931N49lwpaVsoA=
-----END CERTIFICATE-----

I've stored this cert in the repo here. The test is as follows:

#[test]
fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
    let args = vec!["real-cert".to_owned()];
    let validator = FakeValidator { is_file: true };
    let result = execute(validator, args);
    assert!(result.is_err())
}

The update to the execute function will need a bit of refactoring, but first, let's implement it in the simplest way possible.

fn execute(validator: impl PathValidator, args: Vec<String>) -> Result<(), String> {
    if args.len() != 1 {
        let error = format!(
            "{}{}",
            "Error: did not receive a single argument, ",
            "please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
        );
        return Err(error);
    }
    let path = &args[0];
    if !validator.is_file(path) {
        return Err(
            "Error: path given is not a regular file, please update to point to a certificate."
                .to_owned(),
        );
    }
    // read file to string
    let cert = std::fs::read_to_string(path).unwrap();
    // pem to der
    let _ = pem_to_der(cert.as_bytes()).unwrap();
    Ok(())
}

We use std::fs::read_to_string to read the file path we pass as an argument directly to a string. This call returns a Result as it can fail if the path does not exist. But we know it does exist at this point, so we just unwrap the value, giving us our cert as a string. Then we pass that string as bytes, by calling the as_bytes function on it, to pem_to_der. This can fail and because here we just call unwrap this will panic if pem_to_der returns an Err value instead of and Ok value.

To see what I mean, update the test so it reads Cargo.toml.

#[test]
fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
    let args = vec!["Cargo.toml".to_owned()];
    let validator = FakeValidator { is_file: true };
    let result = execute(validator, args);
    assert!(result.is_err())
}

It will fail as follows because Cargo.toml is not PEM encoded:

➜ cargo -q test pem

running 1 test
F
failures:

---- test::should_error_if_given_argument_is_not_a_pem_encoded_certificate stdout ----
thread 'test::should_error_if_given_argument_is_not_a_pem_encoded_certificate' panicked at 'called `Result::unwrap()` on an `Err` value: Error(MissingHeader)', src/main.rs:33:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    test::should_error_if_given_argument_is_not_a_pem_encoded_certificate

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 3 filtered out

error: test failed, to rerun pass '--bin cert-decode'

Even though it did error, it didn't do so in a way we could handle in our test. It would be better to not call unwrap on the return of pem_to_der. To do so, we need to change the return type of execute so it allows us to return both our existing String errors and the PEMError which pem_to_der returns.

Error handling

There are many different ways to handle errors in Rust. A lot is going on in this space currently in regards libaries and discussions in the language itself. How you handle errors in a library vs in an application can vary wildly too. I don't want to add any more dependencies here and I also want to keep this simple, so we'll use the most general type for handling errors, Box<dyn std::error::Error>2.

Box is a simple way of allocating something on the heap in Rust. Box<dyn std::error::Error> is a trait object3, this allows us to return a value of any type that implements the std::error::Error trait.

Let's refactor. First, update execute as follows.

-fn execute(validator: impl PathValidator, args: Vec<String>) -> Result<(), String> {
+fn execute(
+    validator: impl PathValidator,
+    args: Vec<String>,
+) -> Result<(), Box<dyn std::error::Error>> {
     if args.len() != 1 {
         let error = format!(
             "{}{}",
             "Error: did not receive a single argument, ",
             "please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
         );
-        return Err(error);
+        return Err(error.into());
     }
     let path = &args[0];
     if !validator.is_file(path) {
         return Err(
             "Error: path given is not a regular file, please update to point to a certificate."
-                .to_owned(),
+                .into(),
         );
     }
     let cert = std::fs::read_to_string(path).unwrap();
-    let _ = pem_to_der(cert.as_bytes()).unwrap();
+    let _ = pem_to_der(cert.as_bytes())?;
     Ok(())
 }

We change the return type to of execute to Result<(), Box<dyn std::error::Error>>. We were previously returning String's from our custom errors, we can call into on our strings and this will convert them into Box<dyn std::error::Error>. There is an instance of From for converting a String to a Box<dyn std::error::Error>, because of this we get an Into instance for automatically.

Finally, we add a ?4 to immediately return the error if pem_to_der returns an error. Next update main.

-fn main() -> Result<(), String> {
+fn main() -> Result<(), Box<dyn std::error::Error>> {
     let args = std::env::args().skip(1).collect();
     let validator = CertValidator;
     execute(validator, args)
 }

We just change the return type here. Finally, update the tests.

 #[cfg(test)]
 mod test {
     ... 
     #[test]
     fn should_error_if_not_given_a_single_argument() {
         ...
         assert!(result.is_err());
         assert_eq!(
-            result.err().unwrap(),
+            format!("{}", result.err().unwrap()),
             format!(
                 "{}{}",
                 "Error: did not receive a single argument, ",
                 "please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
             )
         );
     }

     #[test]
     fn should_error_if_argument_is_not_a_regular_file() {
         ...
         assert!(result.is_err());
         assert_eq!(
-            result.err().unwrap(),
+            format!("{}", result.err().unwrap()),
             "Error: path given is not a regular file, please update to point to a certificate."
         );
     }

     #[test]
     fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
        ...
     }

     #[test]
     fn should_succeed() {
-        let args = vec!["a-file".to_owned()];
+        let args = vec!["resources/google.com.crt".to_owned()];
         let validator = FakeValidator { is_file: true };
         let result = execute(validator, args);
         assert!(result.is_ok());
     }

We call format on the error message to turn the Box<dyn std::error::Error> in to a string. We also change the should_succeed test to read the real cert. This does IO, but that's ok for now. Re-run and the tests should be green.

✦ ➜ cargo -q test

running 4 tests
....
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

🔗 wayofthepie/cert-decoder@37cbc87

Refactor

Now we have refactored to allow returning different types of errors, read, and decoded the PEM certificate into DER format. Let's clean things up a little. We are doing IO again, so let's tackle that first. Right now we are passing an implementation of PathValidator to execute. It would make sense to expand what this trait does, but we should rename it. Let's call it FileProcessor. Implementations will have is_file and read_to_string so this makes sense. Let's also rename CertValidator to CertProcessor.

 use std::path::Path;
 use x509_parser::pem::pem_to_der;

-trait PathValidator {
+trait FileProcessor {
     fn is_file(&self, path: &str) -> bool;
 }

-struct CertValidator;
+struct CertProcessor;

-impl PathValidator for CertValidator {
+impl FileProcessor for CertProcessor {
     fn is_file(&self, path: &str) -> bool {
         Path::new(path).is_file()
     }
 }

 fn execute(
-    validator: impl PathValidator,
+    validator: impl FileProcessor,
     args: Vec<String>,
 ) -> Result<(), Box<dyn std::error::Error>> {
    ...
    Ok(())
 }

 fn main() -> Result<(), Box<dyn std::error::Error>> {
     let args = std::env::args().skip(1).collect();
-    let validator = CertValidator;
+    let validator = CertProcessor;
     execute(validator, args)
 }

 #[cfg(test)]
 mod test {

-    use crate::{execute, PathValidator};
+    use crate::{execute, FileProcessor};

-    struct FakeValidator {
+    struct FakeProcessor {
         is_file: bool,
     }

-    impl PathValidator for FakeValidator {
+    impl FileProcessor for FakeProcessor {
         fn is_file(&self, _: &str) -> bool {
             self.is_file
         }
     }

     #[test]
     fn should_error_if_not_given_a_single_argument() {
         // arrange
         let args = Vec::new();
-        let validator = FakeValidator { is_file: false };
+        let validator = FakeProcessor { is_file: false };

         ...
     }

     #[test]
     fn should_error_if_argument_is_not_a_regular_file() {
         // arrange
         let args = vec!["not-a-regular-file".to_owned()];
-        let validator = FakeValidator { is_file: false };
+        let validator = FakeProcessor { is_file: false };

         ...
     }

     #[test]
     fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
         let args = vec!["Cargo.toml".to_owned()];
-        let validator = FakeValidator { is_file: true };
+        let validator = FakeProcessor { is_file: true };
         let result = execute(validator, args);
         assert!(result.is_err())
     }

     #[test]
     fn should_succeed() {
         let args = vec!["resources/google.com.crt".to_owned()];
-        let validator = FakeValidator { is_file: true };
+        let validator = FakeProcessor { is_file: true };
         let result = execute(validator, args);
         assert!(result.is_ok());
     }
 }

📝 Note
You may have noticed I forgot to update the name of the variables from validator to something more appropriate like processor!
This was indeed a mistake. I added a small refactor section near the end of the post which fixes this.

🔗 wayofthepie/cert-decoder@a30ae7c

Now we can add a read_to_string method to the FileProcessor trait and implement.

 use std::path::Path;
 use x509_parser::pem::pem_to_der;

 trait FileProcessor {
     fn is_file(&self, path: &str) -> bool;
+    fn read_to_string(&self, path: &str) -> Result<String, Box<dyn std::error::Error>>;
 }

 struct CertProcessor;

 impl FileProcessor for CertProcessor {
     fn is_file(&self, path: &str) -> bool {
         Path::new(path).is_file()
     }
+    fn read_to_string(&self, path: &str) -> Result<String, Box<dyn std::error::Error>> {
+        Ok(std::fs::read_to_string(path)?)
+    }
 }

 fn execute(
-    validator: impl FileProcessor,
+    processor: impl FileProcessor,
     args: Vec<String>,
 ) -> Result<(), Box<dyn std::error::Error>> {
     if args.len() != 1 {
         let error = format!(
             "{}{}",
             "Error: did not receive a single argument, ",
             "please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
         );
         return Err(error.into());
     }
     let path = &args[0];
-    if !validator.is_file(path) {
+    if !processor.is_file(path) {
         return Err(
             "Error: path given is not a regular file, please update to point to a certificate."
                 .into(),
         );
     }
-    let cert = std::fs::read_to_string(path).unwrap();
+    let cert = processor.read_to_string(path)?;
     let _ = pem_to_der(cert.as_bytes())?;
     Ok(())
 }

 fn main() -> Result<(), Box<dyn std::error::Error>> {
     let args = std::env::args().skip(1).collect();
     let validator = CertProcessor;
     execute(validator, args)
 }

 #[cfg(test)]
 mod test {

     use crate::{execute, FileProcessor};

     struct FakeProcessor {
         is_file: bool,
+        file_str: String,
     }

     impl FileProcessor for FakeProcessor {
         fn is_file(&self, _: &str) -> bool {
             self.is_file
         }
+        fn read_to_string(&self, _: &str) -> Result<String, Box<dyn std::error::Error>> {
+            Ok(self.file_str.clone())
+        }
     }

     #[test]
     fn should_error_if_not_given_a_single_argument() {
         // arrange
         let args = Vec::new();
-        let validator = FakeProcessor { is_file: false };
+        let validator = FakeProcessor {
+            is_file: false,
+            file_str: "".to_owned(),
+        };

         // act
         let result = execute(validator, args);

         // assert
         assert!(result.is_err());
         assert_eq!(
             format!("{}", result.err().unwrap()),
             format!(
                 "{}{}",
                 "Error: did not receive a single argument, ",
                 "please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
             )
         );
     }

     #[test]
     fn should_error_if_argument_is_not_a_regular_file() {
         // arrange
         let args = vec!["not-a-regular-file".to_owned()];
-        let validator = FakeProcessor { is_file: false };
+        let validator = FakeProcessor {
+            is_file: false,
+            file_str: "".to_owned(),
+        };

         // act
         let result = execute(validator, args);

         // assert
         assert!(result.is_err());
         assert_eq!(
             format!("{}", result.err().unwrap()),
             "Error: path given is not a regular file, please update to point to a certificate."
         );
     }

     #[test]
     fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
         let args = vec!["Cargo.toml".to_owned()];
-        let validator = FakeProcessor { is_file: true };
+        let validator = FakeProcessor {
+            is_file: true,
+            file_str: "".to_owned(),
+        };
         let result = execute(validator, args);
         assert!(result.is_err())
     }

     #[test]
     fn should_succeed() {
-        let args = vec!["resources/google.com.crt".to_owned()];
-        let validator = FakeProcessor { is_file: true };
+        let cert = include_str!("../resources/google.com.crt");
+        let args = vec!["doesnt-really-matter".to_owned()];
+        let validator = FakeProcessor {
+            is_file: true,
+            file_str: cert.to_owned(),
+        };
         let result = execute(validator, args);
         assert!(result.is_ok());
     }

📝 Note

In the should_succeed test we use the include_str macro to read the real cert at compile time. This is cleaner than pasting the cert directly in the test.

🔗 wayofthepie/cert-decoder@436d85f

We can improve the tests by deriving5 Default for our FakeProcessor. This will give us a basic implementation of FakeProcessor, defaulting all the fields to the value of the Default implementation for their type. For example, the default for bool is false and for String is the empty string.

 #[cfg(test)]
 mod test {

     use crate::{execute, FileProcessor};

     ...
-
+    #[derive(Default)]
     struct FakeProcessor {
         is_file: bool,
         file_str: String,
     }

     #[test]
     fn should_error_if_not_given_a_single_argument() {
-        // arrange
         let args = Vec::new();
-        let validator = FakeProcessor {
-            is_file: false,
-            file_str: "".to_owned(),
-        };
-
-        // act
+        let validator = FakeProcessor::default();
         let result = execute(validator, args);
-
-        // assert
         assert!(result.is_err());
         assert_eq!(
             format!("{}", result.err().unwrap()),
             format!(
                 "{}{}",
                 "Error: did not receive a single argument, ",
                 "please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
             )
         );
     }

     #[test]
     fn should_error_if_argument_is_not_a_regular_file() {
-        // arrange
         let args = vec!["not-a-regular-file".to_owned()];
-        let validator = FakeProcessor {
-            is_file: false,
-            file_str: "".to_owned(),
-        };
-
-        // act
+        let validator = FakeProcessor::default();
         let result = execute(validator, args);
-
-        // assert
         assert!(result.is_err());
         assert_eq!(
             format!("{}", result.err().unwrap()),
             "Error: path given is not a regular file, please update to point to a certificate."
         );
     }

     #[test]
     fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
         let args = vec!["Cargo.toml".to_owned()];
         let validator = FakeProcessor {
             is_file: true,
-            file_str: "".to_owned(),
+            ..FakeProcessor::default()
         };
         let result = execute(validator, args);
         assert!(result.is_err())
     }
     ... 
 }

📝 Note
In a test above we used struct update syntax, ..FakeProcessor::default(). This will "fill in" any fields we do not explicitly set. It will allow us to add more fields to FileProcessor if needed and not have to update all tests.

After each change, you should run the tests! If you run them now they should still be green.

✦ ➜ cargo -q test

running 4 tests
....
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

🔗 wayofthepie/cert-decoder@e984bd7f

Parse the der encoded cert

Let's parse the DER bytes into an X509Certificate. In the x509 parser API section we saw an example of this using the parse_x509_der function. It can fail, so first, a test.

#[test]
fn should_error_if_argument_is_not_a_valid_certificate() {
    let cert = include_str!("../resources/bad.crt");
    let args = vec!["doesnt-really-matter".to_owned()];
    let processor = FakeProcessor {
        is_file: true,
        file_str: cert.to_owned(),
    };
    let result = execute(processor, args);
    assert!(result.is_err());
}

I have added a file called bad.crt to the resources folder. This just contains a base64 encoded string, which is not a valid certificate. So it will succeed in the pem_to_der call but calling parse_x509_der should return an error. First, let's see this test fail.

✦ ➜ cargo -q test

running 5 tests
...F.
failures:

---- test::should_error_if_argument_is_not_a_valid_certificate stdout ----
thread 'test::should_error_if_argument_is_not_a_valid_certificate' panicked at 'assertion failed: result.is_err()', src/main.rs:118:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    test::should_error_if_argument_is_not_a_valid_certificate

test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out

error: test failed, to rerun pass '--bin cert-decode'

Great! Now, let's parse the DER we get.

fn execute(
    processor: impl FileProcessor,
    args: Vec<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    // stripped out irrelevant code 
    ...

    let cert = processor.read_to_string(path)?;
    let (_, pem) = pem_to_der(cert.as_bytes())?;
    let _ = parse_x509_der(&pem.contents)?;
    Ok(())
}

And re-run the tests.

✦ ➜ cargo -q test

running 5 tests
.....
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Great! We didn't need to update the should_succeed test, meaning it is reading our real certificate correctly. There are few things we can improve here, but first, let's mark off the first item in our test list.


Read and print certificate

  • ✔️ Validate file is a certificate
  • ☐ Print the certificate

🔗 wayofthepie/cert-decoder@11ff773

Print the certificate

It turns out we will have to do a bit more processing to get a human-readable output format, so I'm going to cheat here! On success, parse_x509_der returns a tuple with remaining bytes and an X509Certificate. The X509Certificate type implements Debug so we can print its debug format.


 fn execute(
     processor: impl FileProcessor,
     args: Vec<String>,
 ) -> Result<(), Box<dyn std::error::Error>> {
     ... 
     let cert = processor.read_to_string(path)?;
     let (_, pem) = pem_to_der(cert.as_bytes())?;
-    let _ = parse_x509_der(&pem.contents)?;
+    let (_, cert) = parse_x509_der(&pem.contents)?;
+    let output = format!("{:#?}", cert.tbs_certificate);
+    println!("{}", output);
     Ok(())
 }

 ...

We use the debug format specifier {:?} in the format macro. We also add a # to pretty print it, {:#?}. The only thing we print here is the tbs_certificate field as that contains all the details we will need. To test this, let's run the actual cli.

📝 Note
From the above change you might be thinking "Why create a pre-formatted string and pass that to println!? Couldn't you just use the debug format specifier directly in println!?". You can do this, try it and do cargo -q run -- resources/google.com.crt. Now do it again with a pipe - cargo -q run -- resources/google.com.crt | head -n20 - it will fail. I may do a short post on why this happens.

✦ ➜ cargo -q run -- resources/google.com.crt | head -n20
TbsCertificate {
    version: 2,
    serial: BigUint {
        data: [
            4412903,
            134217728,
            247394332,
            1459281101,
        ],
    },
    signature: AlgorithmIdentifier {
        algorithm: OID(1.2.840.113549.1.1.11),
        parameters: BerObject {
            class: 0,
            structured: 0,
            tag: EndOfContent,
            content: ContextSpecific(
                EndOfContent,
                Some(
                    BerObject {

Pretty unreadable! But it's a start, we're making some headway. In the next post, we'll clean this up.


Read and print certificate

  • ✔️ Validate file is a certificate
  • ✔️ Print the certificate

🔗 wayofthepie/cert-decoder@11058e3

Tiny refactor

I realized I misnamed a few things. I missed them when refactoring. In the tests, most of the FakeProcessor variables are still called validator. Similarly in main. Let's update those.

  ...

 fn main() -> Result<(), Box<dyn std::error::Error>> {
     let args = std::env::args().skip(1).collect();
-    let validator = CertProcessor;
-    execute(validator, args)
+    let processor = CertProcessor;
+    execute(processor, args)
 }

 #[cfg(test)]
 mod test {

     ...


     #[test]
     fn should_error_if_not_given_a_single_argument() {
         let args = Vec::new();
-        let validator = FakeProcessor::default();
-        let result = execute(validator, args);
+        let processor = FakeProcessor::default();
+        let result = execute(processor, args);
         assert!(result.is_err());
         assert_eq!(
             format!("{}", result.err().unwrap()),
             format!(
                 "{}{}",
                 "Error: did not receive a single argument, ",
                 "please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."
             )
         );
     }

     #[test]
     fn should_error_if_argument_is_not_a_regular_file() {
         let args = vec!["not-a-regular-file".to_owned()];
-        let validator = FakeProcessor::default();
-        let result = execute(validator, args);
+        let processor = FakeProcessor::default();
+        let result = execute(processor, args);
         assert!(result.is_err());
         assert_eq!(
             format!("{}", result.err().unwrap()),
             "Error: path given is not a regular file, please update to point to a certificate."
         );
     }

     #[test]
     fn should_error_if_given_argument_is_not_a_pem_encoded_certificate() {
         let args = vec!["Cargo.toml".to_owned()];
-        let validator = FakeProcessor {
+        let processor = FakeProcessor {
             is_file: true,
             ..FakeProcessor::default()
         };
-        let result = execute(validator, args);
+        let result = execute(processor, args);
         assert!(result.is_err())
     }

     ...

     #[test]
     fn should_succeed() {
         let cert = include_str!("../resources/google.com.crt");
         let args = vec!["doesnt-really-matter".to_owned()];
-        let validator = FakeProcessor {
+        let processor = FakeProcessor {
             is_file: true,
             file_str: cert.to_owned(),
         };
-        let result = execute(validator, args);
+        let result = execute(processor, args);
         println!("{:#?}", result);
         assert!(result.is_ok());
     }
 }

🔗 wayofthepie/cert-decoder@371ea84

Conclusion

There were a few things I glossed over that appeared in this post. I will take note of them and make sure they appear in one of the next posts. For example lifetimes, a better explanation of Box, and that println issue I mentioned in a note in the last section.

There is also a small display issue when an error occurs. For example, if we pass no argument:

✦ ➜ cargo -q run --
Error: "Error: did not receive a single argument, please invoke cert-decoder as follows: ./cert-decoder /path/to/cert."

It repeats the word "Error" and also wraps our error string in quotes. We will fix this too in the next post!


  1. See Generic Types, Traits, and Lifetimes in the Rust book. ↩

  2. Error handling in Rust is a big topic, to get started see the Error Handling chapter in the Rust book. For more on boxed errors see Boxing errors.d ↩

  3. See Using Trait Objects That Allow for Values of Different Types in the Rust book. ↩

  4. For more on how the ? works see The ? operator for easier error handling. ↩

  5. For more on deriving see Derive ↩

TDD and Rust: A CLI for decoding certs (3 Part Series)

1) A Rust CLI for decoding certs 2) Rust and GitHub Actions 3) Decode a certificate

Posted on Jun 7 by:

Discussion

markdown guide
 

Hi,
x509-parser author here :)
Thanks for your very detailed post! It gives many examples and details.

If you have feedback on the provided API, or suggested features, they are welcome!

 

Hi Pierre,

Thanks, I have been working with the x509-parser crate for a while now, it is great thanks for building it! I'm building a few things around the certificate transparency logs with this crate so if I come across anything I will definitely open an issue.

 

Thank you. I don't need this today but I will need it soon. Great figures and examples. +1