handle pins with special characters, use pkcs15-init to write certs, add check-in subcommand

This commit is contained in:
Emil Lerch 2026-10-04 12:35:56 -07:00
parent c032498244
commit e6e5157c94
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 95 additions and 14 deletions

View file

@ -78,16 +78,28 @@ 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):
clone of this repository. The certificate is assembled in the container and
signed on the token with the user PIN, so the key never leaves it. Writing it
to an OpenPGP card (such as the Nitrokey Pro) takes the card's Admin PIN as
well: give it as `ADMIN_PIN`, and `make-cert` writes the certificate with
OpenSC's `pkcs15-init`, as Nitrokey documents for those cards. Without
`ADMIN_PIN`, the user PIN writes it over PKCS#11 (SoftHSM and similar tokens).
```sh
docker build -t hsm-signer signer
read -rs PKCS11_PIN && export PKCS11_PIN
read -rs ADMIN_PIN && export ADMIN_PIN
# Each PIN tried once: a wrong one costs a try, and a card locks a PIN after three
docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \
-e PKCS11_PIN hsm-signer make-cert '/CN=Your Name/O=example.org'
-e PKCS11_PIN -e ADMIN_PIN hsm-signer check-pin
docker run --rm -v /run/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro \
-e PKCS11_PIN -e ADMIN_PIN hsm-signer make-cert '/CN=Your Name/O=example.org'
```
`check-pin` and `make-cert` refuse to try a PIN that is on its last try. A
correct PIN resets the count, so a normal signing run (with the PIN CI uses)
gets the tries back.
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

View file

