688 lines
16 KiB
Bash
Executable File
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
|