Home | History | Annotate | Line # | Download | only in certctl
certctl.sh revision 1.5
      1 #!/bin/sh
      2 
      3 #	$NetBSD: certctl.sh,v 1.5 2023/09/05 12:32:30 riastradh Exp $
      4 #
      5 # Copyright (c) 2023 The NetBSD Foundation, Inc.
      6 # All rights reserved.
      7 #
      8 # Redistribution and use in source and binary forms, with or without
      9 # modification, are permitted provided that the following conditions
     10 # are met:
     11 # 1. Redistributions of source code must retain the above copyright
     12 #    notice, this list of conditions and the following disclaimer.
     13 # 2. Redistributions in binary form must reproduce the above copyright
     14 #    notice, this list of conditions and the following disclaimer in the
     15 #    documentation and/or other materials provided with the distribution.
     16 #
     17 # THIS SOFTWARE IS PROVIDED BY THE NETBSD FOUNDATION, INC. AND CONTRIBUTORS
     18 # ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
     19 # TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
     20 # PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE FOUNDATION OR CONTRIBUTORS
     21 # BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
     22 # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
     23 # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
     24 # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
     25 # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
     26 # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
     27 # POSSIBILITY OF SUCH DAMAGE.
     28 #
     29 
     30 set -o pipefail
     31 set -Ceu
     32 
     33 progname=$(basename -- "$0")
     34 
     35 ### Options and arguments
     36 
     37 usage()
     38 {
     39 	exec >&2
     40 	printf 'Usage: %s %s\n' \
     41 	    "$progname" \
     42 	    "[-nv] [-C <config>] [-c <certsdir>] [-u <untrusted>]"
     43 	printf '               <cmd> <args>...\n'
     44 	printf '       %s list\n' "$progname"
     45 	printf '       %s rehash\n' "$progname"
     46 	printf '       %s trust <cert>\n' "$progname"
     47 	printf '       %s untrust <cert>\n' "$progname"
     48 	printf '       %s untrusted\n' "$progname"
     49 	exit 1
     50 }
     51 
     52 certsdir=/etc/openssl/certs
     53 config=/etc/openssl/certs.conf
     54 distrustdir=/etc/openssl/untrusted
     55 nflag=false			# dry run
     56 vflag=false			# verbose
     57 
     58 # Options used by FreeBSD:
     59 #
     60 #	-D destdir
     61 #	-M metalog
     62 #	-U		(unprivileged)
     63 #	-d distbase
     64 #
     65 while getopts C:c:nu:v f; do
     66 	case $f in
     67 	C)	config=$OPTARG;;
     68 	c)	certsdir=$OPTARG;;
     69 	n)	nflag=true;;
     70 	u)	distrustdir=$OPTARG;;
     71 	v)	vflag=true;;
     72 	\?)	usage;;
     73 	esac
     74 done
     75 shift $((OPTIND - 1))
     76 
     77 if [ $# -lt 1 ]; then
     78 	usage
     79 fi
     80 cmd=$1
     81 
     82 ### Global state
     83 
     84 config_paths=
     85 config_manual=false
     86 tmpfile=
     87 
     88 # If tmpfile is set to nonempty, clean it up on exit.
     89 
     90 trap 'test -n "$tmpfile" && rm -f "$tmpfile"' EXIT HUP INT TERM
     91 
     92 ### Subroutines
     93 
     94 # error <msg> ...
     95 #
     96 #	Print an error message to stderr.
     97 #
     98 #	Does not exit the process.
     99 #
    100 error()
    101 {
    102 	echo "$progname:" "$@" >&2
    103 }
    104 
    105 # run <cmd> <args>...
    106 #
    107 #	Print a command if verbose, and run it unless it's a dry run.
    108 #
    109 run()
    110 {
    111 	local t q cmdline
    112 
    113 	if $vflag; then	       # print command if verbose
    114 		for t; do
    115 			case $t in
    116 			''|*[^[:alnum:]+,-./:=_@]*)
    117 				# empty or unsafe -- quotify
    118 				;;
    119 			*)
    120 				# nonempty and safe-only -- no quotify
    121 				cmdline="${cmdline:+$cmdline }$t"
    122 				continue
    123 				;;
    124 			esac
    125 			q=$(printf '%s' "$t" | sed -e "s/'/'\\\''/g'")
    126 			cmdline="${cmdline:+$cmdline }'$q'"
    127 		done
    128 		printf '%s\n' "$cmdline"
    129 	fi
    130 	if ! $nflag; then	# skip command if dry run
    131 		"$@"
    132 	fi
    133 }
    134 
    135 # configure
    136 #
    137 #	Parse the configuration file, initializing config_*.
    138 #
    139 configure()
    140 {
    141 	local lineno status formatok vconfig line contline op path vpath vop
    142 
    143 	# Count line numbers, record a persistent error status to
    144 	# return at the end, and record whether we got a format line.
    145 	lineno=0
    146 	status=0
    147 	formatok=false
    148 
    149 	# vis the config name for terminal-safe error messages.
    150 	vconfig=$(printf '%s' "$config" | vis -M)
    151 
    152 	# Read and process each line of the config file.
    153 	while read -r line; do
    154 		lineno=$((lineno + 1))
    155 
    156 		# If the line ends in an odd number of backslashes, it
    157 		# has a continuation line, so read on.
    158 		while expr "$line" : '^\(\\\\\)*\\' >/dev/null ||
    159 		    expr "$line" : '^.*[^\\]\(\\\\\)*\\$' >/dev/null; do
    160 			if ! read -r contline; then
    161 				error "$vconfig:$lineno: premature end of file"
    162 				return 1
    163 			fi
    164 			line="$line$contline"
    165 		done
    166 
    167 		# Skip blank lines and comments.
    168 		case $line in
    169 		''|'#'*)
    170 			continue
    171 			;;
    172 		esac
    173 
    174 		# Require the first non-blank/comment line to identify
    175 		# the config file format.
    176 		if ! $formatok; then
    177 			if [ "$line" = "netbsd-certctl 20230816" ]; then
    178 				formatok=true
    179 				continue
    180 			else
    181 				error "$vconfig:$lineno: missing format line"
    182 				status=1
    183 				break
    184 			fi
    185 		fi
    186 
    187 		# Split the line into words and dispatch on the first.
    188 		set -- $line
    189 		op=$1
    190 		case $op in
    191 		manual)
    192 			config_manual=true
    193 			;;
    194 		path)
    195 			if [ $# -lt 2 ]; then
    196 				error "$vconfig:$lineno: missing path"
    197 				status=1
    198 				continue
    199 			fi
    200 			if [ $# -gt 3 ]; then
    201 				error "$vconfig:$lineno: excess args"
    202 				status=1
    203 				continue
    204 			fi
    205 
    206 			# Unvis the path.  Hack: if the user has had
    207 			# the audacity to choose a path ending in
    208 			# newlines, prevent the shell from consuming
    209 			# them so we don't choke on their subterfuge.
    210 			path=$(printf '%s.' "$2" | unvis)
    211 			path=${path%.}
    212 
    213 			# Ensure the path is absolute.  It is unclear
    214 			# what directory it should be relative to if
    215 			# not.
    216 			case $path in
    217 			/*)
    218 				;;
    219 			*)
    220 				error "$vconfig:$lineno:" \
    221 				    "relative path forbidden"
    222 				status=1
    223 				continue
    224 				;;
    225 			esac
    226 
    227 			# Record the vis-encoded path in a
    228 			# space-separated list.
    229 			vpath=$(printf '%s' "$path" | vis -M)
    230 			config_paths="$config_paths $vpath"
    231 			;;
    232 		*)
    233 			vop=$(printf '%s' "$op" | vis -M)
    234 			error "$vconfig:$lineno: unknown command: $vop"
    235 			;;
    236 		esac
    237 	done <$config || status=$?
    238 
    239 	return $status
    240 }
    241 
    242 # list_default_trusted
    243 #
    244 #	List the vis-encoded certificate paths and their base names,
    245 #	separated by a space, for the certificates that are trusted by
    246 #	default according to the configuration.
    247 #
    248 #	No order guaranteed; caller must sort.
    249 #
    250 list_default_trusted()
    251 {
    252 	local vpath path cert base vcert vbase
    253 
    254 	for vpath in $config_paths; do
    255 		path=$(printf '%s.' "$vpath" | unvis)
    256 		path=${path%.}
    257 
    258 		# Enumerate the .pem, .cer, and .crt files.
    259 		for cert in "$path"/*.pem "$path"/*.cer "$path"/*.crt; do
    260 			# vis the certificate path.
    261 			vcert=$(printf '%s' "$cert" | vis -M)
    262 
    263 			# If the file doesn't exist, then either:
    264 			#
    265 			# (a) it's a broken symlink, so fail;
    266 			# or
    267 			# (b) the shell glob failed to match,
    268 			#     so ignore it and move on.
    269 			if [ ! -e "$cert" ]; then
    270 				if [ -h "$cert" ]; then
    271 					error "broken symlink: $vcert"
    272 					status=1
    273 				fi
    274 				continue
    275 			fi
    276 
    277 			# Print the vis-encoded absolute path to the
    278 			# certificate and base name on a single line.
    279 			vbase=$(basename -- "$vcert.")
    280 			vbase=${vbase%.}
    281 			printf '%s %s\n' "$vcert" "$vbase"
    282 		done
    283 	done
    284 }
    285 
    286 # list_distrusted
    287 #
    288 #	List the vis-encoded certificate paths and their base names,
    289 #	separated by a space, for the certificates that have been
    290 #	distrusted by the user.
    291 #
    292 #	No order guaranteed; caller must sort.
    293 #
    294 list_distrusted()
    295 {
    296 	local status link vlink cert vcert
    297 
    298 	status=0
    299 
    300 	for link in "$distrustdir"/*; do
    301 		# vis the link for terminal-safe error messages.
    302 		vlink=$(printf '%s' "$link" | vis -M)
    303 
    304 		# The distrust directory must only have symlinks to
    305 		# certificates.  If we find a non-symlink, print a
    306 		# warning and arrange to fail.
    307 		if [ ! -h "$link" ]; then
    308 			if [ ! -e "$link" ] && \
    309 			    [ "$link" = "$distrustdir/*" ]; then
    310 				# Shell glob matched nothing -- just
    311 				# ignore it.
    312 				break
    313 			fi
    314 			error "distrusted non-symlink: $vlink"
    315 			status=1
    316 			continue
    317 		fi
    318 
    319 		# Read the target of the symlink, nonrecursively.  If
    320 		# the user has had the audacity to make a symlink whose
    321 		# target ends in newline, prevent the shell from
    322 		# consuming them so we don't choke on their subterfuge.
    323 		cert=$(readlink -n -- "$link" && printf .)
    324 		cert=${cert%.}
    325 
    326 		# Warn if the target is relative.  Although it is clear
    327 		# what directory it would be relative to, there might
    328 		# be issues with canonicalization.
    329 		case $cert in
    330 		/*)
    331 			;;
    332 		*)
    333 			vlink=$(printf '%s' "$link" | vis -M)
    334 			vcert=$(printf '%s' "$cert" | vis -M)
    335 			error "distrusted relative symlink: $vlink -> $vcert"
    336 			;;
    337 		esac
    338 
    339 		# Print the vis-encoded absolute path to the
    340 		# certificate and base name on a single line.
    341 		vcert=$(printf '%s' "$cert" | vis -M)
    342 		vbase=$(basename -- "$vcert.")
    343 		vbase=${vbase%.}
    344 		printf '%s %s\n' "$vcert" "$vbase"
    345 	done
    346 
    347 	return $status
    348 }
    349 
    350 # list_trusted
    351 #
    352 #	List the trusted certificates, excluding the distrusted one, as
    353 #	one vis(3) line per certificate.  Reject duplicate base names,
    354 #	since we will be creating symlinks to the same base names in
    355 #	the certsdir.  Sorted lexicographically by vis-encoding.
    356 #
    357 list_trusted()
    358 {
    359 
    360 	# XXX Use dev/ino to match files instead of symlink targets?
    361 
    362 	{
    363 		list_default_trusted \
    364 		| while read -r vcert vbase; do
    365 			printf 'trust %s %s\n' "$vcert" "$vbase"
    366 		done
    367 
    368 		# XXX Find a good way to list the default-untrusted
    369 		# certificates, so if you have already distrusted one
    370 		# and it is removed from default-trust on update,
    371 		# nothing warns about this.
    372 
    373 		# list_default_untrusted \
    374 		# | while read -r vcert vbase; do
    375 		# 	printf 'distrust %s %s\n' "$vcert" "$vbase"
    376 		# done
    377 
    378 		list_distrusted \
    379 		| while read -r vcert vbase; do
    380 			printf 'distrust %s %s\n' "$vcert" "$vbase"
    381 		done
    382 	} | awk -v progname="$progname" '
    383 		BEGIN			{ status = 0 }
    384 		$1 == "trust" && $3 in trust && $2 != trust[$3] {
    385 			printf "%s: duplicate base name %s\n  %s\n  %s\n", \
    386 			    progname, $3, trust[$3], $2 >"/dev/stderr"
    387 			status = 1
    388 			next
    389 		}
    390 		$1 == "trust"		{ trust[$3] = $2 }
    391 		$1 == "distrust" && !trust[$3] && !distrust[$3] {
    392 			printf "%s: distrusted certificate not found: %s\n", \
    393 			    progname, $3 >"/dev/stderr"
    394 			status = 1
    395 		}
    396 		$1 == "distrust" && $2 in trust && $2 != trust[$3] {
    397 			printf "%s: distrusted certificate %s" \
    398 			    " has multiple paths\n" \
    399 			    "  %s\n  %s\n",
    400 			    progname, $3, trust[$3], $2 >"/dev/stderr"
    401 			status = 1
    402 		}
    403 		$1 == "distrust"	{ distrust[$3] = 1 }
    404 		END			{
    405 			for (vbase in trust) {
    406 				if (!distrust[vbase])
    407 					print trust[vbase]
    408 			}
    409 			exit status
    410 		}
    411 	' | sort -u
    412 }
    413 
    414 # rehash
    415 #
    416 #	Delete and rebuild certsdir.
    417 #
    418 rehash()
    419 {
    420 	local vcert cert certbase hash counter bundle vbundle
    421 
    422 	# If manual operation is enabled, refuse to rehash the
    423 	# certsdir, but succeed anyway so this can safely be used in
    424 	# automated scripts.
    425 	if $config_manual; then
    426 		error "manual certificates enabled, not rehashing"
    427 		return
    428 	fi
    429 
    430 	# Delete the active certificates symlink cache, if either it is
    431 	# empty or nonexistent, or it is tagged for use by certctl.
    432 	if [ -f "$certsdir/.certctl" ]; then
    433 		# Directory exists and is managed by certctl(8).
    434 		# Safe to delete it and everything in it.
    435 		run rm -rf -- "$certsdir"
    436 	elif [ -h "$certsdir" ]; then
    437 		# Paranoia: refuse to chase a symlink.  (Caveat: this
    438 		# is not secure against an adversary who can recreate
    439 		# the symlink at any time.  Just a helpful check for
    440 		# mistakes.)
    441 		error "certificates directory is a symlink"
    442 		return 1
    443 	elif [ ! -e "$certsdir" ]; then
    444 		# Directory doesn't exist at all.  Nothing to do!
    445 	elif [ ! -d "$certsdir" ]; then
    446 		error "certificates directory is not a directory"
    447 		return 1
    448 	elif ! find -f "$certsdir" -- -maxdepth 0 -type d -empty -exit 1; then
    449 		# certsdir exists, is a directory, and is empty.  Safe
    450 		# to delete it with rmdir and take it over.
    451 		run rmdir -- "$certsdir"
    452 	else
    453 		error "existing certificates; set manual or move them"
    454 		return 1
    455 	fi
    456 	run mkdir -- "$certsdir"
    457 	if $vflag; then
    458 		printf '# initialize %s\n' "$certsdir"
    459 	fi
    460 	if ! $nflag; then
    461 		printf 'This directory is managed by certctl(8).\n' \
    462 		    >$certsdir/.certctl
    463 	fi
    464 
    465 	# Create a temporary file for the single-file bundle.  This
    466 	# will be automatically deleted on normal exit or
    467 	# SIGHUP/SIGINT/SIGTERM.
    468 	if ! $nflag; then
    469 		tmpfile=$(mktemp -t "$progname.XXXXXX")
    470 	fi
    471 
    472 	# Recreate symlinks for all of the trusted certificates.
    473 	list_trusted \
    474 	| while read -r vcert; do
    475 		cert=$(printf '%s.' "$vcert" | unvis)
    476 		cert=${cert%.}
    477 		run ln -s -- "$cert" "$certsdir"
    478 
    479 		# Add the certificate to the single-file bundle.
    480 		if ! $nflag; then
    481 			cat -- "$cert" >>$tmpfile
    482 		fi
    483 	done
    484 
    485 	# Hash the directory with openssl.
    486 	#
    487 	# XXX Pass `-v' to openssl in a way that doesn't mix with our
    488 	# shell-safe verbose commands?  (Need to handle `-n' too.)
    489 	run openssl rehash -- "$certsdir"
    490 
    491 	# Install the single-file bundle.
    492 	bundle=$certsdir/ca-certificates.crt
    493 	vbundle=$(printf '%s' "$bundle" | vis -M)
    494 	$vflag && printf '# create %s\n' "$vbundle"
    495 	if ! $nflag; then
    496 		(umask 0022; cat <$tmpfile >${bundle}.tmp)
    497 		mv -f -- "${bundle}.tmp" "$bundle"
    498 		rm -f -- "$tmpfile"
    499 		tmpfile=
    500 	fi
    501 }
    502 
    503 ### Commands
    504 
    505 usage_list()
    506 {
    507 	exec >&2
    508 	printf 'Usage: %s list\n' "$progname"
    509 	exit 1
    510 }
    511 cmd_list()
    512 {
    513 	test $# -eq 1 || usage_list
    514 
    515 	configure
    516 
    517 	list_trusted \
    518 	| while read -r vcert vbase; do
    519 		printf '%s\n' "$vcert"
    520 	done
    521 }
    522 
    523 usage_rehash()
    524 {
    525 	exec >&2
    526 	printf 'Usage: %s rehash\n' "$progname"
    527 	exit 1
    528 }
    529 cmd_rehash()
    530 {
    531 	test $# -eq 1 || usage_rehash
    532 
    533 	configure
    534 
    535 	rehash
    536 }
    537 
    538 usage_trust()
    539 {
    540 	exec >&2
    541 	printf 'Usage: %s trust <cert>\n' "$progname"
    542 	exit 1
    543 }
    544 cmd_trust()
    545 {
    546 	local cert vcert certbase vcertbase
    547 
    548 	test $# -eq 2 || usage_trust
    549 	cert=$2
    550 
    551 	configure
    552 
    553 	# XXX Accept base name.
    554 
    555 	# vis the certificate path for terminal-safe error messages.
    556 	vcert=$(printf '%s' "$cert" | vis -M)
    557 
    558 	# Verify the certificate actually exists.
    559 	if [ ! -f "$cert" ]; then
    560 		error "no such certificate: $vcert"
    561 		return 1
    562 	fi
    563 
    564 	# Verify we currently distrust a certificate by this base name.
    565 	certbase=$(basename -- "$cert.")
    566 	certbase=${certbase%.}
    567 	if [ ! -h "$distrustdir/$certbase" ]; then
    568 		error "not currently distrusted: $vcert"
    569 		return 1
    570 	fi
    571 
    572 	# Verify the certificate we distrust by this base name is the
    573 	# same one.
    574 	target=$(readlink -n -- "$distrustdir/$certbase" && printf .)
    575 	target=${target%.}
    576 	if [ "$cert" != "$target" ]; then
    577 		vcertbase=$(basename -- "$vcert")
    578 		error "distrusted $vcertbase does not point to $vcert"
    579 		return 1
    580 	fi
    581 
    582 	# Remove the link from the distrusted directory, and rehash --
    583 	# quietly, so verbose output emphasizes the distrust part and
    584 	# not the whole certificate set.
    585 	run rm -- "$distrustdir/$certbase"
    586 	$vflag && echo '# rehash'
    587 	vflag=false
    588 	rehash
    589 }
    590 
    591 usage_untrust()
    592 {
    593 	exec >&2
    594 	printf 'Usage: %s untrust <cert>\n' "$progname"
    595 	exit 1
    596 }
    597 cmd_untrust()
    598 {
    599 	local cert vcert certbase vcertbase target vtarget
    600 
    601 	test $# -eq 2 || usage_untrust
    602 	cert=$2
    603 
    604 	configure
    605 
    606 	# vis the certificate path for terminal-safe error messages.
    607 	vcert=$(printf '%s' "$cert" | vis -M)
    608 
    609 	# Verify the certificate actually exists.  Otherwise, you might
    610 	# fail to distrust a certificate you intended to distrust,
    611 	# e.g. if you made a typo in its path.
    612 	if [ ! -f "$cert" ]; then
    613 		error "no such certificate: $vcert"
    614 		return 1
    615 	fi
    616 
    617 	# Check whether this certificate is already distrusted.
    618 	# - If the same base name points to the same path, stop here.
    619 	# - Otherwise, fail noisily.
    620 	certbase=$(basename "$cert.")
    621 	certbase=${certbase%.}
    622 	if [ -h "$distrustdir/$certbase" ]; then
    623 		target=$(readlink -n -- "$distrustdir/$certbase" && printf .)
    624 		target=${target%.}
    625 		if [ "$target" = "$cert" ]; then
    626 			$vflag && echo '# already distrusted'
    627 			return
    628 		fi
    629 		vcertbase=$(printf '%s' "$certbase" | vis -M)
    630 		vtarget=$(printf '%s' "$target" | vis -M)
    631 		error "distrusted $vcertbase at different path $vtarget"
    632 		return 1
    633 	fi
    634 
    635 	# Create the distrustdir if needed, create a symlink in it, and
    636 	# rehash -- quietly, so verbose output emphasizes the distrust
    637 	# part and not the whole certificate set.
    638 	test -d "$distrustdir" || run mkdir -- "$distrustdir"
    639 	run ln -s -- "$cert" "$distrustdir"
    640 	$vflag && echo '# rehash'
    641 	vflag=false
    642 	rehash
    643 }
    644 
    645 usage_untrusted()
    646 {
    647 	exec >&2
    648 	printf 'Usage: %s untrusted\n' "$progname"
    649 	exit 1
    650 }
    651 cmd_untrusted()
    652 {
    653 	test $# -eq 1 || usage_untrusted
    654 
    655 	configure
    656 
    657 	list_distrusted \
    658 	| while read -r vcert vbase; do
    659 		printf '%s\n' "$vcert"
    660 	done
    661 }
    662 
    663 ### Main
    664 
    665 # We accept the following aliases for user interface compatibility with
    666 # FreeBSD:
    667 #
    668 #	blacklist = untrust
    669 #	blacklisted = untrusted
    670 #	unblacklist = trust
    671 
    672 case $cmd in
    673 list)	cmd_list "$@"
    674 	;;
    675 rehash)	cmd_rehash "$@"
    676 	;;
    677 trust|unblacklist)
    678 	cmd_trust "$@"
    679 	;;
    680 untrust|blacklist)
    681 	cmd_untrust "$@"
    682 	;;
    683 untrusted|blacklisted)
    684 	cmd_untrusted "$@"
    685 	;;
    686 *)	vcmd=$(printf '%s' "$cmd" | vis -M)
    687 	printf '%s: unknown command: %s\n' "$progname" "$vcmd" >&2
    688 	usage
    689 	;;
    690 esac
    691