use crate::{DeviceTrustDecision, DeviceTrustOutcome, TrustTier};
use time::{Duration, OffsetDateTime};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SessionSubjectAltName {
Uri(String),
DnsName(String),
IpAddress(String),
Email(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CsrExtensionRequest {
ClientAuth,
ServerAuth,
CodeSigning,
CustomOid(String),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SessionExtendedKeyUsage {
ClientAuth,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionCsrProfile {
pub subject: String,
pub public_key_fingerprint: String,
pub requested_subject_alt_names: Vec<SessionSubjectAltName>,
pub requested_extensions: Vec<CsrExtensionRequest>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionCertificateRequest {
pub csr: SessionCsrProfile,
pub requested_ttl: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionCertificatePolicy {
pub max_ttl: Duration,
pub refresh_window: Duration,
pub allowed_uri_san_prefixes: Vec<String>,
}
impl SessionCertificatePolicy {
#[must_use]
pub fn production() -> Self {
Self {
max_ttl: Duration::days(30),
refresh_window: Duration::days(7),
allowed_uri_san_prefixes: vec!["urn:sunlit:".to_owned()],
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionCertificateProfile {
pub subject: String,
pub public_key_fingerprint: String,
pub subject_alt_names: Vec<SessionSubjectAltName>,
pub extended_key_usages: Vec<SessionExtendedKeyUsage>,
pub not_before: OffsetDateTime,
pub not_after: OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignedSessionCertificate {
pub certificate_der: Vec<u8>,
pub ca_chain_der: Vec<Vec<u8>>,
pub serial: String,
pub fingerprint: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RevocationHandle {
pub serial: String,
pub fingerprint: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SessionCertificateBundle {
pub certificate_der: Vec<u8>,
pub ca_chain_der: Vec<Vec<u8>>,
pub serial: String,
pub fingerprint: String,
pub not_before: OffsetDateTime,
pub expires_at: OffsetDateTime,
pub refresh_after: OffsetDateTime,
pub revocation_handle: RevocationHandle,
pub profile: SessionCertificateProfile,
}
pub trait SessionCertificateSigner {
fn sign(
&self,
profile: &SessionCertificateProfile,
) -> Result<SignedSessionCertificate, SessionCertificateError>;
}
pub trait RevocationChecker {
fn is_revoked(&self, handle: &RevocationHandle) -> bool;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct NoRevocations;
impl RevocationChecker for NoRevocations {
fn is_revoked(&self, _handle: &RevocationHandle) -> bool {
false
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CsrRejectionReason {
MissingClientAuth,
ForbiddenExtension,
ForbiddenSubjectAltName,
EmptyPublicKeyFingerprint,
InvalidTtl,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SessionCertificateError {
DeniedDeviceTrust,
InvalidCsr {
reason: CsrRejectionReason,
},
Revoked,
RefreshTooEarly,
SessionExpired,
SignerRejected,
}
impl std::fmt::Display for SessionCertificateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DeniedDeviceTrust => write!(f, "device trust decision denied issuance"),
Self::InvalidCsr { reason } => write!(f, "invalid session certificate CSR: {reason:?}"),
Self::Revoked => write!(f, "session certificate is revoked"),
Self::RefreshTooEarly => write!(f, "session certificate refresh requested too early"),
Self::SessionExpired => write!(f, "session certificate is expired"),
Self::SignerRejected => write!(f, "session certificate signer rejected profile"),
}
}
}
impl std::error::Error for SessionCertificateError {}
#[derive(Clone, Debug)]
pub struct SessionCertificateIssuer<S> {
policy: SessionCertificatePolicy,
signer: S,
}
impl<S> SessionCertificateIssuer<S>
where
S: SessionCertificateSigner,
{
#[must_use]
pub fn new(policy: SessionCertificatePolicy, signer: S) -> Self {
Self { policy, signer }
}
pub fn issue(
&self,
request: &SessionCertificateRequest,
decision: &DeviceTrustDecision,
now: OffsetDateTime,
) -> Result<SessionCertificateBundle, SessionCertificateError> {
if !device_decision_allows_session(decision) {
return Err(SessionCertificateError::DeniedDeviceTrust);
}
self.validate_request(request)?;
let ttl = request.requested_ttl.min(self.policy.max_ttl);
let not_before = now;
let not_after = now + ttl;
let refresh_after = if ttl > self.policy.refresh_window {
not_after - self.policy.refresh_window
} else {
now
};
let profile = SessionCertificateProfile {
subject: request.csr.subject.clone(),
public_key_fingerprint: request.csr.public_key_fingerprint.clone(),
subject_alt_names: request.csr.requested_subject_alt_names.clone(),
extended_key_usages: vec![SessionExtendedKeyUsage::ClientAuth],
not_before,
not_after,
};
let signed = self.signer.sign(&profile)?;
if signed.certificate_der.is_empty()
|| signed.serial.is_empty()
|| signed.fingerprint.is_empty()
{
return Err(SessionCertificateError::SignerRejected);
}
Ok(SessionCertificateBundle {
certificate_der: signed.certificate_der,
ca_chain_der: signed.ca_chain_der,
serial: signed.serial.clone(),
fingerprint: signed.fingerprint.clone(),
not_before,
expires_at: not_after,
refresh_after,
revocation_handle: RevocationHandle {
serial: signed.serial,
fingerprint: signed.fingerprint,
},
profile,
})
}
pub fn refresh(
&self,
existing: &SessionCertificateBundle,
request: &SessionCertificateRequest,
decision: &DeviceTrustDecision,
revocation: &dyn RevocationChecker,
now: OffsetDateTime,
) -> Result<SessionCertificateBundle, SessionCertificateError> {
if !device_decision_allows_session(decision) {
return Err(SessionCertificateError::DeniedDeviceTrust);
}
if revocation.is_revoked(&existing.revocation_handle) {
return Err(SessionCertificateError::Revoked);
}
if now >= existing.expires_at {
return Err(SessionCertificateError::SessionExpired);
}
if now < existing.refresh_after {
return Err(SessionCertificateError::RefreshTooEarly);
}
self.issue(request, decision, now)
}
fn validate_request(
&self,
request: &SessionCertificateRequest,
) -> Result<(), SessionCertificateError> {
if request.requested_ttl <= Duration::ZERO {
return Err(invalid_csr(CsrRejectionReason::InvalidTtl));
}
if request.csr.public_key_fingerprint.trim().is_empty() {
return Err(invalid_csr(CsrRejectionReason::EmptyPublicKeyFingerprint));
}
if request.csr.subject.trim().is_empty() || request.csr.subject.len() > 200 {
return Err(invalid_csr(CsrRejectionReason::ForbiddenSubjectAltName));
}
if request
.csr
.requested_extensions
.iter()
.any(|extension| !matches!(extension, CsrExtensionRequest::ClientAuth))
{
return Err(invalid_csr(CsrRejectionReason::ForbiddenExtension));
}
if !request
.csr
.requested_extensions
.iter()
.any(|extension| matches!(extension, CsrExtensionRequest::ClientAuth))
{
return Err(invalid_csr(CsrRejectionReason::MissingClientAuth));
}
if request.csr.requested_subject_alt_names.is_empty()
|| !request
.csr
.requested_subject_alt_names
.iter()
.all(|san| self.san_is_allowed(san))
{
return Err(invalid_csr(CsrRejectionReason::ForbiddenSubjectAltName));
}
Ok(())
}
fn san_is_allowed(&self, san: &SessionSubjectAltName) -> bool {
let SessionSubjectAltName::Uri(value) = san else {
return false;
};
!value.is_empty()
&& value.len() <= 200
&& value.bytes().all(|b| b.is_ascii_graphic())
&& self
.policy
.allowed_uri_san_prefixes
.iter()
.any(|prefix| value.starts_with(prefix))
}
}
fn invalid_csr(reason: CsrRejectionReason) -> SessionCertificateError {
SessionCertificateError::InvalidCsr { reason }
}
pub(crate) fn device_decision_allows_session(decision: &DeviceTrustDecision) -> bool {
decision.outcome() != DeviceTrustOutcome::Denied && decision.tier() > TrustTier::None
}