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());
}
}