Home | History | Annotate | Line # | Download | only in certctl
      1 #!/bin/sh
      2 
      3 #	$NetBSD: certctl.sh,v 1.7 2024/03/04 20:37:31 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=${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=${vcert##*/}
    280 			printf '%s %s\n' "$vcert" "$vbase"
    281 		done
    282 	done
    283 }
    284 
    285 # list_distrusted
    286 #
    287 #	List the vis-encoded certificate paths and their base names,
    288 #	separated by a space, for the certificates that have been
    289 #	distrusted by the user.
    290 #
    291 #	No order guaranteed; caller must sort.
    292 #
    293 list_distrusted()
    294 {
    295 	local status link vlink cert vcert
    296 
    297 	status=0
    298 
    299 	for link in "$distrustdir"/*; do
    300 		# vis the link for terminal-safe error messages.
    301 		vlink=$(printf '%s' "$link" | vis -M)
    302 
    303 		# The distrust directory must only have symlinks to
    304 		# certificates.  If we find a non-symlink, print a
    305 		# warning and arrange to fail.
    306 		if [ ! -h "$link" ]; then
    307 			if [ ! -e "$link" ] && \
    308 			    [ "$link" = "$distrustdir/*" ]; then
    309 				# Shell glob matched nothing -- just
    310 				# ignore it.
    311 				break
    312 			fi
    313 			error "distrusted non-symlink: $vlink"
    314 			status=1
    315 			continue
    316 		fi
    317 
    318 		# Read the target of the symlink, nonrecursively.  If
    319 		# the user has had the audacity to make a symlink whose
    320 		# target ends in newline, prevent the shell from
    321 		# consuming them so we don't choke on their subterfuge.
    322 		cert=$(readlink -n -- "$link" && printf .)
    323 		cert=${cert%.}
    324 
    325 		# Warn if the target is relative.  Although it is clear
    326 		# what directory it would be relative to, there might
    327 		# be issues with canonicalization.
    328 		case $cert in
    329 		/*)
    330 			;;
    331 		*)
    332 			vlink=$(printf '%s' "$link" | vis -M)
    333 			vcert=$(printf '%s' "$cert" | vis -M)
    334 			error "distrusted relative symlink: $vlink -> $vcert"
    335 			;;
    336 		esac
    337 
    338 		# Print the vis-encoded absolute path to the
    339 		# certificate and base name on a single line.
    340 		vcert=$(printf '%s' "$cert" | vis -M)
    341 		vbase=${vcert##*/}
    342 		printf '%s %s\n' "$vcert" "$vbase"
    343 	done
    344 
    345 	return $status
    346 }
    347 
    348 # list_trusted
    349 #
    350 #	List the trusted certificates, excluding the distrusted one, as
    351 #	one vis(3) line per certificate.  Reject duplicate base names,
    352 #	since we will be creating symlinks to the same base names in
    353 #	the certsdir.  Sorted lexicographically by vis-encoding.
    354 #
    355 list_trusted()
    356 {
    357 
    358 	# XXX Use dev/ino to match files instead of symlink targets?
    359 
    360 	{
    361 		list_default_trusted \
    362 		| while read -r vcert vbase; do
    363 			printf 'trust %s %s\n' "$vcert" "$vbase"
    364 		done
    365 
    366 		# XXX Find a good way to list the default-untrusted
    367 		# certificates, so if you have already distrusted one
    368 		# and it is removed from default-trust on update,
    369 		# nothing warns about this.
    370 
    371 		# list_default_untrusted \
    372 		# | while read -r vcert vbase; do
    373 		# 	printf 'distrust %s %s\n' "$vcert" "$vbase"
    374 		# done
    375 
    376 		list_distrusted \
    377 		| while read -r vcert vbase; do
    378 			printf 'distrust %s %s\n' "$vcert" "$vbase"
    379 		done
    380 	} | awk -v progname="$progname" '
    381 		BEGIN			{ status = 0 }
    382 		$1 == "trust" && $3 in trust && $2 != trust[$3] {
    383 			printf "%s: duplicate base name %s\n  %s\n  %s\n", \
    384 			    progname, $3, trust[$3], $2 >"/dev/stderr"
    385 			status = 1
    386 			next
    387 		}
    388 		$1 == "trust"		{ trust[$3] = $2 }
    389 		$1 == "distrust" && !trust[$3] && !distrust[$3] {
    390 			printf "%s: distrusted certificate not found: %s\n", \
    391 			    progname, $3 >"/dev/stderr"
    392 			status = 1
    393 		}
    394 		$1 == "distrust" && $2 in trust && $2 != trust[$3] {
    395 			printf "%s: distrusted certificate %s" \
    396 			    " has multiple paths\n" \
    397 			    "  %s\n  %s\n",
    398 			    progname, $3, trust[$3], $2 >"/dev/stderr"
    399 			status = 1
    400 		}
    401 		$1 == "distrust"	{ distrust[$3] = 1 }
    402 		END			{
    403 			for (vbase in trust) {
    404 				if (!distrust[vbase])
    405 					print trust[vbase]
    406 			}
    407 			exit status
    408 		}
    409 	' | sort -u
    410 }
    411 
    412 # rehash
    413 #
    414 #	Delete and rebuild certsdir.
    415 #
    416 rehash()
    417 {
    418 	local vcert cert certbase hash counter bundle vbundle
    419 
    420 	# If manual operation is enabled, refuse to rehash the
    421 	# certsdir, but succeed anyway so this can safely be used in
    422 	# automated scripts.
    423 	if $config_manual; then
    424 		error "manual certificates enabled, not rehashing"
    425 		return
    426 	fi
    427 
    428 	# Delete the active certificates symlink cache, if either it is
    429 	# empty or nonexistent, or it is tagged for use by certctl.
    430 	if [ -f "$certsdir/.certctl" ]; then
    431 		# Directory exists and is managed by certctl(8).
    432 		# Safe to delete it and everything in it.
    433 		run rm -rf -- "$certsdir"
    434 	elif [ -h "$certsdir" ]; then
    435 		# Paranoia: refuse to chase a symlink.  (Caveat: this
    436 		# is not secure against an adversary who can recreate
    437 		# the symlink at any time.  Just a helpful check for
    438 		# mistakes.)
    439 		error "certificates directory is a symlink"
    440 		return 1
    441 	elif [ ! -e "$certsdir" ]; then
    442 		# Directory doesn't exist at all.  Nothing to do!
    443 		:
    444 	elif [ ! -d "$certsdir" ]; then
    445 		error "certificates directory is not a directory"
    446 		return 1
    447 	elif ! find -f "$certsdir" -- -maxdepth 0 -type d -empty -exit 1; then
    448 		# certsdir exists, is a directory, and is empty.  Safe
    449 		# to delete it with rmdir and take it over.
    450 		run rmdir -- "$certsdir"
    451 	else
    452 		error "existing certificates; set manual or move them"
    453 		return 1
    454 	fi
    455 	run mkdir -- "$certsdir"
    456 	if $vflag; then
    457 		printf '# initialize %s\n' "$certsdir"
    458 	fi
    459 	if ! $nflag; then
    460 		printf 'This directory is managed by certctl(8).\n' \
    461 		    >$certsdir/.certctl
    462 	fi
    463 
    464 	# Create a temporary file for the single-file bundle.  This
    465 	# will be automatically deleted on normal exit or
    466 	# SIGHUP/SIGINT/SIGTERM.
    467 	if ! $nflag; then
    468 		tmpfile=$(mktemp -t "$progname.XXXXXX")
    469 	fi
    470 
    471 	# Recreate symlinks for all of the trusted certificates.
    472 	list_trusted \
    473 	| while read -r vcert; do
    474 		cert=$(printf '%s.' "$vcert" | unvis)
    475 		cert=${cert%.}
    476 		run ln -s -- "$cert" "$certsdir"
    477 
    478 		# Add the certificate to the single-file bundle.
    479 		if ! $nflag; then
    480 			cat -- "$cert" >>$tmpfile
    481 		fi
    482 	done
    483 
    484 	# Hash the directory with openssl.
    485 	#
    486 	# XXX Pass `-v' to openssl in a way that doesn't mix with our
    487 	# shell-safe verbose commands?  (Need to handle `-n' too.)
    488 	run openssl rehash -- "$certsdir"
    489 
    490 	# Install the single-file bundle.
    491 	bundle=$certsdir/ca-certificates.crt
    492 	vbundle=$(printf '%s' "$bundle" | vis -M)
    493 	$vflag && printf '# create %s\n' "$vbundle"
    494 	if ! $nflag; then
    495 		(umask 0022; cat <$tmpfile >${bundle}.tmp)
    496 		mv -f -- "${bundle}.tmp" "$bundle"
    497 		rm -f -- "$tmpfile"
    498 		tmpfile=
    499 	fi
    500 }
    501 
    502 ### Commands
    503 
    504 usage_list()
    505 {
    506 	exec >&2
    507 	printf 'Usage: %s list\n' "$progname"
    508 	exit 1
    509 }
    510 cmd_list()
    511 {
    512 	test $# -eq 1 || usage_list
    513 
    514 	configure
    515 
    516 	list_trusted \
    517 	| while read -r vcert vbase; do
    518 		printf '%s\n' "$vcert"
    519 	done
    520 }
    521 
    522 usage_rehash()
    523 {
    524 	exec >&2
    525 	printf 'Usage: %s rehash\n' "$progname"
    526 	exit 1
    527 }
    528 cmd_rehash()
    529 {
    530 	test $# -eq 1 || usage_rehash
    531 
    532 	configure
    533 
    534 	rehash
    535 }
    536 
    537 usage_trust()
    538 {
    539 	exec >&2
    540 	printf 'Usage: %s trust <cert>\n' "$progname"
    541 	exit 1
    542 }
    543 cmd_trust()
    544 {
    545 	local cert vcert certbase vcertbase
    546 
    547 	test $# -eq 2 || usage_trust
    548 	cert=$2
    549 
    550 	configure
    551 
    552 	# XXX Accept base name.
    553 
    554 	# vis the certificate path for terminal-safe error messages.
    555 	vcert=$(printf '%s' "$cert" | vis -M)
    556 
    557 	# Verify the certificate actually exists.
    558 	if [ ! -f "$cert" ]; then
    559 		error "no such certificate: $vcert"
    560 		return 1
    561 	fi
    562 
    563 	# Verify we currently distrust a certificate by this base name.
    564 	certbase=${cert##*/}
    565 	if [ ! -h "$distrustdir/$certbase" ]; then
    566 		error "not currently distrusted: $vcert"
    567 		return 1
    568 	fi
    569 
    570 	# Verify the certificate we distrust by this base name is the
    571 	# same one.
    572 	target=$(readlink -n -- "$distrustdir/$certbase" && printf .)
    573 	target=${target%.}
    574 	if [ "$cert" != "$target" ]; then
    575 		vcertbase=${vcert##*/}
    576 		error "distrusted $vcertbase does not point to $vcert"
    577 		return 1
    578 	fi
    579 
    580 	# Remove the link from the distrusted directory, and rehash --
    581 	# quietly, so verbose output emphasizes the distrust part and
    582 	# not the whole certificate set.
    583 	run rm -- "$distrustdir/$certbase"
    584 	$vflag && echo '# rehash'
    585 	vflag=false
    586 	rehash
    587 }
    588 
    589 usage_untrust()
    590 {
    591 	exec >&2
    592 	printf 'Usage: %s untrust <cert>\n' "$progname"
    593 	exit 1
    594 }
    595 cmd_untrust()
    596 {
    597 	local cert vcert certbase vcertbase target vtarget
    598 
    599 	test $# -eq 2 || usage_untrust
    600 	cert=$2
    601 
    602 	configure
    603 
    604 	# vis the certificate path for terminal-safe error messages.
    605 	vcert=$(printf '%s' "$cert" | vis -M)
    606 
    607 	# Verify the certificate actually exists.  Otherwise, you might
    608 	# fail to distrust a certificate you intended to distrust,
    609 	# e.g. if you made a typo in its path.
    610 	if [ ! -f "$cert" ]; then
    611 		error "no such certificate: $vcert"
    612 		return 1
    613 	fi
    614 
    615 	# Check whether this certificate is already distrusted.
    616 	# - If the same base name points to the same path, stop here.
    617 	# - Otherwise, fail noisily.
    618 	certbase=${cert##*/}
    619 	if [ -h "$distrustdir/$certbase" ]; then
    620 		target=$(readlink -n -- "$distrustdir/$certbase" && printf .)
    621 		target=${target%.}
    622 		if [ "$target" = "$cert" ]; then
    623 			$vflag && echo '# already distrusted'
    624 			return
    625 		fi
    626 		vcertbase=$(printf '%s' "$certbase" | vis -M)
    627 		vtarget=$(printf '%s' "$target" | vis -M)
    628 		error "distrusted $vcertbase at different path $vtarget"
    629 		return 1
    630 	fi
    631 
    632 	# Create the distrustdir if needed, create a symlink in it, and
    633 	# rehash -- quietly, so verbose output emphasizes the distrust
    634 	# part and not the whole certificate set.
    635 	test -d "$distrustdir" || run mkdir -- "$distrustdir"
    636 	run ln -s -- "$cert" "$distrustdir"
    637 	$vflag && echo '# rehash'
    638 	vflag=false
    639 	rehash
    640 }
    641 
    642 usage_untrusted()
    643 {
    644 	exec >&2
    645 	printf 'Usage: %s untrusted\n' "$progname"
    646 	exit 1
    647 }
    648 cmd_untrusted()
    649 {
    650 	test $# -eq 1 || usage_untrusted
    651 
    652 	configure
    653 
    654 	list_distrusted \
    655 	| while read -r vcert vbase; do
    656 		printf '%s\n' "$vcert"
    657 	done
    658 }
    659 
    660 ### Main
    661 
    662 # We accept the following aliases for user interface compatibility with
    663 # FreeBSD:
    664 #
    665 #	blacklist = untrust
    666 #	blacklisted = untrusted
    667 #	unblacklist = trust
    668 
    669 case $cmd in
    670 list)	cmd_list "$@"
    671 	;;
    672 rehash)	cmd_rehash "$@"
    673 	;;
    674 trust|unblacklist)
    675 	cmd_trust "$@"
    676 	;;
    677 untrust|blacklist)
    678 	cmd_untrust "$@"
    679 	;;
    680 untrusted|blacklisted)
    681 	cmd_untrusted "$@"
    682 	;;
    683 *)	vcmd=$(printf '%s' "$cmd" | vis -M)
    684 	printf '%s: unknown command: %s\n' "$progname" "$vcmd" >&2
    685 	usage
    686 	;;
    687 esac
    688