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