]> git.ipfire.org Git - thirdparty/curl.git/commitdiff
mk-ca-bundle.pl: Use stricter logic to process the certificates
authorJay Satiro <raysatiro@yahoo.com>
Wed, 9 Feb 2022 08:19:01 +0000 (03:19 -0500)
committerJay Satiro <raysatiro@yahoo.com>
Fri, 18 Mar 2022 07:15:44 +0000 (03:15 -0400)
.. and bump version to 1.29.

This change makes the script properly ignore unknown blocks and
otherwise fail when Mozilla changes the certdata format in ways we
don't expect. Though this is less flexible behavior it makes it far less
likely that an invalid certificate can slip through.

Prior to this change the state machine did not always properly reset,
and it was possible that a certificate marked as invalid could then
later be marked as valid when there was conflicting trust info or
an unknown block was erroneously processed as part of the certificate.

Ref: https://github.com/curl/curl/pull/7801#pullrequestreview-768384569

Closes https://github.com/curl/curl/pull/8411

lib/mk-ca-bundle.pl

index e5a7420c0eb4f1e5b181e994c86dfbc0dab247ba..6c981ce1b9f9c7948bed3728b9b52dd1057f41dd 100755 (executable)
@@ -6,7 +6,7 @@
 # *                            | (__| |_| |  _ <| |___
 # *                             \___|\___/|_| \_\_____|
 # *
-# * Copyright (C) 1998 - 2021, Daniel Stenberg, <daniel@haxx.se>, et al.
+# * Copyright (C) 1998 - 2022, Daniel Stenberg, <daniel@haxx.se>, et al.
 # *
 # * This software is licensed as described in the file COPYING, which
 # * you should have received as part of this distribution. The terms
@@ -63,11 +63,12 @@ $opt_d = 'release';
 # If the OpenSSL commandline is not in search path you can configure it here!
 my $openssl = 'openssl';
 
-my $version = '1.28';
+my $version = '1.29';
 
 $opt_w = 76; # default base64 encoded lines length
 
-# default cert types to include in the output (default is to include CAs which may issue SSL server certs)
+# default cert types to include in the output (default is to include CAs which
+# may issue SSL server certs)
 my $default_mozilla_trust_purposes = "SERVER_AUTH";
 my $default_mozilla_trust_levels = "TRUSTED_DELEGATOR";
 $opt_p = $default_mozilla_trust_purposes . ":" . $default_mozilla_trust_levels;
