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