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