#!/bin/sh # $NetBSD: certctl.sh,v 1.7 2024/03/04 20:37:31 riastradh Exp $ # # Copyright (c) 2023 The NetBSD Foundation, Inc. # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. # # THIS SOFTWARE IS PROVIDED BY THE NETBSD FOUNDATION, INC. AND CONTRIBUTORS # ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR CONTRIBUTORS # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # set -o pipefail set -Ceu progname=${0##*/} ### Options and arguments usage() { exec >&2 printf 'Usage: %s %s\n' \ "$progname" \ "[-nv] [-C ] [-c ] [-u ]" printf ' ...\n' printf ' %s list\n' "$progname" printf ' %s rehash\n' "$progname" printf ' %s trust \n' "$progname" printf ' %s untrust \n' "$progname" printf ' %s untrusted\n' "$progname" exit 1 } certsdir=/etc/openssl/certs config=/etc/openssl/certs.conf distrustdir=/etc/openssl/untrusted nflag=false # dry run vflag=false # verbose # Options used by FreeBSD: # # -D destdir # -M metalog # -U (unprivileged) # -d distbase # while getopts C:c:nu:v f; do case $f in C) config=$OPTARG;; c) certsdir=$OPTARG;; n) nflag=true;; u) distrustdir=$OPTARG;; v) vflag=true;; \?) usage;; esac done shift $((OPTIND - 1)) if [ $# -lt 1 ]; then usage fi cmd=$1 ### Global state config_paths= config_manual=false tmpfile= # If tmpfile is set to nonempty, clean it up on exit. trap 'test -n "$tmpfile" && rm -f "$tmpfile"' EXIT HUP INT TERM ### Subroutines # error ... # # Print an error message to stderr. # # Does not exit the process. # error() { echo "$progname:" "$@" >&2 } # run ... # # Print a command if verbose, and run it unless it's a dry run. # run() { local t q cmdline if $vflag; then # print command if verbose for t; do case $t in ''|*[^[:alnum:]+,-./:=_@]*) # empty or unsafe -- quotify ;; *) # nonempty and safe-only -- no quotify cmdline="${cmdline:+$cmdline }$t" continue ;; esac q=$(printf '%s' "$t" | sed -e "s/'/'\\\''/g'") cmdline="${cmdline:+$cmdline }'$q'" done printf '%s\n' "$cmdline" fi if ! $nflag; then # skip command if dry run "$@" fi } # configure # # Parse the configuration file, initializing config_*. # configure() { local lineno status formatok vconfig line contline op path vpath vop # Count line numbers, record a persistent error status to # return at the end, and record whether we got a format line. lineno=0 status=0 formatok=false # vis the config name for terminal-safe error messages. vconfig=$(printf '%s' "$config" | vis -M) # Read and process each line of the config file. while read -r line; do lineno=$((lineno + 1)) # If the line ends in an odd number of backslashes, it # has a continuation line, so read on. while expr "$line" : '^\(\\\\\)*\\' >/dev/null || expr "$line" : '^.*[^\\]\(\\\\\)*\\$' >/dev/null; do if ! read -r contline; then error "$vconfig:$lineno: premature end of file" return 1 fi line="$line$contline" done # Skip blank lines and comments. case $line in ''|'#'*) continue ;; esac # Require the first non-blank/comment line to identify # the config file format. if ! $formatok; then if [ "$line" = "netbsd-certctl 20230816" ]; then formatok=true continue else error "$vconfig:$lineno: missing format line" status=1 break fi fi # Split the line into words and dispatch on the first. set -- $line op=$1 case $op in manual) config_manual=true ;; path) if [ $# -lt 2 ]; then error "$vconfig:$lineno: missing path" status=1 continue fi if [ $# -gt 3 ]; then error "$vconfig:$lineno: excess args" status=1 continue fi # Unvis the path. Hack: if the user has had # the audacity to choose a path ending in # newlines, prevent the shell from consuming # them so we don't choke on their subterfuge. path=$(printf '%s.' "$2" | unvis) path=${path%.} # Ensure the path is absolute. It is unclear # what directory it should be relative to if # not. case $path in /*) ;; *) error "$vconfig:$lineno:" \ "relative path forbidden" status=1 continue ;; esac # Record the vis-encoded path in a # space-separated list. vpath=$(printf '%s' "$path" | vis -M) config_paths="$config_paths $vpath" ;; *) vop=$(printf '%s' "$op" | vis -M) error "$vconfig:$lineno: unknown command: $vop" ;; esac done <$config || status=$? return $status } # list_default_trusted # # List the vis-encoded certificate paths and their base names, # separated by a space, for the certificates that are trusted by # default according to the configuration. # # No order guaranteed; caller must sort. # list_default_trusted() { local vpath path cert base vcert vbase for vpath in $config_paths; do path=$(printf '%s.' "$vpath" | unvis) path=${path%.} # Enumerate the .pem, .cer, and .crt files. for cert in "$path"/*.pem "$path"/*.cer "$path"/*.crt; do # vis the certificate path. vcert=$(printf '%s' "$cert" | vis -M) # If the file doesn't exist, then either: # # (a) it's a broken symlink, so fail; # or # (b) the shell glob failed to match, # so ignore it and move on. if [ ! -e "$cert" ]; then if [ -h "$cert" ]; then error "broken symlink: $vcert" status=1 fi continue fi # Print the vis-encoded absolute path to the # certificate and base name on a single line. vbase=${vcert##*/} printf '%s %s\n' "$vcert" "$vbase" done done } # list_distrusted # # List the vis-encoded certificate paths and their base names, # separated by a space, for the certificates that have been # distrusted by the user. # # No order guaranteed; caller must sort. # list_distrusted() { local status link vlink cert vcert status=0 for link in "$distrustdir"/*; do # vis the link for terminal-safe error messages. vlink=$(printf '%s' "$link" | vis -M) # The distrust directory must only have symlinks to # certificates. If we find a non-symlink, print a # warning and arrange to fail. if [ ! -h "$link" ]; then if [ ! -e "$link" ] && \ [ "$link" = "$distrustdir/*" ]; then # Shell glob matched nothing -- just # ignore it. break fi error "distrusted non-symlink: $vlink" status=1 continue fi # Read the target of the symlink, nonrecursively. If # the user has had the audacity to make a symlink whose # target ends in newline, prevent the shell from # consuming them so we don't choke on their subterfuge. cert=$(readlink -n -- "$link" && printf .) cert=${cert%.} # Warn if the target is relative. Although it is clear # what directory it would be relative to, there might # be issues with canonicalization. case $cert in /*) ;; *) vlink=$(printf '%s' "$link" | vis -M) vcert=$(printf '%s' "$cert" | vis -M) error "distrusted relative symlink: $vlink -> $vcert" ;; esac # Print the vis-encoded absolute path to the # certificate and base name on a single line. vcert=$(printf '%s' "$cert" | vis -M) vbase=${vcert##*/} printf '%s %s\n' "$vcert" "$vbase" done return $status } # list_trusted # # List the trusted certificates, excluding the distrusted one, as # one vis(3) line per certificate. Reject duplicate base names, # since we will be creating symlinks to the same base names in # the certsdir. Sorted lexicographically by vis-encoding. # list_trusted() { # XXX Use dev/ino to match files instead of symlink targets? { list_default_trusted \ | while read -r vcert vbase; do printf 'trust %s %s\n' "$vcert" "$vbase" done # XXX Find a good way to list the default-untrusted # certificates, so if you have already distrusted one # and it is removed from default-trust on update, # nothing warns about this. # list_default_untrusted \ # | while read -r vcert vbase; do # printf 'distrust %s %s\n' "$vcert" "$vbase" # done list_distrusted \ | while read -r vcert vbase; do printf 'distrust %s %s\n' "$vcert" "$vbase" done } | awk -v progname="$progname" ' BEGIN { status = 0 } $1 == "trust" && $3 in trust && $2 != trust[$3] { printf "%s: duplicate base name %s\n %s\n %s\n", \ progname, $3, trust[$3], $2 >"/dev/stderr" status = 1 next } $1 == "trust" { trust[$3] = $2 } $1 == "distrust" && !trust[$3] && !distrust[$3] { printf "%s: distrusted certificate not found: %s\n", \ progname, $3 >"/dev/stderr" status = 1 } $1 == "distrust" && $2 in trust && $2 != trust[$3] { printf "%s: distrusted certificate %s" \ " has multiple paths\n" \ " %s\n %s\n", progname, $3, trust[$3], $2 >"/dev/stderr" status = 1 } $1 == "distrust" { distrust[$3] = 1 } END { for (vbase in trust) { if (!distrust[vbase]) print trust[vbase] } exit status } ' | sort -u } # rehash # # Delete and rebuild certsdir. # rehash() { local vcert cert certbase hash counter bundle vbundle # If manual operation is enabled, refuse to rehash the # certsdir, but succeed anyway so this can safely be used in # automated scripts. if $config_manual; then error "manual certificates enabled, not rehashing" return fi # Delete the active certificates symlink cache, if either it is # empty or nonexistent, or it is tagged for use by certctl. if [ -f "$certsdir/.certctl" ]; then # Directory exists and is managed by certctl(8). # Safe to delete it and everything in it. run rm -rf -- "$certsdir" elif [ -h "$certsdir" ]; then # Paranoia: refuse to chase a symlink. (Caveat: this # is not secure against an adversary who can recreate # the symlink at any time. Just a helpful check for # mistakes.) error "certificates directory is a symlink" return 1 elif [ ! -e "$certsdir" ]; then # Directory doesn't exist at all. Nothing to do! : elif [ ! -d "$certsdir" ]; then error "certificates directory is not a directory" return 1 elif ! find -f "$certsdir" -- -maxdepth 0 -type d -empty -exit 1; then # certsdir exists, is a directory, and is empty. Safe # to delete it with rmdir and take it over. run rmdir -- "$certsdir" else error "existing certificates; set manual or move them" return 1 fi run mkdir -- "$certsdir" if $vflag; then printf '# initialize %s\n' "$certsdir" fi if ! $nflag; then printf 'This directory is managed by certctl(8).\n' \ >$certsdir/.certctl fi # Create a temporary file for the single-file bundle. This # will be automatically deleted on normal exit or # SIGHUP/SIGINT/SIGTERM. if ! $nflag; then tmpfile=$(mktemp -t "$progname.XXXXXX") fi # Recreate symlinks for all of the trusted certificates. list_trusted \ | while read -r vcert; do cert=$(printf '%s.' "$vcert" | unvis) cert=${cert%.} run ln -s -- "$cert" "$certsdir" # Add the certificate to the single-file bundle. if ! $nflag; then cat -- "$cert" >>$tmpfile fi done # Hash the directory with openssl. # # XXX Pass `-v' to openssl in a way that doesn't mix with our # shell-safe verbose commands? (Need to handle `-n' too.) run openssl rehash -- "$certsdir" # Install the single-file bundle. bundle=$certsdir/ca-certificates.crt vbundle=$(printf '%s' "$bundle" | vis -M) $vflag && printf '# create %s\n' "$vbundle" if ! $nflag; then (umask 0022; cat <$tmpfile >${bundle}.tmp) mv -f -- "${bundle}.tmp" "$bundle" rm -f -- "$tmpfile" tmpfile= fi } ### Commands usage_list() { exec >&2 printf 'Usage: %s list\n' "$progname" exit 1 } cmd_list() { test $# -eq 1 || usage_list configure list_trusted \ | while read -r vcert vbase; do printf '%s\n' "$vcert" done } usage_rehash() { exec >&2 printf 'Usage: %s rehash\n' "$progname" exit 1 } cmd_rehash() { test $# -eq 1 || usage_rehash configure rehash } usage_trust() { exec >&2 printf 'Usage: %s trust \n' "$progname" exit 1 } cmd_trust() { local cert vcert certbase vcertbase test $# -eq 2 || usage_trust cert=$2 configure # XXX Accept base name. # vis the certificate path for terminal-safe error messages. vcert=$(printf '%s' "$cert" | vis -M) # Verify the certificate actually exists. if [ ! -f "$cert" ]; then error "no such certificate: $vcert" return 1 fi # Verify we currently distrust a certificate by this base name. certbase=${cert##*/} if [ ! -h "$distrustdir/$certbase" ]; then error "not currently distrusted: $vcert" return 1 fi # Verify the certificate we distrust by this base name is the # same one. target=$(readlink -n -- "$distrustdir/$certbase" && printf .) target=${target%.} if [ "$cert" != "$target" ]; then vcertbase=${vcert##*/} error "distrusted $vcertbase does not point to $vcert" return 1 fi # Remove the link from the distrusted directory, and rehash -- # quietly, so verbose output emphasizes the distrust part and # not the whole certificate set. run rm -- "$distrustdir/$certbase" $vflag && echo '# rehash' vflag=false rehash } usage_untrust() { exec >&2 printf 'Usage: %s untrust \n' "$progname" exit 1 } cmd_untrust() { local cert vcert certbase vcertbase target vtarget test $# -eq 2 || usage_untrust cert=$2 configure # vis the certificate path for terminal-safe error messages. vcert=$(printf '%s' "$cert" | vis -M) # Verify the certificate actually exists. Otherwise, you might # fail to distrust a certificate you intended to distrust, # e.g. if you made a typo in its path. if [ ! -f "$cert" ]; then error "no such certificate: $vcert" return 1 fi # Check whether this certificate is already distrusted. # - If the same base name points to the same path, stop here. # - Otherwise, fail noisily. certbase=${cert##*/} if [ -h "$distrustdir/$certbase" ]; then target=$(readlink -n -- "$distrustdir/$certbase" && printf .) target=${target%.} if [ "$target" = "$cert" ]; then $vflag && echo '# already distrusted' return fi vcertbase=$(printf '%s' "$certbase" | vis -M) vtarget=$(printf '%s' "$target" | vis -M) error "distrusted $vcertbase at different path $vtarget" return 1 fi # Create the distrustdir if needed, create a symlink in it, and # rehash -- quietly, so verbose output emphasizes the distrust # part and not the whole certificate set. test -d "$distrustdir" || run mkdir -- "$distrustdir" run ln -s -- "$cert" "$distrustdir" $vflag && echo '# rehash' vflag=false rehash } usage_untrusted() { exec >&2 printf 'Usage: %s untrusted\n' "$progname" exit 1 } cmd_untrusted() { test $# -eq 1 || usage_untrusted configure list_distrusted \ | while read -r vcert vbase; do printf '%s\n' "$vcert" done } ### Main # We accept the following aliases for user interface compatibility with # FreeBSD: # # blacklist = untrust # blacklisted = untrusted # unblacklist = trust case $cmd in list) cmd_list "$@" ;; rehash) cmd_rehash "$@" ;; trust|unblacklist) cmd_trust "$@" ;; untrust|blacklist) cmd_untrust "$@" ;; untrusted|blacklisted) cmd_untrusted "$@" ;; *) vcmd=$(printf '%s' "$cmd" | vis -M) printf '%s: unknown command: %s\n' "$progname" "$vcmd" >&2 usage ;; esac