feat: add keytab client credentials and standards-compliant SASL/GSSAPI acceptor support#681
Conversation
Port of crabka's local sspi modifications, originally vendored into third_party/sspi as a `cargo package` snapshot of sspi v0.21.0. Adds the Kerberos-side support needed for SASL/GSSAPI authentication across the client and server exchanges (AS-REQ generators/extractors, ts_request, auth_identity, and CredSSP plumbing). Rebased onto the upstream sspi-v0.21.0 tag so the changes can be maintained and rebased against upstream directly. Cargo-packaging artifacts (normalized Cargo.toml, Cargo.lock, Cargo.toml.orig, .cargo_vcs_info.json) and renovate/dependabot dep bumps from the vendored tree are intentionally excluded. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…#338) * chore(deps): load sspi fork from git instead of vendoring third_party Replace the vendored `cargo package` snapshot under third_party/sspi with a git dependency on our sspi fork branch, wired through `[patch.crates-io]`. The fork (branched off the sspi-v0.21.0 tag) carries our SASL/GSSAPI Kerberos work: keytab client credentials, integrity-only Wrap tokens, and multi-SPN acceptor keys. Upstreaming tracked at Devolutions/sspi-rs#681. Cargo.lock pins the exact fork commit, so builds stay reproducible despite the ref being a branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(deny): allow our sspi fork git source cargo-deny's [sources] denies unknown git sources; allow the sspi fork URL now that sspi is pulled from git rather than vendored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR extends the sspi Kerberos implementation to interoperate with standards-compliant SASL/GSSAPI (RFC 4752 / RFC 4121) peers by adding keytab-backed client credentials, supporting integrity-only (unsealed) GSS_Wrap tokens, and allowing acceptors to validate tickets for multiple configured service principals.
Changes:
- Add
KeytabIdentity/Credentials::{Keytab,...}to authenticate Kerberos clients using a pre-derived long-term key instead of a password. - Accept integrity-only (conf=false)
Wraptokens by validating the checksum and returning the cleartext payload. - Allow a Kerberos acceptor to resolve the ticket decryption key across multiple configured SPNs (keytab-like behavior), and improve non-mutual-auth context establishment for SASL flows.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/lib.rs | Re-exports KeytabIdentity from the crate root. |
| src/auth_identity.rs | Introduces KeytabIdentity and plumbs Keytab through Credentials / CredentialsBuffers. |
| src/kerberos/client/mod.rs | Enables AS exchange using keytab-based pre-auth and session-key extraction. |
| src/kerberos/client/generators.rs | Adds keytab PA-DATA generation and fixes AS-REQ cname encoding for service principals. |
| src/kerberos/client/extractors.rs | Adds AS-REP session-key extraction using a pre-derived long-term key, with broader tag acceptance. |
| src/kerberos/pa_datas.rs | Adds Keytab variants for PA-DATA generation and AS-REP session-key extraction paths. |
| src/kerberos/mod.rs | Adds integrity-only WRAP verification and routes unsealed WRAP tokens through it. |
| src/kerberos/server/mod.rs | Adds multi-SPN ticket-key resolution and improves non-mutual-auth context establishment for SASL use. |
| src/kerberos/server/as_exchange.rs | Explicitly rejects keytab credentials in server AS-exchange paths where unsupported. |
| src/credssp/mod.rs | Explicitly rejects keytab credentials for NTLM/CredSSP paths. |
| src/credssp/ts_request/mod.rs | Explicitly rejects keytab credentials for CredSSP TS credentials encoding. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Stop logging raw long-term/derived key material in the AS timestamp trace. - Reject integrity-only WRAP tokens for non-AES cipher suites with an explicit error instead of silently defaulting to AES-256. - Store the keytab pre-auth key as Secret<Vec<u8>> to avoid extra non-zeroizing copies of long-term secrets. - Add a unit test covering the integrity-only (unsealed) WRAP path. - Fix "clint time" typo in the authenticator ctime error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pavlo Myroniuk (TheBestTvarynka)
left a comment
There was a problem hiding this comment.
Hi, Matthew Stone (@robot-head)
First of all, sorry for the late review 🥺 🙏
I reviewed your changes. The code is good, and I have a few questions and comments.
The implemented feature is nice and will be useful in the future.
| // 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<_>>>()?, | ||
| )), |
There was a problem hiding this comment.
// components (RFC 1964 §2.1.1). Service principals such as // `kafka/host` carry two components; user principals carry one.
Question: cname field means client name, not service name. rfc4120#section-5.3:
This field contains the name part of the client's principal identifier.
For example:
The cname field is usually a [NT_PRINCIPAL, plain username] or [NT_ENTERPRISE, fqdn].
Am I missing something?
There was a problem hiding this comment.
In keytab-based auth the client principal is itself a service principal: keytab.principal.account_name() is e.g. kafka/host, a two-component name, and the /-split encodes those components into cname's name-string. For ordinary user principals (no /) the split yields a single component, so the password/smart-card flows are unchanged. So here cname legitimately carries a multi-component client name. I can gate the split on the keytab path specifically if you'd rather keep the shared builder strictly single-component — let me know.
There was a problem hiding this comment.
Hm, I read more about it. Let's keep this then.
I have one question. I have never tried keytab logon myself. In your case, the cname type will be NT_PRINCIPAL, and the cname value will be a sequence keytab.principal.account_name().split('/'). Does it work well for you? (I am just curious)
There was a problem hiding this comment.
It does appear to work for us yes
- Reject EncTGSRepPart in the AS-REP session-key extractor: the AS exchange enc-part is EncASRepPart (APPLICATION 25); the TGS tag does not belong here. - Type the keytab key encryption type as CipherSuite instead of a raw u8 enctype number on KeytabIdentity / GenerateKeytabPaDataOptions. - Simplify the integrity-only WRAP checksum header zeroing to a single copy_from_slice over the contiguous EC+RRC range. - Rename `raw` to `seq_number_bytes` in the authenticator sequence number decode for clarity. - Collapse the nested service-name/ticket-key match into a let-chain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pavlo Myroniuk (TheBestTvarynka)
left a comment
There was a problem hiding this comment.
LGTM. (sorry for the late response. I was on vacation)
|
Benoît Cortier (@CBenoit), I approved the PR. Now it's your turn 🙃 |
|
Matthew Stone (@robot-head), I approved the CI. You can fix the CI errors in the meantime |
CI's "Check formatting" job runs `cargo fmt --all -- --check` on the pinned stable toolchain (1.93.0). Four files introduced in this branch were formatted with nightly-only import rules and did not satisfy stable rustfmt, failing the job and gating every downstream CI job. Apply `cargo fmt --all` so the formatting check passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ab5DDjcquWcitoUmWwVvmS
d95f249
into
Devolutions:master
Summary
This PR adds the pieces needed for
sspito interoperate as both initiator and acceptor with standard SASL/GSSAPI (RFC 4752) Kerberos peers — most notably mainstream clients like the JDK/Apache Kafka GSSAPI stack. It contains three independent, additive capabilities:Wraptoken decryption — acceptGSS_Wraptokens produced withconf_req_flag == FALSE(RFC 4121 §4.2.4), which standard clients use for the RFC 4752 security-layer negotiation.Each is usable on its own; together they enable a full SASL/GSSAPI handshake against stock clients.
Motivation
A service acting on behalf of a machine identity has no password — it has a keytab. And real-world SASL/GSSAPI clients (e.g. the JDK GSSAPI provider used by Apache Kafka) negotiate the security layer with an unsealed Wrap token and frequently obtain tickets for one of several host SPNs held in a single keytab. Today
sspi:Wraptoken without theSealedflag ("the Sealed flag has to be set in WRAP token"), andThis PR closes those three gaps.
Changes
1.
KeytabIdentitycredential (auth_identity.rs,lib.rs)KeytabIdentity { principal: Username, key: Secret<Vec<u8>>, key_enctype: u8 }, re-exported from the crate root, plusCredentials::Keytab/CredentialsBuffers::Keytabvariants and aFrom<KeytabIdentity> for Credentialsimpl.kerberos/client/mod.rs) uses the raw key directly: it encrypts thePA-ENC-TIMESTAMPpre-auth value and decrypts the AS-REP with the long-term key, skipping string-to-key derivation (newGenerateKeytabPaDataOptions/generate_pa_datas_for_as_req_with_keyingenerators.rs, andextract_session_key_from_as_rep_with_keyinextractors.rs).credssp/).2. Integrity-only
Wraptoken support (kerberos/mod.rs)Sealedflag is clear, instead of erroring, the acceptor now verifies the token via a newdecrypt_integrity_only_wraphelper and returns the cleartext payload.plaintext | checksum, recompute the keyed checksum overplaintext | header(with the EC and RRC header fields zeroed) using the SEAL key usage, and compare. Integrity failures surface asKerberosCryptoError::IntegrityCheck.3. Multiple acceptor service keys (
kerberos/server/mod.rs)ServerPropertiesgainsadditional_service_keys: Vec<(PrincipalName, Secret<Vec<u8>>)>and a builder methodadd_service_key(&mut self, sname: &[&str], key).ticket_decryption_key_for(&self, ticket_sname)resolves the decryption key by matching the ticket's service name against the primaryservice_nameand the additional keys, comparing name components only (ignoringname-type, per RFC 4120 §6.2).accept_security_contextuses this resolver instead of a single hard-coded equality check, so an AP-REQ is accepted as long as its ticket names some configured SPN.Compatibility / scope
CredentialsBuffers::{into,to,as,as_mut}_auth_identityis a non-functional cleanup (the catch-all arm replaces a#[cfg(feature = "scard")]-gated one now that a non-scard variant exists).sspi-v0.21.0tag.cargo checkpasses on default features.Testing notes / open questions for review
tests/sspi/client_server/kerberosharness seems the natural home).