Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/lib/src/pkcs11_signer.rs

//! ★ **The evidence signer in a hardware token** (lane HOL-NCSC, NCSC P3, feature `pkcs11`).
//!
//! `attestation.signer_hsm: Some(Pkcs11(module, token_label, key_label, pin_file))`: the
//! module is loaded at runtime, the token found by its label, the user logs in with the PIN in
//! `pin_file` (a key file, [`crate::keyfile`]) and the private key with `key_label` signs; the
//! key never leaves the token. It is a znippy [`ArchiveSigner`] for DSSE (the evidence and the
//! audit checkpoints, `sign_raw`): ECDSA P-256 (`CKM_ECDSA` over the SHA-256 of the message,
//! the token's `r || s` returned as the DER `ECDSA-Sig-Value` znippy verifies) or Ed25519
//! (`CKM_EDDSA`). CMS (`sign_digest_with_attrs`) is not offered: the attestation signer signs
//! DSSE only. Every refusal is `holger-key-2`, by name.

use std::path::Path;
use std::sync::Mutex;

use cryptoki::context::{CInitializeArgs, CInitializeFlags, Pkcs11};
use cryptoki::mechanism::eddsa::{EddsaParams, EddsaSignatureScheme};
use cryptoki::mechanism::Mechanism;
use cryptoki::object::{Attribute, ObjectClass, ObjectHandle};
use cryptoki::session::{Session, UserType};
use cryptoki::types::AuthPin;
use znippy_common::sign::{ArchiveSigner, SigAlg};

fn refused(why: impl std::fmt::Display) -> String {
    holger_errcode::KEY_HSM_REFUSED.tag(why)
}

/// One logged-in session on one key.
pub struct Pkcs11Signer {
    alg: SigAlg,
    cert_der: Vec<u8>,
    key: ObjectHandle,
    session: Mutex<Session>,
    // Kept alive as long as the session: the module stays loaded.
    _ctx: Pkcs11,
}

impl Pkcs11Signer {
    /// Load `module`, log into the token `token_label` with the PIN in `pin_file`, find the one
    /// private key `key_label`.
    pub fn open(
        module: &str,
        token_label: &str,
        key_label: &str,
        pin_file: &Path,
        alg: SigAlg,
        cert_der: Vec<u8>,
    ) -> Result<Self, String> {
        let pin = crate::keyfile::read_private(pin_file, "attestation.signer_hsm pin_file")
            .map_err(|e| e.to_string())?;
        let pin = String::from_utf8(pin)
            .map_err(|_| refused(format!("the PIN in {} is not text", pin_file.display())))?;
        let ctx = Pkcs11::new(module)
            .map_err(|e| refused(format!("the PKCS#11 module {module} does not load: {e}")))?;
        match ctx.initialize(CInitializeArgs::new(CInitializeFlags::OS_LOCKING_OK)) {
            // A module this process loaded before (another signer, a re-read config) is one.
            Ok(())
            | Err(cryptoki::error::Error::Pkcs11(
                cryptoki::error::RvError::CryptokiAlreadyInitialized,
                _,
            )) => {}
            Err(e) => return Err(refused(format!("{module}: C_Initialize: {e}"))),
        }
        let slots = ctx
            .get_slots_with_token()
            .map_err(|e| refused(format!("{module}: the slots: {e}")))?;
        let slot = slots
            .into_iter()
            .find(|s| {
                ctx.get_token_info(*s)
                    .is_ok_and(|t| t.label().trim() == token_label)
            })
            .ok_or_else(|| refused(format!("{module} has no token labelled {token_label:?}")))?;
        let session = ctx
            .open_ro_session(slot)
            .map_err(|e| refused(format!("token {token_label:?}: a session: {e}")))?;
        match session.login(
            UserType::User,
            Some(&AuthPin::new(pin.trim().to_owned().into())),
        ) {
            // The login is the token's per process: a second session of this process is in.
            Ok(())
            | Err(cryptoki::error::Error::Pkcs11(
                cryptoki::error::RvError::UserAlreadyLoggedIn,
                _,
            )) => {}
            Err(e) => {
                return Err(refused(format!(
                    "token {token_label:?}: the PIN from {} is refused: {e}",
                    pin_file.display()
                )))
            }
        }
        let keys = session
            .find_objects(&[
                Attribute::Class(ObjectClass::PRIVATE_KEY),
                Attribute::Label(key_label.as_bytes().to_vec()),
            ])
            .map_err(|e| refused(format!("token {token_label:?}: finding {key_label:?}: {e}")))?;
        let key = match keys.as_slice() {
            [one] => *one,
            [] => {
                return Err(refused(format!(
                    "token {token_label:?} holds no private key labelled {key_label:?}"
                )))
            }
            many => {
                return Err(refused(format!(
                    "token {token_label:?} holds {} private keys labelled {key_label:?}; name one",
                    many.len()
                )))
            }
        };
        Ok(Self {
            alg,
            cert_der,
            key,
            session: Mutex::new(session),
            _ctx: ctx,
        })
    }
}