@@ -94,8 +95,12 @@ my @valid_mozilla_trust_purposes = (
 my @valid_mozilla_trust_levels = (
   "TRUSTED_DELEGATOR",    # CAs
   "NOT_TRUSTED",          # Don't trust these certs.
-  "MUST_VERIFY_TRUST",    # This explicitly tells us that it ISN'T a CA but is otherwise ok. In other words, this should tell the app to ignore any other sources that claim this is a CA.
-  "TRUSTED"               # This cert is trusted, but only for itself and not for delegates (i.e. it is not a CA).
+  "MUST_VERIFY_TRUST",    # This explicitly tells us that it ISN'T a CA but is
+                          # otherwise ok. In other words, this should tell the
+                          # app to ignore any other sources that claim this is
+                          # a CA.
+  "TRUSTED"               # This cert is trusted, but only for itself and not
+                          # for delegates (i.e. it is not a CA).
 );
 
 my $default_signature_algorithms = $opt_s = "MD5";
@@ -210,8 +215,8 @@ sub is_in_list($@) {
   return defined(List::Util::first { $target eq $_ } @_);
 }
 
-# Parses $param_string as a case insensitive comma separated list with optional whitespace
-# validates that only allowed parameters are supplied
+# Parses $param_string as a case insensitive comma separated list with optional
+# whitespace validates that only allowed parameters are supplied
 sub parse_csv_param($$@) {
   my $description = shift;
   my $param_string = shift;
@@ -227,7 +232,8 @@ sub parse_csv_param($$@) {
   my @invalid = grep { !is_in_list($_,"ALL",@valid_values) } @values;
 
   if ( scalar(@invalid) > 0 ) {
-    # Tell the user which parameters were invalid and print the standard help message which will exit
+    # Tell the user which parameters were invalid and print the standard help
+    # message which will exit
     print "Error: Invalid ", $description, scalar(@invalid) == 1 ? ": " : "s: ", join( ", ", map { "\"$_\"" } @invalid ), "\n";
     HELP_MESSAGE();
   }
@@ -282,7 +288,8 @@ sub should_output_cert(%) {
   my %trust_purposes_by_level = @_;
 
   foreach my $level (@included_mozilla_trust_levels) {
-    # for each level we want to output, see if any of our desired purposes are included
+    # for each level we want to output, see if any of our desired purposes are
+    # included
     return 1 if ( defined( List::Util::first { is_in_list( $_, @included_mozilla_trust_purposes ) } @{$trust_purposes_by_level{$level}} ) );
   }
 
@@ -421,9 +428,13 @@ my $caname;
 my $certnum = 0;
 my $skipnum = 0;
 my $start_of_cert = 0;
+my $main_block = 0;
+my $main_block_name;
+my $trust_block = 0;
+my $trust_block_name;
 my @precert;
 my $cka_value;
-my $valid = 1;
+my $valid = 0;
 
 open(TXT,"$txt") or die "Couldn't open $txt: $!\n";
 while (<TXT>) {
@@ -435,101 +446,170 @@ while (<TXT>) {
       print if ($opt_l);
       last if (/\*\*\*\*\* END LICENSE BLOCK \*\*\*\*\*/);
     }
+    next;
   }
-# Not Valid After : Thu Sep 30 14:01:15 2021
-  elsif(/^# Not Valid After : (.*)/) {
-      my $stamp = $1;
-      use Time::Piece;
-      my $t = Time::Piece->strptime
-          ($stamp, "%a %b %d %H:%M:%S %Y");
-      my $delta = ($t->epoch - time()); # negative means no longer valid
-      if($delta < 0) {
+  # The input file format consists of blocks of Mozilla objects.
+  # The blocks are separated by blank lines but may be related.
+  elsif(/^\s*$/) {
+    $main_block = 0;
+    $trust_block = 0;
+    next;
+  }
+  # Each certificate has a main block.
+  elsif(/^# Certificate "(.*)"/) {
+    (!$main_block && !$trust_block) or die "Unexpected certificate block";
+    $main_block = 1;
+    $main_block_name = $1;
+    # Reset all other certificate variables.
+    $trust_block = 0;
+    $trust_block_name = "";
+    $valid = 0;
+    $start_of_cert = 0;
+    $caname = "";
+    $cka_value = "";
+    undef @precert;
+    next;
+  }
+  # Each certificate's main block is followed by a trust block.
+  elsif(/^# Trust for (?:Certificate )?"(.*)"/) {
+    (!$main_block && !$trust_block) or die "Unexpected trust block";
+    $trust_block = 1;
+    $trust_block_name = $1;
+    if($main_block_name ne $trust_block_name) {
+      die "cert name \"$main_block_name\" != trust name \"$trust_block_name\"";
+    }
+    next;
+  }
+  # Ignore other blocks.
+  #
+  # There is a documentation comment block, a BEGINDATA block, and a bunch of
+  # blocks starting with "# Explicitly Distrust <certname>".
+  #
+  # The latter is for certificates that have already been removed and are not
+  # included. Not all explicitly distrusted certificates are ignored at this
+  # point, just those without an actual certificate.
+  elsif(!$main_block && !$trust_block) {
+    next;
+  }
+  elsif(/^#/) {
+    # The commented lines in a main block are plaintext metadata that describes
+    # the certificate. Issuer, Subject, Fingerprint, etc.
+    if($main_block) {
+      push @precert, $_ if not /^#$/;
+      if(/^# Not Valid After : (.*)/) {
+        my $stamp = $1;
+        use Time::Piece;
+        # Not Valid After : Thu Sep 30 14:01:15 2021
+        my $t = Time::Piece->strptime($stamp, "%a %b %d %H:%M:%S %Y");
+        my $delta = ($t->epoch - time()); # negative means no longer valid
+        if($delta < 0) {
           $skipnum++;
-          report "Skipping: $caname is not valid anymore" if ($opt_v);
+          report "Skipping: $main_block_name is not valid anymore" if ($opt_v);
           $valid = 0;
-      }
-      else {
+        }
+        else {
           $valid = 1;
+        }
       }
-      next;
-  }
-  elsif(/^# (Issuer|Serial Number|Subject|Not Valid Before|Fingerprint \(MD5\)|Fingerprint \(SHA1\)):/) {
-      push @precert, $_;
-      next;
+    }
+    next;
   }
-  elsif(/^#|^\s*$/) {
-      undef @precert;
-      next;
+  elsif(!$valid) {
+    next;
   }
-  chomp;
 
-  # Example:
-  # CKA_NSS_SERVER_DISTRUST_AFTER MULTILINE_OCTAL
-  # \062\060\060\066\061\067\060\060\060\060\060\060\132
-  # END
+  chomp;
 
-  if (/^CKA_NSS_SERVER_DISTRUST_AFTER (CK_BBOOL CK_FALSE|MULTILINE_OCTAL)/) {
+  if($main_block) {
+    if(/^CKA_CLASS CK_OBJECT_CLASS CKO_CERTIFICATE/) {
+      !$start_of_cert or die "Duplicate CKO_CERTIFICATE object";
+      $start_of_cert = 1;
+      next;
+    }
+    elsif(!$start_of_cert) {
+      next;
+    }
+    elsif(/^CKA_LABEL UTF8 \"(.*)\"/) {
+      ($caname eq "") or die "Duplicate CKA_LABEL attribute";
+      $caname = $1;
+      if($caname ne $main_block_name) {
+        die "caname \"$caname\" != cert name \"$main_block_name\"";
+      }
+      next;
+    }
+    elsif(/^CKA_VALUE MULTILINE_OCTAL/) {
+      ($cka_value eq "") or die "Duplicate CKA_VALUE attribute";
+      while (<TXT>) {
+        last if (/^END/);
+        chomp;
+        my @octets = split(/\\/);
+        shift @octets;
+        for (@octets) {
+          $cka_value .= chr(oct);
+        }
+      }
+      next;
+    }
+    elsif (/^CKA_NSS_SERVER_DISTRUST_AFTER (CK_BBOOL CK_FALSE|MULTILINE_OCTAL)/) {
+      # Example:
+      # CKA_NSS_SERVER_DISTRUST_AFTER MULTILINE_OCTAL
+      # \062\060\060\066\061\067\060\060\060\060\060\060\132
+      # END
       if($1 eq "MULTILINE_OCTAL") {
-          my @timestamp;
-          while (<TXT>) {
-              last if (/^END/);
-              chomp;
-              my @octets = split(/\\/);
-              shift @octets;
-              for (@octets) {
-                  push @timestamp, chr(oct);
-              }
-          }
-          # A trailing Z in the timestamp signifies UTC
-          if($timestamp[12] ne "Z") {
-              report "distrust date stamp is not using UTC";
-          }
-          # Example date: 200617000000Z
-          # Means 2020-06-17 00:00:00 UTC
-          my $distrustat =
-            timegm($timestamp[10] . $timestamp[11], # second
-                   $timestamp[8] . $timestamp[9],   # minute
-                   $timestamp[6] . $timestamp[7],   # hour
-                   $timestamp[4] . $timestamp[5],   # day
-                   ($timestamp[2] . $timestamp[3]) - 1, # month
-                   "20" . $timestamp[0] . $timestamp[1]); # year
-          if(time >= $distrustat) {
-              # not trusted anymore
-              $skipnum++;
-              report "Skipping: $caname is not trusted anymore" if ($opt_v);
-              $valid = 0;
-          }
-          else {
-              # still trusted
+        my @timestamp;
+        while (<TXT>) {
+          last if (/^END/);
+          chomp;
+          my @octets = split(/\\/);
+          shift @octets;
+          for (@octets) {
+            push @timestamp, chr(oct);
           }
+        }
+        scalar(@timestamp) == 13 or die "Failed parsing timestamp";
+        # A trailing Z in the timestamp signifies UTC
+        if($timestamp[12] ne "Z") {
+          report "distrust date stamp is not using UTC";
+        }
+        # Example date: 200617000000Z
+        # Means 2020-06-17 00:00:00 UTC
+        my $distrustat =
+          timegm($timestamp[10] . $timestamp[11], # second
+                 $timestamp[8] . $timestamp[9],   # minute
+                 $timestamp[6] . $timestamp[7],   # hour
+                 $timestamp[4] . $timestamp[5],   # day
+                 ($timestamp[2] . $timestamp[3]) - 1, # month
+                 "20" . $timestamp[0] . $timestamp[1]); # year
+        if(time >= $distrustat) {
+          # not trusted anymore
+          $skipnum++;
+          report "Skipping: $main_block_name is not trusted anymore" if ($opt_v);
+          $valid = 0;
+        }
+        else {
+          # still trusted
+        }
       }
       next;
+    }
+    else {
+      next;
+    }
   }
 
-  # this is a match for the start of a certificate
-  if (/^CKA_CLASS CK_OBJECT_CLASS CKO_CERTIFICATE/) {
-    $start_of_cert = 1
-  }
-  if ($start_of_cert && /^CKA_LABEL UTF8 \"(.*)\"/) {
-    $caname = $1;
+  if(!$trust_block || !$start_of_cert || $caname eq "" || $cka_value eq "") {
+    die "Certificate extraction failed";
   }
+
   my %trust_purposes_by_level;
-  if ($start_of_cert && /^CKA_VALUE MULTILINE_OCTAL/) {
-    $cka_value="";
-    while (<TXT>) {
-      last if (/^END/);
-      chomp;
-      my @octets = split(/\\/);
-      shift @octets;
-      for (@octets) {
-        $cka_value .= chr(oct);
-      }
-    }
-  }
-  if(/^CKA_CLASS CK_OBJECT_CLASS CKO_NSS_TRUST/ && $valid) {
+
+  if(/^CKA_CLASS CK_OBJECT_CLASS CKO_NSS_TRUST/) {
     # now scan the trust part to determine how we should trust this cert
     while (<TXT>) {
-      last if (/^#/);
+      if(/^\s*$/) {
+        $trust_block = 0;
+        last;
+      }
       if (/^CKA_TRUST_([A-Z_]+)\s+CK_TRUST\s+CKT_NSS_([A-Z_]+)\s*$/) {
         if ( !is_in_list($1,@valid_mozilla_trust_purposes) ) {
           report "Warning: Unrecognized trust purpose for cert: $caname. Trust purpose: $1. Trust Level: $2";
@@ -541,33 +621,42 @@ while (<TXT>) {
       }
     }
 
+    # Sanity check that an explicitly distrusted certificate only has trust
+    # purposes with a trust level of NOT_TRUSTED.
+    #
+    # Certificate objects that are explicitly distrusted are in a certificate
+    # block that starts # Certificate "Explicitly Distrust(ed) <certname>",
+    # where "Explicitly Distrust(ed) " was prepended to the original cert name.
+    if($caname =~ /distrust/i ||
+       $main_block_name =~ /distrust/i ||
+       $trust_block_name =~ /distrust/i) {
+      my @levels = keys %trust_purposes_by_level;
+      if(scalar(@levels) != 1 || $levels[0] ne "NOT_TRUSTED") {
+        die "\"$caname\" must have all trust purposes at level NOT_TRUSTED.";
+      }
+    }
+
     if ( !should_output_cert(%trust_purposes_by_level) ) {
       $skipnum ++;
-      report "Skipping: $caname" if ($opt_v);
+      report "Skipping: $caname lacks acceptable trust level" if ($opt_v);
     } else {
-      my $data = $cka_value;
-      $cka_value = "";
-
-      if(!length($data)) {
-          # if empty, skip
-          next;
-      }
-      my $encoded = MIME::Base64::encode_base64($data, '');
+      my $encoded = MIME::Base64::encode_base64($cka_value, '');
       $encoded =~ s/(.{1,${opt_w}})/$1\n/g;
       my $pem = "-----BEGIN CERTIFICATE-----\n"
               . $encoded
               . "-----END CERTIFICATE-----\n";
       print CRT "\n$caname\n";
-      print CRT @precert if($opt_m);
       my $maxStringLength = length(decode('UTF-8', $caname, Encode::FB_CROAK | Encode::LEAVE_SRC));
+      print CRT ("=" x $maxStringLength . "\n");
       if ($opt_t) {
         foreach my $key (sort keys %trust_purposes_by_level) {
            my $string = $key . ": " . join(", ", @{$trust_purposes_by_level{$key}});
-           $maxStringLength = List::Util::max( length($string), $maxStringLength );
            print CRT $string . "\n";
         }
       }
-      print CRT ("=" x $maxStringLength . "\n");
+      if($opt_m) {
+        print CRT for @precert;
+      }
       if (!$opt_t) {
         print CRT $pem;
       } else {
@@ -597,13 +686,10 @@ while (<TXT>) {
           open(CRT, ">>$crt.~") or die "Couldn't open $crt.~: $!";
         }
       }
-      report "Parsing: $caname" if ($opt_v);
+      report "Processed: $caname" if ($opt_v);
       $certnum ++;
-      $start_of_cert = 0;
     }
-    undef @precert;
   }
-
 }
 close(TXT) or die "Couldn't close $txt: $!\n";
 close(CRT) or die "Couldn't close $crt.~: $!\n";