Skip to content

Commit 10ed474

Browse files
feat(kdc): add initial KDC implementation (#541)
1 parent 56d5117 commit 10ed474

File tree

9 files changed

+1244
-4
lines changed

9 files changed

+1244
-4
lines changed

Cargo.lock

Lines changed: 53 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ members = [
2424
"crates/dpapi-native-transport",
2525
"crates/dpapi-fuzzing",
2626
"crates/dpapi-web",
27+
"crates/kdc",
2728
]
2829
exclude = [
2930
"tools/wasm-testcompile",

crates/kdc/Cargo.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
[package]
2+
name = "kdc"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
picky-asn1 = { workspace = true, features = ["time_conversion"] }
8+
picky-asn1-der.workspace = true
9+
picky-asn1-x509.workspace = true
10+
picky-krb.workspace = true
11+
tracing.workspace = true
12+
time.workspace = true
13+
serde.workspace = true
14+
15+
sspi = { path = "../..", version = "0.18" }
16+
17+
thiserror = "2.0"
18+
argon2 = { version = "0.5", features = ["std"] }
19+
20+
[lints]
21+
workspace = true

crates/kdc/src/as_exchange.rs

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
use std::time::Duration;
2+
3+
use argon2::password_hash::rand_core::{OsRng, RngCore as _};
4+
use picky_asn1::restricted_string::IA5String;
5+
use picky_asn1::wrapper::{
6+
Asn1SequenceOf, ExplicitContextTag0, ExplicitContextTag1, ExplicitContextTag2, ExplicitContextTag3,
7+
ExplicitContextTag4, ExplicitContextTag5, ExplicitContextTag6, IntegerAsn1, OctetStringAsn1, Optional,
8+
};
9+
use picky_krb::constants::etypes::{AES128_CTS_HMAC_SHA1_96, AES256_CTS_HMAC_SHA1_96};
10+
use picky_krb::constants::key_usages::AS_REP_ENC;
11+
use picky_krb::constants::types::{
12+
AS_REP_MSG_TYPE, AS_REQ_MSG_TYPE, ENC_AS_REP_PART_TYPE, PA_ENC_TIMESTAMP, PA_ENC_TIMESTAMP_KEY_USAGE,
13+
PA_ETYPE_INFO2_TYPE,
14+
};
15+
use picky_krb::crypto::CipherSuite;
16+
use picky_krb::data_types::{EncryptedData, EtypeInfo2Entry, KerberosStringAsn1, PaData, PaEncTsEnc};
17+
use picky_krb::messages::{AsRep, AsReq, KdcRep, KdcReq, KdcReqBody};
18+
use sspi::kerberos::TGT_SERVICE_NAME;
19+
use sspi::{KERBEROS_VERSION, Secret};
20+
use time::OffsetDateTime;
21+
22+
use crate::config::{DomainUser, KerberosServer};
23+
use crate::error::KdcError;
24+
use crate::ticket::{MakeTicketParams, RepEncPartParams, make_rep_enc_part, make_ticket};
25+
use crate::{find_user_credentials, validate_request_from_and_till, validate_request_sname};
26+
27+
/// Validates AS-REQ PA-DATAs.
28+
///
29+
/// The current implementation accepts only [PA_ENC_TIMESTAMP] pa-data (i.e. password-based logon).
30+
/// [PA_PK_AS_REQ] pa-data (i.e. scard-based logon) is not supported.
31+
fn validate_pa_data_timestamp(
32+
domain_user: &DomainUser,
33+
max_time_skew: u64,
34+
pa_datas: &[PaData],
35+
) -> Result<Secret<Vec<u8>>, KdcError> {
36+
let pa_data = pa_datas
37+
.iter()
38+
.find_map(|pa_data| {
39+
if pa_data.padata_type.0.0 == PA_ENC_TIMESTAMP {
40+
Some(pa_data.padata_data.0.0.as_slice())
41+
} else {
42+
None
43+
}
44+
})
45+
.ok_or(KdcError::PreAuthRequired(
46+
"PA_ENC_TIMESTAMP is not present in AS_REQ padata",
47+
))?;
48+
49+
let encrypted_timestamp: EncryptedData =
50+
picky_asn1_der::from_bytes(pa_data).map_err(|_| KdcError::PreAuthFailed("unable to decode pa-data value"))?;
51+
52+
let cipher = CipherSuite::try_from(encrypted_timestamp.etype.0.0.as_slice())
53+
.map_err(|_| KdcError::PreAuthFailed("invalid etype in PA_ENC_TIMESTAMP"))?
54+
.cipher();
55+
let key = Secret::new(
56+
cipher
57+
.generate_key_from_password(domain_user.password.as_bytes(), domain_user.salt.as_bytes())
58+
.map_err(|_| KdcError::InternalError("failed to generate user's key"))?,
59+
);
60+
61+
let timestamp_data = cipher
62+
.decrypt(
63+
key.as_ref(),
64+
PA_ENC_TIMESTAMP_KEY_USAGE,
65+
&encrypted_timestamp.cipher.0.0,
66+
)
67+
.map_err(|_| KdcError::Modified("PA_ENC_TIMESTAMP"))?;
68+
let timestamp: PaEncTsEnc = picky_asn1_der::from_bytes(&timestamp_data)
69+
.map_err(|_| KdcError::PreAuthFailed("unable to decode PaEncTsEnc value"))?;
70+
71+
let client_timestamp = OffsetDateTime::try_from(timestamp.patimestamp.0.0)
72+
.map_err(|_| KdcError::PreAuthFailed("unable to decode PaEncTsEnc timestamp value"))?;
73+
let current = OffsetDateTime::now_utc();
74+
75+
if client_timestamp > current || current - client_timestamp > Duration::from_secs(max_time_skew) {
76+
return Err(KdcError::ClockSkew("invalid pa-data: clock skew too great"));
77+
}
78+
79+
Ok(key)
80+
}
81+
82+
/// Performs AS exchange according to the RFC 4120.
83+
///
84+
/// RFC: [The Authentication Service Exchange](https://www.rfc-editor.org/rfc/rfc4120#section-3.1).
85+
pub(super) fn handle_as_req(as_req: &AsReq, kdc_config: &KerberosServer) -> Result<AsRep, KdcError> {
86+
let KdcReq {
87+
pvno,
88+
msg_type,
89+
padata,
90+
req_body,
91+
} = &as_req.0;
92+
93+
if pvno.0.0 != [KERBEROS_VERSION] {
94+
return Err(KdcError::BadKrbVersion {
95+
version: pvno.0.0.clone(),
96+
expected: KERBEROS_VERSION,
97+
});
98+
}
99+
100+
if msg_type.0.0 != [AS_REQ_MSG_TYPE] {
101+
return Err(KdcError::BadMsgType {
102+
msg_type: msg_type.0.0.clone(),
103+
expected: AS_REQ_MSG_TYPE,
104+
});
105+
}
106+
107+
let KdcReqBody {
108+
kdc_options,
109+
cname,
110+
realm: realm_asn1,
111+
sname,
112+
from,
113+
till,
114+
rtime: _,
115+
nonce,
116+
etype,
117+
addresses,
118+
enc_authorization_data: _,
119+
additional_tickets: _,
120+
} = &req_body.0;
121+
122+
let sname = sname
123+
.0
124+
.clone()
125+
.ok_or(KdcError::InvalidSname(
126+
"sname is not present in KDC request sname".to_owned(),
127+
))?
128+
.0;
129+
// The AS_REQ service name must meet the following requirements:
130+
// * The first string in sname must be equal to TGT_SERVICE_NAME.
131+
// * The second string in sname must be equal to KDC realm.
132+
validate_request_sname(&sname, &[TGT_SERVICE_NAME, &kdc_config.realm])?;
133+
134+
let realm = realm_asn1.0.0.as_utf8();
135+
if !realm.eq_ignore_ascii_case(&kdc_config.realm) {
136+
return Err(KdcError::WrongRealm(realm.to_owned()));
137+
}
138+
139+
let cname = &cname
140+
.0
141+
.as_ref()
142+
.ok_or(KdcError::ClientPrincipalUnknown(
143+
"the incoming KDC request does not contain client principal name".to_owned(),
144+
))?
145+
.0;
146+
let domain_user = find_user_credentials(cname, realm, kdc_config)?;
147+
148+
let pa_datas = &padata
149+
.0
150+
.as_ref()
151+
.ok_or(KdcError::PreAuthRequired("pa-data is missing in incoming AS_REQ"))?
152+
.0
153+
.0;
154+
155+
let user_key = validate_pa_data_timestamp(domain_user, kdc_config.max_time_skew, pa_datas)?;
156+
let as_req_nonce = nonce.0.0.clone();
157+
let etype_raw = etype
158+
.0
159+
.0
160+
.iter()
161+
.find(|etype| {
162+
// 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):
163+
// > The KDC will not issue tickets with a weak session key encryption type.
164+
if let Some(etype) = etype.0.first().copied().map(usize::from) {
165+
etype == AES256_CTS_HMAC_SHA1_96 || etype == AES128_CTS_HMAC_SHA1_96
166+
} else {
167+
false
168+
}
169+
})
170+
.ok_or(KdcError::NoSuitableEtype)?
171+
.0
172+
.as_slice();
173+
let etype = CipherSuite::try_from(etype_raw).map_err(|_| KdcError::NoSuitableEtype)?;
174+
let cipher = etype.cipher();
175+
let realm = realm_asn1.0.clone();
176+
let (auth_time, end_time) = validate_request_from_and_till(from.0.as_deref(), &till.0, kdc_config.max_time_skew)?;
177+
178+
let mut rng = OsRng;
179+
let mut session_key = vec![0; cipher.key_size()];
180+
rng.fill_bytes(&mut session_key);
181+
182+
let as_rep_enc_data = make_rep_enc_part::<ENC_AS_REP_PART_TYPE>(
183+
RepEncPartParams {
184+
etype: etype.clone(),
185+
session_key: session_key.clone(),
186+
nonce: as_req_nonce,
187+
kdc_options: kdc_options.0.clone(),
188+
auth_time,
189+
end_time,
190+
realm: realm.clone(),
191+
sname: sname.clone(),
192+
addresses: addresses.0.clone().map(|addresses| addresses.0),
193+
},
194+
user_key.as_ref(),
195+
AS_REP_ENC,
196+
)?;
197+
198+
Ok(AsRep::from(KdcRep {
199+
pvno: ExplicitContextTag0::from(IntegerAsn1::from(vec![KERBEROS_VERSION])),
200+
msg_type: ExplicitContextTag1::from(IntegerAsn1::from(vec![AS_REP_MSG_TYPE])),
201+
padata: Optional::from(Some(ExplicitContextTag2::from(Asn1SequenceOf::from(vec![PaData {
202+
padata_type: ExplicitContextTag1::from(IntegerAsn1::from(PA_ETYPE_INFO2_TYPE.to_vec())),
203+
padata_data: ExplicitContextTag2::from(OctetStringAsn1::from(picky_asn1_der::to_vec(
204+
&Asn1SequenceOf::from(vec![EtypeInfo2Entry {
205+
etype: ExplicitContextTag0::from(IntegerAsn1::from(etype_raw.to_vec())),
206+
salt: Optional::from(Some(ExplicitContextTag1::from(KerberosStringAsn1::from(
207+
IA5String::from_string(domain_user.salt.clone()).expect("salt to be a valid KerberosString"),
208+
)))),
209+
s2kparams: Optional::from(None),
210+
}]),
211+
)?)),
212+
}])))),
213+
crealm: ExplicitContextTag3::from(realm.clone()),
214+
cname: ExplicitContextTag4::from(cname.clone()),
215+
ticket: ExplicitContextTag5::from(make_ticket(MakeTicketParams {
216+
realm,
217+
session_key,
218+
ticket_encryption_key: &kdc_config.krbtgt_key,
219+
kdc_options: kdc_options.0.clone(),
220+
sname,
221+
cname: cname.clone(),
222+
etype,
223+
auth_time,
224+
end_time,
225+
})?),
226+
enc_part: ExplicitContextTag6::from(EncryptedData {
227+
etype: ExplicitContextTag0::from(IntegerAsn1::from(etype_raw.to_vec())),
228+
kvno: Optional::from(None),
229+
cipher: ExplicitContextTag2::from(OctetStringAsn1::from(as_rep_enc_data)),
230+
}),
231+
}))
232+
}

0 commit comments

Comments
 (0)