/// Pure: the DER `ECDSA-Sig-Value` of a token's `r || s` (each half big-endian, equal length).
pub fn ecdsa_der(raw: &[u8]) -> Result<Vec<u8>, String> {
    if raw.is_empty() || !raw.len().is_multiple_of(2) {
        return Err(format!(
            "the token's ECDSA signature is {} bytes, not r || s",
            raw.len()
        ));
    }
    let int = |b: &[u8]| -> Vec<u8> {
        let b = &b[b.iter().position(|x| *x != 0).unwrap_or(b.len() - 1)..];
        let mut v = Vec::with_capacity(b.len() + 3);
        v.push(0x02);
        let pad = b[0] & 0x80 != 0;
        v.push((b.len() + usize::from(pad)) as u8);
        if pad {
            v.push(0);
        }
        v.extend_from_slice(b);
        v
    };
    let (r, s) = raw.split_at(raw.len() / 2);
    let body = [int(r), int(s)].concat();
    let mut out = vec![0x30];
    if body.len() < 0x80 {
        out.push(body.len() as u8);
    } else {
        out.extend([0x81, body.len() as u8]);
    }
    out.extend(body);
    Ok(out)
}

impl ArchiveSigner for Pkcs11Signer {
    fn algorithm(&self) -> SigAlg {
        self.alg
    }

    fn certificate_der(&self) -> &[u8] {
        &self.cert_der
    }

    fn sign_digest_with_attrs(
        &self,
        _digest: &[u8; 32],
        _extra: &[x509_cert::attr::Attribute],
    ) -> anyhow::Result<Vec<u8>> {
        anyhow::bail!(
            "{}",
            refused("the PKCS#11 evidence signer signs DSSE only (no CMS)")
        )
    }

    fn sign_raw(&self, msg: &[u8]) -> anyhow::Result<Vec<u8>> {
        use sha2::Digest as _;
        let session = crate::lock::LockOrRecover::lock_or_recover(&self.session);
        match self.alg {
            SigAlg::EcdsaP256 => {
                let digest = sha2::Sha256::digest(msg);
                let raw = session
                    .sign(&Mechanism::Ecdsa, self.key, &digest)
                    .map_err(|e| {
                        anyhow::anyhow!("{}", refused(format!("the token did not sign: {e}")))
                    })?;
                ecdsa_der(&raw).map_err(|e| anyhow::anyhow!("{}", refused(e)))
            }
            SigAlg::Ed25519 => session
                .sign(
                    &Mechanism::Eddsa(EddsaParams::new(EddsaSignatureScheme::Pure)),
                    self.key,
                    msg,
                )
                .map_err(|e| {
                    anyhow::anyhow!("{}", refused(format!("the token did not sign: {e}")))
                }),
        }
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn a_tokens_r_and_s_become_one_der_signature() {
        let mut raw = vec![0u8; 64];
        raw[0] = 0x80; // r needs a pad byte
        raw[31] = 1;
        raw[63] = 7; // s is 7
        let der = super::ecdsa_der(&raw).unwrap();
        assert_eq!(&der[..4], &[0x30, 2 + 33 + 3, 0x02, 33]);
        assert_eq!(der[4], 0, "the pad");
        assert_eq!(
            &der[der.len() - 3..],
            &[0x02, 1, 7],
            "leading zeros dropped"
        );
        assert!(super::ecdsa_der(&[1, 2, 3]).is_err());
    }
}