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
30set -o pipefail
31set -Ceu
32
33progname=${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=${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#
293list_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#
355list_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#
416rehash()
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
504usage_list()
505{
506	exec >&2
507	printf 'Usage: %s list\n' "$progname"
508	exit 1
509}
510cmd_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
522usage_rehash()
523{
524	exec >&2
525	printf 'Usage: %s rehash\n' "$progname"
526	exit 1
527}
528cmd_rehash()
529{
530	test $# -eq 1 || usage_rehash
531
532	configure
533
534	rehash
535}
536
537usage_trust()
538{
539	exec >&2
540	printf 'Usage: %s trust <cert>\n' "$progname"
541	exit 1
542}
543cmd_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
589usage_untrust()
590{
591	exec >&2
592	printf 'Usage: %s untrust <cert>\n' "$progname"
593	exit 1
594}
595cmd_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
642usage_untrusted()
643{
644	exec >&2
645	printf 'Usage: %s untrusted\n' "$progname"
646	exit 1
647}
648cmd_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
669case $cmd in
670list)	cmd_list "$@"
671	;;
672rehash)	cmd_rehash "$@"
673	;;
674trust|unblacklist)
675	cmd_trust "$@"
676	;;
677untrust|blacklist)
678	cmd_untrust "$@"
679	;;
680untrusted|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	;;
687esac
688