NetBSD/usr.sbin/certctl/certctl.sh

688 lines
16 KiB
Bash
Executable File

#!/bin/sh
# $NetBSD: certctl.sh,v 1.7 2024/03/04 20:37:31 riastradh Exp $
#
# Copyright (c) 2023 The NetBSD Foundation, Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE NETBSD FOUNDATION, INC. AND CONTRIBUTORS
# ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR CONTRIBUTORS
# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
set -o pipefail
set -Ceu
progname=${0##*/}
### Options and arguments
usage()
{
exec >&2
printf 'Usage: %s %s\n' \
"$progname" \
"[-nv] [-C <config>] [-c <certsdir>] [-u <untrusted>]"
printf ' <cmd> <args>...\n'
printf ' %s list\n' "$progname"
printf ' %s rehash\n' "$progname"
printf ' %s trust <cert>\n' "$progname"
printf ' %s untrust <cert>\n' "$progname"
printf ' %s untrusted\n' "$progname"
exit 1
}
certsdir=/etc/openssl/certs
config=/etc/openssl/certs.conf
distrustdir=/etc/openssl/untrusted
nflag=false # dry run
vflag=false # verbose
# Options used by FreeBSD:
#
# -D destdir
# -M metalog
# -U (unprivileged)
# -d distbase
#
while getopts C:c:nu:v f; do
case $f in
C) config=$OPTARG;;
c) certsdir=$OPTARG;;
n) nflag=true;;
u) distrustdir=$OPTARG;;
v) vflag=true;;
\?) usage;;
esac
done
shift $((OPTIND - 1))
if [ $# -lt 1 ]; then
usage
fi
cmd=$1
### Global state
config_paths=
config_manual=false
tmpfile=
# If tmpfile is set to nonempty, clean it up on exit.
trap 'test -n "$tmpfile" && rm -f "$tmpfile"' EXIT HUP INT TERM
### Subroutines
# error <msg> ...
#
# Print an error message to stderr.
#
# Does not exit the process.
#
error()
{
echo "$progname:" "$@" >&2
}
# run <cmd> <args>...
#
# Print a command if verbose, and run it unless it's a dry run.
#
run()
{
local t q cmdline
if $vflag; then # print command if verbose
for t; do
case $t in
''|*[^[:alnum:]+,-./:=_@]*)
# empty or unsafe -- quotify
;;
*)
# nonempty and safe-only -- no quotify
cmdline="${cmdline:+$cmdline }$t"
continue
;;
esac
q=$(printf '%s' "$t" | sed -e "s/'/'\\\''/g'")
cmdline="${cmdline:+$cmdline }'$q'"
done
printf '%s\n' "$cmdline"
fi
if ! $nflag; then # skip command if dry run
"$@"
fi
}
# configure
#
# Parse the configuration file, initializing config_*.
#
configure()
{
local lineno status formatok vconfig line contline op path vpath vop
# Count line numbers, record a persistent error status to
# return at the end, and record whether we got a format line.
lineno=0
status=0
formatok=false
# vis the config name for terminal-safe error messages.
vconfig=$(printf '%s' "$config" | vis -M)
# Read and process each line of the config file.
while read -r line; do
lineno=$((lineno + 1))
# If the line ends in an odd number of backslashes, it
# has a continuation line, so read on.
while expr "$line" : '^\(\\\\\)*\\' >/dev/null ||
expr "$line" : '^.*[^\\]\(\\\\\)*\\$' >/dev/null; do
if ! read -r contline; then
error "$vconfig:$lineno: premature end of file"
return 1
fi
line="$line$contline"
done
# Skip blank lines and comments.
case $line in
''|'#'*)
continue
;;
esac
# Require the first non-blank/comment line to identify
# the config file format.
if ! $formatok; then
if [ "$line" = "netbsd-certctl 20230816" ]; then
formatok=true
continue
else
error "$vconfig:$lineno: missing format line"
status=1
break
fi
fi
# Split the line into words and dispatch on the first.
set -- $line
op=$1
case $op in
manual)
config_manual=true
;;
path)
if [ $# -lt 2 ]; then
error "$vconfig:$lineno: missing path"
status=1
continue
fi
if [ $# -gt 3 ]; then
error "$vconfig:$lineno: excess args"
status=1
continue
fi
# Unvis the path. Hack: if the user has had
# the audacity to choose a path ending in
# newlines, prevent the shell from consuming
# them so we don't choke on their subterfuge.
path=$(printf '%s.' "$2" | unvis)
path=${path%.}
# Ensure the path is absolute. It is unclear
# what directory it should be relative to if
# not.
case $path in
/*)
;;
*)
error "$vconfig:$lineno:" \
"relative path forbidden"
status=1
continue
;;
esac
# Record the vis-encoded path in a
# space-separated list.
vpath=$(printf '%s' "$path" | vis -M)
config_paths="$config_paths $vpath"
;;
*)
vop=$(printf '%s' "$op" | vis -M)
error "$vconfig:$lineno: unknown command: $vop"
;;
esac
done <$config || status=$?
return $status
}
# list_default_trusted
#
# List the vis-encoded certificate paths and their base names,
# separated by a space, for the certificates that are trusted by
# default according to the configuration.
#
# No order guaranteed; caller must sort.
#
list_default_trusted()
{
local vpath path cert base vcert vbase
for vpath in $config_paths; do
path=$(printf '%s.' "$vpath" | unvis)
path=${path%.}
# Enumerate the .pem, .cer, and .crt files.
for cert in "$path"/*.pem "$path"/*.cer "$path"/*.crt; do
# vis the certificate path.
vcert=$(printf '%s' "$cert" | vis -M)
# If the file doesn't exist, then either:
#
# (a) it's a broken symlink, so fail;
# or
# (b) the shell glob failed to match,
# so ignore it and move on.
if [ ! -e "$cert" ]; then
if [ -h "$cert" ]; then
error "broken symlink: $vcert"
status=1
fi
continue
fi
# Print the vis-encoded absolute path to the
# certificate and base name on a single line.
vbase=${vcert##*/}
printf '%s %s\n' "$vcert" "$vbase"
done
done
}
# list_distrusted
#
# List the vis-encoded certificate paths and their base names,
# separated by a space, for the certificates that have been
# distrusted by the user.
#
# No order guaranteed; caller must sort.
#
list_distrusted()
{
local status link vlink cert vcert
status=0
for link in "$distrustdir"/*; do
# vis the link for terminal-safe error messages.
vlink=$(printf '%s' "$link" | vis -M)
# The distrust directory must only have symlinks to
# certificates. If we find a non-symlink, print a
# warning and arrange to fail.
if [ ! -h "$link" ]; then
if [ ! -e "$link" ] && \
[ "$link" = "$distrustdir/*" ]; then
# Shell glob matched nothing -- just
# ignore it.
break
fi
error "distrusted non-symlink: $vlink"
status=1
continue
fi
# Read the target of the symlink, nonrecursively. If
# the user has had the audacity to make a symlink whose
# target ends in newline, prevent the shell from
# consuming them so we don't choke on their subterfuge.
cert=$(readlink -n -- "$link" && printf .)
cert=${cert%.}
# Warn if the target is relative. Although it is clear
# what directory it would be relative to, there might
# be issues with canonicalization.
case $cert in
/*)
;;
*)
vlink=$(printf '%s' "$link" | vis -M)
vcert=$(printf '%s' "$cert" | vis -M)
error "distrusted relative symlink: $vlink -> $vcert"
;;
esac
# Print the vis-encoded absolute path to the
# certificate and base name on a single line.
vcert=$(printf '%s' "$cert" | vis -M)
vbase=${vcert##*/}
printf '%s %s\n' "$vcert" "$vbase"
done
return $status
}
# list_trusted
#
# List the trusted certificates, excluding the distrusted one, as
# one vis(3) line per certificate. Reject duplicate base names,
# since we will be creating symlinks to the same base names in
# the certsdir. Sorted lexicographically by vis-encoding.
#
list_trusted()
{
# XXX Use dev/ino to match files instead of symlink targets?
{
list_default_trusted \
| while read -r vcert vbase; do
printf 'trust %s %s\n' "$vcert" "$vbase"
done
# XXX Find a good way to list the default-untrusted
# certificates, so if you have already distrusted one
# and it is removed from default-trust on update,
# nothing warns about this.
# list_default_untrusted \
# | while read -r vcert vbase; do
# printf 'distrust %s %s\n' "$vcert" "$vbase"
# done
list_distrusted \
| while read -r vcert vbase; do
printf 'distrust %s %s\n' "$vcert" "$vbase"
done
} | awk -v progname="$progname" '
BEGIN { status = 0 }
$1 == "trust" && $3 in trust && $2 != trust[$3] {
printf "%s: duplicate base name %s\n %s\n %s\n", \
progname, $3, trust[$3], $2 >"/dev/stderr"
status = 1
next
}
$1 == "trust" { trust[$3] = $2 }
$1 == "distrust" && !trust[$3] && !distrust[$3] {
printf "%s: distrusted certificate not found: %s\n", \
progname, $3 >"/dev/stderr"
status = 1
}
$1 == "distrust" && $2 in trust && $2 != trust[$3] {
printf "%s: distrusted certificate %s" \
" has multiple paths\n" \
" %s\n %s\n",
progname, $3, trust[$3], $2 >"/dev/stderr"
status = 1
}
$1 == "distrust" { distrust[$3] = 1 }
END {
for (vbase in trust) {
if (!distrust[vbase])
print trust[vbase]
}
exit status
}
' | sort -u
}
# rehash
#
# Delete and rebuild certsdir.
#
rehash()
{
local vcert cert certbase hash counter bundle vbundle
# If manual operation is enabled, refuse to rehash the
# certsdir, but succeed anyway so this can safely be used in
# automated scripts.
if $config_manual; then
error "manual certificates enabled, not rehashing"
return
fi
# Delete the active certificates symlink cache, if either it is
# empty or nonexistent, or it is tagged for use by certctl.
if [ -f "$certsdir/.certctl" ]; then
# Directory exists and is managed by certctl(8).
# Safe to delete it and everything in it.
run rm -rf -- "$certsdir"
elif [ -h "$certsdir" ]; then
# Paranoia: refuse to chase a symlink. (Caveat: this
# is not secure against an adversary who can recreate
# the symlink at any time. Just a helpful check for
# mistakes.)
error "certificates directory is a symlink"
return 1
elif [ ! -e "$certsdir" ]; then
# Directory doesn't exist at all. Nothing to do!
:
elif [ ! -d "$certsdir" ]; then
error "certificates directory is not a directory"
return 1
elif ! find -f "$certsdir" -- -maxdepth 0 -type d -empty -exit 1; then
# certsdir exists, is a directory, and is empty. Safe
# to delete it with rmdir and take it over.
run rmdir -- "$certsdir"
else
error "existing certificates; set manual or move them"
return 1
fi
run mkdir -- "$certsdir"
if $vflag; then
printf '# initialize %s\n' "$certsdir"
fi
if ! $nflag; then
printf 'This directory is managed by certctl(8).\n' \
>$certsdir/.certctl
fi
# Create a temporary file for the single-file bundle. This
# will be automatically deleted on normal exit or
# SIGHUP/SIGINT/SIGTERM.
if ! $nflag; then
tmpfile=$(mktemp -t "$progname.XXXXXX")
fi
# Recreate symlinks for all of the trusted certificates.
list_trusted \
| while read -r vcert; do
cert=$(printf '%s.' "$vcert" | unvis)
cert=${cert%.}
run ln -s -- "$cert" "$certsdir"
# Add the certificate to the single-file bundle.
if ! $nflag; then
cat -- "$cert" >>$tmpfile
fi
done
# Hash the directory with openssl.
#
# XXX Pass `-v' to openssl in a way that doesn't mix with our
# shell-safe verbose commands? (Need to handle `-n' too.)
run openssl rehash -- "$certsdir"
# Install the single-file bundle.
bundle=$certsdir/ca-certificates.crt
vbundle=$(printf '%s' "$bundle" | vis -M)
$vflag && printf '# create %s\n' "$vbundle"
if ! $nflag; then
(umask 0022; cat <$tmpfile >${bundle}.tmp)
mv -f -- "${bundle}.tmp" "$bundle"
rm -f -- "$tmpfile"
tmpfile=
fi
}
### Commands
usage_list()
{
exec >&2
printf 'Usage: %s list\n' "$progname"
exit 1
}
cmd_list()
{
test $# -eq 1 || usage_list
configure
list_trusted \
| while read -r vcert vbase; do
printf '%s\n' "$vcert"
done
}
usage_rehash()
{
exec >&2
printf 'Usage: %s rehash\n' "$progname"
exit 1
}
cmd_rehash()
{
test $# -eq 1 || usage_rehash
configure
rehash
}
usage_trust()
{
exec >&2
printf 'Usage: %s trust <cert>\n' "$progname"
exit 1
}
cmd_trust()
{
local cert vcert certbase vcertbase
test $# -eq 2 || usage_trust
cert=$2
configure
# XXX Accept base name.
# vis the certificate path for terminal-safe error messages.
vcert=$(printf '%s' "$cert" | vis -M)
# Verify the certificate actually exists.
if [ ! -f "$cert" ]; then
error "no such certificate: $vcert"
return 1
fi
# Verify we currently distrust a certificate by this base name.
certbase=${cert##*/}
if [ ! -h "$distrustdir/$certbase" ]; then
error "not currently distrusted: $vcert"
return 1
fi
# Verify the certificate we distrust by this base name is the
# same one.
target=$(readlink -n -- "$distrustdir/$certbase" && printf .)
target=${target%.}
if [ "$cert" != "$target" ]; then
vcertbase=${vcert##*/}
error "distrusted $vcertbase does not point to $vcert"
return 1
fi
# Remove the link from the distrusted directory, and rehash --
# quietly, so verbose output emphasizes the distrust part and
# not the whole certificate set.
run rm -- "$distrustdir/$certbase"
$vflag && echo '# rehash'
vflag=false
rehash
}
usage_untrust()
{
exec >&2
printf 'Usage: %s untrust <cert>\n' "$progname"
exit 1
}
cmd_untrust()
{
local cert vcert certbase vcertbase target vtarget
test $# -eq 2 || usage_untrust
cert=$2
configure
# vis the certificate path for terminal-safe error messages.
vcert=$(printf '%s' "$cert" | vis -M)
# Verify the certificate actually exists. Otherwise, you might
# fail to distrust a certificate you intended to distrust,
# e.g. if you made a typo in its path.
if [ ! -f "$cert" ]; then
error "no such certificate: $vcert"
return 1
fi
# Check whether this certificate is already distrusted.
# - If the same base name points to the same path, stop here.
# - Otherwise, fail noisily.
certbase=${cert##*/}
if [ -h "$distrustdir/$certbase" ]; then
target=$(readlink -n -- "$distrustdir/$certbase" && printf .)
target=${target%.}
if [ "$target" = "$cert" ]; then
$vflag && echo '# already distrusted'
return
fi
vcertbase=$(printf '%s' "$certbase" | vis -M)
vtarget=$(printf '%s' "$target" | vis -M)
error "distrusted $vcertbase at different path $vtarget"
return 1
fi
# Create the distrustdir if needed, create a symlink in it, and
# rehash -- quietly, so verbose output emphasizes the distrust
# part and not the whole certificate set.
test -d "$distrustdir" || run mkdir -- "$distrustdir"
run ln -s -- "$cert" "$distrustdir"
$vflag && echo '# rehash'
vflag=false
rehash
}
usage_untrusted()
{
exec >&2
printf 'Usage: %s untrusted\n' "$progname"
exit 1
}
cmd_untrusted()
{
test $# -eq 1 || usage_untrusted
configure
list_distrusted \
| while read -r vcert vbase; do
printf '%s\n' "$vcert"
done
}
### Main
# We accept the following aliases for user interface compatibility with
# FreeBSD:
#
# blacklist = untrust
# blacklisted = untrusted
# unblacklist = trust
case $cmd in
list) cmd_list "$@"
;;
rehash) cmd_rehash "$@"
;;
trust|unblacklist)
cmd_trust "$@"
;;
untrust|blacklist)
cmd_untrust "$@"
;;
untrusted|blacklisted)
cmd_untrusted "$@"
;;
*) vcmd=$(printf '%s' "$cmd" | vis -M)
printf '%s: unknown command: %s\n' "$progname" "$vcmd" >&2
usage
;;
esac