add certificate pinning capabilities
This commit is contained in:
parent
fd265f0023
commit
e4d31848fd
5 changed files with 168 additions and 37 deletions
68
README.md
68
README.md
|
|
@ -54,6 +54,8 @@ APK signing
|
|||
pin: ${{ secrets.HSM_USER_PIN }}
|
||||
apk: unsigned/app-release-unsigned.apk
|
||||
apk_output: dist/app.apk
|
||||
# The app's signing certificate (see below): any other fails the step
|
||||
apk_cert_sha256: '<sha256 printed by make-cert>'
|
||||
# Optional: detached signatures of everything in dist/, the signed APK
|
||||
# included
|
||||
files: dist/*
|
||||
|
|
@ -63,38 +65,58 @@ APK signing
|
|||
The APK must be zipaligned (AGP's release output already is).
|
||||
|
||||
An APK signature carries a certificate, and Java (which `apksigner` runs on)
|
||||
only offers a PKCS#11 key that has a certificate on the token. **Once, before
|
||||
the first APK is signed**, make a self-signed certificate for the key and store
|
||||
it beside the key. On the HSM host, with the HSM powered, from a clone of this
|
||||
repository:
|
||||
only offers a PKCS#11 key that has a certificate on the token. Whatever
|
||||
certificate the token holds for the key is used, and a token holds one per key,
|
||||
so **every APK signed with a key carries the same certificate**: one signing
|
||||
identity for all of them, which is the usual arrangement for an Android
|
||||
developer. Give it a generic subject (you or your organization, not an app),
|
||||
and use a separate key if an app ever needs an identity of its own.
|
||||
|
||||
The certificate is public, so it is no secret where it is made; what matters is
|
||||
that it is chosen once: it is the identity of every app signed with it, on
|
||||
every device, and an APK signed with a different certificate is refused as an
|
||||
update (users uninstall and reinstall).
|
||||
|
||||
To give the key a certificate, on the HSM host, with the HSM powered, from a
|
||||
clone of this repository (the certificate is assembled in the container and
|
||||
signed on the token, so the key never leaves it):
|
||||
|
||||
```sh
|
||||
docker build -t hsm-signer signer
|
||||
read -rs PKCS11_PIN && export PKCS11_PIN
|
||||
docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \
|
||||
-e PKCS11_PIN hsm-signer make-cert '/CN=My App/O=example.org'
|
||||
-e PKCS11_PIN hsm-signer make-cert '/CN=Your Name/O=example.org'
|
||||
```
|
||||
|
||||
It prints the certificate; keep a copy. The certificate is the app's identity
|
||||
on every device from then on: replacing it means users uninstall and reinstall.
|
||||
If the key already has a certificate, `make-cert` shows it and stops. Add
|
||||
`-e REPLACE_CERT=1` to replace it (keep the PEM it showed, if anything else
|
||||
uses that certificate). The new certificate is read back from the token to
|
||||
check it, and printed as PEM, with its `sha256 (apk_cert_sha256)`. Keep the PEM
|
||||
(it is public: publish it beside the public key, or keep it in each app's
|
||||
repository) and put the sha256 in each app's workflow as `apk_cert_sha256`, so
|
||||
a certificate replaced on the token later fails the build instead of silently
|
||||
changing the apps' identity.
|
||||
|
||||
Its 30-year validity does not matter for APKs, whose certificate dates Android
|
||||
does not check. Running the same container with `list` instead of `make-cert`
|
||||
does not check, but is long enough for Google Play, which wants validity past
|
||||
2033-10-22. Running the same container with `list` instead of `make-cert`
|
||||
shows what Java sees on the token.
|
||||
|
||||
Inputs
|
||||
------
|
||||
|
||||
| input | default | |
|
||||
|----------------|---------|--------------------------------------------------------------------------|
|
||||
| `files` | | files to make detached signatures of |
|
||||
| `apk` | | unsigned APK to sign |
|
||||
| `apk_output` | | where to write the signed APK; required with `apk` |
|
||||
| `pin` | | the HSM's user PIN (required) |
|
||||
| `key_id` | `03` | the key's CKA_ID, in hex |
|
||||
| `slot_index` | `0` | PKCS#11 slot, by position in the slot list |
|
||||
| `key_alias` | | APK only: which key, when the token has more than one with a certificate |
|
||||
| `public_key` | | URL of the PEM public key; set it to log detached signatures to sigstore |
|
||||
| `uhub_control` | `false` | power cycle the HSM on a smart USB hub (below) |
|
||||
| input | default | |
|
||||
|-------------------|---------|--------------------------------------------------------------------------|
|
||||
| `files` | | files to make detached signatures of |
|
||||
| `apk` | | unsigned APK to sign |
|
||||
| `apk_output` | | where to write the signed APK; required with `apk` |
|
||||
| `pin` | | the HSM's user PIN (required) |
|
||||
| `key_id` | `03` | the key's CKA_ID, in hex |
|
||||
| `slot_index` | `0` | PKCS#11 slot, by position in the slot list |
|
||||
| `key_alias` | | APK only: which key, when the token has more than one with a certificate |
|
||||
| `apk_cert_sha256` | | APK only: the certificate the APK must be signed with; any other fails |
|
||||
| `public_key` | | URL of the PEM public key; set it to log detached signatures to sigstore |
|
||||
| `uhub_control` | `false` | power cycle the HSM on a smart USB hub (below) |
|
||||
|
||||
At least one of `files` and `apk` is required.
|
||||
|
||||
|
|
@ -122,9 +144,11 @@ host's Debian release (`BASE` in [signer/Dockerfile](signer/Dockerfile)).
|
|||
|
||||
[signer/test.sh](signer/test.sh) runs the signer end to end against SoftHSM,
|
||||
with docker or podman: a token with a bare key, a detached signature checked
|
||||
with openssl against the token's public key and, given an APK, `make-cert`,
|
||||
APK signing, `apksigner verify`, and a check that the APK's signing key is the
|
||||
token's:
|
||||
with openssl against the token's public key and, given an APK, `make-cert`
|
||||
(including its refusal to replace a certificate, and a replacement), APK
|
||||
signing pinned to the current certificate (and refused when pinned to the
|
||||
replaced one), `apksigner verify`, and a check that the APK's signing key is
|
||||
the token's:
|
||||
|
||||
```sh
|
||||
signer/test.sh # detached only
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ inputs:
|
|||
description: 'APK only: the key alias, when the token holds more than one key with a certificate'
|
||||
required: false
|
||||
default: ''
|
||||
apk_cert_sha256:
|
||||
description: 'APK only: SHA-256 of the certificate the APK must be signed with (the app identity). Signing with any other certificate fails the step. Recommended once the certificate is chosen'
|
||||
required: false
|
||||
default: ''
|
||||
public_key:
|
||||
description: 'URL to PEM format public key. Specify only if uploading detached signatures to sigstore'
|
||||
required: false
|
||||
|
|
|
|||
|
|
@ -243,7 +243,7 @@ die() {
|
|||
run_signer() {
|
||||
container="$(docker create \
|
||||
-v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \
|
||||
-e PKCS11_PIN -e PKCS11_SLOT_INDEX -e KEY_ID -e KEY_ALIAS \
|
||||
-e PKCS11_PIN -e PKCS11_SLOT_INDEX -e KEY_ID -e KEY_ALIAS -e APK_CERT_SHA256 \
|
||||
"${signer}" "$1" "/home/user/$4" "/home/user/$5")" ||
|
||||
die "could not create the signer container"
|
||||
docker cp "$2" "${container}:/home/user/$4" || die "could not copy $2 into the signer"
|
||||
|
|
@ -297,6 +297,7 @@ export PKCS11_PIN="${INPUT_PIN:-}"
|
|||
export PKCS11_SLOT_INDEX="${INPUT_SLOT_INDEX:-0}"
|
||||
export KEY_ID="${INPUT_KEY_ID:-03}"
|
||||
export KEY_ALIAS="${INPUT_KEY_ALIAS:-}"
|
||||
export APK_CERT_SHA256="${INPUT_APK_CERT_SHA256:-}"
|
||||
|
||||
if [ "${INPUT_UHUB_CONTROL:-false}" != "false" ]; then
|
||||
uhub_on
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@
|
|||
# hsm-sign detached IN OUT a detached signature of IN (RSA PKCS#1 v1.5 over
|
||||
# SHA-256), written to OUT
|
||||
# hsm-sign apk IN.apk OUT.apk sign an APK (signature schemes v2 and v3), then verify
|
||||
# hsm-sign make-cert SUBJECT one-time setup for apk: a self-signed certificate for
|
||||
# the key, written to the token beside it, and printed
|
||||
# as PEM
|
||||
# hsm-sign make-cert SUBJECT setup for apk: a self-signed certificate for the key,
|
||||
# signed on the token, written to it beside the key,
|
||||
# read back, and printed as PEM. Refuses if the key
|
||||
# already has a certificate, unless REPLACE_CERT=1
|
||||
# hsm-sign list what Java sees on the token, for diagnosing an alias
|
||||
#
|
||||
# Environment:
|
||||
|
|
@ -16,12 +17,15 @@
|
|||
# KEY_ID the key's CKA_ID in hex; default 03
|
||||
# KEY_LABEL the label make-cert gives the certificate; default apk-cert
|
||||
# KEY_ALIAS which key apksigner uses, when the token holds more than one
|
||||
# APK_CERT_SHA256 apk: the certificate the APK must be signed with (the SHA-256 of
|
||||
# its DER, as make-cert and apk print it); any other is an error
|
||||
# REPLACE_CERT make-cert: 1 to replace an existing certificate
|
||||
#
|
||||
# Why apk needs a certificate on the token: an APK signature embeds the signer's
|
||||
# certificate, and Java's PKCS#11 key store only offers a private key that has a
|
||||
# certificate with the same CKA_ID. A key used only for detached signatures has none
|
||||
# until make-cert writes one. Android pins an app to its signing certificate for good,
|
||||
# so make-cert runs once and its certificate is kept.
|
||||
# certificate with the same CKA_ID. Whatever certificate is there is used, whether
|
||||
# make-cert wrote it or not. Android pins an app to its signing certificate for good,
|
||||
# so it is chosen once and kept (and pinned with APK_CERT_SHA256).
|
||||
set -eu
|
||||
|
||||
die() {
|
||||
|
|
@ -33,11 +37,33 @@ die() {
|
|||
module="${PKCS11_MODULE:-$(find /usr/lib -name opensc-pkcs11.so | head -n 1)}"
|
||||
[ -f "$module" ] || die "no PKCS#11 module at '$module'"
|
||||
slot_index="${PKCS11_SLOT_INDEX:-0}"
|
||||
key_id="${KEY_ID:-03}"
|
||||
# Lower case and an even number of digits, as pkcs11-tool lists ids and as the pkcs11:
|
||||
# URI below needs them.
|
||||
key_id="$(printf '%s' "${KEY_ID:-03}" | tr 'A-F' 'a-f')"
|
||||
[ $((${#key_id} % 2)) -eq 0 ] || key_id="0${key_id}"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
chmod 700 "$work"
|
||||
|
||||
# The certificate for the key, in DER, to $1; fails if there is none. Certificates are
|
||||
# public objects: no login.
|
||||
read_cert() {
|
||||
pkcs11-tool --module "$module" --slot-index "$slot_index" \
|
||||
--read-object --type cert --id "$key_id" --output-file "$1" >/dev/null 2>&1 && [ -s "$1" ]
|
||||
}
|
||||
|
||||
# How many certificates the token holds for the key.
|
||||
cert_count() {
|
||||
pkcs11-tool --module "$module" --slot-index "$slot_index" --list-objects --type cert 2>/dev/null |
|
||||
grep -cE "^ *ID: *${key_id}\$" || true
|
||||
}
|
||||
|
||||
# A certificate (DER, $1) as a person and as apk_cert_sha256 want to see it.
|
||||
describe_cert() {
|
||||
openssl x509 -inform DER -in "$1" -noout -subject -startdate -enddate
|
||||
echo "sha256 (apk_cert_sha256): $(sha256sum "$1" | cut -d' ' -f1)"
|
||||
}
|
||||
|
||||
# SunPKCS11's configuration: which library, which slot.
|
||||
cat > "$work/pkcs11.cfg" <<EOF
|
||||
name = HSM
|
||||
|
|
@ -84,12 +110,42 @@ case "${1:-}" in
|
|||
$alias_args \
|
||||
--in "$1" --out "$2"
|
||||
# Proof, in the log, of what was signed and by whom.
|
||||
apksigner verify --verbose --print-certs "$2"
|
||||
verified="$(apksigner verify --verbose --print-certs "$2")"
|
||||
echo "$verified"
|
||||
signers="$(echo "$verified" | sed -n 's/^Number of signers: //p')"
|
||||
[ "$signers" = 1 ] || die "expected 1 signer, apksigner reports '${signers}'"
|
||||
got="$(echo "$verified" | sed -n 's/^Signer #1 certificate SHA-256 digest: //p')"
|
||||
echo "hsm-sign: signed with the certificate whose sha256 (apk_cert_sha256) is ${got}"
|
||||
# The certificate is the app's identity: with APK_CERT_SHA256 set, an APK signed
|
||||
# with any other certificate (one replaced on the token, a second key) is an error,
|
||||
# not an update every installed copy refuses.
|
||||
if [ -n "${APK_CERT_SHA256:-}" ]; then
|
||||
want="$(printf '%s' "${APK_CERT_SHA256}" | tr -d ': ' | tr 'A-F' 'a-f')"
|
||||
if [ "$got" != "$want" ]; then
|
||||
rm -f "$2"
|
||||
die "the APK was signed with certificate ${got}, not the expected ${want} (APK_CERT_SHA256)"
|
||||
fi
|
||||
echo "hsm-sign: that is the expected certificate"
|
||||
fi
|
||||
;;
|
||||
|
||||
make-cert)
|
||||
[ $# -eq 2 ] || die "usage: hsm-sign make-cert '/CN=...'"
|
||||
subject="$2"
|
||||
# The certificate is the app's identity on every device that installs an APK signed
|
||||
# with it, so an existing one is not replaced by accident: it is shown, and kept,
|
||||
# unless REPLACE_CERT=1.
|
||||
if read_cert "$work/old.der"; then
|
||||
echo "hsm-sign: key ${key_id} already has a certificate:" >&2
|
||||
describe_cert "$work/old.der" >&2
|
||||
openssl x509 -inform DER -in "$work/old.der" >&2
|
||||
[ "${REPLACE_CERT:-}" = 1 ] ||
|
||||
die "not replacing it. Set REPLACE_CERT=1 to replace it (keep the PEM above if anything uses it)"
|
||||
echo "hsm-sign: REPLACE_CERT=1: replacing it" >&2
|
||||
replacing=1
|
||||
else
|
||||
replacing=0
|
||||
fi
|
||||
# OpenSSL's PKCS#11 engine, pointed at the same module, signs the certificate with
|
||||
# the HSM's key. The PIN is in a file only this process can read, not an argument.
|
||||
cat > "$work/openssl.cnf" <<EOF
|
||||
|
|
@ -109,13 +165,30 @@ distinguished_name = dn
|
|||
EOF
|
||||
uri_id="$(printf '%s' "$key_id" | sed 's/../%&/g')"
|
||||
# Thirty years: the certificate's dates are not checked for APK signatures, and
|
||||
# replacing it would mean a new app identity on every device.
|
||||
# replacing it would mean a new app identity on every device. Also long enough for
|
||||
# Google Play, which wants validity past 2033-10-22.
|
||||
OPENSSL_CONF="$work/openssl.cnf" openssl req -new -x509 -sha256 -days 10950 \
|
||||
-subj "$subject" -engine pkcs11 -keyform engine \
|
||||
-key "pkcs11:id=${uri_id};type=private" -out "$work/cert.pem"
|
||||
openssl x509 -in "$work/cert.pem" -outform DER -out "$work/cert.der"
|
||||
if [ "$replacing" = 1 ]; then
|
||||
# Removed first, so tokens that keep several objects with one id do not end up
|
||||
# with two certificates. Cards with one certificate per key may refuse; the
|
||||
# write below then replaces it, and the check after it says whether that worked.
|
||||
pkcs11-tool --module "$module" --slot-index "$slot_index" --login --pin env:PKCS11_PIN \
|
||||
--delete-object --type cert --id "$key_id" >&2 ||
|
||||
echo "hsm-sign: could not delete the old certificate; writing over it" >&2
|
||||
fi
|
||||
pkcs11-tool --module "$module" --slot-index "$slot_index" --login --pin env:PKCS11_PIN \
|
||||
--write-object "$work/cert.der" --type cert --id "$key_id" --label "${KEY_LABEL:-apk-cert}"
|
||||
--write-object "$work/cert.der" --type cert --id "$key_id" --label "${KEY_LABEL:-apk-cert}" >&2
|
||||
# Read back: exactly one certificate for the key, and it is the new one.
|
||||
count="$(cert_count)"
|
||||
[ "$count" = 1 ] || die "the token has ${count} certificates for key ${key_id} after writing, expected 1"
|
||||
read_cert "$work/check.der" || die "could not read the certificate back"
|
||||
cmp -s "$work/cert.der" "$work/check.der" ||
|
||||
die "the certificate read back is not the one written"
|
||||
echo "hsm-sign: written and read back:" >&2
|
||||
describe_cert "$work/cert.der" >&2
|
||||
cat "$work/cert.pem"
|
||||
;;
|
||||
|
||||
|
|
|
|||
|
|
@ -65,19 +65,48 @@ fi
|
|||
openssl dgst -sha256 -verify "$HOME/pub.pem" -signature /out/artifact.sig artifact
|
||||
|
||||
[ -f in.apk ] || exit 0
|
||||
certsha() { openssl x509 -in "$1" -outform DER | sha256sum | cut -d" " -f1; }
|
||||
certs() { pkcs11-tool --module "$PKCS11_MODULE" --list-objects --type cert 2>/dev/null | grep -cE "^ *ID: *$KEY_ID\$" || true; }
|
||||
|
||||
echo "== make-cert"
|
||||
hsm-sign make-cert "/CN=hsm-sign test signer" > /out/cert.pem
|
||||
openssl x509 -in /out/cert.pem -noout -subject -fingerprint -sha256
|
||||
echo "== apk"
|
||||
hsm-sign apk in.apk /out/signed.apk
|
||||
first="$(certsha /out/cert.pem)"
|
||||
echo "== make-cert again, without REPLACE_CERT: must refuse"
|
||||
if hsm-sign make-cert "/CN=another signer" > /out/refused.pem 2> /out/refused.err; then
|
||||
echo "make-cert replaced a certificate without REPLACE_CERT=1"; exit 1
|
||||
fi
|
||||
grep -q "already has a certificate" /out/refused.err
|
||||
[ "$(certs)" = 1 ]
|
||||
echo "refused, and the token still has one certificate"
|
||||
echo "== make-cert with REPLACE_CERT=1"
|
||||
REPLACE_CERT=1 hsm-sign make-cert "/CN=hsm-sign test signer 2" > /out/cert2.pem
|
||||
second="$(certsha /out/cert2.pem)"
|
||||
[ "$first" != "$second" ]
|
||||
[ "$(certs)" = 1 ]
|
||||
echo "replaced, and the token has one certificate: $second"
|
||||
|
||||
echo "== apk, pinned to the current certificate"
|
||||
APK_CERT_SHA256="$second" hsm-sign apk in.apk /out/signed.apk
|
||||
echo "== apk, pinned to the replaced certificate: must fail"
|
||||
if APK_CERT_SHA256="$first" hsm-sign apk in.apk /out/wrong.apk > /out/wrong.log 2>&1; then
|
||||
echo "apk signed with a certificate other than the pinned one"; exit 1
|
||||
fi
|
||||
grep -q "not the expected" /out/wrong.log
|
||||
[ ! -e /out/wrong.apk ]
|
||||
echo "refused, and no APK left behind"
|
||||
echo "== independent verify"
|
||||
apksigner verify --verbose /out/signed.apk
|
||||
# The APK is signed by the key, not merely by a certificate naming it: the public key
|
||||
# apksigner reports is the token'"'"'s.
|
||||
got="$(apksigner verify --verbose --print-certs /out/signed.apk | sed -n "s/^Signer #1 public key SHA-256 digest: //p")"
|
||||
# apksigner reports is the token'"'"'s, and the certificate is the current one.
|
||||
printed="$(apksigner verify --verbose --print-certs /out/signed.apk)"
|
||||
got="$(echo "$printed" | sed -n "s/^Signer #1 public key SHA-256 digest: //p")"
|
||||
gotcert="$(echo "$printed" | sed -n "s/^Signer #1 certificate SHA-256 digest: //p")"
|
||||
echo "token public key sha256: $want"
|
||||
echo "APK signer key sha256: $got"
|
||||
echo "APK certificate sha256: $gotcert"
|
||||
[ "$want" = "$got" ]
|
||||
[ "$gotcert" = "$second" ]
|
||||
'
|
||||
echo "test.sh: detached signature verified"
|
||||
if [ -n "$apk_in" ]; then
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue