#!/bin/sh

#       $NetBSD: t_certctl.sh,v 1.10 2023/09/05 12:32:30 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.
#

CERTCTL="certctl -C certs.conf -c certs -u untrusted"

# setupconf <subdir>...
#
#       Create certs/ and set up certs.conf to search the specified
#       subdirectories of the source directory.
#
setupconf()
{
       local sep subdir dir

       mkdir certs
       cat <<EOF >certs.conf
netbsd-certctl 20230816

# comment at line start
       # comment not at line start, plus some intentional whitespace

# THE WHITESPACE ABOVE IS INTENTIONAL, DO NOT DELETE
EOF
       # Start with a continuation line separator; then switch to
       # non-continuation lines.
       sep=$(printf ' \\\n\t')
       for subdir; do
               dir=$(atf_get_srcdir)/$subdir
               cat <<EOF >>certs.conf
path$sep$(printf '%s' "$dir" | vis -M)
EOF
               sep=' '
       done
}

# check_empty
#
#       Verify the certs directory is empty after dry runs or after
#       clearing the directory.
#
check_empty()
{
       local why

       why=${1:-dry run}
       for x in certs/*; do
               if [ -e "$x" -o -h "$x" ]; then
                       atf_fail "certs/ should be empty after $why"
               fi
       done
}

# check_nonempty
#
#       Verify the certs directory is nonempty.
#
check_nonempty()
{
       for x in certs/*.0; do
               test -e "$x" && test -h "$x" && return
       done
       atf_fail "certs/ should be nonempty"
}

# checks <certsN>...
#
#       Run various checks with certctl.
#
checks()
{
       local certs1 diginotar_base diginotar diginotar_hash subdir srcdir

       certs1=$(atf_get_srcdir)/certs1
       diginotar_base=Explicitly_Distrust_DigiNotar_Root_CA.pem
       diginotar=$certs1/$diginotar_base
       diginotar_hash=$(openssl x509 -hash -noout <$diginotar)

       # Do a dry run of rehash and make sure the directory is still
       # empty.
       atf_check -s exit:0 $CERTCTL -n rehash
       check_empty

       # Distrust and trust one CA, as a dry run.  The trust should
       # fail because it's not currently distrusted.
       atf_check -s exit:0 $CERTCTL -n untrust "$diginotar"
       check_empty
       atf_check -s not-exit:0 -e match:currently \
           $CERTCTL -n trust "$diginotar"
       check_empty

       # Do a real rehash, not a dry run.
       atf_check -s exit:0 $CERTCTL rehash

       # Make sure all the certificates are trusted.
       for subdir; do
               case $subdir in
               /*)     srcdir=$subdir;;
               *)      srcdir=$(atf_get_srcdir)/$subdir;;
               esac
               for cert in "$srcdir"/*.pem; do
                       # Verify the certificate is linked by its base name.
                       certbase=$(basename "$cert")
                       atf_check -s exit:0 -o inline:"$cert" \
                           readlink -n "certs/$certbase"

                       # Verify the certificate is linked by a hash.
                       hash=$(openssl x509 -hash -noout <$cert)
                       counter=0
                       found=false
                       while [ $counter -lt 10 ]; do
                               if cmp -s "certs/$hash.$counter" "$cert"; then
                                       found=true
                                       break
                               fi
                               counter=$((counter + 1))
                       done
                       if ! $found; then
                               atf_fail "missing $cert"
                       fi

                       # Delete both links.
                       rm "certs/$certbase"
                       rm "certs/$hash.$counter"
               done
       done

       # Verify the certificate bundle is there with the right
       # permissions (0644) and delete it.
       #
       # XXX Verify its content.
       atf_check -s exit:0 test -f certs/ca-certificates.crt
       atf_check -s exit:0 test ! -h certs/ca-certificates.crt
       atf_check -s exit:0 -o inline:'100644\n' \
           stat -f %p certs/ca-certificates.crt
       rm certs/ca-certificates.crt

       # Make sure after deleting everything there's nothing left.
       check_empty "removing all expected certificates"

       # Distrust, trust, and re-distrust one CA, and verify that it
       # ceases to appear, reappears, and again ceases to appear.
       # (This one has no subject hash collisions to worry about, so
       # we hard-code the `.0' suffix.)
       atf_check -s exit:0 $CERTCTL untrust "$diginotar"
       atf_check -s exit:0 test -e "untrusted/$diginotar_base"
       atf_check -s exit:0 test -h "untrusted/$diginotar_base"
       atf_check -s exit:0 test ! -e "certs/$diginotar_base"
       atf_check -s exit:0 test ! -h "certs/$diginotar_base"
       atf_check -s exit:0 test ! -e "certs/$diginotar_hash.0"
       atf_check -s exit:0 test ! -h "certs/$diginotar_hash.0"
       check_nonempty

       atf_check -s exit:0 $CERTCTL trust "$diginotar"
       atf_check -s exit:0 test ! -e "untrusted/$diginotar_base"
       atf_check -s exit:0 test ! -h "untrusted/$diginotar_base"
       atf_check -s exit:0 test -e "certs/$diginotar_base"
       atf_check -s exit:0 test -h "certs/$diginotar_base"
       atf_check -s exit:0 test -e "certs/$diginotar_hash.0"
       atf_check -s exit:0 test -h "certs/$diginotar_hash.0"
       rm "certs/$diginotar_base"
       rm "certs/$diginotar_hash.0"
       check_nonempty

       atf_check -s exit:0 $CERTCTL untrust "$diginotar"
       atf_check -s exit:0 test -e "untrusted/$diginotar_base"
       atf_check -s exit:0 test -h "untrusted/$diginotar_base"
       atf_check -s exit:0 test ! -e "certs/$diginotar_base"
       atf_check -s exit:0 test ! -h "certs/$diginotar_base"
       atf_check -s exit:0 test ! -e "certs/$diginotar_hash.0"
       atf_check -s exit:0 test ! -h "certs/$diginotar_hash.0"
       check_nonempty
}

atf_test_case empty
empty_head()
{
       atf_set "descr" "Test empty certificates store"
}
empty_body()
{
       setupconf               # no directories
       check_empty "empty cert path"
       atf_check -s exit:0 $CERTCTL -n rehash
       check_empty
       atf_check -s exit:0 $CERTCTL rehash
       atf_check -s exit:0 test -f certs/ca-certificates.crt
       atf_check -s exit:0 test \! -h certs/ca-certificates.crt
       atf_check -s exit:0 test \! -s certs/ca-certificates.crt
       atf_check -s exit:0 rm certs/ca-certificates.crt
       check_empty "empty cert path"
}

atf_test_case onedir
onedir_head()
{
       atf_set "descr" "Test one certificates directory"
}
onedir_body()
{
       setupconf certs1
       checks certs1
}

atf_test_case twodir
twodir_head()
{
       atf_set "descr" "Test two certificates directories"
}
twodir_body()
{
       setupconf certs1 certs2
       checks certs1 certs2
}

atf_test_case collidehash
collidehash_head()
{
       atf_set "descr" "Test colliding hashes"
}
collidehash_body()
{
       # certs3 has two certificates with the same subject hash
       setupconf certs1 certs3
       checks certs1 certs3
}

atf_test_case collidebase
collidebase_head()
{
       atf_set "descr" "Test colliding base names"
}
collidebase_body()
{
       # certs1 and certs4 both have DigiCert_Global_Root_CA.pem,
       # which should cause list and rehash to fail and mention
       # duplicates.
       setupconf certs1 certs4
       atf_check -s not-exit:0 -o ignore -e match:duplicate $CERTCTL list
       atf_check -s not-exit:0 -o ignore -e match:duplicate $CERTCTL rehash
}

atf_test_case manual
manual_head()
{
       atf_set "descr" "Test manual operation"
}
manual_body()
{
       local certs1 diginotar_base diginotar diginotar_hash

       certs1=$(atf_get_srcdir)/certs1
       diginotar_base=Explicitly_Distrust_DigiNotar_Root_CA.pem
       diginotar=$certs1/$diginotar_base
       diginotar_hash=$(openssl x509 -hash -noout <$diginotar)

       setupconf certs1 certs2
       cat <<EOF >>certs.conf
manual
EOF
       touch certs/bogus.pem
       ln -s bogus.pem certs/0123abcd.0

       # Listing shouldn't mention anything in the certs/ cache.
       atf_check -s exit:0 -o not-match:bogus $CERTCTL list
       atf_check -s exit:0 -o not-match:bogus $CERTCTL untrusted

       # Rehashing and changing the configuration should succeed, but
       # mention `manual' in a warning message and should not touch
       # the cache.
       atf_check -s exit:0 -e match:manual $CERTCTL rehash
       atf_check -s exit:0 -e match:manual $CERTCTL untrust "$diginotar"
       atf_check -s exit:0 -e match:manual $CERTCTL trust "$diginotar"

       # The files we created should still be there.
       atf_check -s exit:0 test -f certs/bogus.pem
       atf_check -s exit:0 test -h certs/0123abcd.0
}

atf_test_case evilcertsdir
evilcertsdir_head()
{
       atf_set "descr" "Test certificate directory with evil characters"
}
evilcertsdir_body()
{
       local certs1 diginotar_base diginotar evilcertsdir evildistrustdir

       certs1=$(atf_get_srcdir)/certs1
       diginotar_base=Explicitly_Distrust_DigiNotar_Root_CA.pem
       diginotar=$certs1/$diginotar_base

       evilcertsdir=$(printf '-evil certs\n.')
       evilcertsdir=${evilcertsdir%.}
       evildistrustdir=$(printf '-evil untrusted\n.')
       evildistrustdir=${evildistrustdir%.}

       setupconf certs1

       # initial (re)hash, nonexistent certs directory
       atf_check -s exit:0 $CERTCTL rehash
       atf_check -s exit:0 certctl -C certs.conf \
           -c "$evilcertsdir" -u "$evildistrustdir" \
           rehash
       atf_check -s exit:0 diff -ruN -- certs "$evilcertsdir"
       atf_check -s exit:0 test ! -e untrusted
       atf_check -s exit:0 test ! -h untrusted
       atf_check -s exit:0 test ! -e "$evildistrustdir"
       atf_check -s exit:0 test ! -h "$evildistrustdir"

       # initial (re)hash, empty certs directory
       atf_check -s exit:0 rm -rf -- certs
       atf_check -s exit:0 rm -rf -- "$evilcertsdir"
       atf_check -s exit:0 mkdir -- certs
       atf_check -s exit:0 mkdir -- "$evilcertsdir"
       atf_check -s exit:0 $CERTCTL rehash
       atf_check -s exit:0 certctl -C certs.conf \
           -c "$evilcertsdir" -u "$evildistrustdir" \
           rehash
       atf_check -s exit:0 diff -ruN -- certs "$evilcertsdir"
       atf_check -s exit:0 test ! -e untrusted
       atf_check -s exit:0 test ! -h untrusted
       atf_check -s exit:0 test ! -e "$evildistrustdir"
       atf_check -s exit:0 test ! -h "$evildistrustdir"

       # test distrusting a CA
       atf_check -s exit:0 $CERTCTL untrust "$diginotar"
       atf_check -s exit:0 certctl -C certs.conf \
           -c "$evilcertsdir" -u "$evildistrustdir" \
           untrust "$diginotar"
       atf_check -s exit:0 diff -ruN -- certs "$evilcertsdir"
       atf_check -s exit:0 diff -ruN -- untrusted "$evildistrustdir"

       # second rehash
       atf_check -s exit:0 $CERTCTL rehash
       atf_check -s exit:0 certctl -C certs.conf \
           -c "$evilcertsdir" -u "$evildistrustdir" \
           rehash
       atf_check -s exit:0 diff -ruN -- certs "$evilcertsdir"
       atf_check -s exit:0 diff -ruN -- untrusted "$evildistrustdir"
}

atf_test_case evilpath
evilpath_head()
{
       atf_set "descr" "Test certificate paths with evil characters"
}
evilpath_body()
{
       local evildir

       evildir=$(printf 'evil\n.')
       evildir=${evildir%.}
       mkdir "$evildir"

       cp -p "$(atf_get_srcdir)/certs2"/*.pem "$evildir"/

       setupconf certs1
       cat <<EOF >>certs.conf
path $(printf '%s' "$(pwd)/$evildir" | vis -M)
EOF
       checks certs1 "$(pwd)/$evildir"
}

atf_test_case missingconf
missingconf_head()
{
       atf_set "descr" "Test certctl with missing certs.conf"
}
missingconf_body()
{
       mkdir certs
       atf_check -s exit:0 test ! -e certs.conf
       atf_check -s not-exit:0 -e match:'certs\.conf' \
           $CERTCTL rehash
}

atf_test_case nonexistentcertsdir
nonexistentcertsdir_head()
{
       atf_set "descr" "Test certctl succeeds when certsdir is nonexistent"
}
nonexistentcertsdir_body()
{
       setupconf certs1
       rmdir certs
       checks certs1
}

atf_test_case symlinkcertsdir
symlinkcertsdir_head()
{
       atf_set "descr" "Test certctl fails when certsdir is a symlink"
}
symlinkcertsdir_body()
{
       setupconf certs1
       rmdir certs
       mkdir empty
       ln -sfn empty certs

       atf_check -s not-exit:0 -e match:symlink $CERTCTL -n rehash
       atf_check -s not-exit:0 -e match:symlink $CERTCTL rehash
       atf_check -s exit:0 rmdir empty
}

atf_test_case regularfilecertsdir
regularfilecertsdir_head()
{
       atf_set "descr" "Test certctl fails when certsdir is a regular file"
}
regularfilecertsdir_body()
{
       setupconf certs1
       rmdir certs
       echo 'hello world' >certs

       atf_check -s not-exit:0 -e match:directory $CERTCTL -n rehash
       atf_check -s not-exit:0 -e match:directory $CERTCTL rehash
       atf_check -s exit:0 rm certs
}

atf_test_case prepopulatedcerts
prepopulatedcerts_head()
{
       atf_set "descr" "Test certctl fails when directory is prepopulated"
}
prepopulatedcerts_body()
{
       local cert certbase target

       setupconf certs1
       ln -sfn "$(atf_get_srcdir)/certs2"/*.pem certs/

       atf_check -s not-exit:0 -e match:manual $CERTCTL -n rehash
       atf_check -s not-exit:0 -e match:manual $CERTCTL rehash
       for cert in "$(atf_get_srcdir)/certs2"/*.pem; do
               certbase=$(basename "$cert")
               atf_check -s exit:0 -o inline:"$cert" \
                   readlink -n "certs/$certbase"
               rm "certs/$certbase"
       done
       check_empty
}

atf_init_test_cases()
{
       atf_add_test_case collidebase
       atf_add_test_case collidehash
       atf_add_test_case empty
       atf_add_test_case evilcertsdir
       atf_add_test_case evilpath
       atf_add_test_case manual
       atf_add_test_case missingconf
       atf_add_test_case nonexistentcertsdir
       atf_add_test_case onedir
       atf_add_test_case prepopulatedcerts
       atf_add_test_case regularfilecertsdir
       atf_add_test_case symlinkcertsdir
       atf_add_test_case twodir
}