cellos-core 0.8.0-pre

CellOS domain types and ports — typed authority, formation DAG, CloudEvent envelopes, RBAC primitives. No I/O.
Documentation
//! S34 / C13: PKCS#11 hardware-signer custody.
//!
//! [`Pkcs11Signer`] implements the [`Signer`](crate::trust_keys::Signer) seam
//! (ADR-0031, S32) over a PKCS#11 token: the Ed25519 private key never leaves
//! the module — `sign` performs `C_Sign` (CKM_EDDSA) inside the token and only
//! the 64-byte signature crosses the boundary. Gated behind `--features pkcs11`
//! so the default build gains no native dependency.
//!
//! **RESIDUAL (non-CMVP).** This proves *key custody* — the signing key lives in
//! hardware. It does NOT by itself make the signature FIPS-validated: that
//! depends on the token's own CMVP status (operator-procured, S38), and the
//! verify path remains whatever [`crate::crypto::provider`] resolves to (only
//! validated under `--features fips`). A green PKCS#11 wiring test against
//! SoftHSMv2 is NOT a FIPS attestation.

use crate::crypto::TrustAnchorPublicKey;
use crate::error::CellosError;
use crate::trust_keys::Signer;
use cryptoki::context::{CInitializeArgs, Pkcs11};
use cryptoki::mechanism::Mechanism;
use cryptoki::object::{Attribute, AttributeType, ObjectClass, ObjectHandle};
use cryptoki::session::{Session, UserType};
use cryptoki::slot::Slot;
use cryptoki::types::AuthPin;
use std::path::Path;
use zeroize::Zeroizing;

/// Env: filesystem path to the operator's PKCS#11 module (`.so`/`.dll`).
pub const PKCS11_MODULE_ENV: &str = "CELLOS_PKCS11_MODULE";
/// Env: the token label to select among the module's slots.
pub const PKCS11_TOKEN_LABEL_ENV: &str = "CELLOS_PKCS11_TOKEN_LABEL";
/// Env: the `CKA_LABEL` of the Ed25519 key pair to sign with.
pub const PKCS11_KEY_LABEL_ENV: &str = "CELLOS_PKCS11_KEY_LABEL";
/// Env: the user PIN for `C_Login`.
pub const PKCS11_PIN_ENV: &str = "CELLOS_PKCS11_PIN";

/// A [`Signer`] backed by a PKCS#11 token. The private key is non-extractable;
/// the public key is read once at construction and cached.
pub struct Pkcs11Signer {
    kid: String,
    ctx: Pkcs11,
    slot: Slot,
    key_label: Vec<u8>,
    pin: Zeroizing<String>,
    public: TrustAnchorPublicKey,
}

fn err(stage: &str, e: impl std::fmt::Display) -> CellosError {
    CellosError::InvalidSpec(format!("pkcs11 {stage}: {e}"))
}

impl Pkcs11Signer {
    /// Open a signer over `module_path`, selecting the slot whose token label is
    /// `token_label`, signing with the Ed25519 key labeled `key_label`.
    ///
    /// # Errors
    ///
    /// Returns [`CellosError`] if the module cannot be loaded/initialized, the
    /// token/key cannot be found, login fails, or the public key is malformed.
    pub fn open(
        kid: impl Into<String>,
        module_path: &Path,
        token_label: &str,
        key_label: &str,
        pin: &str,
    ) -> Result<Self, CellosError> {
        let ctx = Pkcs11::new(module_path).map_err(|e| err("load module", e))?;
        ctx.initialize(CInitializeArgs::OsThreads)
            .map_err(|e| err("initialize", e))?;
        let slot = find_slot(&ctx, token_label)?;
        let key_label = key_label.as_bytes().to_vec();
        let pin = Zeroizing::new(pin.to_string());

        // Read + cache the public key once.
        let public = {
            let session = open_login(&ctx, slot, &pin)?;
            let pub_handle = find_one(&session, ObjectClass::PUBLIC_KEY, &key_label)?;
            let point = ec_point(&session, pub_handle)?;
            TrustAnchorPublicKey::from_bytes_unchecked(parse_ed25519_point(&point)?)
        };

        Ok(Self {
            kid: kid.into(),
            ctx,
            slot,
            key_label,
            pin,
            public,
        })
    }
}

// NOTE: cellos-core is env-pure (doctrine D11) — there is deliberately no
// `from_env` here. The `PKCS11_*_ENV` names above are the *convention* a caller
// (a binary / integration layer) reads; it then calls [`Pkcs11Signer::open`]
// with explicit parameters. See `tests/pkcs11_softhsm.rs`.

impl Signer for Pkcs11Signer {
    fn kid(&self) -> &str {
        &self.kid
    }

