Skip to content

Commit

Permalink
Rework CA certificate support to allow rootless containers (#538)
Browse files Browse the repository at this point in the history
* Rework CA certificate support to allow rootless containers

This patch includes several improvements and simplifications in CA certificate handling:

* Support for CA certificates in containers running as a non-root user
* Support for CA certificates in containers running with read-only filesystem
* Unification of Docker entrypoint scripts into one
* Entrypoint script now exports CACERT environment variable to point to the used truststore file

Docs updates at https://github.com/docker-library/official-images/ pending.

Possibly fixes: #464

* Update Dockerfiles
  • Loading branch information
rassie authored May 9, 2024
1 parent 37bf963 commit 4ecd1b9
Show file tree
Hide file tree
Showing 53 changed files with 3,294 additions and 795 deletions.
2 changes: 1 addition & 1 deletion .test/tests/java-ca-certificates-update/certs/README.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
This certificate/key pair has been generated with `openssl req -nodes -new -x509 -days 358000 -subj "/DC=Temurin/CN=DockerBuilder" -keyout certs/server.key -out certs/server.crt` and is only used for testing
This certificate/key pair has been generated with `openssl req -nodes -new -x509 -days 358000 -subj "/DC=Temurin/CN=DockerBuilder" -keyout certs/dockerbuilder.key -out certs/dockerbuilder.crt` and is only used for testing
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0101010001
01010100010101010001
105 changes: 63 additions & 42 deletions .test/tests/java-ca-certificates-update/run.sh
Original file line number Diff line number Diff line change
@@ -1,40 +1,38 @@
#!/bin/bash

set -o pipefail
set -o pipefail

testDir="$(readlink -f "$(dirname "$BASH_SOURCE")")"
runDir="$(dirname "$(readlink -f "$BASH_SOURCE")")"

# Find Java major/minor/build/patch version
#
# https://stackoverflow.com/a/74459237/6460
IFS='"' read -r _ java_version_string _ < <(docker run "$1" java -version 2>&1)
IFS='._' read -r \
java_version_major \
java_version_minor \
java_version_build \
java_version_patch \
<<<"$java_version_string"

# CMD1 in each run is just a `date` to make sure nothing is broken with or without the entrypoint
CMD1=date

# CMD2 in each run is to check for the `dockerbuilder` certificate in the Java keystore
if [ "$java_version_major" -lt 11 ]; then
# We are working with JDK/JRE 8
#
# `keytool` from JDK/JRE 8 does not have the `-cacerts` option and also does not have standardized location for the
# `cacerts` file between the JDK and JRE, so we'd want to check both possible locations.
CACERTS=/opt/java/openjdk/lib/security/cacerts
CACERTS2=/opt/java/openjdk/jre/lib/security/cacerts

CMD2=(sh -c "keytool -list -keystore $CACERTS -storepass changeit -alias dockerbuilder || keytool -list -keystore $CACERTS2 -storepass changeit -alias dockerbuilder")
else
CMD2=(keytool -list -cacerts -storepass changeit -alias dockerbuilder)
fi

#
# We need to use `docker run`, since `run-in-container.sh` overwrites the entrypoint
# CMD2 in each run is to check for the `dockerbuilder` certificate in the Java keystore. Entrypoint export $CACERT to
# point to the Java keystore.
CMD2=(sh -c "keytool -list -keystore \$CACERT -storepass changeit -alias dockerbuilder")

# For a custom entrypoint test, we need to create a new image. This image will get cleaned up at the end of the script
# by the `finish` trap function.
TESTIMAGE=$1.test

function finish {
docker rmi "$TESTIMAGE" >&/dev/null
}
trap finish EXIT HUP INT TERM

# But first, we need to create an image with an overridden entrypoint
docker build -t "$1.test" "$runDir" -f - <<EOF >&/dev/null
FROM $1
COPY custom-entrypoint.sh /
ENTRYPOINT ["/custom-entrypoint.sh"]
EOF

# NB: In this script, we need to use `docker run` explicitly, since the normally used `run-in-container.sh` overwrites
# the entrypoint.

#
# PHASE 1: Root containers
#

# Test run 1: No added certificates and environment variable is not set. We expect CMD1 to succeed and CMD2 to fail.
Expand Down Expand Up @@ -63,24 +61,47 @@ echo -n $?
docker run --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$1" "${CMD2[@]}" >&/dev/null
echo -n $?

TESTIMAGE=$1.test
# Test run 5: Certificates are mounted and the environment variable is set, but the entrypoint is overridden. We expect
# CMD1 to succeed and CMD2 to fail.
docker run --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" $CMD1 >&/dev/null
echo -n $?
docker run --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" "${CMD2[@]}" >&/dev/null
echo -n $?

function finish {
docker rmi "$TESTIMAGE" >&/dev/null
}
trap finish EXIT HUP INT TERM
#
# PHASE 2: Non-root containers
#

# Test run 1: No added certificates and environment variable is not set. We expect CMD1 to succeed and CMD2 to fail.
docker run --read-only --user 1000:1000 --rm "$1" $CMD1 >&/dev/null
echo -n $?
docker run --read-only --user 1000:1000 --rm "$1" "${CMD2[@]}" >&/dev/null
echo -n $?

# Test run 2: No added certificates, but the environment variable is set. Since there are no certificates, we still
# expect CMD1 to succeed and CMD2 to fail.
docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 "$1" $CMD1 >&/dev/null
echo -n $?
docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 "$1" "${CMD2[@]}" >&/dev/null
echo -n $?

# Test run 3: Certificates are mounted, but the environment variable is not set, i.e. certificate importing should not
# be activated. We expect CMD1 to succeed and CMD2 to fail.
docker run --read-only --user 1000:1000 --rm --volume=$testDir/certs:/certificates "$1" $CMD1 >&/dev/null
echo -n $?
docker run --read-only --user 1000:1000 --rm --volume=$testDir/certs:/certificates "$1" "${CMD2[@]}" >&/dev/null
echo -n $?

# Test run 4: Certificates are mounted and the environment variable is set. We expect both CMD1 and CMD2 to succeed.
docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$1" $CMD1 >&/dev/null
echo -n $?
docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$1" "${CMD2[@]}" >&/dev/null
echo -n $?

# Test run 5: Certificates are mounted and the environment variable is set, but the entrypoint is overridden. We expect
# CMD1 to succeed and CMD2 to fail.
#
# But first, we need to create an image with an overridden entrypoint
docker build -t "$1.test" "$runDir" -f - <<EOF >&/dev/null
FROM $1
COPY custom-entrypoint.sh /
ENTRYPOINT ["/custom-entrypoint.sh"]
EOF

docker run --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" $CMD1 >&/dev/null
docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" $CMD1 >&/dev/null
echo -n $?
docker run --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" "${CMD2[@]}" >&/dev/null
docker run --read-only --user 1000:1000 -v /tmp --rm -e USE_SYSTEM_CA_CERTS=1 --volume=$testDir/certs:/certificates "$TESTIMAGE" "${CMD2[@]}" >&/dev/null
echo -n $?
87 changes: 73 additions & 14 deletions 11/jdk/alpine/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,87 @@

set -e

# JDK truststore location
CACERT=$JAVA_HOME/lib/security/cacerts

# JDK8 puts its JRE in a subdirectory
if [ -f "$JAVA_HOME/jre/lib/security/cacerts" ]; then
CACERT=$JAVA_HOME/jre/lib/security/cacerts
fi

# Opt-in is only activated if the environment variable is set
if [ -n "$USE_SYSTEM_CA_CERTS" ]; then

# Copy certificates from /certificates to the system truststore, but only if the directory exists and is not empty.
# The reason why this is not part of the opt-in is because it leaves open the option to mount certificates at the
# system location, for whatever reason.
if [ -d /certificates ] && [ -n "$(ls -A /certificates 2>/dev/null)" ]; then
cp -a /certificates/* /usr/local/share/ca-certificates/
if [ ! -w /tmp ]; then
echo "Using additional CA certificates requires write permissions to /tmp. Cannot create truststore."
exit 1
fi

CACERT="$JAVA_HOME/lib/security/cacerts"

# JDK8 puts its JRE in a subdirectory
if [ -f "$JAVA_HOME/jre/lib/security/cacerts" ]; then
CACERT="$JAVA_HOME/jre/lib/security/cacerts"
# Figure out whether we can write to the JVM truststore. If we can, we'll add the certificates there. If not,
# we'll use a temporary truststore.
if [ ! -w "$CACERT" ]; then
# We cannot write to the JVM truststore, so we create a temporary one
CACERT_NEW=$(mktemp)
echo "Using a temporary truststore at $CACERT_NEW"
cp $CACERT $CACERT_NEW
CACERT=$CACERT_NEW
# If we use a custom truststore, we need to make sure that the JVM uses it
export JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS} -Djavax.net.ssl.trustStore=${CACERT} -Djavax.net.ssl.trustStorePassword=changeit"
fi

# OpenJDK images used to create a hook for `update-ca-certificates`. Since we are using an entrypoint anyway, we
# might as well just generate the truststore and skip the hooks.
update-ca-certificates
tmp_store=$(mktemp)

# Copy full system CA store to a temporary location
trust extract --overwrite --format=java-cacerts --filter=ca-anchors --purpose=server-auth "$tmp_store"

# Add the system CA certificates to the JVM truststore.
keytool -importkeystore -destkeystore "$CACERT" -srckeystore "$tmp_store" -srcstorepass changeit -deststorepass changeit -noprompt # >/dev/null

# Import the additional certificate into JVM truststore
for i in /certificates/*crt; do
if [ ! -f "$i" ]; then
continue
fi
keytool -import -noprompt -alias "$(basename "$i" .crt)" -file "$i" -keystore "$CACERT" -storepass changeit # >/dev/null
done

trust extract --overwrite --format=java-cacerts --filter=ca-anchors --purpose=server-auth "$CACERT"
# Add additional certificates to the system CA store. This requires write permissions to several system
# locations, which is not possible in a container with read-only filesystem and/or non-root container.
if [ "$(id -u)" -eq 0 ]; then

# Copy certificates from /certificates to the system truststore, but only if the directory exists and is not empty.
# The reason why this is not part of the opt-in is because it leaves open the option to mount certificates at the
# system location, for whatever reason.
if [ -d /certificates ] && [ "$(ls -A /certificates 2>/dev/null)" ]; then

# UBI/CentOS
if [ -d /usr/share/pki/ca-trust-source/anchors/ ]; then
cp -a /certificates/* /usr/share/pki/ca-trust-source/anchors/
fi

# Ubuntu/Alpine
if [ -d /usr/local/share/ca-certificates/ ]; then
cp -a /certificates/* /usr/local/share/ca-certificates/
fi
fi

# UBI/CentOS
if which update-ca-trust >/dev/null; then
update-ca-trust
fi

# Ubuntu/Alpine
if which update-ca-certificates >/dev/null; then
update-ca-certificates
fi
else
# If we are not root, we cannot update the system truststore. That's bad news for tools like `curl` and `wget`,
# but since the JVM is the primary focus here, we can live with that.
true
fi
fi

# Let's provide a variable with the correct path for tools that want or need to use it
export CACERT

exec "$@"
91 changes: 75 additions & 16 deletions 11/jdk/centos/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,30 +1,89 @@
#!/usr/bin/env bash
# Shebang needs to be `bash`, see https://github.com/adoptium/containers/issues/415 for details
#!/usr/bin/env sh
# Converted to POSIX shell to avoid the need for bash in the image

set -e

# JDK truststore location
CACERT=$JAVA_HOME/lib/security/cacerts

# JDK8 puts its JRE in a subdirectory
if [ -f "$JAVA_HOME/jre/lib/security/cacerts" ]; then
CACERT=$JAVA_HOME/jre/lib/security/cacerts
fi

# Opt-in is only activated if the environment variable is set
if [ -n "$USE_SYSTEM_CA_CERTS" ]; then

# Copy certificates from /certificates to the system truststore, but only if the directory exists and is not empty.
# The reason why this is not part of the opt-in is because it leaves open the option to mount certificates at the
# system location, for whatever reason.
if [ -d /certificates ] && [ "$(ls -A /certificates)" ]; then
cp -a /certificates/* /usr/share/pki/ca-trust-source/anchors/
if [ ! -w /tmp ]; then
echo "Using additional CA certificates requires write permissions to /tmp. Cannot create truststore."
exit 1
fi

CACERT=$JAVA_HOME/lib/security/cacerts

# JDK8 puts its JRE in a subdirectory
if [ -f "$JAVA_HOME/jre/lib/security/cacerts" ]; then
CACERT=$JAVA_HOME/jre/lib/security/cacerts
# Figure out whether we can write to the JVM truststore. If we can, we'll add the certificates there. If not,
# we'll use a temporary truststore.
if [ ! -w "$CACERT" ]; then
# We cannot write to the JVM truststore, so we create a temporary one
CACERT_NEW=$(mktemp)
echo "Using a temporary truststore at $CACERT_NEW"
cp $CACERT $CACERT_NEW
CACERT=$CACERT_NEW
# If we use a custom truststore, we need to make sure that the JVM uses it
export JAVA_TOOL_OPTIONS="${JAVA_TOOL_OPTIONS} -Djavax.net.ssl.trustStore=${CACERT} -Djavax.net.ssl.trustStorePassword=changeit"
fi

# RHEL-based images already include a routine to update a java truststore from the system CA bundle within
# `update-ca-trust`. All we need to do is to link the system CA bundle to the java truststore.
update-ca-trust
tmp_store=$(mktemp)

# Copy full system CA store to a temporary location
trust extract --overwrite --format=java-cacerts --filter=ca-anchors --purpose=server-auth "$tmp_store"

# Add the system CA certificates to the JVM truststore.
keytool -importkeystore -destkeystore "$CACERT" -srckeystore "$tmp_store" -srcstorepass changeit -deststorepass changeit -noprompt # >/dev/null

# Import the additional certificate into JVM truststore
for i in /certificates/*crt; do
if [ ! -f "$i" ]; then
continue
fi
keytool -import -noprompt -alias "$(basename "$i" .crt)" -file "$i" -keystore "$CACERT" -storepass changeit # >/dev/null
done

ln -sf /etc/pki/ca-trust/extracted/java/cacerts "$CACERT"
# Add additional certificates to the system CA store. This requires write permissions to several system
# locations, which is not possible in a container with read-only filesystem and/or non-root container.
if [ "$(id -u)" -eq 0 ]; then

# Copy certificates from /certificates to the system truststore, but only if the directory exists and is not empty.
# The reason why this is not part of the opt-in is because it leaves open the option to mount certificates at the
# system location, for whatever reason.
if [ -d /certificates ] && [ "$(ls -A /certificates 2>/dev/null)" ]; then

# UBI/CentOS
if [ -d /usr/share/pki/ca-trust-source/anchors/ ]; then
cp -a /certificates/* /usr/share/pki/ca-trust-source/anchors/
fi

# Ubuntu/Alpine
if [ -d /usr/local/share/ca-certificates/ ]; then
cp -a /certificates/* /usr/local/share/ca-certificates/
fi
fi

# UBI/CentOS
if which update-ca-trust >/dev/null; then
update-ca-trust
fi

# Ubuntu/Alpine
if which update-ca-certificates >/dev/null; then
update-ca-certificates
fi
else
# If we are not root, we cannot update the system truststore. That's bad news for tools like `curl` and `wget`,
# but since the JVM is the primary focus here, we can live with that.
true
fi
fi

# Let's provide a variable with the correct path for tools that want or need to use it
export CACERT

exec "$@"
Loading

0 comments on commit 4ecd1b9

Please sign in to comment.