← Week 1: HSM fundamentals + PKCS#11

Day 4: Sign and Verify with a PKCS#11 Key

Phase 5 · August 29, 2026

← Week 1: HSM fundamentals + PKCS#11

Agenda (2–3 hours)

  • Build (90 min): hsm-demo sign and hsm-demo verify subcommands
  • Test (45 min): Sign data, export public key, verify signature
  • Experiment (30 min): What breaks when you tamper with the signature?
← Week 1: HSM fundamentals + PKCS#11

The Signing Flow

Recall from Day 2: C_Sign requires a pre-initialized operation:

Data → SHA-256 hash → C_SignInit(mechanism=ECDSA) → C_Sign(digest) → signature bytes

In Rust with cryptoki:

use cryptoki::mechanism::Mechanism;
use sha2::{Digest, Sha256};

// Hash the data first (ECDSA in PKCS#11 signs a hash, not raw data)
let mut hasher = Sha256::new();
hasher.update(data.as_bytes());
let digest = hasher.finalize();

// Sign the digest
let signature = session.sign(
    &Mechanism::Ecdsa,
    priv_key_handle,
    &digest,
)?;

println!("Signature: {}", hex::encode(&signature));
← Week 1: HSM fundamentals + PKCS#11

sign Subcommand

// src/sign.rs
pub fn sign_data(
    pkcs11: &Pkcs11,
    slot: cryptoki::slot::Slot,
    pin: &str,
    label: &str,
    data: &str,
) -> anyhow::Result<Vec<u8>> {
    let session = pkcs11.open_rw_session(slot)?;
    session.login(UserType::User, Some(&AuthPin::new(pin.into())))?;

    let priv_key = find_key_by_label_and_class(
        &session, label, ObjectClass::PRIVATE_KEY
    )?;

    let mut hasher = Sha256::new();
    hasher.update(data.as_bytes());
    let digest = hasher.finalize();

    let signature = session.sign(&Mechanism::Ecdsa, priv_key, &digest)?;

    println!("Signed {} bytes", data.len());
    println!("Signature ({} bytes): {}", signature.len(), hex::encode(&signature));

    Ok(signature)
}
← Week 1: HSM fundamentals + PKCS#11

Exporting the Public Key

To verify a signature outside the HSM, you need the public key bytes.
Public keys in PKCS#11 use CKA_EC_POINT (the DER-encoded uncompressed point):

pub fn export_public_key(
    session: &Session,
    pub_handle: ObjectHandle,
) -> anyhow::Result<Vec<u8>> {
    // EC_POINT is the uncompressed public key: 04 || x || y (65 bytes for P-256)
    let attrs = session.get_attributes(pub_handle, &[AttributeType::EcPoint])?;

    for attr in attrs {
        if let Attribute::EcPoint(point) = attr {
            // SoftHSM2 wraps the point in an OCTET STRING DER header
            // Strip it: if first byte is 0x04 and length = 65, it's raw
            // If first two bytes are 0x04 0x41, strip the DER wrapper
            let raw = strip_der_octet_string_wrapper(&point);
            return Ok(raw.to_vec());
        }
    }

    Err(anyhow::anyhow!("no EC_POINT attribute found"))
}
← Week 1: HSM fundamentals + PKCS#11

verify Subcommand

Verification uses the public key and happens entirely in software:

// src/verify.rs
use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
use p256::EncodedPoint;

pub fn verify_signature(
    public_key_bytes: &[u8],  // uncompressed EC point: 04 || x || y
    data: &str,
    signature_hex: &str,
) -> anyhow::Result<()> {
    let encoded_point = EncodedPoint::from_bytes(public_key_bytes)?;
    let verifying_key = VerifyingKey::from_encoded_point(&encoded_point)?;

    let sig_bytes = hex::decode(signature_hex)?;
    let signature = Signature::from_slice(&sig_bytes)?;

    let mut hasher = Sha256::new();
    hasher.update(data.as_bytes());
    let digest = hasher.finalize();

    verifying_key.verify_digest(digest, &signature)
        .map_err(|_| anyhow::anyhow!("signature verification failed"))?;

    println!("Signature verified OK");
    Ok(())
}

Note: add p256 = "0.13" to Cargo.toml for the p256 crate.

← Week 1: HSM fundamentals + PKCS#11

The CA Signing Analogy

What you're building in hsm-demo is a simplified version of what ACM PCA does:

ACM PCA:
  1. Receives a CSR from your provisioning service
  2. Parses the CSR (extracts the public key and subject)
  3. Constructs a TBSCertificate
  4. SHA-256 hashes the TBSCertificate
  5. Calls the HSM: C_Sign(digest) → signature bytes
  6. Assembles the certificate: TBSCertificate + signature
  7. Returns the DER-encoded certificate

Your hsm-demo:
  1. Takes arbitrary data from the CLI
  2. SHA-256 hashes it
  3. Calls SoftHSM2: session.sign(digest) → signature bytes
  4. Returns the hex-encoded signature

The HSM never knows it's signing a certificate vs. arbitrary data.
It just signs the bytes you give it.

← Week 1: HSM fundamentals + PKCS#11

Experiment: What Breaks?

After completing both subcommands, run these experiments:

Experiment 1: Tampered data

# Sign "hello world"
cargo run -- sign --label my-ca-key --data "hello world"
# Verify against "hello world" → should succeed

# Verify against "hello worlD" → should fail
cargo run -- verify --label my-ca-key --data "hello worlD" --sig <hex>

Experiment 2: Tampered signature

# Flip one byte in the signature hex and try to verify
# Expected: "signature verification failed"

Experiment 3: Wrong key

cargo run -- generate-key --label another-key
# Verify with another-key's public key → should fail
← Week 1: HSM fundamentals + PKCS#11

Challenge Assignment

Complete both subcommands end-to-end:

  1. hsm-demo sign --label ca-key-01 --data "test payload" — prints hex signature
  2. hsm-demo verify --label ca-key-01 --data "test payload" --sig <hex> — prints "OK" or error

Requirements:

  • Sign must use the private key from SoftHSM2 (via cryptoki)
  • Verify must use the public key extracted from SoftHSM2 (not hardcoded)
  • Both experiments from the previous slide must work as expected

Stretch goal: save the signature to a file (--out sig.bin) and public key to a file
(--pubkey-out pub.bin) so they can be used in separate processes.

← Week 1: HSM fundamentals + PKCS#11

Resources

  • cryptoki sign example: github.com/parallaxsecond/rust-cryptoki/blob/main/cryptoki/tests/basic.rs
  • p256 crate ECDSA: docs.rs/p256 → ecdsa module
  • EC point encoding: SEC 1 spec §2.3.3 (uncompressed = 04 || x || y)
  • ECDSA signature encoding: PKCS#11 returns raw r || s (not DER-encoded); p256::Signature::from_slice handles this