xref: /netbsd-src/usr.sbin/certctl/certctl.sh (revision 3f351f34c6d827cf017cdcff3543f6ec0c88b420)
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
30set -o pipefail
31set -Ceu
32
33progname=$(basename -- "$0")
34
35### Options and arguments
36
37usage()
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
52certsdir=/etc/openssl/certs
53config=/etc/openssl/certs.conf
54distrustdir=/etc/openssl/untrusted
55nflag=false			# dry run
56vflag=false			# verbose
57
58# Options used by FreeBSD:
59#
60#	-D destdir
61#	-M metalog
62#	-U		(unprivileged)
63#	-d distbase
64#
65while 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
74done
75shift $((OPTIND - 1))
76
77if [ $# -lt 1 ]; then
78	usage
79fi
80cmd=$1
81
82### Global state
83
84config_paths=
85config_manual=false
86tmpfile=
87
88# If tmpfile is set to nonempty, clean it up on exit.
89
90trap '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#
100error()
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#
109run()
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#
139configure()
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#
250list_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#
294list_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#
357list_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#
418rehash()
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
505usage_list()
506{
507	exec >&2
508	printf 'Usage: %s list\n' "$progname"
509	exit 1
510}
511cmd_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
523usage_rehash()
524{
525	exec >&2
526	printf 'Usage: %s rehash\n' "$progname"
527	exit 1
528}
529cmd_rehash()
530{
531	test $# -eq 1 || usage_rehash
532
533	configure
534
535	rehash
536}
537
538usage_trust()
539{
540	exec >&2
541	printf 'Usage: %s trust <cert>\n' "$progname"
542	exit 1
543}
544cmd_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
591usage_untrust()
592{
593	exec >&2
594	printf 'Usage: %s untrust <cert>\n' "$progname"
595	exit 1
596}
597cmd_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
645usage_untrusted()
646{
647	exec >&2
648	printf 'Usage: %s untrusted\n' "$progname"
649	exit 1
650}
651cmd_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
672case $cmd in
673list)	cmd_list "$@"
674	;;
675rehash)	cmd_rehash "$@"
676	;;
677trust|unblacklist)
678	cmd_trust "$@"
679	;;
680untrust|blacklist)
681	cmd_untrust "$@"
682	;;
683untrusted|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	;;
690esac
691