(* input is a certdata.txt from nss, output is a ml file with the trust anchors *)

(* ideas from FreeBSD's security/ca-root-nss perl script, available at:
   https://github.com/freebsd/freebsd-ports/blob/master/security/ca_root_nss/files/MAca-bundle.pl.in *)
let until_end data =
  let rec go acc = function
    | [] -> invalid_arg "unexpected end of input (expected END)"
    | "END" :: tl -> (List.rev acc, tl)
    | x :: tl -> go (x :: acc) tl
  in
  go [] data

let decode_octal data =
  let nums = String.split_on_char '\\' data in
  let nums = List.filter (fun s -> String.length s = 3) nums in
  let numbers = List.map (fun s -> int_of_string ("0o" ^ s)) nums in
  let out = Bytes.create (List.length nums) in
  List.iteri (fun i x -> Bytes.set out i (char_of_int x)) numbers;
  Bytes.unsafe_to_string out

let label_token = "CKA_LABEL UTF8 "
let serial_token = "CKA_SERIAL_NUMBER MULTILINE_OCTAL"

let is_prefix token x =
  let tl = String.length token in
  String.length x >= tl && String.(equal (sub x 0 tl) token)

let is_suffix token x =
  let tl = String.length token in
  let xl = String.length x in
  xl >= tl && String.(equal (sub x (xl - tl) tl) token)

let strip_prefix token x =
  let tl = String.length token in
  let xl = String.length x in
  String.sub x tl (xl - tl)

let label_serial id serial = function
  | [] -> assert false
  | x :: tl ->
      if is_prefix label_token x then
        let id = strip_prefix label_token x in
        (Some id, serial, tl)
      else if String.equal x serial_token then
        let serial, rest = until_end tl in
        let serial = decode_octal (String.concat "" serial) in
        (id, Some serial, rest)
      else (id, serial, tl)

let get_id_serial id serial =
  let id = match id with None -> invalid_arg "no ID" | Some id -> id
  and serial =
    match serial with None -> invalid_arg "no serial" | Some s -> s
  in
  (id, serial)

let grab_cert input =
  let rec go id serial cert = function
    | [] -> (
        let id, serial = get_id_serial id serial in
        match cert with
        | None -> invalid_arg "missing certificate"
        | Some x -> (id, serial, x))
    | "CKA_VALUE MULTILINE_OCTAL" :: tl ->
        let cert, tl = until_end tl in
        go id serial (Some cert) tl
    | tl ->
        let id, serial, tl = label_serial id serial tl in
        go id serial cert tl
  in
  go None None None input

let trust_ok_token = "CKT_NSS_TRUSTED_DELEGATOR"
and not_trusted_token = "CKT_NSS_NOT_TRUSTED"
and verify_token = "CKT_NSS_MUST_VERIFY_TRUST"

let web_server_token = "CKA_TRUST_SERVER_AUTH"
and email_token = "CKA_TRUST_EMAIL_PROTECTION"
and code_signing_token = "CKA_TRUST_CODE_SIGNING"

let ck_trust_token = " CK_TRUST "

let extract_trust x =
  let is_trusted x =
    if is_suffix trust_ok_token x then `Trusted
    else if is_suffix not_trusted_token x then `Not_trusted
    else if is_suffix verify_token x then `Must_verify
    else invalid_arg "unknown trust setting"
  in
  if is_prefix (web_server_token ^ ck_trust_token) x then
    Some (`Web, is_trusted x)
  else if is_prefix (email_token ^ ck_trust_token) x then
    Some (`Email, is_trusted x)
  else if is_prefix (code_signing_token ^ ck_trust_token) x then
    Some (`Code_signing, is_trusted x)
  else None

let grab_trust input =
  let rec go id serial trust = function
    | [] ->
        let id, serial = get_id_serial id serial in
        (id, serial, trust)
    | x :: tl -> (
        match extract_trust x with
        | None ->
            let id, serial, tl = label_serial id serial (x :: tl) in
            go id serial trust tl
        | Some y -> go id serial (y :: trust) tl)
  in
  go None None [] input

let add (certs, trust) mode acc =
  match mode with
  | Some `Cert -> (List.rev acc :: certs, trust)
  | Some `Trust -> (certs, List.rev acc :: trust)
  | None -> (certs, trust)

let rec split_into_certs_and_trust dbs mode acc = function
  | [] ->
      let certs, trust = add dbs mode acc in
      (List.rev certs, List.rev trust)
  | "CKA_CLASS CK_OBJECT_CLASS CKO_CERTIFICATE" :: tl ->
      let dbs = add dbs mode acc in
      split_into_certs_and_trust dbs (Some `Cert) [] tl
  | "CKA_CLASS CK_OBJECT_CLASS CKO_NSS_TRUST" :: tl ->
      let dbs = add dbs mode acc in
      split_into_certs_and_trust dbs (Some `Trust) [] tl
  | x :: tl -> split_into_certs_and_trust dbs mode (x :: acc) tl

module M = Map.Make (struct
  type t = string * string

  let compare (lbl, serial) (lbl', serial') =
    match String.compare lbl lbl' with
    | 0 -> String.compare serial serial'
    | y -> y
end)