    fn sign(&self, message: &[u8]) -> Result<[u8; 64], CellosError> {
        let session = open_login(&self.ctx, self.slot, &self.pin)?;
        let priv_handle = find_one(&session, ObjectClass::PRIVATE_KEY, &self.key_label)?;
        let sig = session
            .sign(&Mechanism::Eddsa, priv_handle, message)
            .map_err(|e| err("sign", e))?;
        sig.as_slice().try_into().map_err(|_| {
            CellosError::InvalidSpec(format!(
                "pkcs11 sign: expected a 64-byte Ed25519 signature, got {}",
                sig.len()
            ))
        })
    }

    fn verifying_key(&self) -> TrustAnchorPublicKey {
        self.public
    }
}

fn find_slot(ctx: &Pkcs11, token_label: &str) -> Result<Slot, CellosError> {
    let slots = ctx
        .get_slots_with_token()
        .map_err(|e| err("list slots", e))?;
    for slot in slots {
        if let Ok(info) = ctx.get_token_info(slot) {
            if info.label().trim() == token_label {
                return Ok(slot);
            }
        }
    }
    Err(CellosError::InvalidSpec(format!(
        "pkcs11: no token with label {token_label:?}"
    )))
}

fn open_login(ctx: &Pkcs11, slot: Slot, pin: &str) -> Result<Session, CellosError> {
    let session = ctx
        .open_rw_session(slot)
        .map_err(|e| err("open session", e))?;
    session
        .login(UserType::User, Some(&AuthPin::new(pin.to_string())))
        .map_err(|e| err("login", e))?;
    Ok(session)
}

fn find_one(
    session: &Session,
    class: ObjectClass,
    label: &[u8],
) -> Result<ObjectHandle, CellosError> {
    let template = vec![Attribute::Class(class), Attribute::Label(label.to_vec())];
    let handles = session
        .find_objects(&template)
        .map_err(|e| err("find object", e))?;
    handles
        .into_iter()
        .next()
        .ok_or_else(|| CellosError::InvalidSpec("pkcs11: object not found for label".into()))
}

fn ec_point(session: &Session, handle: ObjectHandle) -> Result<Vec<u8>, CellosError> {
    let attrs = session
        .get_attributes(handle, &[AttributeType::EcPoint])
        .map_err(|e| err("read EC point", e))?;
    for a in attrs {
        if let Attribute::EcPoint(bytes) = a {
            return Ok(bytes);
        }
    }
    Err(CellosError::InvalidSpec(
        "pkcs11: public key has no EC point".into(),
    ))
}

/// `CKA_EC_POINT` for an Edwards key is a DER `OCTET STRING` wrapping the raw
/// 32-byte point (`04 20 <32 bytes>`); some tokens return the raw 32 bytes.
/// Accept both.
fn parse_ed25519_point(point: &[u8]) -> Result<[u8; 32], CellosError> {
    let raw: &[u8] = if point.len() == 34 && point[0] == 0x04 && point[1] == 0x20 {
        &point[2..]
    } else if point.len() == 32 {
        point
    } else {
        return Err(CellosError::InvalidSpec(format!(
            "pkcs11: unexpected EC point length {} (want 32 raw or 34 DER-wrapped)",
            point.len()
        )));
    };
    raw.try_into()
        .map_err(|_| CellosError::InvalidSpec("pkcs11: EC point is not 32 bytes".into()))
}

// Pure unit tests for the token-encoding parser (no token, no env — D11-safe).
#[cfg(test)]
mod tests {
    use super::parse_ed25519_point;

    #[test]
    fn raw_32_byte_point_is_accepted_verbatim() {
        let raw = [7u8; 32];
        assert_eq!(parse_ed25519_point(&raw).unwrap(), raw);
    }

    #[test]
    fn der_wrapped_point_is_unwrapped() {
        // CKA_EC_POINT DER OCTET STRING: 0x04 0x20 <32 bytes>.
        let mut der = vec![0x04, 0x20];
        der.extend_from_slice(&[9u8; 32]);
        assert_eq!(parse_ed25519_point(&der).unwrap(), [9u8; 32]);
    }

    #[test]
    fn der_prefix_must_be_exact() {
        // 34 bytes but wrong DER prefix (0x03 not 0x04) -> not 32, rejected.
        let mut bad = vec![0x03, 0x20];
        bad.extend_from_slice(&[1u8; 32]);
        assert!(parse_ed25519_point(&bad).is_err());
        // Right tag, wrong length byte.
        let mut badlen = vec![0x04, 0x21];
        badlen.extend_from_slice(&[1u8; 32]);
        assert!(parse_ed25519_point(&badlen).is_err());
    }

    #[test]
    fn wrong_lengths_are_rejected_without_panic() {
        for n in [0usize, 1, 31, 33, 35, 64] {
            assert!(
                parse_ed25519_point(&vec![0u8; n]).is_err(),
                "len {n} must be rejected, not panic"
            );
        }
    }
}