Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 53 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ members = [
"crates/dpapi-native-transport",
"crates/dpapi-fuzzing",
"crates/dpapi-web",
"crates/kdc",
]
exclude = [
"tools/wasm-testcompile",
Expand Down
21 changes: 21 additions & 0 deletions crates/kdc/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "kdc"
version = "0.1.0"
edition = "2024"

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: If you don’t want to publish the initial version right now, you should add publish = false here (and remove that later when publishing is intended)

[dependencies]
picky-asn1 = { workspace = true, features = ["time_conversion"] }
picky-asn1-der.workspace = true
picky-asn1-x509.workspace = true
Comment on lines +7 to +9
Copy link
Member

@CBenoit CBenoit Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m really sorry to say that now, but we’ll stop using the picky crates soon in the future in favor of using the der crate from RustCrypto. Still, I think it’s fine to merge this code now that it’s here, but we should stop writing any new code using this infrastructure from now on. If it’s not too much work for you, you may consider changing that before we merge.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we create a task on our side for migrating the sspi-rs to der crate from RustCrypto?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it’s not too much work for you, you may consider changing that before we merge.

To remove picky-asn1-* usage in the kdc implementation, we need to migrate the picky-krb crate to der-based encoding/decoding. It will significantly delay the credentials-injection feature.

I would like to address it in a separate PR. We need to refactor the picky-krb first.

Copy link
Member

@CBenoit CBenoit Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see! In that case there was no choice for this crate. Please do not refactor immediately though, we need to establish priority first. Shipping the credentials injection feature is more important. My only request for now is to avoid using picky-asn1-* for new code unless absolutely required. I’ll send a follow up on Slack in the next days.

picky-krb.workspace = true
Copy link
Member

@CBenoit CBenoit Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For picky-krb it’s fine to keep it, but ultimately we’ll move it into sspi-rs, and migrate away from the picky infrastructure for serialization/deserialization.

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
232 changes: 232 additions & 0 deletions crates/kdc/src/as_exchange.rs
Original file line number Diff line number Diff line change
@@ -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<Secret<Vec<u8>>, 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(&timestamp_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<AsRep, KdcError> {
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::<ENC_AS_REP_PART_TYPE>(
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)),
}),
}))
}
Loading