let to_hex s =
  let char_hex n =
    Char.unsafe_chr (n + if n < 10 then Char.code '0' else Char.code 'a' - 10)
  in
  let slen = String.length s in
  let out = Bytes.create (slen * 2) in
  for i = 0 to pred slen do
    let c = Char.code s.[i] in
    Bytes.unsafe_set out (i * 2) (char_hex (c lsr 4));
    Bytes.unsafe_set out ((i * 2) + 1) (char_hex (c land 0x0f))
  done;
  Bytes.unsafe_to_string out

let decode data =
  let certs, trust = split_into_certs_and_trust ([], []) None [] data in
  let db =
    List.fold_left
      (fun db data ->
        let id, serial, cert = grab_cert data in
        let db =
          M.update (id, serial)
            (function
              | None -> Some (Some cert, None)
              | Some (None, x) -> Some (Some cert, x)
              | Some (Some _, _) ->
                  Logs.warn (fun m ->
                      m "cert with %s (serial %s) already present" id
                        (to_hex serial));
                  invalid_arg "duplicate certificate")
            db
        in
        db)
      M.empty certs
  in
  List.fold_left
    (fun db data ->
      let id, serial, trust = grab_trust data in
      let db =
        M.update (id, serial)
          (function
            | None -> Some (None, Some trust)
            | Some (x, None) -> Some (x, Some trust)
            | Some (_, Some _) ->
                Logs.warn (fun m ->
                    m "trust with %s (serial %s) already present" id
                      (to_hex serial));
                invalid_arg "duplicate trust")
          db
      in
      db)
    db trust

let filter_trusted ?(purpose = fun (_, _) -> true) db =
  let is_trusted ys =
    let y = List.filter purpose ys in
    List.exists (function _, `Trusted -> true | _ -> false) y
    && List.for_all (function _, `Not_trusted -> false | _ -> true) ys
  in
  M.fold
    (fun (id, serial) (cert, trust) (acc, untrusted) ->
      match (cert, trust) with
      | None, _ ->
          Logs.debug (fun m ->
              m "ignoring %s (serial %s), no corresponding certificate" id
                (to_hex serial));
          (acc, untrusted)
      | Some _, None ->
          Logs.warn (fun m ->
              m "ignoring %s (serial %s), no corresponding trust" id
                (to_hex serial));
          (acc, succ untrusted)
      | Some cert, Some t ->
          if is_trusted t then (M.add (id, serial) cert acc, untrusted)
          else (
            Logs.warn (fun m ->
                m "Untrusted certificate %s (serial %s)" id (to_hex serial));
            (acc, succ untrusted)))
    db (M.empty, 0)

let header =
  "(* automatically extracted from certdata.txt by ca-certs-nss v3.117. *)"

let stats ucount tcount dcount =
  Fmt.str "(* processed %d certificates, %d untrusted, %d trusted. *)%s"
    (ucount + tcount + dcount)
    ucount tcount
    (if dcount > 0 then
       "\n(* Omitted " ^ string_of_int dcount ^ " certificates (decoding). *)"
     else "")

let to_ml untrusted db =
  let certs, decoding_issues =
    M.fold
      (fun (lbl, _) cert (acc, dec) ->
        let der = decode_octal (String.concat "" cert) in
        match X509.Certificate.decode_der der with
        | Ok _cert ->
            (("(* " ^ lbl ^ " *) \"" ^ String.escaped der ^ "\"") :: acc, dec)
        | Error (`Msg msg) ->
            Logs.warn (fun m -> m "failed to decode certificate: %s" msg);
            (acc, succ dec))
      db ([], 0)
  in
  String.concat "\n"
    [
      header;
      stats untrusted (List.length certs) decoding_issues;
      "";
      "let certificates = [";
      "  " ^ String.concat ";\n  " (List.rev certs);
      "]";
      "";
    ]

let jump () filename output =
  Result.bind
    (Bos.OS.File.read_lines (Fpath.v filename))
    (fun data ->
      let certs = decode data in
      let trusted_certs, untrusted = filter_trusted certs in
      Logs.debug (fun m ->
          m "found %d certificates (%d total):" (M.cardinal trusted_certs)
            (M.cardinal certs));
      let out = to_ml untrusted trusted_certs in
      let fn = match output with None -> "-" | Some filename -> filename in
      Bos.OS.File.write (Fpath.v fn) out)

let setup_log style_renderer level =
  Fmt_tty.setup_std_outputs ?style_renderer ();
  Logs.set_level level;
  Logs.set_reporter (Logs_fmt.reporter ~dst:Format.std_formatter ())

open Cmdliner

let setup_log =
  Term.(const setup_log $ Fmt_cli.style_renderer () $ Logs_cli.level ())

let input =
  let doc = "Full path to certdata.txt." in
  Arg.(required & pos 0 (some file) None & info [] ~doc ~docv:"CERTDATA.TXT")

let output =
  let doc = "Output filename (defaults to stdout)." in
  Arg.(value & opt (some string) None & info [ "output" ] ~doc)

let cmd =
  let doc = "Extract NSS certdata.txt into OCaml code" in
  let term = Term.(term_result (const jump $ setup_log $ input $ output))
  and info = Cmd.info "extract-from-certdata" ~version:"3.117" ~doc in
  Cmd.v info term

let () = exit (Cmd.eval cmd)