@ -8,6 +8,8 @@
# 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 check-pin check PKCS11_PIN (and ADMIN_PIN, if set) with one login
# each, before make-cert needs them
# hsm-sign list what Java sees on the token, for diagnosing an alias
#
# Environment:
@ -20,6 +22,8 @@
# 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
# ADMIN_PIN make-cert: the PIN that may write objects (pkcs15-init, auth id 3);
# required on OpenPGP cards (their Admin PIN). Unset: the user PIN
#
# 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
@ -72,6 +76,13 @@ token_label() {
s == n && /^[[:space:]]*token label[[:space:]]*:/ { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }'
}
# The flags of that token (PIN tries among them), as pkcs11-tool prints them.
token_flags() {
pkcs11-tool --module "$module" --list-slots 2>/dev/null | awk -v n="$slot_index" '
/^Slot [0-9]+ / { s = $2 }
s == n && /^[[:space:]]*token flags[[:space:]]*:/ { sub(/^[^:]*:[[:space:]]*/, ""); print; exit }'
}
# $1 percent-encoded, every byte, for a pkcs11: URI (RFC 7512).
uri_encode() {
printf '%s' "$1" | od -An -v -tx1 | tr -d ' \n' | tr 'a-f' 'A-F' | sed 's/../%&/g'
@ -145,6 +156,15 @@ case "${1:-}" in
make-cert)
[ $# -eq 2 ] || die "usage: hsm-sign make-cert '/CN=...'"
subject="$2"
# make-cert logs in up to three times, and a wrong PIN costs a try each time: never
# start on a token with a PIN on its last try. (A correct PIN resets the count, so a
# normal signing run with the known-good PIN is the way back.)
flags="$(token_flags)"
case "$flags" in
*"final user PIN try"* | *"user PIN locked"* | *"final SO PIN try"* | *"SO PIN locked"*)
die "the token reports '${flags}': not risking a login. Sign once with the correct PIN to reset the count, then try again"
;;
esac
# 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.
@ -160,7 +180,10 @@ case "${1:-}" in
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.
# the HSM's key. The PIN is in a file only this process can read, not an argument,
# with every character but letters and digits backslash-escaped: in OpenSSL's config
# syntax # starts a comment and $ a variable, which would change the PIN sent.
pin_conf="$(printf '%s' "$PKCS11_PIN" | sed 's/[^A-Za-z0-9]/\\&/g')"
cat > "$work/openssl.cnf" <<EOF
openssl_conf = conf
[conf]
@ -170,7 +193,7 @@ pkcs11 = p11
[p11]
engine_id = pkcs11
MODULE_PATH = $module
PIN = $PKCS11_PIN
PIN = $pin_conf
init = 0
[req]
distinguished_name = dn
@ -191,16 +214,32 @@ EOF
-subj "$subject" -engine pkcs11 -keyform engine \
-key "$uri" -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.
if [ -n "${ADMIN_PIN:-}" ]; then
# OpenPGP cards (the Nitrokey Pro): writing a certificate needs the Admin PIN, and
# goes through OpenSC's pkcs15-init with it (auth id 3), as Nitrokey documents for
# the Pro, rather than PKCS#11. OpenSC will not store a certificate under an id
# that already has one, so a replacement deletes it in the same run, as
# Nitrokey's own instructions do: the store then selects the key's certificate
# DO and writes over it. The read-back below checks the result either way.
if [ "$replacing" = 1 ]; then
set -- --delete-objects cert
else
set --
fi
pkcs15-init "$@" --store-certificate "$work/cert.pem" --format pem --id "$key_id" \
--auth-id 3 --verify-pin --pin env:ADMIN_PIN >&2 ||
die "pkcs15-init could not store the certificate; the read-back of what the card has now: $(read_cert "$work/now.der" && sha256sum "$work/now.der" | cut -d' ' -f1 || echo none). If none, put the old one back (the PEM above): pkcs15-init --store-certificate OLD.pem --id ${key_id} --auth-id 3 --verify-pin --pin env:ADMIN_PIN"
else
if [ "$replacing" = 1 ]; then
# Removed first, so tokens that keep several objects with one id do not end up
# with two certificates.
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 \
--delete-object --type cert --id "$key_id" >&2 ||
echo "hsm-sign: could not delete the old certificate; writing over it" >&2
--write-object "$work/cert.der" --type cert --id "$key_id" --label "${KEY_LABEL:-apk-cert}" >&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}" >&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"
@ -212,12 +251,42 @@ EOF
cat "$work/cert.pem"
;;
check-pin)
# One login with PKCS11_PIN and, if ADMIN_PIN is set, one admin PIN check: says
# whether each is right before make-cert needs them. A wrong PIN costs a try, as
# it would anywhere, so a PIN on its last try is not checked.
flags="$(token_flags)"
echo "token flags: ${flags}"
case "$flags" in
*"final user PIN try"* | *"user PIN locked"*)
die "user PIN is on its last try or locked: not checking it" ;;
esac
if pkcs11-tool --module "$module" --slot-index "$slot_index" --login --pin env:PKCS11_PIN \
--list-objects --type privkey >/dev/null 2>&1; then
echo "user PIN (PKCS11_PIN): correct"
else
die "user PIN (PKCS11_PIN): WRONG (one try used)"
fi
if [ -n "${ADMIN_PIN:-}" ]; then
case "$flags" in
*"final SO PIN try"* | *"SO PIN locked"*)
die "admin PIN is on its last try or locked: not checking it" ;;
esac
# The OpenPGP card's own VERIFY for PW3, the Admin PIN; OpenPGP cards only.
if openpgp-tool --verify CHV3 --pin env:ADMIN_PIN >/dev/null 2>&1; then
echo "admin PIN (ADMIN_PIN): correct"
else
die "admin PIN (ADMIN_PIN): WRONG (one try used)"
fi
fi
;;
list)
# shellcheck disable=SC2046 # java_store prints one argument per line
keytool -list -v -storepass:env PKCS11_PIN -keystore NONE $(java_store)
;;
*)
die "usage: hsm-sign detached IN OUT | apk IN OUT | make-cert SUBJECT | list"
die "usage: hsm-sign detached IN OUT | apk IN OUT | make-cert SUBJECT | check-pin | list"
;;
esac