diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 231f72b..5efe647 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,6 +115,19 @@ jobs: if: matrix.target != 'x86_64-apple-darwin' && !matrix.cross run: cargo run --release --target ${{ matrix.target }} -- --help + # Post-build check of the module as a PKCS#11 host sees it, no credentials + # needed: it loads, exposes the ssign token and the SHA-256 RSA mechanisms. + - name: Smoke test the PKCS#11 module + if: matrix.lint + run: | + sudo apt-get update -qq && sudo apt-get install -y opensc + module=target/${{ matrix.target }}/release/libssign_pkcs11.so + pkcs11-tool --module "$module" -L | tee slots.txt + grep -q "token label *: ssign" slots.txt + pkcs11-tool --module "$module" --list-mechanisms | tee mechanisms.txt + grep -q "^ *SHA256-RSA-PKCS, sign" mechanisms.txt + grep -q "^ *RSA-PKCS, sign" mechanisms.txt + # --- Package (binaires non signés ; la signature Apple est faite plus bas, # dans un job dédié derrière l'environnement `signing`). --- - name: Package (Unix) @@ -149,13 +162,54 @@ jobs: name: ssign-pkcs11-${{ matrix.label }} path: ssign-pkcs11-${{ matrix.label }}.* + # Live checks against the Certum cloud, after the build and before anything is + # signed: PKCS#11 mechanisms, jsign (SunPKCS11), osslsigncode on every format + # and the CLI, all on the Linux binaries of this run. Both signing jobs, and so + # the release, wait for it. Behind `signing` because it reads the Certum secrets. + # Also runs on workflow_dispatch, to check a branch without tagging. + pre-sign-checks: + needs: build + if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + environment: signing + env: + JSIGN_VERSION: "7.5" + JSIGN_SHA256: 602a51c3545a6dc4fb99bd2ea7152b26d1345916d0c93ddfbd5936cb735af91c + steps: + - uses: actions/checkout@v7 + + - name: Download the Linux CLI and module + uses: actions/download-artifact@v8 + with: + pattern: ssign-*linux-x86_64 + path: dl + + - name: Install the test tools + run: | + sudo apt-get update -qq + sudo apt-get install -y opensc osslsigncode libengine-pkcs11-openssl + curl -fsSL -o "$RUNNER_TEMP/jsign.jar" \ + "https://github.com/ebourg/jsign/releases/download/$JSIGN_VERSION/jsign-$JSIGN_VERSION.jar" + echo "$JSIGN_SHA256 $RUNNER_TEMP/jsign.jar" | sha256sum -c - + java -version + + - name: Run the pre-sign checks + env: + CERTUM_EMAIL: ${{ secrets.CERTUM_EMAIL }} + CERTUM_OTP: ${{ secrets.CERTUM_OTP }} + run: | + mkdir -p bin + tar xzf dl/ssign-linux-x86_64/ssign-linux-x86_64.tar.gz -C bin + tar xzf dl/ssign-pkcs11-linux-x86_64/ssign-pkcs11-linux-x86_64.tar.gz -C bin + JSIGN_JAR="$RUNNER_TEMP/jsign.jar" tests/pre-sign.sh bin/ssign bin/libssign_pkcs11.so + # Signature + notarisation Apple. Derrière `signing` au même titre que Windows : # une signature Developer ID engage l'identité (et donc la réputation) du # propriétaire, elle ne doit jamais partir sans approbation explicite. Les # secrets Apple vivent dans cet environnement — un job qui ne le déclare pas # les lirait vides. sign-apple: - needs: build + needs: [build, pre-sign-checks] if: startsWith(github.ref, 'refs/tags/v') runs-on: macos-latest environment: signing @@ -297,7 +351,7 @@ jobs: # the owner's approval before it can read the Certum secrets and sign, and the # release below waits for it — so a tag becomes: build → approve → signed release. sign-windows: - needs: build + needs: [build, pre-sign-checks] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: signing diff --git a/ssign-core/src/authenticode.rs b/ssign-core/src/authenticode.rs index e1f3bba..62d7c42 100644 --- a/ssign-core/src/authenticode.rs +++ b/ssign-core/src/authenticode.rs @@ -34,7 +34,8 @@ const SPC_ATTR_CONST: &[u8] = &[ 0x00, 0x3e, 0x00, 0x3e, 0x00, 0x3e, ]; -fn sha256(data: &[u8]) -> [u8; 32] { +/// SHA-256 of `data`. +pub fn sha256(data: &[u8]) -> [u8; 32] { let mut h = Sha256::new(); h.update(data); h.finalize().into() diff --git a/ssign-pkcs11/src/lib.rs b/ssign-pkcs11/src/lib.rs index 0e1be7a..7bcbbd8 100644 --- a/ssign-pkcs11/src/lib.rs +++ b/ssign-pkcs11/src/lib.rs @@ -227,19 +227,7 @@ impl PrivateKey for CertumKey { } fn sign(&self, algorithm: &SignatureAlgorithm, data: &[u8]) -> Result> { - let digest = match algorithm { - // signtool-style: the bare 32-byte digest. - SignatureAlgorithm::RsaPkcs1v15Sha256 => data.try_into().map_err(|_| { - boxed(format!("expected a 32-byte SHA-256 digest, got {}", data.len())) - })?, - // osslsigncode-style (CKM_RSA_PKCS): a DER SHA-256 DigestInfo. - SignatureAlgorithm::RsaPkcs1v15Raw => sha256_from_digestinfo(data)?, - other => { - return Err(boxed(format!( - "unsupported algorithm {other:?}; the Certum cloud cert signs SHA-256 RSA PKCS#1 v1.5 only" - ))) - } - }; + let digest = digest_to_sign(algorithm, data)?; self.session .sign_sha256(&digest) .map_err(|e| boxed(format!("cloud signature failed: {e:#}"))) @@ -252,6 +240,22 @@ impl PrivateKey for CertumKey { } } +/// The 32-byte SHA-256 digest the cloud signs, from what the PKCS#11 caller +/// passed for `algorithm`. +fn digest_to_sign(algorithm: &SignatureAlgorithm, data: &[u8]) -> Result<[u8; 32]> { + match algorithm { + // CKM_SHA256_RSA_PKCS (jsign / Java SunPKCS11, pkcs11-tool): the caller + // passes the message itself, possibly over C_SignUpdate/C_SignFinal, and + // the token hashes it. Whatever its length, it is never a digest. + SignatureAlgorithm::RsaPkcs1v15Sha256 => Ok(ssign_core::authenticode::sha256(data)), + // CKM_RSA_PKCS (osslsigncode): a DER SHA-256 DigestInfo. + SignatureAlgorithm::RsaPkcs1v15Raw => sha256_from_digestinfo(data), + other => Err(boxed(format!( + "unsupported algorithm {other:?}; the Certum cloud cert signs SHA-256 RSA PKCS#1 v1.5 only" + ))), + } +} + /// Peel a SHA-256 `DigestInfo` (or accept a bare digest) down to 32 bytes. fn sha256_from_digestinfo(data: &[u8]) -> Result<[u8; 32]> { if data.len() == SHA256_DIGESTINFO_PREFIX.len() + 32 @@ -317,3 +321,74 @@ pub unsafe extern "C" fn C_GetFunctionList(pp_function_list: CK_FUNCTION_LIST_PT *pp_function_list = std::ptr::addr_of_mut!(native_pkcs11::FUNC_LIST); CKR_OK } + +#[cfg(test)] +mod tests { + use super::*; + + fn digestinfo(digest: &[u8; 32]) -> Vec { + [&SHA256_DIGESTINFO_PREFIX[..], digest].concat() + } + + #[test] + fn sha256_rsa_pkcs_hashes_the_message() { + let message = b"signed attributes of an Authenticode signature"; + let digest = digest_to_sign(&SignatureAlgorithm::RsaPkcs1v15Sha256, message).unwrap(); + assert_eq!(digest, ssign_core::authenticode::sha256(message)); + } + + /// A message that happens to look like a digest or a DigestInfo is still a + /// message under CKM_SHA256_RSA_PKCS, and must be hashed. + #[test] + fn sha256_rsa_pkcs_never_guesses_from_the_length() { + let message32 = [0x42u8; 32]; + let message51 = digestinfo(&[0x42u8; 32]); + for message in [&message32[..], &message51[..]] { + let digest = digest_to_sign(&SignatureAlgorithm::RsaPkcs1v15Sha256, message).unwrap(); + assert_eq!(digest, ssign_core::authenticode::sha256(message)); + } + } + + /// Both mechanisms must end up signing the same digest for the same message, + /// or osslsigncode and jsign would produce different signatures. + #[test] + fn both_mechanisms_sign_the_same_digest() { + let message = b"same message, two mechanisms"; + let via_sha256 = digest_to_sign(&SignatureAlgorithm::RsaPkcs1v15Sha256, message).unwrap(); + let hashed = ssign_core::authenticode::sha256(message); + let via_raw = + digest_to_sign(&SignatureAlgorithm::RsaPkcs1v15Raw, &digestinfo(&hashed)).unwrap(); + assert_eq!(via_sha256, via_raw); + } + + #[test] + fn rsa_pkcs_accepts_a_bare_digest() { + let digest = [7u8; 32]; + assert_eq!( + digest_to_sign(&SignatureAlgorithm::RsaPkcs1v15Raw, &digest).unwrap(), + digest + ); + } + + /// A SHA-384 DigestInfo must be refused, not re-hashed into a signature + /// that no verifier would accept. + #[test] + fn rsa_pkcs_rejects_other_digests() { + let sha384_digestinfo = [ + &[ + 0x30, 0x41, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, + 0x02, 0x05, 0x00, 0x04, 0x30, + ][..], + &[0u8; 48], + ] + .concat(); + assert!(digest_to_sign(&SignatureAlgorithm::RsaPkcs1v15Raw, &sha384_digestinfo).is_err()); + assert!(digest_to_sign(&SignatureAlgorithm::RsaPkcs1v15Raw, b"not a digest").is_err()); + } + + #[test] + fn other_algorithms_are_refused() { + assert!(digest_to_sign(&SignatureAlgorithm::RsaPkcs1v15Sha384, b"x").is_err()); + assert!(digest_to_sign(&SignatureAlgorithm::Ecdsa, &[0u8; 32]).is_err()); + } +} diff --git a/tests/pre-sign.sh b/tests/pre-sign.sh new file mode 100755 index 0000000..375d206 --- /dev/null +++ b/tests/pre-sign.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# Live checks run against the Certum cloud before a release is signed: every +# way a user drives ssign must produce a valid signature, or nothing ships. +# +# 1. PKCS#11 mechanisms (pkcs11-tool): CKM_SHA256_RSA_PKCS, fed the message in +# several parts (C_SignUpdate/C_SignFinal), and CKM_RSA_PKCS, fed a SHA-256 +# DigestInfo, give the same signature, and it verifies with the public key. +# Includes a 32-byte and a 51-byte message, which are still messages. +# 2. jsign through Java's SunPKCS11 (issue #11): PE + MSI, verified. +# 3. osslsigncode through the module: every format (sign-all-formats.sh). +# 4. The ssign CLI on a PE file, verified. +# +# The first sign logs in (CERTUM_OTP or CERTUM_TOKEN); the session cache makes +# every later step, in every process, reuse that login. +# +# Requirements: pkcs11-tool (opensc), osslsigncode + libengine-pkcs11-openssl, +# openssl, java 11+, and the jsign jar (JSIGN_JAR). +# +# Usage: +# CERTUM_EMAIL=you@example.com CERTUM_OTP=SEED JSIGN_JAR=jsign.jar \ +# tests/pre-sign.sh target/release/ssign target/release/libssign_pkcs11.so + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIX="$ROOT/ssign-core/tests/fixtures" +INTER_DER="$ROOT/ssign-core/src/certs/ccsca2021.der" + +SSIGN="$(realpath "${1:?usage: pre-sign.sh }")" +MODULE="$(realpath "${2:?usage: pre-sign.sh }")" +: "${CERTUM_EMAIL:?set CERTUM_EMAIL}" +[[ -n "${CERTUM_OTP:-}${CERTUM_TOKEN:-}" ]] || { + echo "error: set CERTUM_OTP (TOTP seed) or CERTUM_TOKEN (6-digit code)" >&2 + exit 2 +} +: "${JSIGN_JAR:?set JSIGN_JAR to the jsign jar}" + +TSA=http://time.certum.pl/ +LABEL="Certum SimplySign (ssign)" + +mkdir -p "$ROOT/target" +WORK="$(mktemp -d "$ROOT/target/pre-sign.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +failures=0 +ok() { printf ' ✓ %s\n' "$1"; } +ko() { printf ' ✗ %s\n' "$1"; failures=$((failures + 1)); } + +# Check the facts osslsigncode reports, not just its exit code: a digest +# mismatch or a missing timestamp still prints a lot of reassuring lines. +verify() { + local out + out=$(osslsigncode verify -in "$1" 2>&1) || true + if grep -q "MISMATCH" <<<"$out"; then + echo "$out"; echo " $1: Authenticode digest mismatch"; return 1 + fi + if grep -q "Timestamp is not available" <<<"$out"; then + echo "$out"; echo " $1: no timestamp"; return 1 + fi + if ! grep -q "Signature verification: ok" <<<"$out"; then + echo "$out"; return 1 + fi +} + +p11() { pkcs11-tool --module "$MODULE" "$@"; } + +# --- 1. PKCS#11 mechanisms ------------------------------------------------- +echo "== PKCS#11 mechanisms (pkcs11-tool)" + +# The certificate from the module itself, and the Certum intermediate. +if p11 --read-object --type cert --label "$LABEL" -o "$WORK/leaf.der" >/dev/null 2>&1; then + openssl x509 -inform DER -in "$WORK/leaf.der" -out "$WORK/leaf.pem" + openssl x509 -in "$WORK/leaf.pem" -pubkey -noout >"$WORK/pub.pem" + ok "certificate read from the module" +else + ko "cannot read the certificate from the module" + p11 --read-object --type cert --label "$LABEL" -o "$WORK/leaf.der" + exit 1 +fi +openssl x509 -inform DER -in "$INTER_DER" -out "$WORK/inter.pem" + +# 1 MiB so pkcs11-tool streams it through several C_SignUpdate calls. +head -c 1048576 /dev/urandom >"$WORK/msg-large" +head -c 32 /dev/urandom >"$WORK/msg-32" +# 51 bytes shaped exactly like a SHA-256 DigestInfo. +{ printf '\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20' + head -c 32 /dev/urandom; } >"$WORK/msg-51" + +for m in msg-large msg-32 msg-51; do + msg="$WORK/$m" + if ! p11 --sign --mechanism SHA256-RSA-PKCS --label "$LABEL" \ + -i "$msg" -o "$msg.sha256-rsa" >/dev/null 2>&1; then + ko "$m: CKM_SHA256_RSA_PKCS sign failed" + p11 --sign --mechanism SHA256-RSA-PKCS --label "$LABEL" -i "$msg" -o "$msg.sha256-rsa" + continue + fi + if openssl dgst -sha256 -verify "$WORK/pub.pem" -signature "$msg.sha256-rsa" "$msg" >/dev/null; then + ok "$m: CKM_SHA256_RSA_PKCS signature verifies" + else + ko "$m: CKM_SHA256_RSA_PKCS signature does not verify" + fi + { printf '\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20' + openssl dgst -sha256 -binary "$msg"; } >"$msg.digestinfo" + if p11 --sign --mechanism RSA-PKCS --label "$LABEL" \ + -i "$msg.digestinfo" -o "$msg.rsa" >/dev/null 2>&1 \ + && cmp -s "$msg.sha256-rsa" "$msg.rsa"; then + ok "$m: CKM_RSA_PKCS over its DigestInfo gives the same signature" + else + ko "$m: CKM_RSA_PKCS over its DigestInfo differs or failed" + fi +done + +# --- 2. jsign (Java SunPKCS11) --------------------------------------------- +echo "== jsign $(basename "$JSIGN_JAR") through SunPKCS11" +printf 'name = ssign\nlibrary = %s\n' "$MODULE" >"$WORK/pkcs11.cfg" +# The module only holds the leaf; jsign gets the full chain from a .p7b. +openssl crl2pkcs7 -nocrl -certfile "$WORK/leaf.pem" -certfile "$WORK/inter.pem" \ + -outform DER -out "$WORK/chain.p7b" +cp "$FIX/hello.exe" "$WORK/jsign.exe" +cp "$FIX/test.msi" "$WORK/jsign.msi" +for f in jsign.exe jsign.msi; do + if java -jar "$JSIGN_JAR" --storetype PKCS11 --keystore "$WORK/pkcs11.cfg" \ + --storepass "" --alias "$LABEL" --certfile "$WORK/chain.p7b" \ + --alg SHA-256 --tsaurl "$TSA" "$WORK/$f" >"$WORK/$f.log" 2>&1 \ + && verify "$WORK/$f"; then + ok "$f signed by jsign and verified" + else + ko "$f: jsign failed" + cat "$WORK/$f.log" + fi +done + +# --- 3. osslsigncode, every format ----------------------------------------- +echo "== osslsigncode through the module" +if SSIGN_PKCS11_MODULE="$MODULE" "$ROOT/ssign-pkcs11/tests/sign-all-formats.sh"; then + ok "every format signed and verified" +else + ko "sign-all-formats.sh failed" +fi + +# --- 4. ssign CLI ------------------------------------------------------------ +echo "== ssign CLI" +cp "$FIX/hello.exe" "$WORK/cli.exe" +if "$SSIGN" -n "ssign pre-sign check" "$WORK/cli.exe" >"$WORK/cli.log" 2>&1 \ + && verify "$WORK/cli.exe"; then + ok "cli.exe signed by ssign and verified" +else + ko "ssign CLI failed" + cat "$WORK/cli.log" +fi + +echo +if [[ $failures -eq 0 ]]; then + echo "pre-sign checks: all passed" +else + echo "pre-sign checks: $failures failed" + exit 1 +fi