feat(core): RDP client on IronRDP

clients::rdp connects over any caller-dialled TCP stream (direct, proxy
or SSH tunnel), upgrades to TLS and logs on with NLA (CredSSP/NTLM),
optionally falling back to TLS-only logon for servers without NLA such
as xrdp (credentials are passed so they log straight in). The server
certificate is pinned through a caller check on its SHA256 fingerprint
before any credentials are sent. The desktop decodes into the VNC
client's RGBA framebuffer; input (IronRDP's scancode/Unicode/mouse
operations), Display Control resizes and the deactivation-reactivation
sequence run on the session thread.

IronRDP's releases need three vendored patches (vendor/README.md):
picky and sspi with their RustCrypto release-candidate pins relaxed so
they share russh's stable versions (their own suites, 402 tests, pass
against them), and ironrdp-session with two unreleased upstream bitmap
fixes (#1486 stride, #1524 RDP6 orientation) without which xrdp desktops
render sheared.

Verified live against xrdp (scottyhardy/docker-remote-desktop): logon,
full desktop rendering, keyboard and mouse input.
This commit is contained in:
Andy Dixon
2026-10-12 10:50:24 +01:00
parent 6a4d7381d9
commit 50c0104d57
227 changed files with 66687 additions and 20 deletions
+70
View File
@@ -0,0 +1,70 @@
use picky_krb::data_types::{KrbResult, ResultExt};
use picky_krb::messages::{AsRep, KdcReqBody};
use crate::generator::YieldPointLocal;
use crate::kerberos::client::extractors::extract_salt_from_krb_error;
use crate::kerberos::client::generators::generate_as_req;
use crate::kerberos::pa_datas::AsReqPaDataOptions;
use crate::kerberos::utils::serialize_message;
use crate::{Error, ErrorKind, Kerberos, Result};
/// Performs AS exchange as specified in [RFC 4210: The Authentication Service Exchange](https://www.rfc-editor.org/rfc/rfc4120#section-3.1).
pub(crate) async fn as_exchange(
client: &mut Kerberos,
yield_point: &mut YieldPointLocal,
kdc_req_body: &KdcReqBody,
mut pa_data_options: AsReqPaDataOptions<'_>,
) -> Result<AsRep> {
pa_data_options.with_pre_auth(false);
let pa_datas = pa_data_options.generate()?;
let as_req = generate_as_req(pa_datas, kdc_req_body.clone());
let response = client.send(yield_point, &serialize_message(&as_req)?).await?;
// first 4 bytes are message len. skipping them
{
if response.len() < 4 {
return Err(Error::new(
ErrorKind::InternalError,
"the KDC reply message is too small: expected at least 4 bytes",
));
}
let mut d = picky_asn1_der::Deserializer::new_from_bytes(&response[4..]);
let as_rep: KrbResult<AsRep> = KrbResult::deserialize(&mut d)?;
if as_rep.is_ok() {
error!("KDC replied with AS_REP to the AS_REQ without the encrypted timestamp. The KRB_ERROR expected.");
return Err(Error::new(
ErrorKind::InvalidToken,
"KDC server should not process AS_REQ without the pa-pac data",
));
}
if let Some(correct_salt) = extract_salt_from_krb_error(&as_rep.unwrap_err())? {
debug!("salt extracted successfully from the KRB_ERROR");
pa_data_options.with_salt(correct_salt.into_bytes());
}
}
pa_data_options.with_pre_auth(true);
let pa_datas = pa_data_options.generate()?;
let as_req = generate_as_req(pa_datas, kdc_req_body.clone());
let response = client.send(yield_point, &serialize_message(&as_req)?).await?;
if response.len() < 4 {
return Err(Error::new(
ErrorKind::InternalError,
"the KDC reply message is too small: expected at least 4 bytes",
));
}
// first 4 bytes are message len. skipping them
let mut d = picky_asn1_der::Deserializer::new_from_bytes(&response[4..]);
Ok(KrbResult::<AsRep>::deserialize(&mut d)?.inspect_err(|err| error!(?err, "AS exchange error"))?)
}
+149
View File
@@ -0,0 +1,149 @@
use picky_krb::crypto::CipherSuite;
use picky_krb::messages::KrbPrivMessage;
use rand::rngs::{StdRng, SysRng};
use rand_core::{Rng as _, SeedableRng as _};
use crate::builders::ChangePassword;
use crate::generator::YieldPointLocal;
use crate::kerberos::client::extractors::{
extract_encryption_params_from_as_rep, extract_session_key_from_as_rep, extract_status_code_from_krb_priv_response,
};
use crate::kerberos::client::generators::{
EncKey, GenerateAsPaDataOptions, GenerateAsReqOptions, GenerateAuthenticatorOptions, generate_as_req_kdc_body,
generate_authenticator, generate_krb_priv_request,
};
use crate::kerberos::client::principal::{get_client_principal_name_type, get_client_principal_realm};
use crate::kerberos::pa_datas::AsReqPaDataOptions;
use crate::kerberos::utils::serialize_message;
use crate::kerberos::{CHANGE_PASSWORD_SERVICE_NAME, DEFAULT_ENCRYPTION_TYPE, KADMIN, client};
use crate::utils::generate_random_symmetric_key;
use crate::{ClientRequestFlags, Error, ErrorKind, Kerberos, Result};
/// [Kerberos Change Password and Set Password Protocols](https://datatracker.ietf.org/doc/html/rfc3244#section-2)
/// "The service accepts requests on UDP port 464 and TCP port 464 as well."
const KPASSWD_PORT: u16 = 464;
#[instrument(level = "debug", ret, fields(state = ?client.state), skip(client, change_password))]
pub async fn change_password<'a>(
client: &'a mut Kerberos,
yield_point: &mut YieldPointLocal,
change_password: ChangePassword<'a>,
) -> Result<()> {
let username = &change_password.account_name;
let domain = &change_password.domain_name;
let password = &change_password.old_password;
let salt = format!("{domain}{username}");
let cname_type = get_client_principal_name_type(username, domain);
let realm = &get_client_principal_realm(username, domain);
let mut rand = StdRng::try_from_rng(&mut SysRng)?;
let nonce = &rand.next_u32().to_ne_bytes();
let options = GenerateAsReqOptions {
realm,
username,
cname_type,
snames: &[KADMIN, CHANGE_PASSWORD_SERVICE_NAME],
// 4 = size of u32
nonce,
hostname: &client.config.client_computer_name,
context_requirements: ClientRequestFlags::empty(),
};
let kdc_req_body = generate_as_req_kdc_body(&options)?;
let pa_data_options = AsReqPaDataOptions::AuthIdentity(GenerateAsPaDataOptions {
password: password.as_ref(),
salt: salt.into_bytes(),
enc_params: client.encryption_params.clone(),
with_pre_auth: false,
});
let as_rep = client::as_exchange(client, yield_point, &kdc_req_body, pa_data_options).await?;
debug!("AS exchange finished successfully.");
client.realm = Some(as_rep.0.crealm.0.to_string());
let (encryption_type, salt) = extract_encryption_params_from_as_rep(&as_rep)?;
debug!(?encryption_type, "Negotiated encryption type");
client.encryption_params.encryption_type = Some(CipherSuite::try_from(usize::from(encryption_type))?);
let session_key = extract_session_key_from_as_rep(&as_rep, &salt, password.as_ref(), &client.encryption_params)?;
let seq_num = client.next_seq_number();
let enc_type = client
.encryption_params
.encryption_type
.as_ref()
.unwrap_or(&DEFAULT_ENCRYPTION_TYPE);
let authenticator_seb_key = generate_random_symmetric_key(enc_type, &mut rand);
let authenticator = generate_authenticator(GenerateAuthenticatorOptions {
kdc_rep: &as_rep.0,
seq_num: Some(seq_num),
sub_key: Some(EncKey {
key_type: enc_type.clone(),
key_value: authenticator_seb_key,
}),
checksum: None,
channel_bindings: client.channel_bindings.as_ref(),
extensions: Vec::new(),
})?;
let krb_priv = generate_krb_priv_request(
as_rep.0.ticket.0,
&session_key,
change_password.new_password.as_ref().as_bytes(),
&authenticator,
&client.encryption_params,
seq_num,
&client.config.client_computer_name,
)?;
if let Some((_realm, mut kdc_url)) = client.get_kdc() {
kdc_url
.set_port(Some(KPASSWD_PORT))
.map_err(|_| Error::new(ErrorKind::InvalidParameter, "Cannot set port for KDC URL"))?;
let response = client.send(yield_point, &serialize_message(&krb_priv)?).await?;
trace!(?response, "Change password raw response");
if response.len() < 4 {
return Err(Error::new(
ErrorKind::InternalError,
"the KDC reply message is too small: expected at least 4 bytes",
));
}
let krb_priv_response = KrbPrivMessage::deserialize(&response[4..]).map_err(|err| {
Error::new(
ErrorKind::InvalidToken,
format!("cannot deserialize krb_priv_response: {err:?}"),
)
})?;
let result_status = extract_status_code_from_krb_priv_response(
&krb_priv_response.krb_priv,
&authenticator.0.subkey.0.as_ref().unwrap().0.key_value.0.0,
&client.encryption_params,
)?;
if result_status != 0 {
return Err(Error::new(
ErrorKind::WrongCredentialHandle,
format!("unsuccessful krb result code: {result_status}. expected 0"),
));
}
} else {
return Err(Error::new(
ErrorKind::NoAuthenticatingAuthority,
"no KDC server found".to_owned(),
));
}
Ok(())
}
+290
View File
@@ -0,0 +1,290 @@
use std::io::Read;
use picky_asn1::wrapper::{Asn1SequenceOf, ObjectIdentifierAsn1};
use picky_asn1_der::Asn1RawDer;
use picky_asn1_der::application_tag::ApplicationTag;
use picky_krb::constants::key_usages::{AP_REP_ENC, AS_REP_ENC, KRB_PRIV_ENC_PART, TGS_REP_ENC_SESSION_KEY};
use picky_krb::constants::types::PA_ETYPE_INFO2_TYPE;
use picky_krb::crypto::CipherSuite;
use picky_krb::data_types::{EncApRepPart, EncKrbPrivPart, EtypeInfo2, PaData, Ticket};
use picky_krb::messages::{ApRep, AsRep, EncAsRepPart, EncTgsRepPart, KrbError, KrbPriv, TgsRep, TgtRep};
use crate::kerberos::{DEFAULT_ENCRYPTION_TYPE, EncryptionParams};
use crate::{Error, ErrorKind, Result, Secret};
/// Extracts password salt from the KRB error.
///
/// We need a salt to derive the correct encryption key from user's password. Usually, the salt is domain+username, but the custom salt
/// value can be set in KDC database. So, we always extract the correct salt from the [KrbError] message. More info in [RFC 4120 PA-ETYPE-INFO2](https://www.rfc-editor.org/rfc/rfc4120#section-5.2.7.5):
///
/// > The ETYPE-INFO2 pre-authentication type is sent by the KDC in a KRB-ERROR indicating a requirement for additional pre-authentication.
/// > It is usually used to notify a client of which key to use for the encryption of an encrypted timestamp for the purposes of sending a
/// > PA-ENC-TIMESTAMP pre-authentication value.
pub fn extract_salt_from_krb_error(error: &KrbError) -> Result<Option<String>> {
trace!(?error, "KRB_ERROR");
if let Some(e_data) = error.0.e_data.0.as_ref() {
let pa_datas: Asn1SequenceOf<PaData> = picky_asn1_der::from_bytes(&e_data.0.0)?;
if let Some(pa_etype_info_2) = pa_datas
.0
.into_iter()
.find(|pa_data| pa_data.padata_type.0.0 == PA_ETYPE_INFO2_TYPE)
{
let etype_info_2: EtypeInfo2 = picky_asn1_der::from_bytes(&pa_etype_info_2.padata_data.0.0)?;
if let Some(params) = etype_info_2.0.first() {
return Ok(params.salt.0.as_ref().map(|salt| salt.0.to_string()));
}
}
}
Ok(None)
}
/// Decrypts the AS-REP enc-part with an already-derived long-term `key` and
/// returns the embedded session key.
fn decode_as_rep_session_key(as_rep: &AsRep, key: &[u8], enc_params: &EncryptionParams) -> Result<Secret<Vec<u8>>> {
let cipher = enc_params
.encryption_type
.as_ref()
.unwrap_or(&DEFAULT_ENCRYPTION_TYPE)
.cipher();
let enc_data = cipher.decrypt(key, AS_REP_ENC, &as_rep.0.enc_part.0.cipher.0.0)?;
// This function extracts the session key from an AS-REP, so the enc-part is
// expected to be tagged EncASRepPart (APPLICATION 25). We do not accept
// EncTGSRepPart here: that tag belongs to the TGS exchange.
let as_rep_enc_part = picky_asn1_der::from_bytes::<EncAsRepPart>(&enc_data)?;
Ok(as_rep_enc_part.0.key.0.key_value.0.to_vec().into())
}
/// Extracts a session from the [AsRep].
#[instrument(level = "trace", ret, skip(password))]
pub fn extract_session_key_from_as_rep(
as_rep: &AsRep,
salt: &str,
password: &str,
enc_params: &EncryptionParams,
) -> Result<Secret<Vec<u8>>> {
let cipher = enc_params
.encryption_type
.as_ref()
.unwrap_or(&DEFAULT_ENCRYPTION_TYPE)
.cipher();
let key = cipher.generate_key_from_password(password.as_bytes(), salt.as_bytes())?;
decode_as_rep_session_key(as_rep, &key, enc_params)
}
/// Extracts a session key from the [AsRep] using a pre-derived long-term key
/// (keytab-based client authentication).
#[instrument(level = "trace", ret, skip(key))]
pub fn extract_session_key_from_as_rep_with_key(
as_rep: &AsRep,
key: &[u8],
enc_params: &EncryptionParams,
) -> Result<Secret<Vec<u8>>> {
decode_as_rep_session_key(as_rep, key, enc_params)
}
/// Extracts a session from the [TgsRep].
#[instrument(level = "trace", ret)]
pub fn extract_session_key_from_tgs_rep(
tgs_rep: &TgsRep,
session_key: &Secret<Vec<u8>>,
enc_params: &EncryptionParams,
) -> Result<Secret<Vec<u8>>> {
let cipher = enc_params
.encryption_type
.as_ref()
.unwrap_or(&DEFAULT_ENCRYPTION_TYPE)
.cipher();
let enc_data = cipher
.decrypt(
session_key.as_ref(),
TGS_REP_ENC_SESSION_KEY,
&tgs_rep.0.enc_part.0.cipher.0.0,
)
.map_err(|e| Error::new(ErrorKind::DecryptFailure, format!("{e:?}")))?;
trace!(?enc_data, "Plain TgsRep::EncData");
let enc_as_rep_part: EncTgsRepPart = picky_asn1_der::from_bytes(&enc_data)?;
Ok(enc_as_rep_part.0.key.0.key_value.0.to_vec().into())
}
/// Extracts encryption type and salt from [AsRep].
///
/// More info in [RFC 4120 Receipt of KRB_AS_REP Message](https://www.rfc-editor.org/rfc/rfc4120#section-3.1.5):
///
/// > If any padata fields are present, they may be used to derive the proper secret key to decrypt the message.
#[instrument(level = "trace", ret)]
pub fn extract_encryption_params_from_as_rep(as_rep: &AsRep) -> Result<(u8, String)> {
match as_rep
.0
.padata
.0
.as_ref()
.map(|v| {
v.0.0
.iter()
.find(|e| e.padata_type.0.0 == PA_ETYPE_INFO2_TYPE)
.map(|pa_data| pa_data.padata_data.0.0.clone())
})
.unwrap_or_default()
{
Some(data) => {
let pa_etype_info2: EtypeInfo2 = picky_asn1_der::from_bytes(&data)?;
let pa_etype_info2 = pa_etype_info2
.0
.first()
.ok_or_else(|| Error::new(ErrorKind::InvalidParameter, "Missing EtypeInto2Entry in EtypeInfo2"))?;
Ok((
pa_etype_info2.etype.0.0.first().copied().unwrap(),
pa_etype_info2
.salt
.0
.as_ref()
.map(|salt| salt.0.to_string())
.ok_or_else(|| Error::new(ErrorKind::InvalidParameter, "Missing salt in EtypeInto2Entry"))?,
))
}
None => Ok((*as_rep.0.enc_part.0.etype.0.0.first().unwrap(), Default::default())),
}
}
/// Extract a status code from the [KrbPriv] message.
pub fn extract_status_code_from_krb_priv_response(
krb_priv: &KrbPriv,
auth_key: &[u8],
encryption_params: &EncryptionParams,
) -> Result<u16> {
let encryption_type = encryption_params
.encryption_type
.clone()
.unwrap_or(CipherSuite::try_from(
*krb_priv
.0
.enc_part
.0
.etype
.0
.0
.first()
.unwrap_or(&((&DEFAULT_ENCRYPTION_TYPE).into())) as usize,
)?);
let cipher = encryption_type.cipher();
let enc_part: EncKrbPrivPart =
picky_asn1_der::from_bytes(&cipher.decrypt(auth_key, KRB_PRIV_ENC_PART, &krb_priv.0.enc_part.0.cipher.0.0)?)?;
let user_data = enc_part.0.user_data.0.0;
if user_data.len() < 2 {
return Err(Error::new(
ErrorKind::InvalidToken,
"Invalid KRB_PRIV message: user-data first is too short (expected at least 2 bytes)",
));
}
Ok(u16::from_be_bytes(user_data[0..2].try_into().unwrap()))
}
/// Extracts the sequence number from the [ApRep].
#[instrument(level = "trace", ret)]
pub fn extract_seq_number_from_ap_rep(
ap_rep: &ApRep,
session_key: &Secret<Vec<u8>>,
enc_params: &EncryptionParams,
) -> Result<Vec<u8>> {
let cipher = enc_params
.encryption_type
.as_ref()
.unwrap_or(&DEFAULT_ENCRYPTION_TYPE)
.cipher();
let res = cipher
.decrypt(session_key.as_ref(), AP_REP_ENC, &ap_rep.0.enc_part.cipher.0.0)
.map_err(|err| {
Error::new(
ErrorKind::DecryptFailure,
format!("cannot decrypt ap_rep.enc_part: {err:?}"),
)
})?;
let ap_rep_enc_part: EncApRepPart = picky_asn1_der::from_bytes(&res)?;
Ok(ap_rep_enc_part
.0
.seq_number
.0
.ok_or_else(|| Error::new(ErrorKind::InvalidToken, "missing sequence number in ap_rep"))?
.0
.0)
}
/// Extracts a sub-session key from the [ApRep].
#[instrument(level = "trace", ret)]
pub fn extract_sub_session_key_from_ap_rep(
ap_rep: &ApRep,
session_key: &Secret<Vec<u8>>,
enc_params: &EncryptionParams,
) -> Result<Secret<Vec<u8>>> {
let cipher = enc_params
.encryption_type
.as_ref()
.unwrap_or(&DEFAULT_ENCRYPTION_TYPE)
.cipher();
let res = cipher
.decrypt(session_key.as_ref(), AP_REP_ENC, &ap_rep.0.enc_part.cipher.0.0)
.map_err(|err| {
Error::new(
ErrorKind::DecryptFailure,
format!("cannot decrypt ap_rep.enc_part: {err:?}"),
)
})?;
let ap_rep_enc_part: EncApRepPart = picky_asn1_der::from_bytes(&res)?;
Ok(ap_rep_enc_part
.0
.subkey
.0
.ok_or_else(|| Error::new(ErrorKind::InvalidToken, "missing sub-key in ap_req"))?
.0
.key_value
.0
.0
.into())
}
/// Extracts TGT Ticket from encoded [NegTokenTarg1].
///
/// Returned OID means the selected authentication mechanism by the target server. More info:
/// * [3.2.1. Syntax](https://datatracker.ietf.org/doc/html/rfc2478#section-3.2.1): `responseToken` field;
///
/// We use this oid to choose between the regular Kerberos 5 and Kerberos 5 User-to-User authentication.
#[instrument(level = "trace", ret)]
pub fn extract_tgt_ticket_with_oid(mut resp_token: &[u8]) -> Result<Option<(Ticket, ObjectIdentifierAsn1)>> {
if resp_token.is_empty() {
return Ok(None);
}
let oid: ApplicationTag<Asn1RawDer, 0> = picky_asn1_der::from_reader(&mut resp_token)?;
let oid: ObjectIdentifierAsn1 = picky_asn1_der::from_bytes(&oid.0.0)?;
let mut t = [0, 0];
resp_token.read_exact(&mut t)?;
let tgt_rep: TgtRep = picky_asn1_der::from_reader(&mut resp_token)?;
Ok(Some((tgt_rep.ticket.0, oid)))
}
+938
View File
@@ -0,0 +1,938 @@
use std::str::FromStr;
use bitflags;
use md5::{Digest, Md5};
use picky_asn1::bit_string::BitString;
use picky_asn1::date::GeneralizedTime;
use picky_asn1::restricted_string::IA5String;
use picky_asn1::wrapper::{
Asn1SequenceOf, ExplicitContextTag0, ExplicitContextTag1, ExplicitContextTag2, ExplicitContextTag3,
ExplicitContextTag4, ExplicitContextTag5, ExplicitContextTag6, ExplicitContextTag7, ExplicitContextTag8,
ExplicitContextTag9, ExplicitContextTag11, GeneralizedTimeAsn1, IntegerAsn1, OctetStringAsn1, Optional,
};
use picky_asn1_x509::oids;
use picky_krb::constants::gss_api::AUTHENTICATOR_CHECKSUM_TYPE;
use picky_krb::constants::key_usages::{
AP_REP_ENC, AP_REQ_AUTHENTICATOR, KRB_PRIV_ENC_PART, TGS_REQ_PA_DATA_AP_REQ_AUTHENTICATOR,
};
use picky_krb::constants::types::{
AD_AUTH_DATA_AP_OPTION_TYPE, AP_REP_MSG_TYPE, AP_REQ_MSG_TYPE, AS_REQ_MSG_TYPE, KERB_AP_OPTIONS_CBT, KRB_PRIV,
NET_BIOS_ADDR_TYPE, NT_SRV_INST, PA_ENC_TIMESTAMP, PA_ENC_TIMESTAMP_KEY_USAGE, PA_PAC_OPTIONS_TYPE,
PA_PAC_REQUEST_TYPE, PA_TGS_REQ_TYPE, TGS_REQ_MSG_TYPE, TGT_REQ_MSG_TYPE,
};
use picky_krb::crypto::CipherSuite;
use picky_krb::data_types::{
ApOptions, Authenticator, AuthenticatorInner, AuthorizationData, AuthorizationDataInner, Checksum, EncApRepPart,
EncApRepPartInner, EncKrbPrivPart, EncKrbPrivPartInner, EncryptedData, EncryptionKey, HostAddress,
KerbPaPacRequest, KerberosFlags, KerberosStringAsn1, KerberosTime, PaData, PaEncTsEnc, PaPacOptions, PrincipalName,
Realm, Ticket,
};
use picky_krb::gss_api::{MechType, MechTypeList};
use picky_krb::messages::{
ApMessage, ApRep, ApRepInner, ApReq, ApReqInner, AsReq, KdcRep, KdcReq, KdcReqBody, KrbPriv, KrbPrivInner,
KrbPrivMessage, TgsReq, TgtReq,
};
use rand::rngs::{StdRng, SysRng};
use rand_core::{Rng as _, SeedableRng as _};
use time::{Duration, OffsetDateTime};
use crate::channel_bindings::ChannelBindings;
use crate::crypto::compute_md5_channel_bindings_hash;
use crate::kerberos::flags::{ApOptions as ApOptionsFlags, KdcOptions};
use crate::kerberos::{DEFAULT_ENCRYPTION_TYPE, EncryptionParams, KERBEROS_VERSION};
use crate::utils::parse_target_name;
use crate::{ClientRequestFlags, Error, ErrorKind, Result, Secret};
/// Moved to [`super::principal::get_client_principal_name_type`].
#[allow(
clippy::deprecated_semver,
reason = "`<next-version>` placeholder filled in at release time"
)]
#[deprecated(
since = "<next-version>",
note = "moved to the `kerberos::client::principal` module — see https://github.com/Devolutions/sspi-rs/issues/708"
)]
pub fn get_client_principal_name_type(username: &str, domain: &str) -> u8 {
super::principal::get_client_principal_name_type(username, domain)
}
/// Moved to [`super::principal::get_client_principal_realm`].
#[allow(
clippy::deprecated_semver,
reason = "`<next-version>` placeholder filled in at release time"
)]
#[deprecated(
since = "<next-version>",
note = "moved to the `kerberos::client::principal` module — see https://github.com/Devolutions/sspi-rs/issues/708"
)]
pub fn get_client_principal_realm(username: &str, domain: &str) -> String {
super::principal::get_client_principal_realm(username, domain)
}
const TGT_TICKET_LIFETIME_DAYS: i64 = 3;
const NONCE_LEN: usize = 4;
/// [Microseconds](https://www.rfc-editor.org/rfc/rfc4120#section-5.2.4).
/// The maximum microseconds value.
///
/// ```not_rust
/// Microseconds ::= INTEGER (0..999999)
/// ```
pub const MAX_MICROSECONDS: u32 = 999_999;
const MD5_CHECKSUM_TYPE: [u8; 1] = [0x07];
// Renewable, Canonicalize, and Renewable-ok are on by default
// https://www.rfc-editor.org/rfc/rfc4120#section-5.4.1
pub const DEFAULT_AS_REQ_OPTIONS: [u8; 4] = [0x00, 0x81, 0x00, 0x10];
// Renewable, Canonicalize.
// https://www.rfc-editor.org/rfc/rfc4120#section-5.4.1
const DEFAULT_TGS_REQ_OPTIONS: [u8; 4] = [0x00, 0x81, 0x00, 0x00];
const DEFAULT_PA_PAC_OPTIONS: [u8; 4] = [0x40, 0x00, 0x00, 0x00];
/// [Authenticator Checksum](https://datatracker.ietf.org/doc/html/rfc4121#section-4.1.1)
///
/// **Important**: the last 4 bytes are [Checksum Flags Field](https://datatracker.ietf.org/doc/html/rfc4121#section-4.1.1.1).
/// This value should be set separately based on provided [CLientRequestFlags] or [GssFlags].
pub const AUTHENTICATOR_DEFAULT_CHECKSUM: [u8; 24] = [
0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00,
];
pub(super) fn generate_tgt_req(sname: &[&str]) -> Result<TgtReq> {
let sname = sname
.iter()
.map(|sname| Ok(KerberosStringAsn1::from(IA5String::from_string(sname.to_string())?)))
.collect::<Result<Vec<_>>>()?;
Ok(TgtReq {
pvno: ExplicitContextTag0::from(IntegerAsn1::from(vec![KERBEROS_VERSION])),
msg_type: ExplicitContextTag1::from(IntegerAsn1::from(vec![TGT_REQ_MSG_TYPE])),
server_name: ExplicitContextTag2::from(PrincipalName {
name_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![NT_SRV_INST])),
name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(sname)),
}),
})
}
/// Parameters for generating pa-datas for [AsReq] message.
#[derive(Debug)]
pub struct GenerateAsPaDataOptions<'a> {
pub password: &'a str,
/// Salt for deriving the encryption key.
///
/// The salt value should be extracted from the [KrbError] message.
pub salt: Vec<u8>,
pub enc_params: EncryptionParams,
/// Flag that indicates whether to generate pa-datas.
pub with_pre_auth: bool,
}
/// Build the PA-ENC-TIMESTAMP pre-auth value, encrypting the current time with
/// an already-derived long-term `key` of type `encryption_type`.
fn encode_enc_timestamp_pa_data(key: &[u8], encryption_type: &CipherSuite) -> Result<PaData> {
let cipher = encryption_type.cipher();
let current_date = OffsetDateTime::now_utc();
let microseconds = current_date.microsecond().min(MAX_MICROSECONDS);
let timestamp = PaEncTsEnc {
patimestamp: ExplicitContextTag0::from(KerberosTime::from(GeneralizedTime::from(current_date))),
pausec: Optional::from(Some(ExplicitContextTag1::from(IntegerAsn1::from(
microseconds.to_be_bytes().to_vec(),
)))),
};
let timestamp_bytes = picky_asn1_der::to_vec(&timestamp)?;
trace!(?encryption_type, "AS timestamp encryption params",);
let encrypted_timestamp = cipher.encrypt(key, PA_ENC_TIMESTAMP_KEY_USAGE, &timestamp_bytes)?;
trace!(
?current_date,
?microseconds,
?timestamp_bytes,
?encrypted_timestamp,
"Encrypted timestamp params",
);
Ok(PaData {
padata_type: ExplicitContextTag1::from(IntegerAsn1::from(PA_ENC_TIMESTAMP.to_vec())),
padata_data: ExplicitContextTag2::from(OctetStringAsn1::from(picky_asn1_der::to_vec(&EncryptedData {
etype: ExplicitContextTag0::from(IntegerAsn1::from(vec![encryption_type.into()])),
kvno: Optional::from(None),
cipher: ExplicitContextTag2::from(OctetStringAsn1::from(encrypted_timestamp)),
})?)),
})
}
/// Build the PA-PAC-REQUEST pre-auth value (always requests a PAC).
fn encode_pac_request_pa_data() -> Result<PaData> {
Ok(PaData {
padata_type: ExplicitContextTag1::from(IntegerAsn1::from(PA_PAC_REQUEST_TYPE.to_vec())),
padata_data: ExplicitContextTag2::from(OctetStringAsn1::from(picky_asn1_der::to_vec(&KerbPaPacRequest {
include_pac: ExplicitContextTag0::from(true),
})?)),
})
}
#[instrument(level = "trace", ret, skip_all, fields(options.salt, options.enc_params, options.with_pre_auth))]
pub fn generate_pa_datas_for_as_req(options: &GenerateAsPaDataOptions<'_>) -> Result<Vec<PaData>> {
let GenerateAsPaDataOptions {
password,
salt,
enc_params,
with_pre_auth,
} = options;
let mut pa_datas = Vec::new();
if *with_pre_auth {
let encryption_type = enc_params.encryption_type.as_ref().unwrap_or(&DEFAULT_ENCRYPTION_TYPE);
let key = encryption_type
.cipher()
.generate_key_from_password(password.as_bytes(), salt)?;
pa_datas.push(encode_enc_timestamp_pa_data(&key, encryption_type)?);
}
pa_datas.push(encode_pac_request_pa_data()?);
Ok(pa_datas)
}
/// Parameters for generating pa-datas for an [AsReq] using a pre-derived
/// long-term key (keytab-based client authentication).
#[derive(Debug)]
pub struct GenerateKeytabPaDataOptions {
/// Raw long-term key bytes.
pub key: Secret<Vec<u8>>,
/// Kerberos encryption type of `key` (e.g. aes256-cts-hmac-sha1-96).
pub key_enctype: CipherSuite,
/// Flag that indicates whether to generate the PA-ENC-TIMESTAMP pa-data.
pub with_pre_auth: bool,
}
#[instrument(level = "trace", ret, skip_all, fields(options.key_enctype, options.with_pre_auth))]
pub fn generate_pa_datas_for_as_req_with_key(options: &GenerateKeytabPaDataOptions) -> Result<Vec<PaData>> {
let mut pa_datas = Vec::new();
if options.with_pre_auth {
pa_datas.push(encode_enc_timestamp_pa_data(
options.key.as_ref(),
&options.key_enctype,
)?);
}
pa_datas.push(encode_pac_request_pa_data()?);
Ok(pa_datas)
}
/// Parameters for generating [AsReq].
#[derive(Debug)]
pub struct GenerateAsReqOptions<'a> {
pub realm: &'a str,
pub username: &'a str,
pub cname_type: u8,
pub snames: &'a [&'a str],
pub nonce: &'a [u8],
pub hostname: &'a str,
pub context_requirements: ClientRequestFlags,
}
#[instrument(level = "trace", ret)]
pub fn generate_as_req_kdc_body(options: &GenerateAsReqOptions<'_>) -> Result<KdcReqBody> {
let GenerateAsReqOptions {
realm,
username,
cname_type,
snames,
nonce,
hostname: address,
context_requirements,
} = options;
let expiration_date = OffsetDateTime::now_utc()
.checked_add(Duration::days(TGT_TICKET_LIFETIME_DAYS))
.unwrap();
let host_address = HostAddress {
addr_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![NET_BIOS_ADDR_TYPE])),
address: ExplicitContextTag1::from(OctetStringAsn1::from(address.as_bytes().to_vec())),
};
let address = Some(ExplicitContextTag9::from(Asn1SequenceOf::from(vec![host_address])));
let mut service_names = Vec::with_capacity(snames.len());
for sname in *snames {
service_names.push(KerberosStringAsn1::from(IA5String::from_string((*sname).to_owned())?));
}
let mut as_req_options = KdcOptions::from_bits(u32::from_be_bytes(DEFAULT_AS_REQ_OPTIONS)).unwrap();
if context_requirements.contains(ClientRequestFlags::DELEGATE) {
as_req_options |= KdcOptions::FORWARDABLE;
}
Ok(KdcReqBody {
kdc_options: ExplicitContextTag0::from(KerberosFlags::from(BitString::with_bytes(
as_req_options.bits().to_be_bytes().to_vec(),
))),
cname: Optional::from(Some(ExplicitContextTag1::from(PrincipalName {
name_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![*cname_type])),
// A Kerberos principal name is a sequence of `/`-separated
// components (RFC 1964 §2.1.1). Service principals such as
// `kafka/host` carry two components; user principals carry one.
name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(
username
.split('/')
.map(|c| Ok(KerberosStringAsn1::from(IA5String::from_string(c.to_owned())?)))
.collect::<Result<Vec<_>>>()?,
)),
}))),
realm: ExplicitContextTag2::from(Realm::from(IA5String::from_string((*realm).into())?)),
sname: Optional::from(Some(ExplicitContextTag3::from(PrincipalName {
name_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![NT_SRV_INST])),
name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(service_names)),
}))),
from: Optional::from(None),
till: ExplicitContextTag5::from(GeneralizedTimeAsn1::from(GeneralizedTime::from(expiration_date))),
rtime: Optional::from(Some(ExplicitContextTag6::from(GeneralizedTimeAsn1::from(
GeneralizedTime::from(expiration_date),
)))),
nonce: ExplicitContextTag7::from(IntegerAsn1::from(nonce.to_vec())),
etype: ExplicitContextTag8::from(Asn1SequenceOf::from(vec![
IntegerAsn1::from(vec![CipherSuite::Aes256CtsHmacSha196.into()]),
IntegerAsn1::from(vec![CipherSuite::Aes128CtsHmacSha196.into()]),
])),
addresses: Optional::from(address),
enc_authorization_data: Optional::from(None),
additional_tickets: Optional::from(None),
})
}
#[instrument(level = "debug", ret, skip_all)]
pub fn generate_as_req(pa_datas: Vec<PaData>, kdc_req_body: KdcReqBody) -> AsReq {
AsReq::from(KdcReq {
pvno: ExplicitContextTag1::from(IntegerAsn1::from(vec![KERBEROS_VERSION])),
msg_type: ExplicitContextTag2::from(IntegerAsn1::from(vec![AS_REQ_MSG_TYPE])),
padata: Optional::from(Some(ExplicitContextTag3::from(Asn1SequenceOf::from(pa_datas)))),
req_body: ExplicitContextTag4::from(kdc_req_body),
})
}
/// Parameters for generating [TgsReq].
#[derive(Debug)]
pub struct GenerateTgsReqOptions<'a> {
pub realm: &'a str,
pub service_principal: &'a str,
pub session_key: &'a Secret<Vec<u8>>,
/// [Ticket] extracted from the [AsRep] message.
pub ticket: Ticket,
/// [Authenticator] to be included in [TgsReq] pa-data.
pub authenticator: &'a mut Authenticator,
/// If the Kerberos U2U auth is negotiated, then this parameter must have one ticket: TGT ticket of the application service.
/// Otherwise, set it to `None`.
pub additional_tickets: Option<Vec<Ticket>>,
pub enc_params: &'a EncryptionParams,
pub context_requirements: ClientRequestFlags,
}
#[instrument(level = "debug", ret)]
pub fn generate_tgs_req(options: GenerateTgsReqOptions<'_>) -> Result<TgsReq> {
let GenerateTgsReqOptions {
realm,
service_principal,
session_key,
ticket,
authenticator,
additional_tickets,
enc_params,
context_requirements,
} = options;
let (service_name, service_principal_name) = parse_target_name(service_principal)?;
let expiration_date = OffsetDateTime::now_utc()
.checked_add(Duration::days(TGT_TICKET_LIFETIME_DAYS))
.unwrap();
let mut tgs_req_options = KdcOptions::from_bits(u32::from_be_bytes(DEFAULT_TGS_REQ_OPTIONS)).unwrap();
if context_requirements.contains(ClientRequestFlags::DELEGATE) {
tgs_req_options |= KdcOptions::FORWARDABLE;
}
if context_requirements.contains(ClientRequestFlags::USE_SESSION_KEY) {
tgs_req_options |= KdcOptions::ENC_TKT_IN_SKEY;
}
let mut rng = StdRng::try_from_rng(&mut SysRng)?;
let mut nonce = [0; NONCE_LEN];
rng.fill_bytes(&mut nonce);
let req_body = KdcReqBody {
kdc_options: ExplicitContextTag0::from(KerberosFlags::from(BitString::with_bytes(
tgs_req_options.bits().to_be_bytes().to_vec(),
))),
cname: Optional::from(None),
realm: ExplicitContextTag2::from(Realm::from(IA5String::from_str(realm)?)),
sname: Optional::from(Some(ExplicitContextTag3::from(PrincipalName {
name_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![NT_SRV_INST])),
name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(vec![
KerberosStringAsn1::from(IA5String::from_string(service_name.into())?),
KerberosStringAsn1::from(IA5String::from_string(service_principal_name.into())?),
])),
}))),
from: Optional::from(None),
till: ExplicitContextTag5::from(GeneralizedTimeAsn1::from(GeneralizedTime::from(expiration_date))),
rtime: Optional::from(None),
nonce: ExplicitContextTag7::from(IntegerAsn1::from(nonce.to_vec())),
etype: ExplicitContextTag8::from(Asn1SequenceOf::from(vec![
IntegerAsn1::from(vec![CipherSuite::Aes256CtsHmacSha196.into()]),
IntegerAsn1::from(vec![CipherSuite::Aes128CtsHmacSha196.into()]),
])),
addresses: Optional::from(None),
enc_authorization_data: Optional::from(None),
additional_tickets: Optional::from(
additional_tickets.map(|tickets| ExplicitContextTag11::from(Asn1SequenceOf::from(tickets))),
),
};
let mut md5 = Md5::new();
md5.update(&picky_asn1_der::to_vec(&req_body)?);
let checksum = md5.finalize();
authenticator.0.cksum = Optional::from(Some(ExplicitContextTag3::from(Checksum {
cksumtype: ExplicitContextTag0::from(IntegerAsn1::from(MD5_CHECKSUM_TYPE.to_vec())),
checksum: ExplicitContextTag1::from(OctetStringAsn1::from(checksum.to_vec())),
})));
let pa_tgs_req =
PaData {
padata_type: ExplicitContextTag1::from(IntegerAsn1::from(PA_TGS_REQ_TYPE.to_vec())),
padata_data: ExplicitContextTag2::from(OctetStringAsn1::from(picky_asn1_der::to_vec(
&generate_tgs_ap_req(ticket, session_key, authenticator, enc_params)?,
)?)),
};
let pa_pac_options = PaData {
padata_type: ExplicitContextTag1::from(IntegerAsn1::from(PA_PAC_OPTIONS_TYPE.to_vec())),
padata_data: ExplicitContextTag2::from(OctetStringAsn1::from(picky_asn1_der::to_vec(&PaPacOptions {
flags: ExplicitContextTag0::from(KerberosFlags::from(BitString::with_bytes(
DEFAULT_PA_PAC_OPTIONS.to_vec(),
))),
})?)),
};
Ok(TgsReq::from(KdcReq {
pvno: ExplicitContextTag1::from(IntegerAsn1::from(vec![KERBEROS_VERSION])),
msg_type: ExplicitContextTag2::from(IntegerAsn1::from(vec![TGS_REQ_MSG_TYPE])),
padata: Optional::from(Some(ExplicitContextTag3::from(Asn1SequenceOf::from(vec![
pa_tgs_req,
pa_pac_options,
])))),
req_body: ExplicitContextTag4::from(req_body),
}))
}
#[derive(Debug)]
pub struct ChecksumOptions {
pub checksum_type: Vec<u8>,
pub checksum_value: ChecksumValues,
}
#[derive(Debug)]
pub struct ChecksumValues {
inner: Vec<u8>, // use named fields for future extensibility, this is a temporary solution
}
impl Default for ChecksumValues {
fn default() -> Self {
Self {
inner: AUTHENTICATOR_DEFAULT_CHECKSUM.to_vec(),
}
}
}
impl From<ChecksumValues> for Vec<u8> {
fn from(val: ChecksumValues) -> Self {
val.inner
}
}
impl From<[u8; 24]> for ChecksumValues {
fn from(bytes: [u8; 24]) -> Self {
ChecksumValues {
inner: Vec::from(bytes),
}
}
}
impl ChecksumValues {
pub(crate) fn set_flags(&mut self, flags: GssFlags) {
let flag_bits = flags.bits();
let flag_bytes = flag_bits.to_le_bytes();
self.inner[20..24].copy_from_slice(&flag_bytes);
}
pub(crate) fn into_inner(self) -> Vec<u8> {
self.inner
}
}
bitflags::bitflags! {
/// The checksum "Flags" field is used to convey service options or extension negotiation information.
/// More info:
/// * https://datatracker.ietf.org/doc/html/rfc4121#section-4.1.1.1
#[derive(Debug,Clone,Copy)]
pub(crate) struct GssFlags: u32 {
// [Checksum Flags Field](https://datatracker.ietf.org/doc/html/rfc4121#section-4.1.1.1).
const GSS_C_DELEG_FLAG = 1;
const GSS_C_MUTUAL_FLAG = 2;
const GSS_C_REPLAY_FLAG = 4;
const GSS_C_SEQUENCE_FLAG = 8;
const GSS_C_CONF_FLAG = 16;
const GSS_C_INTEG_FLAG = 32;
const GSS_C_ANON_FLAG = 64;
const GSS_C_PROT_READY_FLAG = 128;
const GSS_C_TRANS_FLAG = 256;
const GSS_C_DELEG_POLICY_FLAG = 32768;
// Additional GSS flags from MS-KILE specification:
// * [3.2.5.2 Authenticator Checksum Flags](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-kile/387806fc-ed78-445e-afd8-c5639fe4a90a)
// [Mechanism Specific Changes](https://www.rfc-editor.org/rfc/rfc4757.html#section-7.1):
// Setting this flag indicates that the client wants to be informed of extended error information. In
// particular, Windows 2000 status codes may be returned in the data field of a Kerberos error message.
// This allows the client to understand a server failure more precisely.
const GSS_C_EXTENDED_ERROR_FLAG = 0x4000;
// This flag allows the client to indicate to the server that it should only allow the server application to identify
// the client by name and ID, but not to impersonate the client.
const GSS_C_IDENTIFY_FLAG = 0x2000;
// This flag was added for use with Microsoft's implementation of Distributed Computing Environment Remote Procedure
// Call (DCE RPC), which initially expected three legs of authentication.
const GSS_C_DCE_STYLE = 0x1000;
}
}
impl From<ClientRequestFlags> for GssFlags {
/*
the semantics of some of the flags of SSPI are I believe one to one mapped to the GSS flags
*/
fn from(value: ClientRequestFlags) -> Self {
let mut flags = GssFlags::empty();
if value.contains(ClientRequestFlags::DELEGATE) {
flags |= GssFlags::GSS_C_DELEG_FLAG;
}
if value.contains(ClientRequestFlags::MUTUAL_AUTH) {
flags |= GssFlags::GSS_C_MUTUAL_FLAG;
}
if value.contains(ClientRequestFlags::REPLAY_DETECT) {
flags |= GssFlags::GSS_C_REPLAY_FLAG;
}
if value.contains(ClientRequestFlags::SEQUENCE_DETECT) {
flags |= GssFlags::GSS_C_SEQUENCE_FLAG;
}
if value.contains(ClientRequestFlags::CONFIDENTIALITY) {
flags |= GssFlags::GSS_C_CONF_FLAG;
}
if value.contains(ClientRequestFlags::INTEGRITY) {
flags |= GssFlags::GSS_C_INTEG_FLAG;
}
if value.contains(ClientRequestFlags::NO_INTEGRITY) {
flags &= !GssFlags::GSS_C_INTEG_FLAG;
}
if value.contains(ClientRequestFlags::USE_DCE_STYLE) {
flags |= GssFlags::GSS_C_DCE_STYLE;
}
flags
}
}
#[derive(Debug)]
pub struct AuthenticatorChecksumExtension {
pub extension_type: u32,
pub extension_value: Vec<u8>,
}
/// Encryption key.
#[derive(Debug)]
pub struct EncKey {
/// Encryption type.
pub key_type: CipherSuite,
/// Encryption key value.
pub key_value: Vec<u8>,
}
/// Input parameters for generating ApReq Authenticator.
#[derive(Debug)]
pub struct GenerateAuthenticatorOptions<'a> {
/// [KdcRep] from previous interaction with KDC.
pub kdc_rep: &'a KdcRep,
/// Sequence number.
pub seq_num: Option<u32>,
/// Sub-session encryption key.
pub sub_key: Option<EncKey>,
/// Authenticator checksum options.
pub checksum: Option<ChecksumOptions>,
/// Channel bindings.
pub channel_bindings: Option<&'a ChannelBindings>,
/// Possible authenticator extensions.
pub extensions: Vec<AuthenticatorChecksumExtension>,
}
/// Generated ApReq Authenticator.
#[instrument(level = "trace", ret)]
pub fn generate_authenticator(options: GenerateAuthenticatorOptions<'_>) -> Result<Authenticator> {
let GenerateAuthenticatorOptions {
kdc_rep,
seq_num,
sub_key,
checksum,
channel_bindings,
..
} = options;
let current_date = OffsetDateTime::now_utc();
let mut microseconds = current_date.microsecond();
if microseconds > MAX_MICROSECONDS {
microseconds = MAX_MICROSECONDS;
}
let authorization_data = Optional::from(channel_bindings.as_ref().map(|_| {
ExplicitContextTag8::from(AuthorizationData::from(vec![AuthorizationDataInner {
ad_type: ExplicitContextTag0::from(IntegerAsn1::from(AD_AUTH_DATA_AP_OPTION_TYPE.to_vec())),
ad_data: ExplicitContextTag1::from(OctetStringAsn1::from(KERB_AP_OPTIONS_CBT.to_vec())),
}]))
}));
let cksum = if let Some(ChecksumOptions {
checksum_type,
checksum_value,
}) = checksum
{
let mut checksum_value = checksum_value.into_inner();
if checksum_type == AUTHENTICATOR_CHECKSUM_TYPE
&& let Some(channel_bindings) = channel_bindings
{
if checksum_value.len() < 20 {
return Err(Error::new(
ErrorKind::InvalidParameter,
format!(
"Invalid authenticator checksum length: expected >= 20 but got {}. ",
checksum_value.len()
),
));
}
// [Authenticator Checksum](https://datatracker.ietf.org/doc/html/rfc4121#section-4.1.1)
// 4..19 - Channel binding information (19 inclusive).
checksum_value[4..20].copy_from_slice(&compute_md5_channel_bindings_hash(channel_bindings));
}
Optional::from(Some(ExplicitContextTag3::from(Checksum {
cksumtype: ExplicitContextTag0::from(IntegerAsn1::from(checksum_type)),
checksum: ExplicitContextTag1::from(OctetStringAsn1::from(checksum_value)),
})))
} else {
Optional::from(None)
};
Ok(Authenticator::from(AuthenticatorInner {
authenticator_vno: ExplicitContextTag0::from(IntegerAsn1::from(vec![KERBEROS_VERSION])),
crealm: ExplicitContextTag1::from(kdc_rep.crealm.0.clone()),
cname: ExplicitContextTag2::from(kdc_rep.cname.0.clone()),
cksum,
cusec: ExplicitContextTag4::from(IntegerAsn1::from(microseconds.to_be_bytes().to_vec())),
ctime: ExplicitContextTag5::from(KerberosTime::from(GeneralizedTime::from(current_date))),
subkey: Optional::from(sub_key.map(|EncKey { key_type, key_value }| {
ExplicitContextTag6::from(EncryptionKey {
key_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![key_type.into()])),
key_value: ExplicitContextTag1::from(OctetStringAsn1::from(key_value)),
})
})),
seq_number: Optional::from(seq_num.map(|seq_num| {
ExplicitContextTag7::from(IntegerAsn1::from_bytes_be_unsigned(seq_num.to_be_bytes().to_vec()))
})),
authorization_data,
}))
}
#[instrument(level = "trace", skip_all, ret)]
pub fn generate_ap_rep(
session_key: &Secret<Vec<u8>>,
seq_number: Vec<u8>,
enc_params: &EncryptionParams,
) -> Result<ApRep> {
let current_date = OffsetDateTime::now_utc();
let microseconds = current_date.microsecond().min(MAX_MICROSECONDS);
let encryption_type = enc_params.encryption_type.as_ref().unwrap_or(&DEFAULT_ENCRYPTION_TYPE);
let enc_ap_rep_part = EncApRepPart::from(EncApRepPartInner {
ctime: ExplicitContextTag0::from(KerberosTime::from(GeneralizedTime::from(current_date))),
cusec: ExplicitContextTag1::from(IntegerAsn1::from(microseconds.to_be_bytes().to_vec())),
subkey: Optional::from(None),
seq_number: Optional::from(Some(ExplicitContextTag3::from(IntegerAsn1::from(seq_number)))),
});
let cipher = encryption_type.cipher();
let encoded_enc_ap_rep_part = picky_asn1_der::to_vec(&enc_ap_rep_part)?;
let encrypted_enc_ap_rep_part = cipher.encrypt(session_key.as_ref(), AP_REP_ENC, &encoded_enc_ap_rep_part)?;
Ok(ApRep::from(ApRepInner {
pvno: ExplicitContextTag0::from(IntegerAsn1::from(vec![KERBEROS_VERSION])),
msg_type: ExplicitContextTag1::from(IntegerAsn1::from(vec![AP_REP_MSG_TYPE])),
enc_part: ExplicitContextTag2::from(EncryptedData {
etype: ExplicitContextTag0::from(IntegerAsn1::from(vec![encryption_type.into()])),
kvno: Optional::from(None),
cipher: ExplicitContextTag2::from(OctetStringAsn1::from(encrypted_enc_ap_rep_part)),
}),
}))
}
pub fn generate_tgs_ap_req(
ticket: Ticket,
session_key: &Secret<Vec<u8>>,
authenticator: &Authenticator,
enc_params: &EncryptionParams,
) -> Result<ApReq> {
let encryption_type = enc_params.encryption_type.as_ref().unwrap_or(&DEFAULT_ENCRYPTION_TYPE);
let cipher = encryption_type.cipher();
let encoded_authenticator = picky_asn1_der::to_vec(&authenticator)?;
let encrypted_authenticator = cipher.encrypt(
session_key.as_ref(),
TGS_REQ_PA_DATA_AP_REQ_AUTHENTICATOR,
&encoded_authenticator,
)?;
trace!(
?session_key,
?encryption_type,
"TGS AP_REQ authenticator encryption params",
);
trace!(
plain = ?encoded_authenticator,
encrypted = ?encrypted_authenticator,
"TGS AP_REQ authenticator",
);
Ok(ApReq::from(ApReqInner {
pvno: ExplicitContextTag0::from(IntegerAsn1::from(vec![KERBEROS_VERSION])),
msg_type: ExplicitContextTag1::from(IntegerAsn1::from(vec![AP_REQ_MSG_TYPE])),
ap_options: ExplicitContextTag2::from(ApOptions::from(BitString::with_bytes(vec![
// do not need any options when ap_req uses in tgs_req pa_data
0x00, 0x00, 0x00, 0x00,
]))),
ticket: ExplicitContextTag3::from(ticket),
authenticator: ExplicitContextTag4::from(EncryptedData {
etype: ExplicitContextTag0::from(IntegerAsn1::from(vec![encryption_type.into()])),
kvno: Optional::from(None),
cipher: ExplicitContextTag2::from(OctetStringAsn1::from(encrypted_authenticator)),
}),
}))
}
#[instrument(level = "trace", ret)]
pub fn generate_ap_req(
ticket: Ticket,
session_key: &Secret<Vec<u8>>,
authenticator: &Authenticator,
enc_params: &EncryptionParams,
options: ApOptionsFlags,
) -> Result<ApReq> {
let encryption_type = enc_params.encryption_type.as_ref().unwrap_or(&DEFAULT_ENCRYPTION_TYPE);
let cipher = encryption_type.cipher();
let encoded_authenticator = picky_asn1_der::to_vec(&authenticator)?;
let encrypted_authenticator = cipher.encrypt(session_key.as_ref(), AP_REQ_AUTHENTICATOR, &encoded_authenticator)?;
trace!(
plain = ?encoded_authenticator,
encrypted = ?encrypted_authenticator,
"AP_REQ authenticator",
);
Ok(ApReq::from(ApReqInner {
pvno: ExplicitContextTag0::from(IntegerAsn1::from(vec![KERBEROS_VERSION])),
msg_type: ExplicitContextTag1::from(IntegerAsn1::from(vec![AP_REQ_MSG_TYPE])),
ap_options: ExplicitContextTag2::from(ApOptions::from(BitString::with_bytes(
options.bits().to_be_bytes().to_vec(),
))),
ticket: ExplicitContextTag3::from(ticket),
authenticator: ExplicitContextTag4::from(EncryptedData {
etype: ExplicitContextTag0::from(IntegerAsn1::from(vec![encryption_type.into()])),
kvno: Optional::from(None),
cipher: ExplicitContextTag2::from(OctetStringAsn1::from(encrypted_authenticator)),
}),
}))
}
/// Returns supported authentication types.
pub fn get_mech_list() -> MechTypeList {
MechTypeList::from(vec![MechType::from(oids::ms_krb5()), MechType::from(oids::krb5())])
}
#[instrument(level = "trace", ret)]
pub fn generate_krb_priv_request(
ticket: Ticket,
session_key: &Secret<Vec<u8>>,
new_password: &[u8],
authenticator: &Authenticator,
enc_params: &EncryptionParams,
seq_num: u32,
address: &str,
) -> Result<KrbPrivMessage> {
let ap_req = generate_ap_req(ticket, session_key, authenticator, enc_params, ApOptionsFlags::empty())?;
let enc_part = EncKrbPrivPart::from(EncKrbPrivPartInner {
user_data: ExplicitContextTag0::from(OctetStringAsn1::from(new_password.to_vec())),
timestamp: Optional::from(None),
usec: Optional::from(None),
seq_number: Optional::from(Some(ExplicitContextTag3::from(IntegerAsn1::from_bytes_be_unsigned(
seq_num.to_be_bytes().to_vec(),
)))),
s_address: ExplicitContextTag4::from(HostAddress {
addr_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![NET_BIOS_ADDR_TYPE])),
address: ExplicitContextTag1::from(OctetStringAsn1::from(address.as_bytes().to_vec())),
}),
r_address: Optional::from(None),
});
let encryption_type = enc_params.encryption_type.as_ref().unwrap_or(&DEFAULT_ENCRYPTION_TYPE);
let cipher = encryption_type.cipher();
let encryption_key = &authenticator.0.subkey.0.as_ref().unwrap().key_value.0;
let encoded_krb_priv = picky_asn1_der::to_vec(&enc_part)?;
let enc_part = cipher.encrypt(encryption_key, KRB_PRIV_ENC_PART, &encoded_krb_priv)?;
trace!(?encryption_key, ?encryption_type, "KRB_PRIV encryption params",);
trace!(
plain = ?encoded_krb_priv,
encrypted = ?enc_part,
"KRB_PRIV encrypted part",
);
let krb_priv = KrbPriv::from(KrbPrivInner {
pvno: ExplicitContextTag0::from(IntegerAsn1::from(vec![KERBEROS_VERSION])),
msg_type: ExplicitContextTag1::from(IntegerAsn1::from(vec![KRB_PRIV])),
enc_part: ExplicitContextTag3::from(EncryptedData {
etype: ExplicitContextTag0::from(IntegerAsn1::from(vec![encryption_type.into()])),
kvno: Optional::from(None),
cipher: ExplicitContextTag2::from(OctetStringAsn1::from(enc_part)),
}),
});
Ok(KrbPrivMessage {
ap_message: ApMessage::ApReq(ap_req),
krb_priv,
})
}
#[cfg(test)]
mod tests {
use picky_krb::constants::types::NT_PRINCIPAL;
use super::*;
#[test]
fn test_set_flags() {
let mut checksum_values = ChecksumValues::default();
let flags = GssFlags::GSS_C_MUTUAL_FLAG | GssFlags::GSS_C_REPLAY_FLAG;
checksum_values.set_flags(flags);
let expected_bytes = flags.bits().to_le_bytes();
assert_eq!(checksum_values.inner[20..24], expected_bytes);
}
#[test]
fn test_default() {
// ensure backwards compatibility
let checksum_values = ChecksumValues::default();
assert_eq!(checksum_values.into_inner(), AUTHENTICATOR_DEFAULT_CHECKSUM);
}
#[test]
fn test_flag_for_sign_and_seal() {
let mut checksum_values = ChecksumValues::default();
let flags = GssFlags::GSS_C_MUTUAL_FLAG
| GssFlags::GSS_C_REPLAY_FLAG
| GssFlags::GSS_C_SEQUENCE_FLAG
| GssFlags::GSS_C_CONF_FLAG
| GssFlags::GSS_C_INTEG_FLAG;
checksum_values.set_flags(flags);
let expected_bytes = [0x3E, 0x00, 0x00, 0x00];
assert_eq!(checksum_values.inner[20..24], expected_bytes);
}
fn cname_components(username: &str) -> Vec<String> {
let body = generate_as_req_kdc_body(&GenerateAsReqOptions {
realm: "CRABKA.TEST",
username,
cname_type: NT_PRINCIPAL,
snames: &["krbtgt", "CRABKA.TEST"],
nonce: &[0, 0, 0, 1],
hostname: "host",
context_requirements: ClientRequestFlags::empty(),
})
.expect("generate as-req body");
body.cname
.0
.expect("cname present")
.0
.name_string
.0
.0
.iter()
.map(|s| s.0.to_string())
.collect()
}
#[test]
fn single_component_cname() {
assert_eq!(cname_components("alice"), vec!["alice".to_string()]);
}
#[test]
fn service_principal_cname_is_split_on_slash() {
// A `kafka/host` service principal must be encoded as two name-string
// components, or MIT KDC rejects the principal as unknown.
assert_eq!(
cname_components("kafka/host"),
vec!["kafka".to_string(), "host".to_string()]
);
}
#[test]
fn keytab_pa_datas_without_pre_auth_is_just_pac_request() {
let pa_datas = generate_pa_datas_for_as_req_with_key(&GenerateKeytabPaDataOptions {
key: vec![0u8; 32].into(),
key_enctype: CipherSuite::Aes256CtsHmacSha196,
with_pre_auth: false,
})
.expect("generate keytab pa-datas");
// No pre-auth requested: only the PA-PAC-REQUEST is emitted.
assert_eq!(pa_datas.len(), 1);
}
#[test]
fn keytab_pa_datas_with_pre_auth_includes_enc_timestamp() {
let pa_datas = generate_pa_datas_for_as_req_with_key(&GenerateKeytabPaDataOptions {
key: vec![0u8; 32].into(),
key_enctype: CipherSuite::Aes256CtsHmacSha196,
with_pre_auth: true,
})
.expect("generate keytab pa-datas");
// PA-ENC-TIMESTAMP plus PA-PAC-REQUEST.
assert_eq!(pa_datas.len(), 2);
}
}
+597
View File
@@ -0,0 +1,597 @@
mod as_exchange;
mod change_password;
pub mod extractors;
pub mod generators;
pub mod principal;
use std::io::Write;
pub(crate) use as_exchange::as_exchange;
pub use change_password::change_password;
use picky_asn1_x509::oids;
use picky_krb::constants::gss_api::{AP_REP_TOKEN_ID, AP_REQ_TOKEN_ID, AUTHENTICATOR_CHECKSUM_TYPE, TGT_REQ_TOKEN_ID};
use picky_krb::crypto::CipherSuite;
use picky_krb::data_types::{KrbResult, PrincipalName, ResultExt};
use picky_krb::messages::{ApRep, TgsRep};
use rand::rngs::{StdRng, SysRng};
use rand_core::{Rng as _, SeedableRng as _};
use self::extractors::{
extract_encryption_params_from_as_rep, extract_seq_number_from_ap_rep, extract_session_key_from_tgs_rep,
extract_sub_session_key_from_ap_rep, extract_tgt_ticket_with_oid,
};
use self::generators::{
ChecksumOptions, ChecksumValues, EncKey, GenerateAsPaDataOptions, GenerateAsReqOptions,
GenerateAuthenticatorOptions, GenerateKeytabPaDataOptions, GenerateTgsReqOptions, GssFlags, generate_ap_rep,
generate_ap_req, generate_as_req_kdc_body, generate_authenticator, generate_tgs_req,
};
use self::principal::{
ClientPrincipalName, get_client_principal_name, get_client_principal_name_type, get_client_principal_realm,
};
use crate::channel_bindings::ChannelBindings;
use crate::generator::YieldPointLocal;
use crate::kerberos::client::generators::generate_tgt_req;
use crate::kerberos::messages::{decode_krb_message, generate_krb_message};
use crate::kerberos::pa_datas::{AsRepSessionKeyExtractor, AsReqPaDataOptions};
use crate::kerberos::utils::serialize_message;
use crate::kerberos::{DEFAULT_ENCRYPTION_TYPE, EC, TGT_SERVICE_NAME};
use crate::utils::{generate_random_symmetric_key, parse_target_name};
use crate::{
BufferType, ClientRequestFlags, ClientResponseFlags, CredentialsBuffers, Error, ErrorKind,
InitializeSecurityContextResult, Kerberos, KerberosState, Result, SecurityBuffer, SecurityStatus, SspiImpl,
};
/// Inspects the `sname` of a ticket returned in a TGS-REP to decide whether it is a cross-realm
/// referral TGT rather than the requested service ticket.
///
/// A referral TGT has an `sname` of the form `krbtgt/<NEXT_REALM>` (RFC 4120 §3.3.3.2). In that
/// case this returns `Some(next_realm)` and the caller must re-issue the TGS-REQ to `<NEXT_REALM>`.
/// For an actual service ticket (e.g. `TERMSRV/host`) it returns `None`.
fn referral_target_realm(sname: &PrincipalName) -> Option<String> {
let names = &sname.name_string.0.0;
match names.as_slice() {
[service, realm] if service.to_string().eq_ignore_ascii_case(TGT_SERVICE_NAME) => Some(realm.to_string()),
_ => None,
}
}
/// Performs one authentication step.
///
/// The user should call this function until it returns `SecurityStatus::Ok`.
pub async fn initialize_security_context<'a>(
client: &'a mut Kerberos,
yield_point: &mut YieldPointLocal,
builder: &'a mut crate::builders::FilledInitializeSecurityContext<
'_,
'_,
<Kerberos as SspiImpl>::CredentialsHandle,
>,
) -> Result<InitializeSecurityContextResult> {
trace!(?builder);
if let KerberosState::TgtExchange = client.state {
if builder
.context_requirements
.contains(ClientRequestFlags::USE_SESSION_KEY)
{
client.krb5_user_to_user = true;
let (service_name, service_principal_name) = parse_target_name(builder.target_name.ok_or_else(|| {
Error::new(
ErrorKind::NoCredentials,
"Service target name (service principal name) is not provided",
)
})?)?;
let tgt_req = generate_tgt_req(&[service_name, service_principal_name])?;
let encoded_neg_tgt_req = if !builder.context_requirements.contains(ClientRequestFlags::USE_DCE_STYLE) {
generate_krb_message(oids::krb5_user_to_user(), TGT_REQ_TOKEN_ID, tgt_req)?
} else {
// Do not wrap if the `USE_DCE_STYLE` flag is set.
// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-kile/190ab8de-dc42-49cf-bf1b-ea5705b7a087
picky_asn1_der::to_vec(&tgt_req)?
};
let output_token = SecurityBuffer::find_buffer_mut(builder.output, BufferType::Token)?;
output_token.buffer = encoded_neg_tgt_req;
client.state = KerberosState::Preauthentication;
trace!(output_buffers = ?builder.output);
return Ok(InitializeSecurityContextResult {
status: SecurityStatus::ContinueNeeded,
flags: ClientResponseFlags::empty(),
expiry: None,
});
} else {
client.state = KerberosState::Preauthentication;
}
}
let status = match client.state {
KerberosState::Preauthentication => {
let input = builder
.input
.as_ref()
.ok_or_else(|| Error::new(ErrorKind::InvalidToken, "input buffers must be specified"))?;
if let Ok(sec_buffer) =
SecurityBuffer::find_buffer(builder.input.as_ref().unwrap(), BufferType::ChannelBindings)
{
client.channel_bindings = Some(ChannelBindings::from_bytes(&sec_buffer.buffer)?);
}
let input_token = SecurityBuffer::find_buffer(input, BufferType::Token)
.map(|security_buffer| security_buffer.buffer.as_slice())
.unwrap_or_default();
let (tgt_ticket, mech_id) = if let Some((tbt_ticket, mech_oid)) = extract_tgt_ticket_with_oid(input_token)?
{
(Some(tbt_ticket), mech_oid.0)
} else {
(None, oids::krb5())
};
client.krb5_user_to_user = mech_id == oids::krb5_user_to_user();
let credentials = builder
.credentials_handle
.as_ref()
.unwrap()
.as_ref()
.ok_or_else(|| Error::new(ErrorKind::WrongCredentialHandle, "No credentials provided"))?;
let (username, password, realm, cname_type) = match credentials {
CredentialsBuffers::AuthIdentity(auth_identity) => {
let username = auth_identity.user.to_string();
let domain = auth_identity.domain.to_string();
let password = auth_identity.password.as_ref().as_ref().to_string();
let realm = get_client_principal_realm(&username, &domain);
let cname_type = get_client_principal_name_type(&username, &domain);
(username, password, realm, cname_type)
}
#[cfg(feature = "scard")]
CredentialsBuffers::SmartCard(smart_card) => {
let username = smart_card.username.to_string();
let password = smart_card.pin.as_ref().as_ref().to_string();
let realm = get_client_principal_realm(&username, "");
let cname_type = get_client_principal_name_type(&username, "");
(username, password, realm.to_uppercase(), cname_type)
}
CredentialsBuffers::Keytab(keytab) => {
// The name type is read off the principal's user name format explicitly.
let ClientPrincipalName {
name,
realm_domain,
name_type,
} = get_client_principal_name(&keytab.principal);
let realm = get_client_principal_realm(name, realm_domain);
// No password: the keytab key is used directly for pre-auth.
(name.to_owned(), String::new(), realm, name_type)
}
};
client.realm = Some(realm.clone());
let mut rand = StdRng::try_from_rng(&mut SysRng)?;
let options = GenerateAsReqOptions {
realm: &realm,
username: &username,
cname_type,
snames: &[TGT_SERVICE_NAME, &realm],
// 4 = size of u32
nonce: &rand.next_u32().to_be_bytes(),
hostname: &client.config.client_computer_name,
context_requirements: builder.context_requirements,
};
let kdc_req_body = generate_as_req_kdc_body(&options)?;
let pa_data_options = match credentials {
CredentialsBuffers::AuthIdentity(auth_identity) => {
let domain = auth_identity.domain.to_string();
let salt = format!("{domain}{username}").into_bytes();
AsReqPaDataOptions::AuthIdentity(GenerateAsPaDataOptions {
password: &password,
salt,
enc_params: client.encryption_params.clone(),
with_pre_auth: false,
})
}
CredentialsBuffers::Keytab(keytab) => AsReqPaDataOptions::Keytab(GenerateKeytabPaDataOptions {
key: keytab.key.clone(),
key_enctype: keytab.key_enctype.clone(),
with_pre_auth: false,
}),
#[cfg(feature = "scard")]
CredentialsBuffers::SmartCard(scard_identity_buffer) => {
use sha1::{Digest, Sha1};
use crate::pku2u::generate_client_dh_parameters;
use crate::smartcard::SmartCard;
use crate::{SmartCardIdentity, pk_init};
let scard_identity = SmartCardIdentity::try_from(scard_identity_buffer)?;
let mut smart_card = SmartCard::from_credentials(&scard_identity)?;
let p2p_cert = scard_identity.certificate;
client.dh_parameters = Some(generate_client_dh_parameters(&mut rand));
AsReqPaDataOptions::SmartCard(Box::new(pk_init::GenerateAsPaDataOptions {
p2p_cert,
kdc_req_body: &kdc_req_body,
dh_parameters: client.dh_parameters.clone().unwrap(),
sign_data: Box::new(move |data_to_sign| {
let mut sha1 = Sha1::new();
sha1.update(data_to_sign);
let digest = sha1.finalize().to_vec();
smart_card.sign(digest)
}),
with_pre_auth: false,
authenticator_nonce: rand.next_u32().to_be_bytes(),
}))
}
};
let as_rep = as_exchange(client, yield_point, &kdc_req_body, pa_data_options).await?;
debug!("AS exchange finished successfully.");
client.realm = Some(as_rep.0.crealm.0.to_string());
let (encryption_type, salt) = extract_encryption_params_from_as_rep(&as_rep)?;
let encryption_type = CipherSuite::try_from(encryption_type as usize)?;
client.encryption_params.encryption_type = Some(encryption_type);
let mut session_key_extractor = match credentials {
CredentialsBuffers::AuthIdentity(_) => AsRepSessionKeyExtractor::AuthIdentity {
salt: &salt,
password: &password,
enc_params: &mut client.encryption_params,
},
CredentialsBuffers::Keytab(keytab) => AsRepSessionKeyExtractor::Keytab {
key: keytab.key.as_ref(),
enc_params: &client.encryption_params,
},
#[cfg(feature = "scard")]
CredentialsBuffers::SmartCard(_) => AsRepSessionKeyExtractor::SmartCard {
dh_parameters: client.dh_parameters.as_mut().unwrap(),
enc_params: &mut client.encryption_params,
},
};
let session_key_1 = session_key_extractor.session_key(&as_rep)?;
let service_principal = builder.target_name.ok_or_else(|| {
Error::new(
ErrorKind::NoCredentials,
"Service target name (service principal name) is not provided",
)
})?;
let mut context_requirements = builder.context_requirements;
if client.krb5_user_to_user && !context_requirements.contains(ClientRequestFlags::USE_SESSION_KEY) {
warn!(
"KRB5 U2U has been negotiated (selected by the server) but the USE_SESSION_KEY flag is not set. Forcibly turning it on..."
);
context_requirements.set(ClientRequestFlags::USE_SESSION_KEY, true);
}
// Cross-realm referral chasing (RFC 4120 §3.3.3.2 / MS-KILE).
//
// * [Cross-Realm Operation](https://www.rfc-editor.org/rfc/rfc4120.html#section-1.2)
// * [Server Referrals](https://www.rfc-editor.org/rfc/rfc6806.html#section-8)
//
// A KDC can only issue tickets for principals in its own realm. When the requested
// service lives in another realm (e.g. a user in `RJM.LOCAL` targeting a host in the
// child realm `DEV.RJM.LOCAL`), the KDC does not return the service ticket. Instead it
// returns a referral TGT whose `sname` is `krbtgt/<NEXT_REALM>`, and the client must
// re-issue the TGS-REQ for the same service to `<NEXT_REALM>`'s KDC using that referral
// TGT. We loop until the returned ticket's `sname` matches the requested service (i.e.
// it is no longer a `krbtgt/...` referral).
//
// The referral hop is routed via `send_for_realm`, which resolves the target realm's
// KDC through `SSPI_KDC_URL_<REALM>` (env) / krb5.conf / DNS SRV rather than the pinned
// home-realm KDC, which cannot decrypt a `krbtgt/<NEXT_REALM>` referral ticket.
const MAX_REFERRAL_HOPS: usize = 10;
let mut realm = as_rep.0.crealm.0.to_string();
let mut ticket = as_rep.0.ticket.0.clone();
let mut tgt_session_key = session_key_1;
// KDC-REP that the AP_REQ authenticator (cname/crealm) for the next hop is built from.
let mut auth_rep = as_rep.0.clone();
// Only meaningful for U2U; carried on the first hop and dropped afterwards.
let mut additional_tickets = tgt_ticket.map(|ticket| vec![ticket]);
let mut hops = 0;
let (tgs_rep, session_key_2) = loop {
let mut authenticator = generate_authenticator(GenerateAuthenticatorOptions {
kdc_rep: &auth_rep,
seq_num: Some(rand.next_u32()),
sub_key: None,
checksum: None,
channel_bindings: client.channel_bindings.as_ref(),
extensions: Vec::new(),
})?;
let tgs_req = generate_tgs_req(GenerateTgsReqOptions {
realm: &realm,
service_principal,
session_key: &tgt_session_key,
ticket,
authenticator: &mut authenticator,
additional_tickets: additional_tickets.take(),
enc_params: &client.encryption_params,
context_requirements,
})?;
let response = client
.send_for_realm(yield_point, &realm, &serialize_message(&tgs_req)?)
.await?;
if response.len() < 4 {
return Err(Error::new(
ErrorKind::InternalError,
"the KDC reply message is too small: expected at least 4 bytes",
));
}
// first 4 bytes are message len. skipping them
let mut d = picky_asn1_der::Deserializer::new_from_bytes(&response[4..]);
let tgs_rep: KrbResult<TgsRep> = KrbResult::deserialize(&mut d)?;
let tgs_rep = tgs_rep?;
let session_key =
extract_session_key_from_tgs_rep(&tgs_rep, &tgt_session_key, &client.encryption_params)?;
// A referral TGT is identified by an `sname` of the form `krbtgt/<NEXT_REALM>`.
let Some(next_realm) = referral_target_realm(&tgs_rep.0.ticket.0.0.sname.0) else {
debug!("TGS exchange finished successfully");
break (tgs_rep, session_key);
};
debug!(%realm, %next_realm, "Received cross-realm referral TGT; chasing referral");
hops += 1;
if hops >= MAX_REFERRAL_HOPS {
return Err(Error::new(
ErrorKind::NoAuthenticatingAuthority,
format!(
"exceeded maximum Kerberos referral hops ({MAX_REFERRAL_HOPS}) resolving {service_principal}"
),
));
}
if next_realm.eq_ignore_ascii_case(&realm) {
return Err(Error::new(
ErrorKind::NoAuthenticatingAuthority,
format!("Kerberos referral did not progress past realm `{realm}`"),
));
}
ticket = tgs_rep.0.ticket.0.clone();
tgt_session_key = session_key;
auth_rep = tgs_rep.0;
realm = next_realm;
};
client.encryption_params.session_key = Some(session_key_2);
let enc_type = client
.encryption_params
.encryption_type
.as_ref()
.unwrap_or(&DEFAULT_ENCRYPTION_TYPE);
let authenticator_sub_key = generate_random_symmetric_key(enc_type, &mut rand);
// the original flag is
// GSS_C_MUTUAL_FLAG | GSS_C_REPLAY_FLAG | GSS_C_SEQUENCE_FLAG | GSS_C_CONF_FLAG | GSS_C_INTEG_FLAG
// we want to be able to turn of sign and seal, so we leave confidentiality and integrity flags out
let mut flags: GssFlags = builder.context_requirements.into();
if flags.contains(GssFlags::GSS_C_DELEG_FLAG) {
// Below are reasons why we turn off the GSS_C_DELEG_FLAG flag.
//
// RFC4121: The Kerberos Version 5 GSS-API. Section 4.1.1: Authenticator Checksum
// https://datatracker.ietf.org/doc/html/rfc4121#section-4.1.1.1
//
// "The length of the checksum field MUST be at least 24 octets when GSS_C_DELEG_FLAG is not set,
// and at least 28 octets plus Dlgth octets when GSS_C_DELEG_FLAG is set."
// Out implementation _always_ uses the 24 octets checksum and do not support Kerberos credentials delegation.
//
// "When delegation is used, a ticket-granting ticket will be transferred in a KRB_CRED message."
// We do not support KRB_CRED messages. So, the GSS_C_DELEG_FLAG flags should be turned off.
warn!("Kerberos ApReq Authenticator checksum GSS_C_DELEG_FLAG is not supported. Turning it off...");
flags.remove(GssFlags::GSS_C_DELEG_FLAG);
}
debug!(?flags, "ApReq Authenticator checksum flags");
let mut checksum_value = ChecksumValues::default();
checksum_value.set_flags(flags);
let authenticator_options = GenerateAuthenticatorOptions {
kdc_rep: &tgs_rep.0,
// The AP_REQ Authenticator sequence number should be the same as `seq_num` in the first Kerberos Wrap/MIC token generated
// by the `encrypt_message`/`generate_mic_token` method. So, we set the next sequence number but do not increment the counter,
// which will be incremented on each `encrypt_message`/`generate_mic_token` method call.
seq_num: Some(client.seq_number + 1),
sub_key: Some(EncKey {
key_type: enc_type.clone(),
key_value: authenticator_sub_key,
}),
checksum: Some(ChecksumOptions {
checksum_type: AUTHENTICATOR_CHECKSUM_TYPE.to_vec(),
checksum_value,
}),
channel_bindings: client.channel_bindings.as_ref(),
extensions: Vec::new(),
};
let authenticator = generate_authenticator(authenticator_options)?;
let ap_req = generate_ap_req(
tgs_rep.0.ticket.0,
client
.encryption_params
.session_key
.as_ref()
.ok_or_else(|| Error::new(ErrorKind::InternalError, "session key is not set"))?,
&authenticator,
&client.encryption_params,
context_requirements.into(),
)?;
let encoded_neg_ap_req = if !builder.context_requirements.contains(ClientRequestFlags::USE_DCE_STYLE) {
generate_krb_message(mech_id, AP_REQ_TOKEN_ID, ap_req)?
} else {
// Do not wrap if the `USE_DCE_STYLE` flag is set.
// https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-kile/190ab8de-dc42-49cf-bf1b-ea5705b7a087
picky_asn1_der::to_vec(&ap_req)?
};
let output_token = SecurityBuffer::find_buffer_mut(builder.output, BufferType::Token)?;
output_token.buffer = encoded_neg_ap_req;
client.state = KerberosState::ApExchange;
SecurityStatus::ContinueNeeded
}
KerberosState::ApExchange => {
let input = builder
.input
.as_ref()
.ok_or_else(|| Error::new(ErrorKind::InvalidToken, "Input buffers must be specified"))?;
let input_token = SecurityBuffer::find_buffer(input, BufferType::Token)?;
if builder.context_requirements.contains(ClientRequestFlags::USE_DCE_STYLE) {
// The `EC` field depends on the authentication type. For example, during RDP auth
// it is equal to 0, but during RPC auth it is equal to EC.
client.encryption_params.ec = EC;
use picky_krb::messages::ApRep;
let ap_rep: ApRep = picky_asn1_der::from_bytes(&input_token.buffer)?;
let session_key = client
.encryption_params
.session_key
.as_ref()
.ok_or_else(|| Error::new(ErrorKind::InternalError, "session key is not set"))?;
let sub_session_key =
extract_sub_session_key_from_ap_rep(&ap_rep, session_key, &client.encryption_params)?;
let seq_number = extract_seq_number_from_ap_rep(&ap_rep, session_key, &client.encryption_params)?;
trace!(?sub_session_key, "DCE AP_REP sub-session key");
client.encryption_params.sub_session_key = Some(sub_session_key);
let ap_rep = generate_ap_rep(session_key, seq_number, &client.encryption_params)?;
let ap_rep = picky_asn1_der::to_vec(&ap_rep)?;
let output_token = SecurityBuffer::find_buffer_mut(builder.output, BufferType::Token)?;
output_token.buffer.write_all(&ap_rep)?;
} else {
let ap_rep = decode_krb_message::<ApRep>(&input_token.buffer, AP_REP_TOKEN_ID)?;
let session_key = client
.encryption_params
.session_key
.as_ref()
.ok_or_else(|| Error::new(ErrorKind::InternalError, "session key is not set"))?;
let sub_session_key =
extract_sub_session_key_from_ap_rep(&ap_rep, session_key, &client.encryption_params)?;
client.encryption_params.sub_session_key = Some(sub_session_key);
client.next_seq_number();
}
client.state = KerberosState::Final;
SecurityStatus::Ok
}
KerberosState::Final | KerberosState::TgtExchange => {
return Err(Error::new(
ErrorKind::OutOfSequence,
format!("got wrong Kerberos state: {:?}", client.state),
));
}
};
trace!(output_buffers = ?builder.output);
Ok(InitializeSecurityContextResult {
status,
flags: ClientResponseFlags::empty(),
expiry: None,
})
}
#[cfg(test)]
mod tests {
use picky_asn1::restricted_string::IA5String;
use picky_asn1::wrapper::{Asn1SequenceOf, ExplicitContextTag0, ExplicitContextTag1, IntegerAsn1};
use picky_krb::constants::types::{NT_PRINCIPAL, NT_SRV_INST};
use picky_krb::data_types::{KerberosStringAsn1, PrincipalName};
use super::referral_target_realm;
fn principal_name(name_type: u8, names: &[&str]) -> PrincipalName {
PrincipalName {
name_type: ExplicitContextTag0::from(IntegerAsn1::from(vec![name_type])),
name_string: ExplicitContextTag1::from(Asn1SequenceOf::from(
names
.iter()
.map(|n| KerberosStringAsn1::from(IA5String::from_string((*n).to_owned()).unwrap()))
.collect::<Vec<_>>(),
)),
}
}
#[test]
fn referral_target_realm_detects_cross_realm_tgt() {
// krbtgt/<NEXT_REALM> => chase into NEXT_REALM.
let sname = principal_name(NT_SRV_INST, &["krbtgt", "DEV.RJM.LOCAL"]);
assert_eq!(referral_target_realm(&sname), Some("DEV.RJM.LOCAL".to_owned()));
}
#[test]
fn referral_target_realm_is_case_insensitive_on_service() {
// The service component comparison must ignore case ("krbtgt" vs "KRBTGT").
let sname = principal_name(NT_SRV_INST, &["KrbTgt", "CORP.EXAMPLE.COM"]);
assert_eq!(referral_target_realm(&sname), Some("CORP.EXAMPLE.COM".to_owned()));
}
#[test]
fn referral_target_realm_ignores_actual_service_ticket() {
// A real service ticket (TERMSRV/host) is not a referral.
let sname = principal_name(NT_SRV_INST, &["TERMSRV", "WIN-UE7FOENEK0D.dev.rjm.local"]);
assert_eq!(referral_target_realm(&sname), None);
}
#[test]
fn referral_target_realm_ignores_non_two_component_names() {
// A lone "krbtgt" (one component) or a 3-component name is not a referral.
assert_eq!(referral_target_realm(&principal_name(NT_PRINCIPAL, &["krbtgt"])), None);
assert_eq!(
referral_target_realm(&principal_name(NT_SRV_INST, &["krbtgt", "A.COM", "B.COM"])),
None
);
assert_eq!(referral_target_realm(&principal_name(NT_SRV_INST, &[])), None);
}
#[test]
fn referral_target_realm_ignores_non_krbtgt_two_component_service() {
// Two components but not krbtgt (e.g. host-based service with an instance) is not a referral.
let sname = principal_name(NT_SRV_INST, &["cifs", "fileserver.dev.rjm.local"]);
assert_eq!(referral_target_realm(&sname), None);
}
}
+209
View File
@@ -0,0 +1,209 @@
//! Helpers deriving the Kerberos client principal (name, name type and realm) from credentials.
use std::env;
use std::path::Path;
use picky_krb::constants::types::{NT_ENTERPRISE, NT_PRINCIPAL};
use crate::krb::Krb5Conf;
use crate::{Username, UsernameParts};
/// [MS-KILE] 3.3.5.6.1 Client Principal Lookup
/// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-kile/6435d3fb-8cf6-4df5-a156-1277690ed59c
//
// FIXME: this should take a `&Username` instead of two `&str` parameters. It currently sniffs for an
// `@` to guess the name type (and ignores `_domain` entirely), which is exactly the lossy heuristic
// `Username`/`UsernameParts` exist to replace: the name type can be read off the user name format
// directly (see `get_client_principal_name`). Once the deprecated string-based callers are gone (#708),
// fold this into `get_client_principal_name`.
pub fn get_client_principal_name_type(username: &str, _domain: &str) -> u8 {
if username.contains('@') {
NT_ENTERPRISE
} else {
NT_PRINCIPAL
}
}
/// The Kerberos client name (cname) derived from a [`Username`], along with the pieces needed to
/// build the AS-REQ.
///
/// This centralizes the mapping from a [`UserNameFormat`](crate::UserNameFormat) to a Kerberos
/// principal name type: it reads the name type off the username format explicitly instead of
/// sniffing for an `@`, so a UPN principal is an NT-ENTERPRISE name (whose full value is the client
/// name) and a down-level logon name is an NT-PRINCIPAL name identified by its account name alone.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct ClientPrincipalName<'a> {
/// The name to place in the AS-REQ as the Kerberos client name (cname).
pub name: &'a str,
/// The domain used to resolve the client realm (the UPN suffix or the NetBIOS domain).
pub realm_domain: &'a str,
/// The Kerberos principal name type (`NT-ENTERPRISE` or `NT-PRINCIPAL`).
pub name_type: u8,
}
/// Derives the [`ClientPrincipalName`] for a principal from its user name format.
pub fn get_client_principal_name(username: &Username) -> ClientPrincipalName<'_> {
match username.parts() {
UsernameParts::UserPrincipalName(parts) => ClientPrincipalName {
name: parts.upn(),
realm_domain: parts.suffix(),
name_type: NT_ENTERPRISE,
},
UsernameParts::DownLevelLogonName(parts) => ClientPrincipalName {
name: parts.account_name(),
realm_domain: parts.netbios_domain().unwrap_or_default(),
name_type: NT_PRINCIPAL,
},
}
}
// FIXME: like `get_client_principal_name_type`, this should take a `&Username` (or a `UsernameParts`)
// instead of two `&str` parameters. `get_client_principal_realm_impl` re-derives the suffix by
// splitting on `@` to reconcile the `username`/`domain` pair, which duplicates the parsing
// `Username::parts()` already does. Migrating callers off the raw-string form (#708) would
// let this take the parsed username directly and drop the ad-hoc split.
pub fn get_client_principal_realm(username: &str, domain: &str) -> String {
// https://web.mit.edu/kerberos/krb5-current/doc/user/user_config/kerberos.html#environment-variables
let krb5_config = env::var("KRB5_CONFIG").unwrap_or_else(|_| "/etc/krb5.conf:/usr/local/etc/krb5.conf".to_string());
let krb5_conf_paths = krb5_config.split(':').map(Path::new).collect::<Vec<&Path>>();
get_client_principal_realm_impl(&krb5_conf_paths, username, domain)
}
fn get_client_principal_realm_impl(krb5_conf_paths: &[&Path], username: &str, domain: &str) -> String {
let domain = if domain.is_empty() {
// Match `Username::parse`, which splits a UPN on its *last* `@` (account names may contain `@`).
if let Some((_left, right)) = username.rsplit_once('@') {
right.to_string()
} else {
String::new()
}
} else {
domain.to_string()
};
for krb5_conf_path in krb5_conf_paths {
if !krb5_conf_path.exists() {
continue;
}
if let Some(krb5_conf) = Krb5Conf::new_from_file(krb5_conf_path)
&& let Some(mappings) = krb5_conf.get_values_in_section(&["domain_realm"])
{
for (mapping_domain, realm) in mappings {
if matches_domain(&domain, mapping_domain) {
return realm.to_owned();
}
}
}
}
domain.to_uppercase()
}
/// Checks if the given domain matches the mapping domain (usually from krb5.conf file).
///
/// # Mapping rules
///
/// We follow the MIT KRB5 behavior: https://github.com/krb5/krb5/commit/8f5ce824012f2caab6770df464f096c38dc4cb2e.
///
/// - If the mapping domain starts with a dot (e.g., `.example.com`),
/// it matches all hosts under the domain, but not the host with the name `example.com`.
/// For example, "test.example.com" or "d1.example.com" will match `.example.com`, but `example.com` will not.
/// - If the mapping domain does not start with a dot (e.g., `example.com`),
/// it matches all hosts under the domain `example.com` (including `example.com`).
///
/// So, the mappings order in `krb5.conf` matters.
fn matches_domain(domain: &str, mapping_domain: &str) -> bool {
let domain = domain.to_lowercase();
let mapping_domain = mapping_domain.to_lowercase();
if mapping_domain.starts_with('.') {
// If mapping_domain starts with a dot, it matches subdomains only
// e.g., `.example.com` matches `test.example.com` but not `example.com`
domain.ends_with(&mapping_domain)
} else {
// If mapping_domain doesn't start with a dot, it matches the domain itself
// and all subdomains (e.g., `example.com` matches `example.com` and `test.example.com`).
domain == mapping_domain || domain.ends_with(&format!(".{mapping_domain}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
const KRB5_CONFIG_FILE_PATH: &str = "test_assets/krb5.conf";
#[test]
fn client_principal_name_from_upn_is_enterprise() {
let username = Username::parse("user@example.com").expect("UPN");
let cname = get_client_principal_name(&username);
assert_eq!(cname.name, "user@example.com");
assert_eq!(cname.realm_domain, "example.com");
assert_eq!(cname.name_type, NT_ENTERPRISE);
}
#[test]
fn client_principal_name_from_down_level_is_principal() {
let username = Username::parse("EXAMPLE\\user").expect("down-level logon name");
let cname = get_client_principal_name(&username);
assert_eq!(cname.name, "user");
assert_eq!(cname.realm_domain, "EXAMPLE");
assert_eq!(cname.name_type, NT_PRINCIPAL);
}
#[test]
fn client_principal_name_from_bare_name_has_empty_realm_domain() {
let username = Username::parse("user").expect("bare name");
let cname = get_client_principal_name(&username);
assert_eq!(cname.name, "user");
assert_eq!(cname.realm_domain, "");
assert_eq!(cname.name_type, NT_PRINCIPAL);
}
#[test]
fn test_get_client_principal_realm_from_domain() {
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "", "TBT.COM");
assert_eq!(realm, "TBT.COM");
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "", "C1.DEV.TBT.COM");
assert_eq!(realm, "TEST.TBT.COM");
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "", "P1.C2.DEV.TBT.COM");
assert_eq!(realm, "TEST.TBT.COM");
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "", "DEV.TBT.COM");
assert_eq!(realm, "DEV.TBT.COM");
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "", "TEST.TBT.COM");
assert_eq!(realm, "STAGE.TBT.COM");
}
#[test]
fn test_get_client_principal_realm_from_username() {
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "user@tbt.com", "");
assert_eq!(realm, "TBT.COM");
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "user@c1.dev.tbt.com", "");
assert_eq!(realm, "TEST.TBT.COM");
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "user@p1.c2.dev.tbt.com", "");
assert_eq!(realm, "TEST.TBT.COM");
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "user@dev.tbt.com", "");
assert_eq!(realm, "DEV.TBT.COM");
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "user@test.tbt.com", "");
assert_eq!(realm, "STAGE.TBT.COM");
}
#[test]
fn realm_from_username_splits_on_last_at() {
// `Username::parse` splits a UPN on its *last* `@`, so the realm must be derived from the
// suffix after the final `@` even when the account name itself contains an `@`.
let realm = get_client_principal_realm_impl(&[Path::new(KRB5_CONFIG_FILE_PATH)], "user@dept@tbt.com", "");
assert_eq!(realm, "TBT.COM");
}
}