From 08d5e759ddae501601045dc403f910aecd6c8c60 Mon Sep 17 00:00:00 2001 From: Pavlo Myroniuk Date: Tue, 11 Nov 2025 09:35:55 +0200 Subject: [PATCH 1/3] feat(kdc): add initial KDC implementation; --- Cargo.lock | 57 +++++- Cargo.toml | 1 + crates/kdc/Cargo.toml | 21 +++ crates/kdc/src/as_exchange.rs | 232 ++++++++++++++++++++++++ crates/kdc/src/config.rs | 45 +++++ crates/kdc/src/error.rs | 223 +++++++++++++++++++++++ crates/kdc/src/lib.rs | 186 +++++++++++++++++++ crates/kdc/src/tgs_exchange.rs | 320 +++++++++++++++++++++++++++++++++ crates/kdc/src/ticket.rs | 165 +++++++++++++++++ 9 files changed, 1246 insertions(+), 4 deletions(-) create mode 100644 crates/kdc/Cargo.toml create mode 100644 crates/kdc/src/as_exchange.rs create mode 100644 crates/kdc/src/config.rs create mode 100644 crates/kdc/src/error.rs create mode 100644 crates/kdc/src/lib.rs create mode 100644 crates/kdc/src/tgs_exchange.rs create mode 100644 crates/kdc/src/ticket.rs diff --git a/Cargo.lock b/Cargo.lock index 2e2dd15b..9ff6e270 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,6 +77,18 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "async-dnssd" version = "0.5.1" @@ -230,6 +242,15 @@ dependencies = [ "arbitrary", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -610,6 +631,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -1585,6 +1607,22 @@ dependencies = [ "digest 0.11.0-rc.3", ] +[[package]] +name = "kdc" +version = "0.1.0" +dependencies = [ + "argon2", + "picky-asn1", + "picky-asn1-der", + "picky-asn1-x509", + "picky-krb", + "serde", + "sspi", + "thiserror 2.0.17", + "time", + "tracing", +] + [[package]] name = "keccak" version = "0.2.0-rc.0" @@ -1900,6 +1938,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -4064,18 +4113,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "43fa6694ed34d6e57407afbccdeecfa268c470a7d2a5b0cf49ce9fcc345afb90" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 83246dda..0f8f4ac7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ members = [ "crates/dpapi-native-transport", "crates/dpapi-fuzzing", "crates/dpapi-web", + "crates/kdc", ] exclude = [ "tools/wasm-testcompile", diff --git a/crates/kdc/Cargo.toml b/crates/kdc/Cargo.toml new file mode 100644 index 00000000..a62c6a46 --- /dev/null +++ b/crates/kdc/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "kdc" +version = "0.1.0" +edition = "2024" + +[dependencies] +picky-asn1 = { workspace = true, features = ["time_conversion"] } +picky-asn1-der.workspace = true +picky-asn1-x509.workspace = true +picky-krb.workspace = true +tracing.workspace = true +time.workspace = true +serde.workspace = true + +sspi = { path = "../..", version = "0.18" } + +thiserror = "2.0" +argon2 = { version = "0.5", features = ["std"] } + +[lints] +workspace = true diff --git a/crates/kdc/src/as_exchange.rs b/crates/kdc/src/as_exchange.rs new file mode 100644 index 00000000..8897f875 --- /dev/null +++ b/crates/kdc/src/as_exchange.rs @@ -0,0 +1,232 @@ +use std::time::Duration; + +use argon2::password_hash::rand_core::{OsRng, RngCore as _}; +use picky_asn1::restricted_string::IA5String; +use picky_asn1::wrapper::{ + Asn1SequenceOf, ExplicitContextTag0, ExplicitContextTag1, ExplicitContextTag2, ExplicitContextTag3, + ExplicitContextTag4, ExplicitContextTag5, ExplicitContextTag6, IntegerAsn1, OctetStringAsn1, Optional, +}; +use picky_krb::constants::etypes::{AES128_CTS_HMAC_SHA1_96, AES256_CTS_HMAC_SHA1_96}; +use picky_krb::constants::key_usages::AS_REP_ENC; +use picky_krb::constants::types::{ + AS_REP_MSG_TYPE, AS_REQ_MSG_TYPE, ENC_AS_REP_PART_TYPE, PA_ENC_TIMESTAMP, PA_ENC_TIMESTAMP_KEY_USAGE, + PA_ETYPE_INFO2_TYPE, +}; +use picky_krb::crypto::CipherSuite; +use picky_krb::data_types::{EncryptedData, EtypeInfo2Entry, KerberosStringAsn1, PaData, PaEncTsEnc}; +use picky_krb::messages::{AsRep, AsReq, KdcRep, KdcReq, KdcReqBody}; +use sspi::kerberos::TGT_SERVICE_NAME; +use sspi::{KERBEROS_VERSION, Secret}; +use time::OffsetDateTime; + +use crate::config::{DomainUser, KerberosServer}; +use crate::error::KdcError; +use crate::ticket::{MakeTicketParams, RepEncPartParams, make_rep_enc_part, make_ticket}; +use crate::{find_user_credentials, validate_request_from_and_till, validate_request_sname}; + +/// Validates AS-REQ PA-DATAs. +/// +/// The current implementation accepts only [PA_ENC_TIMESTAMP] pa-data (i.e. password-based logon). +/// [PA_PK_AS_REQ] pa-data (i.e. scard-based logon) is not supported. +fn validate_pa_data_timestamp( + domain_user: &DomainUser, + max_time_skew: u64, + pa_datas: &[PaData], +) -> Result>, KdcError> { + let pa_data = pa_datas + .iter() + .find_map(|pa_data| { + if pa_data.padata_type.0.0 == PA_ENC_TIMESTAMP { + Some(pa_data.padata_data.0.0.as_slice()) + } else { + None + } + }) + .ok_or(KdcError::PreAuthRequired( + "PA_ENC_TIMESTAMP is not present in AS_REQ padata", + ))?; + + let encrypted_timestamp: EncryptedData = + picky_asn1_der::from_bytes(pa_data).map_err(|_| KdcError::PreAuthFailed("unable to decode pa-data value"))?; + + let cipher = CipherSuite::try_from(encrypted_timestamp.etype.0.0.as_slice()) + .map_err(|_| KdcError::PreAuthFailed("invalid etype in PA_ENC_TIMESTAMP"))? + .cipher(); + let key = Secret::new( + cipher + .generate_key_from_password(domain_user.password.as_bytes(), domain_user.salt.as_bytes()) + .map_err(|_| KdcError::InternalError("failed to generate user's key"))?, + ); + + let timestamp_data = cipher + .decrypt( + key.as_ref(), + PA_ENC_TIMESTAMP_KEY_USAGE, + &encrypted_timestamp.cipher.0.0, + ) + .map_err(|_| KdcError::Modified("PA_ENC_TIMESTAMP"))?; + let timestamp: PaEncTsEnc = picky_asn1_der::from_bytes(×tamp_data) + .map_err(|_| KdcError::PreAuthFailed("unable to decode PaEncTsEnc value"))?; + + let client_timestamp = OffsetDateTime::try_from(timestamp.patimestamp.0.0) + .map_err(|_| KdcError::PreAuthFailed("unable to decode PaEncTsEnc timestamp value"))?; + let current = OffsetDateTime::now_utc(); + + if client_timestamp > current || current - client_timestamp > Duration::from_secs(max_time_skew) { + return Err(KdcError::ClockSkew("invalid pa-data: clock skew too great")); + } + + Ok(key) +} + +/// Performs AS exchange according to the RFC 4120. +/// +/// RFC: [The Authentication Service Exchange](https://www.rfc-editor.org/rfc/rfc4120#section-3.1). +pub(super) fn handle_as_req(as_req: &AsReq, kdc_config: &KerberosServer) -> Result { + let KdcReq { + pvno, + msg_type, + padata, + req_body, + } = &as_req.0; + + if pvno.0.0 != [KERBEROS_VERSION] { + return Err(KdcError::BadKrbVersion { + version: pvno.0.0.clone(), + expected: KERBEROS_VERSION, + }); + } + + if msg_type.0.0 != [AS_REQ_MSG_TYPE] { + return Err(KdcError::BadMsgType { + msg_type: msg_type.0.0.clone(), + expected: AS_REQ_MSG_TYPE, + }); + } + + let KdcReqBody { + kdc_options, + cname, + realm: realm_asn1, + sname, + from, + till, + rtime: _, + nonce, + etype, + addresses, + enc_authorization_data: _, + additional_tickets: _, + } = &req_body.0; + + let sname = sname + .0 + .clone() + .ok_or(KdcError::InvalidSname( + "sname is not present in KDC request sname".to_owned(), + ))? + .0; + // The AS_REQ service name must meet the following requirements: + // * The first string in sname must be equal to TGT_SERVICE_NAME. + // * The second string in sname must be equal to KDC realm. + validate_request_sname(&sname, &[TGT_SERVICE_NAME, &kdc_config.realm])?; + + let realm = realm_asn1.0.0.as_utf8(); + if !realm.eq_ignore_ascii_case(&kdc_config.realm) { + return Err(KdcError::WrongRealm(realm.to_owned())); + } + + let cname = &cname + .0 + .as_ref() + .ok_or(KdcError::ClientPrincipalUnknown( + "the incoming KDC request does not contain client principal name".to_owned(), + ))? + .0; + let domain_user = find_user_credentials(cname, realm, kdc_config)?; + + let pa_datas = &padata + .0 + .as_ref() + .ok_or(KdcError::PreAuthRequired("pa-data is missing in incoming AS_REQ"))? + .0 + .0; + + let user_key = validate_pa_data_timestamp(domain_user, kdc_config.max_time_skew, pa_datas)?; + let as_req_nonce = nonce.0.0.clone(); + let etype_raw = etype + .0 + .0 + .iter() + .find(|etype| { + // We support only AES256_CTS_HMAC_SHA1_96 and AES128_CTS_HMAC_SHA1_96. According to the RFC (https://datatracker.ietf.org/doc/html/rfc4120#section-3.1.3): + // > The KDC will not issue tickets with a weak session key encryption type. + if let Some(etype) = etype.0.first().copied().map(usize::from) { + etype == AES256_CTS_HMAC_SHA1_96 || etype == AES128_CTS_HMAC_SHA1_96 + } else { + false + } + }) + .ok_or(KdcError::NoSuitableEtype)? + .0 + .as_slice(); + let etype = CipherSuite::try_from(etype_raw).map_err(|_| KdcError::NoSuitableEtype)?; + let cipher = etype.cipher(); + let realm = realm_asn1.0.clone(); + let (auth_time, end_time) = validate_request_from_and_till(from.0.as_deref(), &till.0, kdc_config.max_time_skew)?; + + let mut rng = OsRng; + let mut session_key = vec![0; cipher.key_size()]; + rng.fill_bytes(&mut session_key); + + let as_rep_enc_data = make_rep_enc_part::( + RepEncPartParams { + etype: etype.clone(), + session_key: session_key.clone(), + nonce: as_req_nonce, + kdc_options: kdc_options.0.clone(), + auth_time, + end_time, + realm: realm.clone(), + sname: sname.clone(), + addresses: addresses.0.clone().map(|addresses| addresses.0), + }, + user_key.as_ref(), + AS_REP_ENC, + )?; + + Ok(AsRep::from(KdcRep { + pvno: ExplicitContextTag0::from(IntegerAsn1::from(vec![KERBEROS_VERSION])), + msg_type: ExplicitContextTag1::from(IntegerAsn1::from(vec![AS_REP_MSG_TYPE])), + padata: Optional::from(Some(ExplicitContextTag2::from(Asn1SequenceOf::from(vec![PaData { + padata_type: ExplicitContextTag1::from(IntegerAsn1::from(PA_ETYPE_INFO2_TYPE.to_vec())), + padata_data: ExplicitContextTag2::from(OctetStringAsn1::from(picky_asn1_der::to_vec( + &Asn1SequenceOf::from(vec![EtypeInfo2Entry { + etype: ExplicitContextTag0::from(IntegerAsn1::from(etype_raw.to_vec())), + salt: Optional::from(Some(ExplicitContextTag1::from(KerberosStringAsn1::from( + IA5String::from_string(domain_user.salt.clone()).expect("salt to be a valid KerberosString"), + )))), + s2kparams: Optional::from(None), + }]), + )?)), + }])))), + crealm: ExplicitContextTag3::from(realm.clone()), + cname: ExplicitContextTag4::from(cname.clone()), + ticket: ExplicitContextTag5::from(make_ticket(MakeTicketParams { + realm, + session_key, + ticket_encryption_key: &kdc_config.krbtgt_key, + kdc_options: kdc_options.0.clone(), + sname, + cname: cname.clone(), + etype, + auth_time, + end_time, + })?), + enc_part: ExplicitContextTag6::from(EncryptedData { + etype: ExplicitContextTag0::from(IntegerAsn1::from(etype_raw.to_vec())), + kvno: Optional::from(None), + cipher: ExplicitContextTag2::from(OctetStringAsn1::from(as_rep_enc_data)), + }), + })) +} diff --git a/crates/kdc/src/config.rs b/crates/kdc/src/config.rs new file mode 100644 index 00000000..708ede42 --- /dev/null +++ b/crates/kdc/src/config.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +/// Domain user credentials. +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +pub struct DomainUser { + /// Username in FQDN format (e.g. "pw13@example.com"). + pub username: String, + /// User password. + pub password: String, + /// Salt for generating the user's key. + /// + /// Usually, it is equal to `{REALM}{username}` (e.g. "EXAMPLEpw13"). + pub salt: String, +} + +/// Kerberos server config +/// +/// This config is used to configure the Kerberos server during RDP proxying. +#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] +pub struct KerberosServer { + /// KDC and Kerberos Application Server realm. + /// + /// For example, `cd9bee03-b0aa-49dd-bad7-568b595c8024.jet`. + pub realm: String, + /// Users credentials inside fake KDC. + pub users: Vec, + /// The maximum allowed time difference between client and proxy clocks. + /// + /// The value must be in seconds. + pub max_time_skew: u64, + /// krbtgt service key. + /// + /// This key is used to encrypt/decrypt TGT tickets. + pub krbtgt_key: Vec, + /// Ticket decryption key. + /// + /// This key is used to decrypt the TGS ticket sent by the client. If you do not plan + /// to use Kerberos U2U authentication, then the `ticket_decryption_key` is required. + pub ticket_decryption_key: Option>, + /// The domain user credentials for the Kerberos U2U authentication. + /// + /// This field is needed only for Kerberos User-to-User authentication. If you do not plan + /// to use Kerberos U2U, do not specify it. + pub service_user: Option, +} diff --git a/crates/kdc/src/error.rs b/crates/kdc/src/error.rs new file mode 100644 index 00000000..e67f7b96 --- /dev/null +++ b/crates/kdc/src/error.rs @@ -0,0 +1,223 @@ +use picky_asn1::date::GeneralizedTime; +use picky_asn1::restricted_string::IA5String; +use picky_asn1::wrapper::{ + Asn1SequenceOf, ExplicitContextTag0, ExplicitContextTag1, ExplicitContextTag2, ExplicitContextTag4, + ExplicitContextTag5, ExplicitContextTag6, ExplicitContextTag7, ExplicitContextTag9, ExplicitContextTag10, + ExplicitContextTag11, ExplicitContextTag12, IntegerAsn1, OctetStringAsn1, Optional, +}; +use picky_asn1_der::Asn1DerError; +use picky_krb::constants::error_codes::{ + KDC_ERR_C_PRINCIPAL_UNKNOWN, KDC_ERR_CANNOT_POSTDATE, KDC_ERR_ETYPE_NOSUPP, KDC_ERR_NEVER_VALID, + KDC_ERR_PREAUTH_FAILED, KDC_ERR_PREAUTH_REQUIRED, KDC_ERR_S_PRINCIPAL_UNKNOWN, KDC_ERR_WRONG_REALM, + KRB_AP_ERR_BADVERSION, KRB_AP_ERR_MODIFIED, KRB_AP_ERR_MSG_TYPE, KRB_AP_ERR_SKEW, KRB_AP_ERR_TKT_EXPIRED, + KRB_ERR_GENERIC, +}; +use picky_krb::constants::types::{KRB_ERROR_MSG_TYPE, NT_SRV_INST, PA_ENC_TIMESTAMP, PA_ETYPE_INFO2_TYPE}; +use picky_krb::crypto::CipherSuite; +use picky_krb::data_types::{ + EtypeInfo2Entry, KerberosStringAsn1, KerberosTime, Microseconds, PaData, PrincipalName, Realm, +}; +use picky_krb::messages::{KdcReqBody, KrbError, KrbErrorInner}; +use sspi::KERBEROS_VERSION; +use sspi::kerberos::TGT_SERVICE_NAME; +use thiserror::Error; +use time::OffsetDateTime; + +use crate::config::KerberosServer; +use crate::find_user_credentials; + +#[derive(Error, Debug)] +pub(super) enum KdcError { + #[error("KRB_AP_ERR_BADVERSION: got invalid Kerberos version ({version:?}): expected [{expected}]")] + BadKrbVersion { version: Vec, expected: u8 }, + + #[error("KRB_AP_ERR_MSG_TYPE: got invalid Kerberos message type ({msg_type:?}): expected [{expected}]")] + BadMsgType { msg_type: Vec, expected: u8 }, + + #[error("KDC_ERR_WRONG_REALM: wrong realm: {0}")] + WrongRealm(String), + + #[error("ASN1 DER encoding failed: {0:?}")] + Asn1Encode(#[from] Asn1DerError), + + #[error("encryption failed: {0:?}")] + EncryptionFailed(#[from] picky_krb::crypto::KerberosCryptoError), + + #[error("KDC_ERR_C_PRINCIPAL_UNKNOWN: {0}")] + ClientPrincipalUnknown(String), + + #[error("invalid cname type: {0:?}")] + InvalidCnameType(Vec), + + #[error("invalid sname type: {0:?}")] + InvalidSnameType(Vec), + + #[error("invalid sname: {0}")] + InvalidSname(String), + + #[error("KDC_ERR_ETYPE_NOSUPP: only AES256_CTS_HMAC_SHA1_96 and AES128_CTS_HMAC_SHA1_96 etypes are supported")] + NoSuitableEtype, + + #[error("KDC_ERR_PREAUTH_FAILED: {0}")] + PreAuthFailed(&'static str), + + #[error("KDC_ERR_PREAUTH_REQUIRED: {0}")] + PreAuthRequired(&'static str), + + #[error("KRB_ERR_GENERIC: internal error: {0}")] + InternalError(&'static str), + + #[error("KRB_AP_ERR_SKEW: {0}")] + ClockSkew(&'static str), + + #[error("KRB_AP_ERR_MODIFIED: {0} decryption failed")] + Modified(&'static str), + + #[error("KDC_ERR_CANNOT_POSTDATE: {0}")] + CannotPostdate(&'static str), + + #[error("KDC_ERR_NEVER_VALID: {0}")] + NeverValid(String), + + #[error("KRB_AP_ERR_TKT_EXPIRED: {0}")] + TicketExpired(&'static str), +} + +impl KdcError { + pub(super) fn invalid_raw_krb_message_error(kdc_realm: String) -> KrbError { + let realm = + Realm::from(IA5String::from_string(kdc_realm).expect("configured realm should be valid Kerberos string")); + + let current_date = OffsetDateTime::now_utc(); + // https://www.rfc-editor.org/rfc/rfc4120#section-5.2.4 + // Microseconds ::= INTEGER (0..999999) + let microseconds = current_date.microsecond().min(999_999); + + KrbError::from(KrbErrorInner { + pvno: ExplicitContextTag0::from(IntegerAsn1(vec![KERBEROS_VERSION])), + msg_type: ExplicitContextTag1::from(IntegerAsn1::from(vec![KRB_ERROR_MSG_TYPE])), + ctime: Optional::from(None), + cusec: Optional::from(None), + stime: ExplicitContextTag4::from(KerberosTime::from(GeneralizedTime::from(current_date))), + susec: ExplicitContextTag5::from(Microseconds::from(microseconds.to_be_bytes().to_vec())), + error_code: ExplicitContextTag6::from(KRB_ERR_GENERIC), + crealm: Optional::from(None), + cname: Optional::from(None), + realm: ExplicitContextTag9::from(realm.clone()), + sname: ExplicitContextTag10::from(PrincipalName { + name_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![NT_SRV_INST])), + name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(vec![ + KerberosStringAsn1::from( + IA5String::from_string(TGT_SERVICE_NAME.to_owned()) + .expect("TBT_SERVICE_NAME is valid KerberosString"), + ), + realm, + ])), + }), + e_text: Optional::from(Some(ExplicitContextTag11::from(KerberosStringAsn1::from( + IA5String::from_string("input message is not valid AS_REQ nor TGS_REQ".to_owned()) + .expect("valid Kerberos string"), + )))), + e_data: Optional::from(None), + }) + } + + pub(super) fn into_krb_error(self, kdc_body: &KdcReqBody, kdc_config: &KerberosServer) -> KrbError { + let realm = kdc_body.realm.0.to_string(); + let cname = kdc_body.cname.0.as_ref().map(|cname| cname.0.clone()); + let salt = cname.and_then(|cname| { + find_user_credentials(&cname, &realm, kdc_config) + .map(|user| &user.salt) + .ok() + }); + let realm = + Realm::from(IA5String::from_string(realm).expect("configured realm should be valid Kerberos string")); + + let sname = kdc_body + .sname + .0 + .as_ref() + .map(|sname| sname.0.clone()) + .unwrap_or_else(|| PrincipalName { + name_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![NT_SRV_INST])), + name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(vec![ + KerberosStringAsn1::from( + IA5String::from_string(TGT_SERVICE_NAME.to_owned()) + .expect("TGT_SERVICE_NAME is valid KerberosString"), + ), + realm.clone(), + ])), + }); + + let current_date = OffsetDateTime::now_utc(); + // https://www.rfc-editor.org/rfc/rfc4120#section-5.2.4 + // Microseconds ::= INTEGER (0..999999) + let microseconds = current_date.microsecond().min(999_999); + + let error_code = match self { + KdcError::ClientPrincipalUnknown(_) => KDC_ERR_C_PRINCIPAL_UNKNOWN, + KdcError::InvalidCnameType(_) => KDC_ERR_C_PRINCIPAL_UNKNOWN, + KdcError::InvalidSnameType(_) => KDC_ERR_S_PRINCIPAL_UNKNOWN, + KdcError::InvalidSname(_) => KDC_ERR_S_PRINCIPAL_UNKNOWN, + KdcError::NoSuitableEtype => KDC_ERR_ETYPE_NOSUPP, + KdcError::PreAuthFailed(_) => KDC_ERR_PREAUTH_FAILED, + KdcError::PreAuthRequired(_) => KDC_ERR_PREAUTH_REQUIRED, + KdcError::InternalError(_) => KRB_ERR_GENERIC, + KdcError::ClockSkew(_) => KRB_AP_ERR_SKEW, + KdcError::Modified(_) => KRB_AP_ERR_MODIFIED, + KdcError::Asn1Encode(_) => KRB_ERR_GENERIC, + KdcError::EncryptionFailed(_) => KRB_ERR_GENERIC, + KdcError::BadKrbVersion { .. } => KRB_AP_ERR_BADVERSION, + KdcError::BadMsgType { .. } => KRB_AP_ERR_MSG_TYPE, + KdcError::WrongRealm(_) => KDC_ERR_WRONG_REALM, + KdcError::CannotPostdate(_) => KDC_ERR_CANNOT_POSTDATE, + KdcError::NeverValid(_) => KDC_ERR_NEVER_VALID, + KdcError::TicketExpired(_) => KRB_AP_ERR_TKT_EXPIRED, + }; + + let salt = if let Some(salt) = salt { + vec![ + PaData { + padata_type: ExplicitContextTag1::from(IntegerAsn1::from(PA_ETYPE_INFO2_TYPE.to_vec())), + padata_data: ExplicitContextTag2::from(OctetStringAsn1::from( + picky_asn1_der::to_vec(&Asn1SequenceOf::from(vec![EtypeInfo2Entry { + etype: ExplicitContextTag0::from(IntegerAsn1::from(vec![u8::from( + CipherSuite::Aes256CtsHmacSha196, + )])), + salt: Optional::from(Some(ExplicitContextTag1::from(KerberosStringAsn1::from( + IA5String::from_string(salt.to_owned()).expect("salt to be valid KerberosString"), + )))), + s2kparams: Optional::from(None), + }])) + .unwrap_or_else(|_| Vec::new()), + )), + }, + PaData { + padata_type: ExplicitContextTag1::from(IntegerAsn1::from(PA_ENC_TIMESTAMP.to_vec())), + padata_data: ExplicitContextTag2::from(OctetStringAsn1::from(Vec::new())), + }, + ] + } else { + Vec::new() + }; + let e_data = picky_asn1_der::to_vec(&Asn1SequenceOf::from(salt)).unwrap_or_else(|_| Vec::new()); + + KrbError::from(KrbErrorInner { + pvno: ExplicitContextTag0::from(IntegerAsn1(vec![KERBEROS_VERSION])), + msg_type: ExplicitContextTag1::from(IntegerAsn1::from(vec![KRB_ERROR_MSG_TYPE])), + ctime: Optional::from(None), + cusec: Optional::from(None), + stime: ExplicitContextTag4::from(KerberosTime::from(GeneralizedTime::from(current_date))), + susec: ExplicitContextTag5::from(Microseconds::from(microseconds.to_be_bytes().to_vec())), + error_code: ExplicitContextTag6::from(error_code), + crealm: Optional::from(Some(ExplicitContextTag7::from(realm.clone()))), + cname: Optional::from(None), + realm: ExplicitContextTag9::from(realm), + sname: ExplicitContextTag10::from(sname), + e_text: Optional::from(Some(ExplicitContextTag11::from(KerberosStringAsn1::from( + IA5String::from_string(self.to_string()).expect("error message to be valid KerberosString"), + )))), + e_data: Optional::from(Some(ExplicitContextTag12::from(OctetStringAsn1::from(e_data)))), + }) + } +} diff --git a/crates/kdc/src/lib.rs b/crates/kdc/src/lib.rs new file mode 100644 index 00000000..a293a358 --- /dev/null +++ b/crates/kdc/src/lib.rs @@ -0,0 +1,186 @@ +mod as_exchange; +mod config; +mod error; +mod tgs_exchange; +mod ticket; + +use std::time::Duration; + +use picky_asn1::wrapper::{ExplicitContextTag0, GeneralizedTimeAsn1, OctetStringAsn1}; +use picky_asn1_der::Asn1DerError; +use picky_krb::constants::types::{NT_ENTERPRISE, NT_PRINCIPAL, NT_SRV_INST}; +use picky_krb::data_types::PrincipalName; +use picky_krb::messages::{AsReq, KdcProxyMessage, TgsReq}; +use time::OffsetDateTime; + +use crate::as_exchange::handle_as_req; +use crate::config::{DomainUser, KerberosServer}; +use crate::error::KdcError; +use crate::tgs_exchange::handle_tgs_req; + +fn find_user_credentials<'a>( + cname: &PrincipalName, + realm: &str, + kdc_config: &'a KerberosServer, +) -> Result<&'a DomainUser, KdcError> { + let username = if cname.name_type.0.0 == [NT_PRINCIPAL] { + let cname = &cname + .name_string + .0 + .first() + .ok_or(KdcError::ClientPrincipalUnknown( + "the incoming KDC request does not contain client principal name".to_owned(), + ))? + .0; + format!("{cname}@{realm}") + } else if cname.name_type.0.0 == [NT_ENTERPRISE] { + cname + .name_string + .0 + .first() + .ok_or(KdcError::ClientPrincipalUnknown( + "the incoming KDC request does not contain client principal name".to_owned(), + ))? + .0 + .to_string() + } else { + return Err(KdcError::InvalidCnameType(cname.name_type.0.0.clone())); + }; + + kdc_config + .users + .iter() + .find(|user| user.username.eq_ignore_ascii_case(&username)) + .ok_or(KdcError::ClientPrincipalUnknown(format!( + "the requested client principal name ({username}) is not found in KDC database", + ))) +} + +/// Validates incoming `from` and `till` values of the [KdcReqBody]. +/// Returns `auth_time` and `end_time` for the issued ticket. +/// +/// RFC: [Generation of KRB_AS_REP Message](https://www.rfc-editor.org/rfc/rfc4120#section-3.1.3). +fn validate_request_from_and_till( + from: Option<&GeneralizedTimeAsn1>, + till: &GeneralizedTimeAsn1, + max_time_skew: u64, +) -> Result<(OffsetDateTime, OffsetDateTime), KdcError> { + let now = OffsetDateTime::now_utc(); + let max_time_skew = Duration::from_secs(max_time_skew); + + let auth_time = if let Some(from) = from { + let from = OffsetDateTime::try_from(from.0.clone()) + .map_err(|err| KdcError::NeverValid(format!("KdcReq::from time is not valid: {err}")))?; + // RFC (https://www.rfc-editor.org/rfc/rfc4120#section-3.1.3): + // > If the requested starttime is absent, indicates a time in the past, + // > or is within the window of acceptable clock skew for the KDC ..., + // > then the starttime of the ticket is set to the authentication server's current time. + if from < now + max_time_skew { + now + } else { + // RFC (https://www.rfc-editor.org/rfc/rfc4120#section-3.1.3): + // > If it indicates a time in the future beyond the acceptable clock skew, ..., then the error + // > KDC_ERR_CANNOT_POSTDATE is returned. + return Err(KdcError::CannotPostdate("KdcReq::from time is too far in the future")); + } + } else { + // RFC (https://www.rfc-editor.org/rfc/rfc4120#section-3.1.3): + // > If the requested starttime is absent, ..., then the starttime of the ticket is set to the authentication server's current time. + now + }; + + let till = OffsetDateTime::try_from(till.0.clone()) + .map_err(|err| KdcError::NeverValid(format!("KdcReq::till time is not valid: {err}")))?; + let max_end_time = now + Duration::from_secs(60 * 60 /* 1 hour */); + // RFC (https://www.rfc-editor.org/rfc/rfc4120#section-3.1.3): + // > The expiration time of the ticket will be set to the earlier of the requested endtime and a time determined by local policy... + let end_time = till.min(max_end_time); + + // RFC (https://www.rfc-editor.org/rfc/rfc4120#section-3.1.3): + // > If the requested expiration time minus the starttime (as determined above) is less than a site-determined minimum lifetime, + // > an error message with code KDC_ERR_NEVER_VALID is returned. + // + // We do not have a ticket minimum lifetime value configured, so we only check that the `end_time`` is after the `auth_time`. + if end_time < auth_time { + return Err(KdcError::NeverValid("end_time is earlier than auth_time".to_owned())); + } + + Ok((auth_time, end_time)) +} + +/// Validates the service name of the incoming [KdcReqBody]. +fn validate_request_sname(sname: &PrincipalName, expected_snames: &[&str]) -> Result<(), KdcError> { + if sname.name_type.0.0 != [NT_SRV_INST] { + return Err(KdcError::InvalidSnameType(sname.name_type.0.0.clone())); + } + + let mut sname = sname.name_string.0.0.iter().map(|name| name.to_string()); + + for expected_sname in expected_snames { + let service_name = sname + .next() + .ok_or_else(|| KdcError::InvalidSname(format!("'{expected_sname}' is not present in KDC request sname")))?; + + if !service_name.eq_ignore_ascii_case(expected_sname) { + return Err(KdcError::InvalidSname(format!( + "KDC request sname ({service_name}) is not equal to '{expected_sname}'", + ))); + } + } + + if let Some(service_name) = sname.next() { + return Err(KdcError::InvalidSname(format!( + "unexpected {service_name} service name: KDC request sname has too many names inside", + ))); + } + + Ok(()) +} + +/// Handles [KdcProxyMessage] by mimicking the KDC. +/// +/// The incoming [KdcProxyMessage] must contain either [AsReq] or [TgsReq] Kerberos message inside. +/// This function is _almost_ infailible. Even when an error happens, it converts the error to [KrbError], then encodes it, +/// and sends it back to the client. The only way this function can fail is when it fails to encode AS_REP/TGS_REP/KRB_ERROR. +pub fn handle_kdc_proxy_message( + msg: KdcProxyMessage, + kdc_config: &KerberosServer, + hostname: &str, +) -> Result { + let KdcProxyMessage { + kerb_message, + target_domain, + dclocator_hint, + } = msg; + let raw_krb_message = &kerb_message + .0 + .0 + .as_slice() + .get(4..) + .ok_or_else(|| Asn1DerError::TruncatedData)?; + + let reply_message = if let Ok(as_req) = picky_asn1_der::from_bytes::(raw_krb_message) { + match handle_as_req(&as_req, kdc_config) { + Ok(as_rep) => picky_asn1_der::to_vec(&as_rep)?, + Err(kdc_err) => picky_asn1_der::to_vec(&kdc_err.into_krb_error(&as_req.0.req_body, kdc_config))?, + } + } else if let Ok(tgs_req) = picky_asn1_der::from_bytes::(raw_krb_message) { + match handle_tgs_req(&tgs_req, kdc_config, hostname) { + Ok(tgs_rep) => picky_asn1_der::to_vec(&tgs_rep)?, + Err(kdc_err) => picky_asn1_der::to_vec(&kdc_err.into_krb_error(&tgs_req.0.req_body, kdc_config))?, + } + } else { + picky_asn1_der::to_vec(&KdcError::invalid_raw_krb_message_error(kdc_config.realm.clone()))? + }; + + let len = reply_message.len(); + let mut kerb_message = vec![0; len + 4]; + kerb_message[0..4].copy_from_slice(&u32::try_from(len).expect("usize-to-u32").to_be_bytes()); + kerb_message[4..].copy_from_slice(&reply_message); + + Ok(KdcProxyMessage { + kerb_message: ExplicitContextTag0::from(OctetStringAsn1::from(kerb_message)), + target_domain, + dclocator_hint, + }) +} diff --git a/crates/kdc/src/tgs_exchange.rs b/crates/kdc/src/tgs_exchange.rs new file mode 100644 index 00000000..0fb9181e --- /dev/null +++ b/crates/kdc/src/tgs_exchange.rs @@ -0,0 +1,320 @@ +use std::time::Duration; + +use argon2::password_hash::rand_core::{OsRng, RngCore as _}; +use picky_asn1::wrapper::{ + Asn1SequenceOf, ExplicitContextTag0, ExplicitContextTag1, ExplicitContextTag2, ExplicitContextTag3, + ExplicitContextTag4, ExplicitContextTag5, ExplicitContextTag6, IntegerAsn1, OctetStringAsn1, Optional, +}; +use picky_krb::constants::etypes::{AES128_CTS_HMAC_SHA1_96, AES256_CTS_HMAC_SHA1_96}; +use picky_krb::constants::key_usages::{ + TGS_REP_ENC_SESSION_KEY, TGS_REP_ENC_SUB_KEY, TGS_REQ_PA_DATA_AP_REQ_AUTHENTICATOR, TICKET_REP, +}; +use picky_krb::constants::types::{ + AS_REQ_MSG_TYPE, ENC_TGS_REP_PART_TYPE, PA_TGS_REQ_TYPE, TGS_REP_MSG_TYPE, TGS_REQ_MSG_TYPE, +}; +use picky_krb::crypto::CipherSuite; +use picky_krb::data_types::{ + Authenticator, EncTicketPart, EncTicketPartInner, EncryptedData, PaData, PrincipalName, TicketInner, +}; +use picky_krb::messages::{ApReq, ApReqInner, KdcRep, KdcReq, KdcReqBody, TgsRep, TgsReq}; +use sspi::KERBEROS_VERSION; +use sspi::kerberos::TGT_SERVICE_NAME; +use time::OffsetDateTime; + +use crate::config::KerberosServer; +use crate::error::KdcError; +use crate::ticket::{MakeTicketParams, RepEncPartParams, make_rep_enc_part, make_ticket}; +use crate::{validate_request_from_and_till, validate_request_sname}; + +/// Kerberos service name for the Terminal Server. +const TERMSRV: &str = "TERMSRV"; + +/// Resulting data after the TGS pre-authentication. +struct TgsPreAuth { + /// Extracted key from the PA-DATA. + /// + /// This key is used for `TgsRep::enc_part` encryption. + session_key: Vec, + /// Client's name. + /// + /// Client's name is encoded in the encrypted part of the PA-DATA ticket (TGT ticket). + cname: PrincipalName, + /// Encryption key usage. + /// + /// The key usage depends on whether the PA-DATA ApReq Authenticator sub-key was specified or not. + tgs_rep_key_usage: i32, +} + +/// Performs TGS pre-authentication: validated incoming PA-DATAs and extract needed parameters. +fn tgs_preauth( + realm: &str, + pa_datas: &Asn1SequenceOf, + krbtgt_key: &[u8], + max_time_skew: u64, +) -> Result { + let ap_req: ApReq = picky_asn1_der::from_bytes( + &pa_datas + .0 + .iter() + .find(|pa_data| pa_data.padata_type.0.0 == PA_TGS_REQ_TYPE) + .ok_or(KdcError::PreAuthRequired("missing PA_TGS_REQ pa-data"))? + .padata_data + .0 + .0, + ) + .map_err(|_| KdcError::PreAuthFailed("failed to decode PA_TGS_REQ AP_REQ"))?; + + let ApReqInner { + pvno: _, + msg_type: _, + ap_options: _, + ticket, + authenticator, + } = ap_req.0; + let TicketInner { + sname: ticket_sname, + enc_part: ticket_enc_data, + .. + } = ticket.0.0; + + // * The first string in sname must be equal to TGT_SERVICE_NAME. + // * The second string in sname must be equal to KDC realm. + validate_request_sname(&ticket_sname.0, &[TGT_SERVICE_NAME, realm])?; + + let cipher = CipherSuite::try_from(ticket_enc_data.etype.0.0.as_slice()) + .map_err(|_| KdcError::NoSuitableEtype)? + .cipher(); + + let ticket_enc_part: EncTicketPart = picky_asn1_der::from_bytes( + &cipher + .decrypt(krbtgt_key, TICKET_REP, &ticket_enc_data.cipher.0.0) + .map_err(|_| KdcError::Modified("TGS_REQ TGT ticket"))?, + ) + .map_err(|_| KdcError::PreAuthFailed("failed to decode TGS_REQ TGT enc part"))?; + + let EncTicketPartInner { + key, cname, endtime, .. + } = ticket_enc_part.0; + + let end_time = OffsetDateTime::try_from(endtime.0.0) + .map_err(|err| KdcError::NeverValid(format!("KdcReq::till time is not valid: {err}")))?; + let now = OffsetDateTime::now_utc(); + // RFC 4120 Receipt of KRB_AP_REQ Message (https://www.rfc-editor.org/rfc/rfc4120#section-3.2.3): + // > if the current time is later than end time by more than the allowable clock skew, + // > the KRB_AP_ERR_TKT_EXPIRED error is returned. + if now + Duration::from_secs(max_time_skew) > end_time { + return Err(KdcError::TicketExpired("TGT ticket has expired")); + } + + let session_key = key.0.key_value.0.0; + + let authenticator_enc_data = authenticator.0; + let cipher = CipherSuite::try_from(authenticator_enc_data.etype.0.0.as_slice()) + .map_err(|_| KdcError::NoSuitableEtype)? + .cipher(); + + let authenticator: Authenticator = picky_asn1_der::from_bytes( + &cipher + .decrypt( + &session_key, + TGS_REQ_PA_DATA_AP_REQ_AUTHENTICATOR, + &authenticator_enc_data.cipher.0.0, + ) + .map_err(|_| KdcError::Modified("TGS_REQ TGT ticket"))?, + ) + .map_err(|_| KdcError::PreAuthFailed("failed to decode TGS_REQ PA-DATA Authenticator"))?; + + let (session_key, tgs_rep_key_usage) = if let Some(key) = authenticator.0.subkey.0 { + (key.0.key_value.0.0, TGS_REP_ENC_SUB_KEY) + } else { + (session_key, TGS_REP_ENC_SESSION_KEY) + }; + + Ok(TgsPreAuth { + session_key, + cname: cname.0, + tgs_rep_key_usage, + }) +} + +/// Performs TGS exchange according to the RFC 4120. +/// +/// RFC: [The Ticket-Granting Service (TGS) Exchange](https://www.rfc-editor.org/rfc/rfc4120#section-3.3). +pub(super) fn handle_tgs_req( + tgs_req: &TgsReq, + kdc_config: &KerberosServer, + hostname: &str, +) -> Result { + let KdcReq { + pvno, + msg_type, + padata, + req_body, + } = &tgs_req.0; + + if pvno.0.0 != [KERBEROS_VERSION] { + return Err(KdcError::BadKrbVersion { + version: pvno.0.0.clone(), + expected: KERBEROS_VERSION, + }); + } + + if msg_type.0.0 != [TGS_REQ_MSG_TYPE] { + return Err(KdcError::BadMsgType { + msg_type: msg_type.0.0.clone(), + expected: AS_REQ_MSG_TYPE, + }); + } + + let KdcReqBody { + kdc_options, + cname: _, + realm: realm_asn1, + sname, + from, + till, + rtime: _, + nonce, + etype, + addresses, + enc_authorization_data: _, + additional_tickets, + } = &req_body.0; + + let realm = realm_asn1.0.0.as_utf8(); + if !realm.eq_ignore_ascii_case(&kdc_config.realm) { + return Err(KdcError::WrongRealm(realm.to_owned())); + } + + let sname = &sname + .0 + .as_ref() + .ok_or(KdcError::InvalidSname("sname is missing in TGS_REQ".to_owned()))? + .0; + // The TGS_REQ service name must meet the following requirements: + // * The first string in sname must be equal to [TERMSRV]. + // * The second string in sname must be equal to Devolutions Gateway hostname. + validate_request_sname(sname, &[TERMSRV, hostname])?; + + let pa_datas = &padata + .0 + .as_ref() + .ok_or_else(|| KdcError::PreAuthRequired("TGS_REQ PA-DATA is missing"))? + .0; + let TgsPreAuth { + session_key: initial_key, + cname, + tgs_rep_key_usage, + } = tgs_preauth( + &kdc_config.realm, + pa_datas, + &kdc_config.krbtgt_key, + kdc_config.max_time_skew, + )?; + + // [RFC 4120: KRB_KDC_REQ Definition](https://www.rfc-editor.org/rfc/rfc4120#section-5.4.1): + // > KDCOptions ::= KerberosFlags + // > ... + // > -- enc-tkt-in-skey(28), + let ticket_enc_key = if let (true, Some(tgt_ticket)) = (kdc_options.0.0.is_set(28), additional_tickets.0.as_ref()) { + let TicketInner { sname, enc_part, .. } = &tgt_ticket + .0 + .0 + .first() + .expect("array of additional tickets must not be empty") + .0; + + validate_request_sname(&sname.0, &[TGT_SERVICE_NAME, realm])?; + + let EncryptedData { + etype, + cipher: ticket_enc_data, + kvno: _, + } = &enc_part.0; + + let cipher = CipherSuite::try_from(etype.0.0.as_slice()) + .map_err(|_| KdcError::NoSuitableEtype)? + .cipher(); + + let ticket_enc_part: EncTicketPart = picky_asn1_der::from_bytes( + &cipher + .decrypt(&kdc_config.krbtgt_key, TICKET_REP, &ticket_enc_data.0.0) + .map_err(|_| KdcError::Modified("TGS_REQ Additional Ticket"))?, + ) + .map_err(|_| KdcError::PreAuthFailed("unable to decode pa-data value"))?; + + ticket_enc_part.0.key.0.key_value.0.0 + } else { + kdc_config + .ticket_decryption_key + .clone() + .ok_or(KdcError::InternalError("TGS ticket encryption key is not specified"))? + }; + + let tgs_req_nonce = nonce.0.0.clone(); + let etype_raw = etype + .0 + .0 + .iter() + .find(|etype| { + // We support only AES256_CTS_HMAC_SHA1_96 and AES128_CTS_HMAC_SHA1_96. According to the RFC (https://datatracker.ietf.org/doc/html/rfc4120#section-3.1.3): + // > The KDC will not issue tickets with a weak session key encryption type. + if let Some(etype) = etype.0.first().copied().map(usize::from) { + etype == AES256_CTS_HMAC_SHA1_96 || etype == AES128_CTS_HMAC_SHA1_96 + } else { + false + } + }) + .ok_or(KdcError::NoSuitableEtype)? + .0 + .as_slice(); + let etype = CipherSuite::try_from(etype_raw).map_err(|_| KdcError::NoSuitableEtype)?; + let cipher = etype.cipher(); + let realm = realm_asn1.0.clone(); + + let (auth_time, end_time) = validate_request_from_and_till(from.0.as_deref(), &till.0, kdc_config.max_time_skew)?; + + let mut rng = OsRng; + let mut session_key = vec![0; cipher.key_size()]; + rng.fill_bytes(&mut session_key); + + let tgs_rep_enc_part = make_rep_enc_part::( + RepEncPartParams { + etype: etype.clone(), + session_key: session_key.clone(), + nonce: tgs_req_nonce, + kdc_options: kdc_options.0.clone(), + auth_time, + end_time, + realm: realm.clone(), + sname: sname.clone(), + addresses: addresses.0.clone().map(|addresses| addresses.0), + }, + &initial_key, + tgs_rep_key_usage, + )?; + + Ok(TgsRep::from(KdcRep { + pvno: ExplicitContextTag0::from(IntegerAsn1::from(vec![KERBEROS_VERSION])), + msg_type: ExplicitContextTag1::from(IntegerAsn1::from(vec![TGS_REP_MSG_TYPE])), + padata: Optional::from(None), + crealm: ExplicitContextTag3::from(realm.clone()), + cname: ExplicitContextTag4::from(cname.clone()), + ticket: ExplicitContextTag5::from(make_ticket(MakeTicketParams { + realm, + session_key, + ticket_encryption_key: &ticket_enc_key, + kdc_options: kdc_options.0.clone(), + sname: sname.clone(), + cname, + etype: etype.clone(), + auth_time, + end_time, + })?), + enc_part: ExplicitContextTag6::from(EncryptedData { + etype: ExplicitContextTag0::from(IntegerAsn1::from(vec![u8::from(etype)])), + kvno: Optional::from(None), + cipher: ExplicitContextTag2::from(OctetStringAsn1::from(tgs_rep_enc_part)), + }), + })) +} diff --git a/crates/kdc/src/ticket.rs b/crates/kdc/src/ticket.rs new file mode 100644 index 00000000..af83ed6d --- /dev/null +++ b/crates/kdc/src/ticket.rs @@ -0,0 +1,165 @@ +use std::time::Duration; + +use picky_asn1::date::GeneralizedTime; +use picky_asn1::wrapper::{ + Asn1SequenceOf, ExplicitContextTag0, ExplicitContextTag1, ExplicitContextTag2, ExplicitContextTag3, + ExplicitContextTag4, ExplicitContextTag5, ExplicitContextTag6, ExplicitContextTag7, ExplicitContextTag9, + ExplicitContextTag10, ExplicitContextTag11, IntegerAsn1, OctetStringAsn1, Optional, +}; +use picky_asn1_der::application_tag::ApplicationTag; +use picky_krb::constants::key_usages::TICKET_REP; +use picky_krb::crypto::CipherSuite; +use picky_krb::data_types::{ + EncTicketPart, EncTicketPartInner, EncryptedData, EncryptionKey, HostAddresses, KerberosFlags, KerberosTime, + LastReq, LastReqInner, PrincipalName, Realm, Ticket, TicketInner, TransitedEncoding, +}; +use picky_krb::messages::EncKdcRepPart; +use sspi::KERBEROS_VERSION; +use time::OffsetDateTime; + +use crate::error::KdcError; + +pub(super) struct MakeTicketParams<'ticket_enc_key> { + pub realm: Realm, + pub session_key: Vec, + pub ticket_encryption_key: &'ticket_enc_key [u8], + pub kdc_options: KerberosFlags, + pub sname: PrincipalName, + pub cname: PrincipalName, + pub etype: CipherSuite, + pub auth_time: OffsetDateTime, + pub end_time: OffsetDateTime, +} + +pub(super) fn make_ticket(params: MakeTicketParams<'_>) -> Result { + let MakeTicketParams { + realm, + session_key, + ticket_encryption_key, + kdc_options, + sname, + cname, + etype, + auth_time, + end_time, + } = params; + + let ticket_enc_part = EncTicketPart::from(EncTicketPartInner { + flags: ExplicitContextTag0::from(kdc_options), + key: ExplicitContextTag1::from(EncryptionKey { + key_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![u8::from(etype)])), + key_value: ExplicitContextTag1::from(OctetStringAsn1::from(session_key)), + }), + crealm: ExplicitContextTag2::from(realm.clone()), + cname: ExplicitContextTag3::from(cname), + transited: ExplicitContextTag4::from(TransitedEncoding { + // the client is unable to check these fields, so we can put any values we want + tr_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![0])), + contents: ExplicitContextTag1::from(OctetStringAsn1::from(vec![1])), + }), + auth_time: ExplicitContextTag5::from(KerberosTime::from(GeneralizedTime::from(auth_time))), + starttime: Optional::from(None), + endtime: ExplicitContextTag7::from(KerberosTime::from(GeneralizedTime::from(end_time))), + renew_till: Optional::from(None), + caddr: Optional::from(None), + authorization_data: Optional::from(None), + }); + + // The KDC can use any type of encryption it wants. RFC (https://datatracker.ietf.org/doc/html/rfc4120#section-3.1.3): + // > ...the server will encrypt the ciphertext part of the ticket using the encryption key extracted from the server + // > principal's record in the Kerberos database using the encryption type associated with the server principal's key. + // > (This choice is NOT affected by the etype field in the request.) + // + // So, we always choose the most secure encryption type: AES256_CTS_HMAC_SHA1_96. + let ticket_enc_data = CipherSuite::Aes256CtsHmacSha196.cipher().encrypt( + ticket_encryption_key, + TICKET_REP, + &picky_asn1_der::to_vec(&ticket_enc_part)?, + )?; + + Ok(Ticket::from(TicketInner { + tkt_vno: ExplicitContextTag0::from(IntegerAsn1::from(vec![KERBEROS_VERSION])), + realm: ExplicitContextTag1::from(realm), + sname: ExplicitContextTag2::from(sname), + enc_part: ExplicitContextTag3::from(EncryptedData { + etype: ExplicitContextTag0::from(IntegerAsn1::from(vec![u8::from(CipherSuite::Aes256CtsHmacSha196)])), + kvno: Optional::from(None), + cipher: ExplicitContextTag2::from(OctetStringAsn1::from(ticket_enc_data)), + }), + })) +} + +pub(super) struct RepEncPartParams { + pub etype: CipherSuite, + pub session_key: Vec, + pub nonce: Vec, + pub kdc_options: KerberosFlags, + pub auth_time: OffsetDateTime, + pub end_time: OffsetDateTime, + pub realm: Realm, + pub sname: PrincipalName, + pub addresses: Option, +} + +pub(super) fn make_rep_enc_part( + params: RepEncPartParams, + encryption_key: &[u8], + key_usage: i32, +) -> Result, KdcError> { + let RepEncPartParams { + etype, + session_key, + nonce, + kdc_options, + auth_time, + end_time, + realm, + sname, + addresses, + } = params; + + let enc_part = ApplicationTag::<_, TAG>::from(EncKdcRepPart { + key: ExplicitContextTag0::from(EncryptionKey { + key_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![u8::from(etype.clone())])), + key_value: ExplicitContextTag1::from(OctetStringAsn1::from(session_key)), + }), + // RFC 4120 KRB_KDC_REP Definition (https://www.rfc-editor.org/rfc/rfc4120#section-5.4.2): + // > This field is returned by the KDC and specifies the time(s) of the last request by a principal. + // > Depending on what information is available, this might be the last time that a request for a TGT, + // > was made, or the last time that a request based on a TGT was successful... + // > It is similar in spirit to the last login time displayed when logging in to timesharing systems. + // + // We do not track logons history. Moreover, this information is largely irrelevant to the actual authentication process. + // So, we set the last request time to the ticket's auth time minus one minute. + last_req: ExplicitContextTag1::from(LastReq::from(vec![LastReqInner { + lr_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![0])), + lr_value: ExplicitContextTag1::from(KerberosTime::from(GeneralizedTime::from( + auth_time - Duration::from_secs(60), + ))), + }])), + // RFC (https://datatracker.ietf.org/doc/html/rfc4120#section-3.1): + // > The encrypted part of the KRB_AS_REP message also contains the nonce + // > that MUST be matched with the nonce from the KRB_AS_REQ message. + nonce: ExplicitContextTag2::from(IntegerAsn1::from(nonce)), + key_expiration: Optional::from(None), + flags: ExplicitContextTag4::from(kdc_options), + auth_time: ExplicitContextTag5::from(KerberosTime::from(GeneralizedTime::from(auth_time))), + start_time: Optional::from(Some(ExplicitContextTag6::from(KerberosTime::from( + GeneralizedTime::from(auth_time), + )))), + end_time: ExplicitContextTag7::from(KerberosTime::from(GeneralizedTime::from(end_time))), + renew_till: Optional::from(None), + srealm: ExplicitContextTag9::from(realm), + sname: ExplicitContextTag10::from(sname), + // RFC (https://datatracker.ietf.org/doc/html/rfc4120#section-3.1.3): + // > ...It then formats a KRB_AS_REP message, copying the addresses in the request into the caddr of the response... + caadr: Optional::from(addresses.map(|addresses| ExplicitContextTag11::from(Asn1SequenceOf::from(addresses.0)))), + encrypted_pa_data: Optional::from(None), + }); + + let enc_data = etype + .cipher() + .encrypt(encryption_key, key_usage, &picky_asn1_der::to_vec(&enc_part)?)?; + + Ok(enc_data) +} From 3c165d241f850128927964cc4f0d15911b413b70 Mon Sep 17 00:00:00 2001 From: Pavlo Myroniuk Date: Thu, 13 Nov 2025 00:02:04 +0200 Subject: [PATCH 2/3] feat(kdc): export config module; --- crates/kdc/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/kdc/src/lib.rs b/crates/kdc/src/lib.rs index a293a358..17014ef5 100644 --- a/crates/kdc/src/lib.rs +++ b/crates/kdc/src/lib.rs @@ -1,5 +1,5 @@ mod as_exchange; -mod config; +pub mod config; mod error; mod tgs_exchange; mod ticket; From f8d07550e514ac88abf85ed3f3adad7992c2abd8 Mon Sep 17 00:00:00 2001 From: Pavlo Myroniuk Date: Fri, 14 Nov 2025 12:46:33 +0200 Subject: [PATCH 3/3] refactor(kdc): fix typos; --- crates/kdc/src/error.rs | 2 +- crates/kdc/src/lib.rs | 2 +- crates/kdc/src/tgs_exchange.rs | 8 +++----- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/kdc/src/error.rs b/crates/kdc/src/error.rs index e67f7b96..9193d442 100644 --- a/crates/kdc/src/error.rs +++ b/crates/kdc/src/error.rs @@ -109,7 +109,7 @@ impl KdcError { name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(vec![ KerberosStringAsn1::from( IA5String::from_string(TGT_SERVICE_NAME.to_owned()) - .expect("TBT_SERVICE_NAME is valid KerberosString"), + .expect("TGT_SERVICE_NAME is valid KerberosString"), ), realm, ])), diff --git a/crates/kdc/src/lib.rs b/crates/kdc/src/lib.rs index 17014ef5..eccdd79b 100644 --- a/crates/kdc/src/lib.rs +++ b/crates/kdc/src/lib.rs @@ -100,7 +100,7 @@ fn validate_request_from_and_till( // > If the requested expiration time minus the starttime (as determined above) is less than a site-determined minimum lifetime, // > an error message with code KDC_ERR_NEVER_VALID is returned. // - // We do not have a ticket minimum lifetime value configured, so we only check that the `end_time`` is after the `auth_time`. + // We do not have a ticket minimum lifetime value configured, so we only check that the `end_time` is after the `auth_time`. if end_time < auth_time { return Err(KdcError::NeverValid("end_time is earlier than auth_time".to_owned())); } diff --git a/crates/kdc/src/tgs_exchange.rs b/crates/kdc/src/tgs_exchange.rs index 0fb9181e..fd1cf1ce 100644 --- a/crates/kdc/src/tgs_exchange.rs +++ b/crates/kdc/src/tgs_exchange.rs @@ -9,9 +9,7 @@ use picky_krb::constants::etypes::{AES128_CTS_HMAC_SHA1_96, AES256_CTS_HMAC_SHA1 use picky_krb::constants::key_usages::{ TGS_REP_ENC_SESSION_KEY, TGS_REP_ENC_SUB_KEY, TGS_REQ_PA_DATA_AP_REQ_AUTHENTICATOR, TICKET_REP, }; -use picky_krb::constants::types::{ - AS_REQ_MSG_TYPE, ENC_TGS_REP_PART_TYPE, PA_TGS_REQ_TYPE, TGS_REP_MSG_TYPE, TGS_REQ_MSG_TYPE, -}; +use picky_krb::constants::types::{ENC_TGS_REP_PART_TYPE, PA_TGS_REQ_TYPE, TGS_REP_MSG_TYPE, TGS_REQ_MSG_TYPE}; use picky_krb::crypto::CipherSuite; use picky_krb::data_types::{ Authenticator, EncTicketPart, EncTicketPartInner, EncryptedData, PaData, PrincipalName, TicketInner, @@ -45,7 +43,7 @@ struct TgsPreAuth { tgs_rep_key_usage: i32, } -/// Performs TGS pre-authentication: validated incoming PA-DATAs and extract needed parameters. +/// Performs TGS pre-authentication: validates incoming PA-DATAs and extracts needed parameters. fn tgs_preauth( realm: &str, pa_datas: &Asn1SequenceOf, @@ -162,7 +160,7 @@ pub(super) fn handle_tgs_req( if msg_type.0.0 != [TGS_REQ_MSG_TYPE] { return Err(KdcError::BadMsgType { msg_type: msg_type.0.0.clone(), - expected: AS_REQ_MSG_TYPE, + expected: TGS_REQ_MSG_TYPE, }); }