1use std::sync::{Arc, LazyLock, Mutex};
19
20use anyhow::{anyhow, Context, Result};
21use serde_json::{json, Value};
22
23use super::codec;
24use super::kdc::{Exchange, Grant, Issued, Kdc, Pending};
25use crate::llm::actions::{
26 protocol_trait::{ActionResult, Protocol, Server},
27 ActionDefinition, Parameter, ParameterDefinition,
28};
29use crate::protocol::log_template::LogTemplate;
30use crate::protocol::EventType;
31use crate::state::app_state::AppState;
32
33pub const DEFAULT_MAX_TICKET_LIFETIME_SECS: u64 = 10 * 3600;
35pub const DEFAULT_MAX_RENEWABLE_LIFETIME_SECS: u64 = 7 * 24 * 3600;
37pub const DEFAULT_MAX_CLOCK_SKEW_SECS: u64 = 300;
40pub const DEFAULT_MAX_REQUESTS_PER_MINUTE: u64 = 600;
42pub const DEFAULT_REALM: &str = "NETGET.TEST";
44
45pub const MAX_TICKET_LIFETIME_LIMIT_SECS: u64 = 365 * 24 * 3600;
47pub const MAX_CLOCK_SKEW_LIMIT_SECS: u64 = 3600;
48
49#[derive(Debug, Clone)]
54pub struct Record {
55 pub decision: &'static str,
56 pub detail: String,
57 pub reply: Vec<u8>,
58}
59
60#[derive(Clone)]
62pub struct RequestContext {
63 pub kdc: Arc<Kdc>,
64 pub pending: Arc<Pending>,
65 pub records: Arc<Mutex<Vec<Record>>>,
66}
67
68pub struct KerberosProtocol {
70 request: Option<RequestContext>,
71}
72
73impl Default for KerberosProtocol {
74 fn default() -> Self {
75 Self::new()
76 }
77}
78
79impl KerberosProtocol {
80 pub fn new() -> Self {
81 Self { request: None }
82 }
83
84 pub fn for_request(ctx: RequestContext) -> Self {
85 Self { request: Some(ctx) }
86 }
87
88 fn ctx(&self) -> Result<&RequestContext> {
89 self.request.as_ref().context(
90 "Kerberos action executed without a request context: a reply can only be built for \
91 a request this KDC received and analysed",
92 )
93 }
94
95 fn record(
96 &self,
97 ctx: &RequestContext,
98 decision: &'static str,
99 detail: String,
100 reply: Vec<u8>,
101 ) -> Result<ActionResult> {
102 let mut r = ctx
103 .records
104 .lock()
105 .map_err(|_| anyhow!("the request's record is unavailable"))?;
106 r.push(Record {
107 decision,
108 detail,
109 reply,
110 });
111 Ok(ActionResult::NoAction)
112 }
113}
114
115fn p(name: &str, type_hint: &str, description: &str) -> Parameter {
116 Parameter {
117 name: name.to_string(),
118 type_hint: type_hint.to_string(),
119 description: description.to_string(),
120 required: false,
121 }
122}
123
124fn common_parameters() -> Vec<Parameter> {
125 vec![
126 p(
127 "client",
128 "string",
129 "Client principal name without the realm (alice, host/x)",
130 ),
131 p("client_realm", "string", "The client's realm"),
132 p(
133 "client_known",
134 "boolean",
135 "Whether a key is configured for the client. Without one no ticket can be issued, \
136 whatever you decide",
137 ),
138 p(
139 "service",
140 "string",
141 "Service principal requested (krbtgt/REALM for a TGT)",
142 ),
143 p(
144 "service_known",
145 "boolean",
146 "Whether a key is configured for the service",
147 ),
148 p(
149 "service_is_tgs",
150 "boolean",
151 "True when the client is asking for a TGT",
152 ),
153 p(
154 "requested_lifetime_secs",
155 "number",
156 "Lifetime the client asked for, or null for 'as long as allowed'",
157 ),
158 p(
159 "requested_renewable_lifetime_secs",
160 "number",
161 "Renewable lifetime the client asked for, or null",
162 ),
163 p(
164 "options",
165 "array",
166 "KDC options the client set: forwardable, proxiable, renewable, renewable_ok, \
167 canonicalize",
168 ),
169 p(
170 "etypes",
171 "array",
172 "Enctypes the client offered, in its order",
173 ),
174 p(
175 "addresses",
176 "array",
177 "Client addresses in the request, usually empty",
178 ),
179 p("source_addr", "string", "Where the request came from"),
180 p(
181 "transport",
182 "string",
183 "How the request arrived: udp, or tcp with a 4-byte length prefix",
184 ),
185 p(
186 "max_ticket_lifetime_secs",
187 "number",
188 "The configured ceiling; a longer lifetime is clamped to it",
189 ),
190 p(
191 "max_renewable_lifetime_secs",
192 "number",
193 "The configured renewable ceiling",
194 ),
195 ]
196}
197
198pub static KERBEROS_AS_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
199 let mut params = common_parameters();
200 params.push(p(
201 "preauth",
202 "string",
203 "'verified': the client proved it knows the password (PA-ENC-TIMESTAMP decrypted with \
204 its key, inside the clock skew, not replayed). 'none': no pre-authentication was sent. \
205 A failed pre-authentication never reaches you; the KDC refuses it itself",
206 ));
207 params.push(p(
208 "preauth_required_by_config",
209 "boolean",
210 "Whether this principal must pre-authenticate before a ticket can be issued",
211 ));
212 EventType::new(
213 "kerberos_as_request",
214 "A client asked for an initial ticket (AS-REQ), usually a TGT. Decide: issue it (with \
215 lifetime and flags), require pre-authentication, or refuse with a Kerberos error. \
216 There is no default: if you return nothing the KDC refuses.",
217 json!({"type": "kerberos_require_preauth"}),
218 )
219 .with_parameters(params)
220 .with_actions(vec![
221 issue_ticket_action(),
222 require_preauth_action(),
223 refuse_action(),
224 ])
225});
226
227pub static KERBEROS_TGS_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
228 let mut params = common_parameters();
229 params.push(p(
230 "tgt_flags",
231 "array",
232 "Flags of the TGT the client presented (verified: it decrypted with this KDC's key)",
233 ));
234 params.push(p(
235 "tgt_expires_in_secs",
236 "number",
237 "Seconds until the TGT expires",
238 ));
239 params.push(p(
240 "tgt_authtime",
241 "string",
242 "When the client originally authenticated",
243 ));
244 params.push(p(
245 "tgt_renew_till",
246 "string",
247 "The TGT's renewable end, or null",
248 ));
249 EventType::new(
250 "kerberos_tgs_request",
251 "An authenticated client (its TGT and authenticator verified) asked for a ticket to a \
252 service (TGS-REQ). Decide: issue it, or refuse (S_PRINCIPAL_UNKNOWN, POLICY, …). \
253 There is no default: if you return nothing the KDC refuses.",
254 json!({"type": "kerberos_issue_ticket", "lifetime_secs": 3600}),
255 )
256 .with_parameters(params)
257 .with_actions(vec![issue_ticket_action(), refuse_action()])
258});
259
260fn issue_ticket_action() -> ActionDefinition {
261 ActionDefinition {
262 name: "kerberos_issue_ticket".to_string(),
263 description: "ADMIT the client: issue the requested ticket. The KDC builds and encrypts \
264 it. Lifetimes are clamped to the configured ceilings, the client's own \
265 request and (for a service ticket) its TGT; a flag is granted only when \
266 you set it AND the client asked for it. Flags you do not set are not \
267 granted. Refused anyway, with the right error, when the client or service \
268 has no configured key or pre-authentication is required but missing."
269 .to_string(),
270 parameters: vec![
271 p(
272 "lifetime_secs",
273 "number",
274 "Ticket lifetime in seconds (default and maximum: max_ticket_lifetime_secs)",
275 ),
276 p(
277 "renewable",
278 "boolean",
279 "Make the ticket renewable, if the client asked (default false)",
280 ),
281 p(
282 "renewable_lifetime_secs",
283 "number",
284 "Renewable lifetime in seconds (default and maximum: \
285 max_renewable_lifetime_secs)",
286 ),
287 p(
288 "forwardable",
289 "boolean",
290 "Make the ticket forwardable, if the client asked (default false)",
291 ),
292 p(
293 "proxiable",
294 "boolean",
295 "Make the ticket proxiable, if the client asked (default false)",
296 ),
297 p(
298 "ok_as_delegate",
299 "boolean",
300 "Mark the service as trusted for delegation (service tickets only)",
301 ),
302 ],
303 example: json!({
304 "type": "kerberos_issue_ticket",
305 "lifetime_secs": 28800,
306 "forwardable": true
307 }),
308 log_template: Some(LogTemplate::new().with_info("-> Kerberos ticket issued")),
309 }
310}
311
312fn require_preauth_action() -> ActionDefinition {
313 ActionDefinition {
314 name: "kerberos_require_preauth".to_string(),
315 description: "Answer KDC_ERR_PREAUTH_REQUIRED: the client must prove it knows the \
316 password before a ticket is issued. The KDC adds the salt and enctypes. \
317 Also usable for an unknown principal, to avoid revealing which \
318 principals exist."
319 .to_string(),
320 parameters: vec![],
321 example: json!({"type": "kerberos_require_preauth"}),
322 log_template: Some(LogTemplate::new().with_info("-> Kerberos pre-authentication required")),
323 }
324}
325
326fn refuse_action() -> ActionDefinition {
327 let names: Vec<String> = codec::MODEL_REFUSALS
328 .iter()
329 .map(|c| {
330 codec::error_name(*c)
331 .trim_start_matches("KDC_ERR_")
332 .to_string()
333 })
334 .collect();
335 ActionDefinition {
336 name: "kerberos_refuse".to_string(),
337 description: format!(
338 "REFUSE the request with a Kerberos error. 'error' is one of: {}.",
339 names.join(", ")
340 ),
341 parameters: vec![
342 Parameter {
343 name: "error".to_string(),
344 type_hint: "string".to_string(),
345 description: "The error, e.g. C_PRINCIPAL_UNKNOWN, S_PRINCIPAL_UNKNOWN, POLICY, \
346 CLIENT_REVOKED, KEY_EXPIRED"
347 .to_string(),
348 required: true,
349 },
350 p("text", "string", "Optional e-text shown to the client"),
351 ],
352 example: json!({"type": "kerberos_refuse", "error": "C_PRINCIPAL_UNKNOWN"}),
353 log_template: Some(LogTemplate::new().with_info("-> Kerberos refused")),
354 }
355}
356
357fn secs(action: &Value, key: &str) -> Result<Option<i64>> {
359 match action.get(key) {
360 None | Some(Value::Null) => Ok(None),
361 Some(v) => {
362 let n = v
363 .as_u64()
364 .with_context(|| format!("'{key}' must be a non-negative number of seconds"))?;
365 if n == 0 {
366 return Err(anyhow!("'{key}' must be greater than zero"));
367 }
368 Ok(Some(i64::try_from(n).unwrap_or(i64::MAX)))
369 }
370 }
371}
372
373fn flag(action: &Value, key: &str) -> Result<bool> {
374 match action.get(key) {
375 None | Some(Value::Null) => Ok(false),
376 Some(Value::Bool(b)) => Ok(*b),
377 Some(_) => Err(anyhow!("'{key}' must be true or false")),
378 }
379}
380
381impl Protocol for KerberosProtocol {
382 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
383 Vec::new()
384 }
385
386 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
387 vec![
388 issue_ticket_action(),
389 require_preauth_action(),
390 refuse_action(),
391 ]
392 }
393
394 fn protocol_name(&self) -> &'static str {
395 "Kerberos"
396 }
397
398 fn get_event_types(&self) -> Vec<EventType> {
399 vec![
400 KERBEROS_AS_REQUEST_EVENT.clone(),
401 KERBEROS_TGS_REQUEST_EVENT.clone(),
402 ]
403 }
404
405 fn stack_name(&self) -> &'static str {
406 "ETH>IP>UDP>KERBEROS"
407 }
408
409 fn keywords(&self) -> Vec<&'static str> {
410 vec![
411 "kerberos",
412 "kdc",
413 "kerberos kdc",
414 "krb5",
415 "key distribution center",
416 ]
417 }
418
419 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
420 use crate::protocol::metadata::{
421 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
422 };
423 ProtocolMetadataV2::builder()
424 .connectionless()
425 .answers_on_failure()
429 .state(DevelopmentState::Experimental)
430 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(88))
431 .well_known_udp_port(88)
432 .request_only("A KDC only answers AS-REQ and TGS-REQ; it never writes first")
433 .max_inbound_bytes(codec::MAX_MESSAGE_LEN)
434 .implementation(
435 "Kerberos V5 KDC (RFC 4120) on UDP and TCP (4-octet length prefix) on one port. \
436 ASN.1 types from picky-krb, decoded only after an iterative DER walk bounds \
437 depth (24), element count (4096) and length (64 KiB). AS exchange with \
438 PA-ENC-TIMESTAMP pre-authentication verified in Rust (clock skew, replay \
439 cache) and KDC_ERR_PREAUTH_REQUIRED with PA-ETYPE-INFO2; TGS exchange with the \
440 TGT, authenticator and its keyed checksum over the request body verified, \
441 reply under the authenticator subkey when present. aes256- and \
442 aes128-cts-hmac-sha1-96 (RFC 3962), keys from configured passwords with \
443 PBKDF2; the TGS key is random per start. No DES, RC4, RFC 8009, FAST, PKINIT, \
444 PAC, cross-realm, renewal, validation, S4U or user-to-user. Per-source rate \
445 limit, lookaside cache for retransmissions, KRB_ERR_RESPONSE_TOO_BIG over \
446 UDP, KRB_ERR_FIELD_TOOLONG over TCP.",
447 )
448 .llm_control(
449 "Policy only: whether a principal is admitted, which Kerberos error a refusal \
450 carries, whether to demand pre-authentication (including of principals that \
451 do not exist), and the lifetime, renewable lifetime and flags (forwardable, \
452 proxiable, renewable, ok-as-delegate) of each ticket within the configured \
453 ceilings. The model never sees or chooses a key, password, nonce or salt.",
454 )
455 .e2e_testing(
456 "tests/server/kerberos/real_client_test.rs: MIT krb5 kinit/klist/kvno and \
457 Heimdal kinit/klist/kgetcred each get a TGT from NetGet with a password \
458 (pre-authenticated), see the model's lifetime and flags in klist, and get a \
459 service ticket that MIT kvno verifies against a keytab ktutil derived from \
460 the service's password; a wrong password is refused with \
461 PREAUTH_FAILED, an unknown principal with the model's error, and an \
462 unreachable model gives an error, never a ticket. Traffic from both is \
463 relayed and dissected by tshark's kerberos dissector over UDP and TCP. \
464 e2e_test.rs and codec_test.rs cover every mechanical refusal, the bounds and \
465 the fail-closed paths with NetGet's own client codec. Both peers are required: \
466 the tests fail, not skip, without them.",
467 )
468 .notes(
469 "Experimental: two independent clients complete real sessions against it, but \
470 nobody has reviewed it beyond its tests and the pair job has not run in CI. \
471 FAILS CLOSED: an unreachable model, no usable answer or an action that could \
472 not be executed all produce a KRB-ERROR (decision=fail_closed_*). Passwords \
473 come only from the principal_passwords startup parameter, which is redacted \
474 wherever parameters are printed, and appear in no event.",
475 )
476 .build()
477 }
478
479 fn description(&self) -> &'static str {
480 "Kerberos V5 KDC (AS and TGS exchanges) with model-decided admission and ticket policy"
481 }
482
483 fn example_prompt(&self) -> &'static str {
484 "Run a Kerberos KDC for realm NETGET.TEST with alice (password wonderland) and \
485 host/web.netget.test; give alice 8 hour forwardable tickets and refuse everyone else"
486 }
487
488 fn group_name(&self) -> &'static str {
489 "Authentication"
490 }
491
492 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
493 vec![
494 ParameterDefinition {
495 name: "realm".to_string(),
496 type_hint: "string".to_string(),
497 description: "The realm this KDC serves, conventionally upper case".to_string(),
498 required: false,
499 example: json!("NETGET.TEST"),
500 default: Some(json!(DEFAULT_REALM)),
501 },
502 ParameterDefinition {
503 name: "principal_passwords".to_string(),
504 type_hint: "object".to_string(),
505 description: "Principal name -> password, for every user and service this KDC \
506 knows (e.g. {\"alice\": \"...\", \"host/web.netget.test\": \
507 \"...\"}). Keys are derived from these at startup; the passwords \
508 are never shown in events or logs. Required."
509 .to_string(),
510 required: true,
511 example: json!({"alice": "wonderland", "host/web.netget.test": "s3rvice-key"}),
512 default: None,
513 },
514 ParameterDefinition {
515 name: "no_preauth_principals".to_string(),
516 type_hint: "array".to_string(),
517 description: "Principals that may be issued a ticket without \
518 pre-authentication (every other principal must pre-authenticate)"
519 .to_string(),
520 required: false,
521 example: json!(["legacy-user"]),
522 default: None,
523 },
524 ParameterDefinition {
525 name: "max_ticket_lifetime_secs".to_string(),
526 type_hint: "integer".to_string(),
527 description: "Ceiling on any ticket's lifetime; longer grants are clamped"
528 .to_string(),
529 required: false,
530 example: json!(36000),
531 default: Some(json!(DEFAULT_MAX_TICKET_LIFETIME_SECS)),
532 },
533 ParameterDefinition {
534 name: "max_renewable_lifetime_secs".to_string(),
535 type_hint: "integer".to_string(),
536 description: "Ceiling on any ticket's renewable lifetime".to_string(),
537 required: false,
538 example: json!(604800),
539 default: Some(json!(DEFAULT_MAX_RENEWABLE_LIFETIME_SECS)),
540 },
541 ParameterDefinition {
542 name: "max_clock_skew_secs".to_string(),
543 type_hint: "integer".to_string(),
544 description: "Largest tolerated difference between a client's clock and this \
545 KDC's for pre-authentication and authenticators"
546 .to_string(),
547 required: false,
548 example: json!(300),
549 default: Some(json!(DEFAULT_MAX_CLOCK_SKEW_SECS)),
550 },
551 ParameterDefinition {
552 name: "max_requests_per_minute_per_source".to_string(),
553 type_hint: "integer".to_string(),
554 description: "Requests one source address may make per minute; the rest are \
555 dropped (UDP) or the connection closed (TCP)"
556 .to_string(),
557 required: false,
558 example: json!(600),
559 default: Some(json!(DEFAULT_MAX_REQUESTS_PER_MINUTE)),
560 },
561 ]
562 }
563
564 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
565 use crate::llm::actions::StartupExamples;
566 let params = json!({
567 "realm": "NETGET.TEST",
568 "principal_passwords": {"alice": "wonderland", "host/web.netget.test": "s3rvice-key"}
569 });
570 StartupExamples::new(
571 json!({
572 "type": "open_server",
573 "port": 88,
574 "base_stack": "kerberos",
575 "startup_params": params,
576 "instruction": "Require pre-authentication. Issue alice 8 hour forwardable \
577 tickets and service tickets for host/web.netget.test; refuse \
578 every other principal with C_PRINCIPAL_UNKNOWN."
579 }),
580 json!({
581 "type": "open_server",
582 "port": 88,
583 "base_stack": "kerberos",
584 "startup_params": params,
585 "event_handlers": [{
586 "event_pattern": "kerberos_as_request",
587 "handler": {
588 "type": "script",
589 "language": "python",
590 "code": "respond([{'type': 'kerberos_issue_ticket', 'lifetime_secs': 28800}] if event['preauth'] == 'verified' and event['client'] == 'alice' else [{'type': 'kerberos_require_preauth'}] if event['client'] == 'alice' else [{'type': 'kerberos_refuse', 'error': 'C_PRINCIPAL_UNKNOWN'}])"
591 }
592 }]
593 }),
594 json!({
595 "type": "open_server",
596 "port": 88,
597 "base_stack": "kerberos",
598 "startup_params": params,
599 "event_handlers": [{
600 "event_pattern": "*",
601 "handler": {
602 "type": "static",
603 "actions": [{"type": "kerberos_refuse", "error": "POLICY", "text": "KDC closed"}]
604 }
605 }]
606 }),
607 )
608 }
609}
610
611impl Server for KerberosProtocol {
612 fn spawn(
613 &self,
614 ctx: crate::protocol::SpawnContext,
615 ) -> std::pin::Pin<
616 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
617 > {
618 Box::pin(async move { super::KerberosServer::spawn(ctx).await })
619 }
620
621 fn execute_action(&self, action: Value) -> Result<ActionResult> {
622 let action_type = action
623 .get("type")
624 .and_then(|v| v.as_str())
625 .context("Missing 'type' field in action")?;
626 let ctx = self.ctx()?;
627 let pending = ctx.pending.as_ref();
628 let kdc = ctx.kdc.as_ref();
629 match action_type {
630 "kerberos_issue_ticket" => {
631 let grant = Grant {
632 lifetime_secs: secs(&action, "lifetime_secs")?,
633 renewable_lifetime_secs: secs(&action, "renewable_lifetime_secs")?,
634 renewable: flag(&action, "renewable")?,
635 forwardable: flag(&action, "forwardable")?,
636 proxiable: flag(&action, "proxiable")?,
637 ok_as_delegate: flag(&action, "ok_as_delegate")?,
638 };
639 match kdc.issue(pending, &grant)? {
640 Issued::Ticket {
641 reply,
642 summary,
643 clamped,
644 } => self.record(
645 ctx,
646 if clamped {
647 "model_issue_clamped"
648 } else {
649 "model_issue"
650 },
651 summary.to_string(),
652 reply,
653 ),
654 Issued::Enforced {
655 reply,
656 decision,
657 detail,
658 } => self.record(ctx, decision, detail, reply),
659 }
660 }
661 "kerberos_require_preauth" => {
662 if pending.exchange != Exchange::As {
663 return Err(anyhow!(
664 "kerberos_require_preauth applies to AS requests only; refuse a TGS \
665 request with kerberos_refuse"
666 ));
667 }
668 let reply = kdc.preauth_required(pending)?;
669 self.record(
670 ctx,
671 "model_require_preauth",
672 format!("{}: pre-authentication required", pending.client),
673 reply,
674 )
675 }
676 "kerberos_refuse" => {
677 let code = codec::model_refusal_code(
678 action
679 .get("error")
680 .context("kerberos_refuse requires 'error'")?,
681 )?;
682 let text = action.get("text").and_then(|v| v.as_str());
683 if let Some(t) = text {
684 if t.len() > 256 {
685 return Err(anyhow!("'text' is limited to 256 bytes"));
686 }
687 if !t.is_ascii() {
688 return Err(anyhow!("'text' must be ASCII (KerberosString is IA5)"));
689 }
690 }
691 let reply = kdc.refuse(pending, code, text);
692 self.record(
693 ctx,
694 "model_reject",
695 format!("{}: {}", pending.client, codec::error_name(code)),
696 reply,
697 )
698 }
699 other => Err(anyhow!("Unknown Kerberos KDC action: {other}")),
700 }
701 }
702}