From bf97d83d784c6ec12ae10413c26d6930c8e2e5f7 Mon Sep 17 00:00:00 2001 From: Arthur Gautier Date: Fri, 14 Jun 2024 20:05:14 -0700 Subject: [PATCH] attestation: adds support in mockhsm --- Cargo.lock | 1 + Cargo.toml | 14 +- src/attestation.rs | 9 ++ src/attestation/pkix.rs | 271 ++++++++++++++++++++++++++++++++++ src/device/serial.rs | 2 +- src/mockhsm/command.rs | 162 ++++++++++++++++++++ src/mockhsm/object/objects.rs | 117 ++++++++++++++- tests/command/mod.rs | 1 - 8 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 src/attestation/pkix.rs diff --git a/Cargo.lock b/Cargo.lock index e4de2e81..aaa4aff3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -984,6 +984,7 @@ dependencies = [ "cbc", "ccm", "cmac", + "der", "digest", "ecdsa", "ed25519", diff --git a/Cargo.toml b/Cargo.toml index 87dd59ae..619734fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ bitflags = "2" cmac = "0.8.0-rc.3" cbc = "0.2.0-rc.2" ccm = { version = "0.6.0-rc.2" } +der = { version = "0.8.0-rc.10" } digest = { version = "0.11.0-rc.0", default-features = false } ecdsa = { version = "0.17.0-rc.9", default-features = false, features = ["pkcs8"] } ed25519 = "3.0.0-rc.2" @@ -51,6 +52,7 @@ pbkdf2 = { version = "0.13.0-rc.2", optional = true, default-features = false, f serde_json = { version = "1", optional = true } rusb = { version = "0.9.4", optional = true } tiny_http = { version = "0.12", optional = true } +x509-cert = { version = "0.3.0-rc.1", features = ["builder", "hazmat"], optional = true } [dev-dependencies] ed25519-dalek = "=3.0.0-pre.2" @@ -66,7 +68,17 @@ x509-cert = { version = "0.3.0-rc.2", features = ["builder"] } default = ["http", "passwords", "setup"] http-server = ["tiny_http"] http = [] -mockhsm = ["ecdsa/algorithm", "ed25519-dalek", "p256/ecdsa", "p384/pkcs8", "secp256k1"] +mockhsm = [ + "ecdsa/algorithm", + "ed25519-dalek", + "p256/ecdsa", + "p256/pkcs8", + "p384/pkcs8", + "p521/pkcs8", + "rsa/sha2", + "secp256k1", + "x509-cert" +] passwords = ["hmac", "pbkdf2"] secp256k1 = ["k256"] setup = ["passwords", "serde_json", "uuid/serde"] diff --git a/src/attestation.rs b/src/attestation.rs index a74a427f..f204e2be 100644 --- a/src/attestation.rs +++ b/src/attestation.rs @@ -1,7 +1,16 @@ //! Attestation Certificates: generate an X.509 certificate which attests that //! a key generated with a YubiHSM is genuine +use crate::object; + mod certificate; pub(crate) mod commands; +#[cfg(feature = "mockhsm")] +mod pkix; pub use self::certificate::Certificate; +#[cfg(feature = "mockhsm")] +pub use self::pkix::*; + +/// Default attestation key ID slot +pub const DEFAULT_ATTESTATION_KEY_ID: object::Id = 0; diff --git a/src/attestation/pkix.rs b/src/attestation/pkix.rs new file mode 100644 index 00000000..6dce39be --- /dev/null +++ b/src/attestation/pkix.rs @@ -0,0 +1,271 @@ +#![allow(missing_docs)] +//! Yubico extensions for attestation of asymmetric keys in the YubiHSM. + +use std::string::FromUtf8Error; + +use der::{ + self, + asn1::{BitString, OctetString}, + oid::AssociatedOid, + Error, Sequence, +}; +use spki::ObjectIdentifier; +use x509_cert::{ + ext::{AsExtension, Extension}, + name::Name, +}; + +use crate::{capability, device, domain, object}; + +pub const YUBICO_FIRMWARE_VERSION: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.3.6.1.4.1.41482.4.1"); +pub const YUBICO_SERIAL_NUMBER: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.3.6.1.4.1.41482.4.2"); +pub const YUBICO_ORIGIN: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.4.1.41482.4.3"); +pub const YUBICO_DOMAIN: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.4.1.41482.4.4"); +pub const YUBICO_CAPABILITY: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.3.6.1.4.1.41482.4.5"); +pub const YUBICO_OBJECT_ID: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.3.6.1.4.1.41482.4.6"); +pub const YUBICO_LABEL: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.4.1.41482.4.9"); + +/// Firmware version of the YubiHSM. +#[derive(Clone, Debug, Eq, PartialEq, Sequence)] +pub struct FirmwareVersion { + pub fw_version: OctetString, +} + +impl TryFrom<&device::Info> for FirmwareVersion { + type Error = Error; + + fn try_from(info: &device::Info) -> Result { + let fw_version = OctetString::new(vec![ + info.major_version, + info.minor_version, + info.build_version, + ])?; + + Ok(Self { fw_version }) + } +} + +impl AssociatedOid for FirmwareVersion { + const OID: ObjectIdentifier = YUBICO_FIRMWARE_VERSION; +} + +impl AsExtension for FirmwareVersion { + fn critical(&self, _subject: &Name, _extensions: &[Extension]) -> bool { + false + } +} + +/// Serial number of the YubiHSM. +#[derive(Clone, Debug, Eq, PartialEq, Sequence)] +pub struct Serial { + pub serial: u32, +} + +impl From<&device::Info> for Serial { + fn from(info: &device::Info) -> Self { + let serial = info.serial_number.0; + Self { serial } + } +} + +impl AssociatedOid for Serial { + const OID: ObjectIdentifier = YUBICO_SERIAL_NUMBER; +} + +impl AsExtension for Serial { + fn critical(&self, _subject: &Name, _extensions: &[Extension]) -> bool { + false + } +} + +/// Origin of the object on the YubiHSM. +#[derive(Clone, Debug, Eq, PartialEq, Sequence)] +pub struct Origin { + pub origin: BitString, +} + +impl TryFrom for Origin { + type Error = Error; + fn try_from(origin: object::Origin) -> Result { + let origin = BitString::new(0, vec![origin.to_u8()])?; + Ok(Self { origin }) + } +} + +impl AssociatedOid for Origin { + const OID: ObjectIdentifier = YUBICO_ORIGIN; +} + +impl AsExtension for Origin { + fn critical(&self, _subject: &Name, _extensions: &[Extension]) -> bool { + false + } +} + +/// Domain of the object on the YubiHSM. +#[derive(Clone, Debug, Eq, PartialEq, Sequence)] +pub struct Domain { + pub domain: BitString, +} + +impl TryFrom for Domain { + type Error = Error; + fn try_from(domain: domain::Domain) -> Result { + let domain = BitString::new(0, domain.bits().to_be_bytes())?; + Ok(Self { domain }) + } +} + +impl AssociatedOid for Domain { + const OID: ObjectIdentifier = YUBICO_DOMAIN; +} + +impl AsExtension for Domain { + fn critical(&self, _subject: &Name, _extensions: &[Extension]) -> bool { + false + } +} + +/// Capability of the object on the YubiHSM. +#[derive(Clone, Debug, Eq, PartialEq, Sequence)] +pub struct Capability { + pub capability: BitString, +} + +impl TryFrom for Capability { + type Error = Error; + fn try_from(cap: capability::Capability) -> Result { + let capability = BitString::new(0, cap.bits().to_be_bytes())?; + Ok(Self { capability }) + } +} + +impl AssociatedOid for Capability { + const OID: ObjectIdentifier = YUBICO_CAPABILITY; +} + +impl AsExtension for Capability { + fn critical(&self, _subject: &Name, _extensions: &[Extension]) -> bool { + false + } +} + +/// ID of the object on the YubiHSM. +#[derive(Clone, Debug, Eq, PartialEq, Sequence)] +pub struct ObjectId { + pub id: u16, +} + +impl AssociatedOid for ObjectId { + const OID: ObjectIdentifier = YUBICO_OBJECT_ID; +} + +impl AsExtension for ObjectId { + fn critical(&self, _subject: &Name, _extensions: &[Extension]) -> bool { + false + } +} + +/// Label of the object on the YubiHSM. +#[derive(Clone, Debug, Eq, PartialEq, Sequence)] +pub struct Label { + pub label: String, +} + +impl TryFrom<&object::Label> for Label { + type Error = FromUtf8Error; + fn try_from(label: &object::Label) -> Result { + let label = String::from_utf8(label.0.to_vec())? + .trim_end_matches('\0') + .to_string(); + Ok(Self { label }) + } +} + +impl AssociatedOid for Label { + const OID: ObjectIdentifier = YUBICO_LABEL; +} + +impl AsExtension for Label { + fn critical(&self, _subject: &Name, _extensions: &[Extension]) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use der::Encode; + use hex_literal::hex; + + use super::*; + use crate::device; + + #[test] + fn test_serialize_ext() { + let info = device::Info { + major_version: 2, + minor_version: 2, + build_version: 0, + serial_number: device::SerialNumber::from_str("0018952406").unwrap(), + log_store_capacity: 0, + log_store_used: 0, + algorithms: vec![], + }; + + let fwv = FirmwareVersion::try_from(&info).unwrap(); + + assert_eq!( + fwv.fw_version.to_der().unwrap(), + vec![0x04u8, 0x03, 0x02, 0x02, 0x00] + ); + + let serial = Serial::from(&info); + + assert_eq!( + serial.to_der().unwrap(), + vec![0x30u8, 0x06, 0x02, 0x04, 0x01, 0x21, 0x30, 0xd6] + ); + + let origin = object::Origin::Generated; + let origin = Origin::try_from(origin).unwrap(); + + assert_eq!( + origin.origin.to_der().unwrap(), + vec![0x03u8, 0x02, 0x00, 0x01] + ); + + let domain = domain::Domain::DOM1; + let domain = Domain::try_from(domain).unwrap(); + + assert_eq!( + domain.domain.to_der().unwrap(), + vec![0x03u8, 0x03, 0x00, 0x00, 0x01] + ); + + let cap = capability::Capability::DECRYPT_OAEP; + let cap = Capability::try_from(cap).unwrap(); + + assert_eq!( + cap.capability.to_der().unwrap(), + vec![0x03u8, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00] + ); + + let id = ObjectId { id: 0x0f }; + + assert_eq!(id.id.to_der().unwrap(), vec![0x02u8, 0x01, 0x0f]); + + let label = object::Label::from_str("management: local import").unwrap(); + let label = Label::try_from(&label).unwrap(); + + assert_eq!( + label.label.to_der().unwrap(), + hex!("0C186D616E6167656D656E743A206C6F63616C20696D706F7274") + ); + } +} diff --git a/src/device/serial.rs b/src/device/serial.rs index c5d901b7..a5be23ab 100644 --- a/src/device/serial.rs +++ b/src/device/serial.rs @@ -12,7 +12,7 @@ const NUM_DIGITS: usize = 10; /// YubiHSM serial numbers #[derive(Copy, Clone, Debug, Deserialize, Eq, Hash, PartialEq, PartialOrd, Ord, Serialize)] -pub struct Number(u32); +pub struct Number(pub(crate) u32); impl Display for Number { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { diff --git a/src/mockhsm/command.rs b/src/mockhsm/command.rs index ad6d2978..45de21d0 100644 --- a/src/mockhsm/command.rs +++ b/src/mockhsm/command.rs @@ -4,6 +4,7 @@ use super::{object::Payload, state::State, MOCK_SERIAL_NUMBER}; use crate::{ algorithm::*, asymmetric::{self, commands::*, PublicKey}, + attestation::{self, commands::*}, audit::{commands::*, AuditCommand, AuditOption, AuditTag}, authentication::{self, commands::*}, command::{Code, Message}, @@ -42,8 +43,17 @@ use signature::{ hazmat::{PrehashSigner, RandomizedPrehashSigner}, Signer, }; +use spki::{der::Encode, SubjectPublicKeyInfoOwned, SubjectPublicKeyInfoRef}; use std::{io::Cursor, str::FromStr}; use subtle::ConstantTimeEq; +use x509_cert::{ + builder::{self, profile, Builder, CertificateBuilder}, + ext::{AsExtension, Extension}, + name::Name, + serial_number, + time::Validity, + TbsCertificate, +}; /// Create a new HSM session pub(crate) fn create_session( @@ -132,6 +142,7 @@ pub(crate) fn session_message( Code::SignPss => sign_pss(state, &command.data), Code::SignPkcs1 => sign_pkcs1v15(state, &command.data), Code::DecryptOaep => decrypt_oaep(state, &command.data), + Code::SignAttestationCertificate => sign_attestation_certificate(state, &command.data), unsupported => panic!("unsupported command type: {unsupported:?}"), }; @@ -962,3 +973,154 @@ fn decrypt_oaep(state: &State, cmd_data: &[u8]) -> response::Message { device::ErrorKind::ObjectNotFound.into() } } + +struct AttestationProfile { + device: device::Info, + target: object::Info, +} + +impl profile::BuilderProfile for AttestationProfile { + fn get_issuer(&self, subject: &Name) -> Name { + subject.clone() + } + fn get_subject(&self) -> Name { + Name::from_str(&format!( + "CN=YubiHSM Attestation id:0x{:04x}", + self.target.object_id + )) + .unwrap() + } + + #[allow(clippy::vec_init_then_push)] // clippy suggestion is incorrect as we reference the + // extension in the call to `to_extension`. + fn build_extensions( + &self, + _spk: SubjectPublicKeyInfoRef<'_>, + _issuer_spk: SubjectPublicKeyInfoRef<'_>, + tbs: &TbsCertificate, + ) -> builder::Result> { + let mut extensions = vec![]; + + extensions.push( + attestation::FirmwareVersion::try_from(&self.device) + .unwrap() + .to_extension(tbs.subject(), &extensions)?, + ); + extensions.push( + attestation::Serial::from(&self.device).to_extension(tbs.subject(), &extensions)?, + ); + extensions.push( + attestation::Origin::try_from(self.target.origin) + .unwrap() + .to_extension(tbs.subject(), &extensions)?, + ); + extensions.push( + attestation::Domain::try_from(self.target.domains) + .unwrap() + .to_extension(tbs.subject(), &extensions)?, + ); + extensions.push( + attestation::Capability::try_from(self.target.capabilities) + .unwrap() + .to_extension(tbs.subject(), &extensions)?, + ); + extensions.push( + attestation::ObjectId { + id: self.target.object_id, + } + .to_extension(tbs.subject(), &extensions)?, + ); + extensions.push( + attestation::Label::try_from(&self.target.label) + .unwrap() + .to_extension(tbs.subject(), &extensions)?, + ); + + Ok(extensions) + } +} + +fn sign_attestation_certificate(state: &State, cmd_data: &[u8]) -> response::Message { + let command: SignAttestationCertificateCommand = deserialize(cmd_data) + .unwrap_or_else(|e| panic!("error parsing Code::SignAttestationCertificateCommand: {e:?}")); + + if let Some(target) = state + .objects + .get(command.key_id, object::Type::AsymmetricKey) + { + let mut rng = rand::rng(); + let serial_number = serial_number::SerialNumber::generate(&mut rng); + let validity = Validity::infinity().unwrap(); + let pub_key = match &target.payload { + Payload::RsaKey(private_key) => { + SubjectPublicKeyInfoOwned::from_key(&private_key.to_public_key()).unwrap() + } + Payload::EcdsaNistP256(secret_key) => { + SubjectPublicKeyInfoOwned::from_key(&secret_key.public_key()).unwrap() + } + Payload::EcdsaNistP384(secret_key) => { + SubjectPublicKeyInfoOwned::from_key(&secret_key.public_key()).unwrap() + } + Payload::EcdsaNistP521(secret_key) => { + SubjectPublicKeyInfoOwned::from_key(&secret_key.public_key()).unwrap() + } + _ => todo!(), + }; + + let profile = AttestationProfile { + device: device::Info { + major_version: 2, + minor_version: 2, + build_version: 0, + serial_number: SerialNumber::from_str("0000000042").unwrap(), + log_store_capacity: 0, + log_store_used: 0, + algorithms: vec![], + }, + + target: target.object_info.clone(), + }; + let builder = CertificateBuilder::new(profile, serial_number, validity, pub_key) + .expect("Create certificate builder"); + + let cert = match state + .objects + .get(command.attestation_key_id, object::Type::AsymmetricKey) + .map(|k| &k.payload) + { + None => todo!("object not found"), + Some(Payload::RsaKey(private_key)) => { + // https://docs.yubico.com/hardware/yubihsm-2/hsm-2-user-guide/hsm2-core-concepts.html#attestation + // Signer is SHA256-PKCS#1v1.5 + let signer = pkcs1v15::SigningKey::::new(private_key.clone()); + builder.build(&signer).unwrap() + } + Some(Payload::EcdsaNistP256(secret_key)) => { + let signer = p256::ecdsa::SigningKey::from(secret_key); + builder + .build::<_, p256::ecdsa::DerSignature>(&signer) + .unwrap() + } + Some(Payload::EcdsaNistP384(secret_key)) => { + let signer = p384::ecdsa::SigningKey::from(secret_key); + builder + .build::<_, p384::ecdsa::DerSignature>(&signer) + .unwrap() + } + Some(Payload::EcdsaNistP521(secret_key)) => { + let signer = p521::ecdsa::SigningKey::from(secret_key); + builder + .build::<_, p521::ecdsa::DerSignature>(&signer) + .unwrap() + } + _ => todo!(), + }; + + let certificate = attestation::Certificate(cert.to_der().unwrap()); + + certificate.serialize() + } else { + debug!("no such object ID: {:?}", command.key_id); + device::ErrorKind::ObjectNotFound.into() + } +} diff --git a/src/mockhsm/object/objects.rs b/src/mockhsm/object/objects.rs index 70518d4f..2071c1e4 100644 --- a/src/mockhsm/object/objects.rs +++ b/src/mockhsm/object/objects.rs @@ -2,15 +2,34 @@ use super::{Object, Payload, WrappedObject, DEFAULT_AUTHENTICATION_KEY_LABEL}; use crate::{ + asymmetric, + attestation::DEFAULT_ATTESTATION_KEY_ID, authentication::{self, DEFAULT_AUTHENTICATION_KEY_ID}, mockhsm::{Error, ErrorKind}, object::{Handle, Id, Info, Label, Origin, Type}, + opaque, serialization::{deserialize, serialize}, wrap, Algorithm, Capability, Domain, }; use aes::cipher::consts::{U13, U16}; use ccm::aead::{AeadInOut, KeyInit}; -use std::collections::{btree_map::Iter as MapIter, BTreeMap as Map}; +use der::{ + asn1::{GeneralizedTime, UtcTime}, + DateTime, Encode, +}; +use spki::{SubjectPublicKeyInfoOwned, SubjectPublicKeyInfoRef}; +use std::{ + collections::{btree_map::Iter as MapIter, BTreeMap as Map}, + str::FromStr, +}; +use x509_cert::{ + builder::{self, profile::BuilderProfile, Builder, CertificateBuilder}, + ext::Extension, + name::Name, + serial_number, + time::{Time, Validity}, + Certificate, TbsCertificate, +}; /// AES-CCM with a 128-bit key pub(crate) type Aes128Ccm = ccm::Ccm; @@ -95,6 +114,7 @@ pub(crate) struct Objects(Map); impl Default for Objects { fn default() -> Self { let mut objects = Map::new(); + let mut rng = rand::rng(); // Insert default authentication key let authentication_key_handle = @@ -123,6 +143,60 @@ impl Default for Objects { }, ); + // Key used for attestation by default + let Ok(attestation_key) = p256::SecretKey::try_from_rng(&mut rng); + let attestation_cert = Self::generate_self_signed_cert(&attestation_key); + + let attestation_key_info = Info { + object_id: DEFAULT_ATTESTATION_KEY_ID, + object_type: Type::AsymmetricKey, + algorithm: Algorithm::Asymmetric(asymmetric::Algorithm::EcP256), + capabilities: Capability::SIGN_ATTESTATION_CERTIFICATE, + delegated_capabilities: Capability::empty(), + domains: Domain::all(), + length: 0, + sequence: 0, + origin: Origin::Generated, + label: "MOCKHSM ATTESTATION KEY".into(), + }; + let attestation_cert_info = Info { + object_id: DEFAULT_ATTESTATION_KEY_ID, + object_type: Type::Opaque, + algorithm: Algorithm::Opaque(opaque::Algorithm::X509Certificate), + capabilities: Capability::GET_OPAQUE, + delegated_capabilities: Capability::empty(), + domains: Domain::all(), + length: 0, + sequence: 0, + origin: Origin::Generated, + label: "MOCKHSM ATTESTATION CERT".into(), + }; + + let attestation_cert_payload = Payload::Opaque( + opaque::Algorithm::X509Certificate, + attestation_cert.to_der().unwrap(), + ); + let attestation_key_payload = Payload::EcdsaNistP256(attestation_key); + + let attestation_key_handle = Handle::new(DEFAULT_ATTESTATION_KEY_ID, Type::AsymmetricKey); + let attestation_cert_handle = Handle::new(DEFAULT_ATTESTATION_KEY_ID, Type::Opaque); + + let _ = objects.insert( + attestation_key_handle, + Object { + object_info: attestation_key_info, + payload: attestation_key_payload, + }, + ); + + let _ = objects.insert( + attestation_cert_handle, + Object { + object_info: attestation_cert_info, + payload: attestation_cert_payload, + }, + ); + Objects(objects) } } @@ -343,6 +417,47 @@ impl Objects { )), } } + + fn generate_self_signed_cert(secret_key: &p256::SecretKey) -> Certificate { + struct SelfSigned; + impl BuilderProfile for SelfSigned { + fn get_issuer(&self, subject: &Name) -> Name { + subject.clone() + } + fn get_subject(&self) -> Name { + Name::from_str("CN=MockHSM Attestation").unwrap() + } + fn build_extensions( + &self, + _spk: SubjectPublicKeyInfoRef<'_>, + _issuer_spk: SubjectPublicKeyInfoRef<'_>, + _tbs: &TbsCertificate, + ) -> builder::Result> { + Ok(vec![]) + } + } + + let mut rng = rand::rng(); + let serial_number = serial_number::SerialNumber::generate(&mut rng); + let validity = Validity::new( + // Yubico's Cert uses UTCTime for the not_before and GeneralizedTime for the not_after + // (scheduled for 2071) + Time::UtcTime( + UtcTime::from_date_time(DateTime::new(2017, 1, 1, 0, 0, 0).unwrap()).unwrap(), + ), + Time::GeneralTime(GeneralizedTime::from_date_time( + DateTime::new(2071, 10, 5, 0, 0, 0).unwrap(), + )), + ); + let pub_key = SubjectPublicKeyInfoOwned::from_key(&secret_key.public_key()).unwrap(); + + let builder = CertificateBuilder::new(SelfSigned, serial_number, validity, pub_key) + .expect("Create certificate builder"); + let signer = p256::ecdsa::SigningKey::from(secret_key); + builder + .build::<_, p256::ecdsa::DerSignature>(&signer) + .unwrap() + } } /// Iterator over objects diff --git a/tests/command/mod.rs b/tests/command/mod.rs index b39c72fc..1cbb4867 100644 --- a/tests/command/mod.rs +++ b/tests/command/mod.rs @@ -20,7 +20,6 @@ pub mod put_opaque; #[cfg(feature = "mockhsm")] pub mod reset_device; pub mod set_option; -#[cfg(not(feature = "mockhsm"))] pub mod sign_attestation_certificate; #[cfg(not(feature = "mockhsm"))] pub mod sign_ecdsa;