certctl.sh revision 1.1 1 #!/bin/sh
2
3 # $NetBSD: certctl.sh,v 1.1 2023/08/26 05:27:15 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
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.
431 run rm -rf "$certsdir"
432 run mkdir "$certsdir"
433
434 # Create a temporary file for the single-file bundle. This
435 # will be automatically deleted on normal exit or
436 # SIGHUP/SIGINT/SIGTERM.
437 if ! $nflag; then
438 tmpfile=$(mktemp -t "$progname.XXXXXX")
439 fi
440
441 # Recreate symlinks for all of the trusted certificates.
442 list_trusted \
443 | while read -r vcert; do
444 cert=$(printf '%s.' "$vcert" | unvis)
445 cert=${cert%.}
446 run ln -s -- "$cert" "$certsdir"
447
448 # Add the certificate to the single-file bundle.
449 if ! $nflag; then
450 cat -- "$cert" >>$tmpfile
451 fi
452 done
453
454 # Hash the directory with openssl.
455 #
456 # XXX Pass `-v' to openssl in a way that doesn't mix with our
457 # shell-safe verbose commands? (Need to handle `-n' too.)
458 run openssl rehash -- "$certsdir"
459
460 # Install the single-file bundle.
461 bundle=$certsdir/ca-certificates.crt
462 vbundle=$(printf '%s' "$bundle" | vis -M)
463 $vflag && printf '# create %s\n' "$vbundle"
464 if ! $nflag; then
465 cp -- "$tmpfile" "$bundle"
466 rm -f -- "$tmpfile"
467 tmpfile=
468 fi
469 }
470
471 ### Commands
472
473 usage_list()
474 {
475 exec >&2
476 printf 'Usage: %s list\n' "$progname"
477 exit 1
478 }
479 cmd_list()
480 {
481 test $# -eq 1 || usage_list
482
483 configure
484
485 list_trusted \
486 | while read -r vcert vbase; do
487 printf '%s\n' "$vcert"
488 done
489 }
490
491 usage_rehash()
492 {
493 exec >&2
494 printf 'Usage: %s rehash\n' "$progname"
495 exit 1
496 }
497 cmd_rehash()
498 {
499 test $# -eq 1 || usage_rehash
500
501 configure
502
503 rehash
504 }
505
506 usage_trust()
507 {
508 exec >&2
509 printf 'Usage: %s trust <cert>\n' "$progname"
510 exit 1
511 }
512 cmd_trust()
513 {
514 local cert vcert certbase vcertbase
515
516 test $# -eq 2 || usage_trust
517 cert=$2
518
519 configure
520
521 # XXX Accept base name.
522
523 # vis the certificate path for terminal-safe error messages.
524 vcert=$(printf '%s' "$cert" | vis -M)
525
526 # Verify the certificate actually exists.
527 if [ ! -f "$cert" ]; then
528 error "no such certificate: $vcert"
529 return 1
530 fi
531
532 # Verify we currently distrust a certificate by this base name.
533 certbase=$(basename -- "$cert.")
534 certbase=${certbase%.}
535 if [ ! -h "$distrustdir/$certbase" ]; then
536 error "not currently distrusted: $vcert"
537 return 1
538 fi
539
540 # Verify the certificate we distrust by this base name is the
541 # same one.
542 target=$(readlink -n -- "$distrustdir/$certbase" && printf .)
543 target=${target%.}
544 if [ "$cert" != "$target" ]; then
545 vcertbase=$(basename -- "$vcert")
546 error "distrusted $vcertbase does not point to $vcert"
547 return 1
548 fi
549
550 # Remove the link from the distrusted directory, and rehash --
551 # quietly, so verbose output emphasizes the distrust part and
552 # not the whole certificate set.
553 run rm -- "$distrustdir/$certbase"
554 $vflag && echo '# rehash'
555 vflag=false
556 rehash
557 }
558
559 usage_untrust()
560 {
561 exec >&2
562 printf 'Usage: %s untrust <cert>\n' "$progname"
563 exit 1
564 }
565 cmd_untrust()
566 {
567 local cert vcert certbase vcertbase target vtarget
568
569 test $# -eq 2 || usage_untrust
570 cert=$2
571
572 configure
573
574 # vis the certificate path for terminal-safe error messages.
575 vcert=$(printf '%s' "$cert" | vis -M)
576
577 # Verify the certificate actually exists. Otherwise, you might
578 # fail to distrust a certificate you intended to distrust,
579 # e.g. if you made a typo in its path.
580 if [ ! -f "$cert" ]; then
581 error "no such certificate: $vcert"
582 return 1
583 fi
584
585 # Check whether this certificate is already distrusted.
586 # - If the same base name points to the same path, stop here.
587 # - Otherwise, fail noisily.
588 certbase=$(basename "$cert.")
589 certbase=${certbase%.}
590 if [ -h "$distrustdir/$certbase" ]; then
591 target=$(readlink -n -- "$distrustdir/$certbase" && printf .)
592 target=${target%.}
593 if [ "$target" = "$cert" ]; then
594 $vflag && echo '# already distrusted'
595 return
596 fi
597 vcertbase=$(printf '%s' "$certbase" | vis -M)
598 vtarget=$(printf '%s' "$target" | vis -M)
599 error "distrusted $vcertbase at different path $vtarget"
600 return 1
601 fi
602
603 # Create the distrustdir if needed, create a symlink in it, and
604 # rehash -- quietly, so verbose output emphasizes the distrust
605 # part and not the whole certificate set.
606 test -d "$distrustdir" || run mkdir -- "$distrustdir"
607 run ln -s -- "$cert" "$distrustdir"
608 $vflag && echo '# rehash'
609 vflag=false
610 rehash
611 }
612
613 usage_untrusted()
614 {
615 exec >&2
616 printf 'Usage: %s untrusted\n' "$progname"
617 exit 1
618 }
619 cmd_untrusted()
620 {
621 test $# -eq 1 || usage_untrusted
622
623 configure
624
625 list_distrusted \
626 | while read -r vcert vbase; do
627 printf '%s\n' "$vcert"
628 done
629 }
630
631 ### Main
632
633 # We accept the following aliases for user interface compatibility with
634 # FreeBSD:
635 #
636 # blacklist = untrust
637 # blacklisted = untrusted
638 # unblacklist = trust
639
640 case $cmd in
641 list) cmd_list "$@"
642 ;;
643 rehash) cmd_rehash "$@"
644 ;;
645 trust|unblacklist)
646 cmd_trust "$@"
647 ;;
648 untrust|blacklist)
649 cmd_untrust "$@"
650 ;;
651 untrusted|blacklisted)
652 cmd_untrusted "$@"
653 ;;
654 *) vcmd=$(printf '%s' "$cmd" | vis -M)
655 printf '%s: unknown command: %s\n' "$progname" "$vcmd" >&2
656 usage
657 ;;
658 esac
659