openssh/openssh-9.9p1-gssapi-s4u.patch

1047 lines
36 KiB
Diff

diff --color -ruNp a/auth-krb5.c b/auth-krb5.c
--- a/auth-krb5.c 2026-03-12 11:51:16.406723629 +0100
+++ b/auth-krb5.c 2026-03-12 12:00:49.464289202 +0100
@@ -462,6 +462,19 @@ ssh_krb5_cc_new_unique(krb5_context ctx,
* a primary cache for this collection, if it supports that (non-FILE)
*/
if (krb5_cc_support_switch(ctx, type)) {
+ /*
+ * For collection-type caches (KCM, KEYRING, …) reuse the
+ * existing primary ccache when one is already present. The
+ * caller will reinitialise it with krb5_cc_initialize(), so
+ * its old contents are replaced rather than orphaned. Only
+ * create a fresh unique ccache when no primary exists yet.
+ */
+ if (krb5_cc_default(ctx, ccache) == 0) {
+ debug3_f("reusing existing default ccache of type %s",
+ type);
+ free(type);
+ return 0;
+ }
debug3_f("calling cc_new_unique(%s)", ccname);
ret = krb5_cc_new_unique(ctx, type, NULL, ccache);
free(type);
diff --color -ruNp a/configure.ac b/configure.ac
--- a/configure.ac 2026-03-12 11:51:16.580313749 +0100
+++ b/configure.ac 2026-03-12 12:00:59.319587937 +0100
@@ -4949,10 +4949,16 @@ AC_ARG_WITH([kerberos5],
#elif defined(HAVE_GSSAPI_GSSAPI_GENERIC_H)
# include <gssapi/gssapi_generic.h>
#endif
+#ifdef HAVE_GSSAPI_EXT_H
+# include <gssapi/gssapi_ext.h>
+#endif
+#ifdef HAVE_GSSAPI_KRB5_H
+# include <gssapi/gssapi_krb5.h>
+#endif
]])
saved_LIBS="$LIBS"
- LIBS="$LIBS $K5LIBS"
- AC_CHECK_FUNCS([krb5_cc_new_unique krb5_get_error_message krb5_free_error_message])
+ LIBS="$LIBS $GSSLIBS $K5LIBS "
+ AC_CHECK_FUNCS([krb5_cc_new_unique krb5_get_error_message krb5_free_error_message gss_acquire_cred_from])
LIBS="$saved_LIBS"
fi
diff --color -ruNp a/gss-serv.c b/gss-serv.c
--- a/gss-serv.c 2026-03-12 11:51:16.603196888 +0100
+++ b/gss-serv.c 2026-03-12 12:01:44.171014784 +0100
@@ -53,7 +53,7 @@ extern ServerOptions options;
static ssh_gssapi_client gssapi_client =
{ GSS_C_EMPTY_BUFFER, GSS_C_EMPTY_BUFFER, GSS_C_NO_CREDENTIAL,
- GSS_C_NO_NAME, NULL, {NULL, NULL, NULL, NULL, NULL}, 0, 0, NULL};
+ GSS_C_NO_NAME, NULL, {NULL, NULL, NULL, NULL, NULL}, 0, 0, NULL, 0};
ssh_gssapi_mech gssapi_null_mech =
{ NULL, NULL, {0, NULL}, NULL, NULL, NULL, NULL, NULL};
@@ -486,26 +486,382 @@ ssh_gssapi_getclient(Gssctxt *ctx, ssh_g
return (ctx->major);
}
-/* As user - called on fatal/exit */
+/* Returns non-zero if Kerberos credentials have already been stored. */
+int
+ssh_gssapi_credentials_stored(void)
+{
+ return gssapi_client.store.envval != NULL;
+}
+
+/* Returns a pointer to the credential-cache descriptor for this session. */
+ssh_gssapi_ccache *
+ssh_gssapi_get_ccache(void)
+{
+ return &gssapi_client.store;
+}
+
+/* Log human-readable GSSAPI major and minor status strings. */
+static void
+log_gss_error(OM_uint32 major, OM_uint32 minor, const char *label)
+{
+ OM_uint32 lmin, mctx;
+ gss_buffer_desc emsg = GSS_C_EMPTY_BUFFER;
+
+ mctx = 0;
+ do {
+ gss_display_status(&lmin, major, GSS_C_GSS_CODE,
+ GSS_C_NO_OID, &mctx, &emsg);
+ logit("%s: %.*s", label, (int)emsg.length, (char *)emsg.value);
+ gss_release_buffer(&lmin, &emsg);
+ } while (mctx != 0);
+
+ mctx = 0;
+ do {
+ gss_display_status(&lmin, minor, GSS_C_MECH_CODE,
+ &gssapi_kerberos_mech.oid, &mctx, &emsg);
+ if (emsg.length > 0)
+ logit("%s: %.*s", label,
+ (int)emsg.length, (char *)emsg.value);
+ gss_release_buffer(&lmin, &emsg);
+ } while (mctx != 0);
+}
+
+/* Log the canonical string form of a GSSAPI name as a debug message. */
+static void
+debug_gss_name(const char *label, gss_name_t name)
+{
+ OM_uint32 lmin;
+ gss_buffer_desc buf = GSS_C_EMPTY_BUFFER;
+
+ if (gss_display_name(&lmin, name, &buf, NULL) == GSS_S_COMPLETE) {
+ debug_f("%s: %.*s", label, (int)buf.length, (char *)buf.value);
+ gss_release_buffer(&lmin, &buf);
+ }
+}
+
+/*
+ * Check whether the user already has valid GSSAPI initiator credentials
+ * (e.g. a Kerberos TGT) in their default credential store with at least
+ * min_lifetime seconds remaining. Pass GSS_C_INDEFINITE to accept any
+ * positive remaining lifetime. Runs as the user.
+ * Returns 1 if sufficient credentials exist, 0 otherwise.
+ */
+int
+ssh_gssapi_user_has_valid_tgt(u_int min_lifetime)
+{
+ OM_uint32 major, minor, lifetime = 0;
+ gss_cred_id_t cred = GSS_C_NO_CREDENTIAL;
+ int found = 0;
+
+ major = gss_acquire_cred(&minor, GSS_C_NO_NAME, GSS_C_INDEFINITE,
+ GSS_C_NO_OID_SET, GSS_C_INITIATE, &cred, NULL, &lifetime);
+ if (!GSS_ERROR(major) && lifetime > 0 &&
+ (min_lifetime == GSS_C_INDEFINITE || lifetime >= min_lifetime))
+ found = 1;
+ if (cred != GSS_C_NO_CREDENTIAL)
+ gss_release_cred(&minor, &cred);
+ return found;
+}
+
+
+/*
+ * Perform S4U2Self (protocol transition): acquire a Kerberos service ticket
+ * for the SSH user on behalf of the host principal. Runs privileged.
+ * Populates gssapi_client.{creds,mech,displayname,exportedname} on success.
+ * Returns 0 on success, -1 on failure.
+ */
+/* Privileged */
+int
+ssh_gssapi_s4u2self(const char *user, u_int lifetime)
+{
+ OM_uint32 major, minor, status;
+ gss_OID_set oidset = GSS_C_NO_OID_SET;
+ gss_name_t host_name = GSS_C_NO_NAME;
+ gss_name_t user_name = GSS_C_NO_NAME;
+ gss_cred_id_t host_creds = GSS_C_NO_CREDENTIAL;
+ gss_cred_id_t impersonated_creds = GSS_C_NO_CREDENTIAL;
+ gss_buffer_desc gssbuf, displayname = GSS_C_EMPTY_BUFFER;
+ char lname[NI_MAXHOST];
+ char *val;
+
+ if (gethostname(lname, sizeof(lname)) != 0) {
+ logit_f("gethostname: %s", strerror(errno));
+ return -1;
+ }
+
+ /* Acquire acceptor credential for host/ from the keytab */
+ gss_create_empty_oid_set(&status, &oidset);
+ gss_add_oid_set_member(&status, &gssapi_kerberos_mech.oid, &oidset);
+
+ xasprintf(&val, "host@%s", lname);
+ gssbuf.value = val;
+ gssbuf.length = strlen(val);
+ major = gss_import_name(&minor, &gssbuf,
+ GSS_C_NT_HOSTBASED_SERVICE, &host_name);
+ free(val);
+ if (GSS_ERROR(major)) {
+ logit_f("gss_import_name (host) failed");
+ gss_release_oid_set(&status, &oidset);
+ return -1;
+ }
+ debug_gss_name("host name parsed as", host_name);
+
+ debug_f("acquiring host credentials as uid=%u euid=%u, principal=host@%s",
+ (unsigned)getuid(), (unsigned)geteuid(), lname);
+#ifdef HAVE_GSS_ACQUIRE_CRED_FROM
+ {
+# if defined(KRB5)
+ /*
+ * Resolve the keytab path: krb5_kt_default_name respects
+ * KRB5_KTNAME and krb5.conf default_keytab_name.
+ */
+ char keytab_name[MAXPATHLEN];
+ krb5_context tmp_ctx;
+
+
+ keytab_name[0] = '\0';
+ if (krb5_init_context(&tmp_ctx) == 0) {
+ (void)krb5_kt_default_name(tmp_ctx, keytab_name,
+ sizeof(keytab_name));
+ krb5_free_context(tmp_ctx);
+ }
+ if (keytab_name[0] == '\0')
+ strlcpy(keytab_name, "FILE:/etc/krb5.keytab",
+ sizeof(keytab_name));
+ /*
+ * client_keytab lets GSSAPI do AS-REQ to obtain a TGT for the
+ * host principal (initiator role needed for S4U2Self).
+ * keytab covers the acceptor role.
+ * ccache: MEMORY: keeps the resulting TGT volatile.
+ */
+ gss_key_value_element_desc store_elements[] = {
+ { "client_keytab", keytab_name },
+ { "keytab", keytab_name },
+ { "ccache", "MEMORY:" },
+ };
+ const gss_key_value_set_desc cred_store = { 3, store_elements };
+# else
+ gss_key_value_element_desc store_elements[] = {
+ { "ccache", "MEMORY:" },
+ };
+ const gss_key_value_set_desc cred_store = { 1, store_elements };
+# endif
+
+
+ major = gss_acquire_cred_from(&minor, host_name, lifetime,
+ oidset, GSS_C_BOTH, &cred_store, &host_creds, NULL, NULL);
+ }
+#else
+ major = gss_acquire_cred(&minor, host_name, lifetime,
+ oidset, GSS_C_BOTH, &host_creds, NULL, NULL);
+#endif
+ gss_release_name(&minor, &host_name);
+ if (GSS_ERROR(major)) {
+ logit_f("gss_acquire_cred(host@%s) failed as uid=%u euid=%u",
+ lname, (unsigned)getuid(), (unsigned)geteuid());
+ log_gss_error(major, minor, "S4U2Self: gss_acquire_cred");
+ gss_release_oid_set(&status, &oidset);
+ return -1;
+ }
+
+ /* Import the SSH username as a GSSAPI/Kerberos name */
+ gssbuf.value = (void *)user;
+ gssbuf.length = strlen(user);
+ major = gss_import_name(&minor, &gssbuf,
+ GSS_C_NT_USER_NAME, &user_name);
+ if (GSS_ERROR(major)) {
+ logit_f("gss_import_name (user) failed");
+ gss_release_cred(&minor, &host_creds);
+ gss_release_oid_set(&status, &oidset);
+ return -1;
+ }
+ debug_gss_name("user name parsed as", user_name);
+
+ /* S4U2Self: obtain a service ticket for the user without their creds */
+ debug_f("calling gss_acquire_cred_impersonate_name for user %.100s", user);
+ major = gss_acquire_cred_impersonate_name(&minor,
+ host_creds, user_name, lifetime,
+ oidset, GSS_C_INITIATE,
+ &impersonated_creds, NULL, NULL);
+
+ gss_release_cred(&minor, &host_creds);
+ gss_release_oid_set(&status, &oidset);
+ if (GSS_ERROR(major)) {
+ logit_f("gss_acquire_cred_impersonate_name failed for %.100s",
+ user);
+ log_gss_error(major, minor,
+ "S4U2Self: gss_acquire_cred_impersonate_name");
+ gss_release_name(&minor, &user_name);
+ return -1;
+ }
+
+ /* Get the display name (Kerberos principal string) for storecreds */
+ major = gss_display_name(&minor, user_name, &displayname, NULL);
+ gss_release_name(&minor, &user_name);
+ if (GSS_ERROR(major)) {
+ logit_f("gss_display_name failed");
+ gss_release_cred(&minor, &impersonated_creds);
+ return -1;
+ }
+
+ /* Populate gssapi_client for storecreds_s4u2self and s4u2proxy */
+ gssapi_client.mech = &gssapi_kerberos_mech;
+ gssapi_client.creds = impersonated_creds;
+ gssapi_client.displayname.value = xmalloc(displayname.length + 1);
+ memcpy(gssapi_client.displayname.value,
+ displayname.value, displayname.length);
+ ((char *)gssapi_client.displayname.value)[displayname.length] = '\0';
+ gssapi_client.displayname.length = displayname.length;
+ /*
+ * exportedname is used by ssh_gssapi_krb5_storecreds → krb5_parse_name.
+ * gss_display_name for a user-name returns the canonical principal
+ * string (e.g. user@REALM) which krb5_parse_name can consume directly.
+ */
+ gssapi_client.exportedname.value = xmalloc(displayname.length + 1);
+ memcpy(gssapi_client.exportedname.value,
+ displayname.value, displayname.length);
+ ((char *)gssapi_client.exportedname.value)[displayname.length] = '\0';
+ gssapi_client.exportedname.length = displayname.length;
+
+ gss_release_buffer(&minor, &displayname);
+ debug_f("S4U2Self succeeded for %.100s", user);
+ return 0;
+}
+
+/* As user — write the S4U2Self ticket into a new ccache via mech->storecreds */
void
-ssh_gssapi_cleanup_creds(void)
+ssh_gssapi_storecreds_s4u2self(void)
{
- krb5_ccache ccache = NULL;
- krb5_error_code problem;
+ if (gssapi_client.mech == NULL || gssapi_client.mech->storecreds == NULL) {
+ debug_f("no GSSAPI mechanism for storing S4U2Self credentials");
+ return;
+ }
+ (*gssapi_client.mech->storecreds)(&gssapi_client);
+}
+
+/*
+ * Perform S4U2Proxy for each configured service principal, then flush all
+ * resulting tickets into the user's ccache. Runs as user, after
+ * ssh_gssapi_storecreds_s4u2self() has created the ccache.
+ *
+ * gssapi_client.creds (the S4U2Self proxy credential) is passed as the
+ * initiator to gss_init_sec_context(); the GSSAPI library presents the TGT
+ * and evidence ticket to the KDC via S4U2Proxy TGS-REQ. The output token
+ * (AP-REQ) is discarded — we do not connect to the target service.
+ *
+ * After iterating all services, gss_store_cred() flushes the accumulated
+ * proxy service tickets from the credential's internal ccache into the
+ * KRB5CCNAME ccache that storecreds_s4u2self() already created.
+ */
+/* As user */
+void
+ssh_gssapi_s4u2proxy(char **services, u_int nservices, u_int lifetime)
+{
+ OM_uint32 major, minor;
+ gss_buffer_desc service_buf, output_token = GSS_C_EMPTY_BUFFER;
+ gss_name_t target_name;
+ gss_ctx_id_t ctx;
+ u_int i;
+
+ if (gssapi_client.creds == GSS_C_NO_CREDENTIAL) {
+ debug_f("no proxy credential available");
+ return;
+ }
+ if (gssapi_client.store.envval == NULL) {
+ debug_f("no ccache path set; cannot store proxy tickets");
+ return;
+ }
- if (gssapi_client.store.data != NULL) {
- if ((problem = krb5_cc_resolve(gssapi_client.store.data, gssapi_client.store.envval, &ccache))) {
- debug_f("krb5_cc_resolve(): %.100s",
- krb5_get_err_text(gssapi_client.store.data, problem));
- } else if ((problem = krb5_cc_destroy(gssapi_client.store.data, ccache))) {
- debug_f("krb5_cc_destroy(): %.100s",
- krb5_get_err_text(gssapi_client.store.data, problem));
- } else {
- krb5_free_context(gssapi_client.store.data);
- gssapi_client.store.data = NULL;
+ debug_f("starting S4U2Proxy as uid=%u euid=%u, %u service(s), ccache=%s",
+ (unsigned)getuid(), (unsigned)geteuid(), nservices,
+ gssapi_client.store.envval);
+
+ /* Point the GSSAPI library at the user's ccache for ticket storage */
+ setenv("KRB5CCNAME", gssapi_client.store.envval, 1);
+
+ for (i = 0; i < nservices; i++) {
+ ctx = GSS_C_NO_CONTEXT;
+ target_name = GSS_C_NO_NAME;
+
+ service_buf.value = services[i];
+ service_buf.length = strlen(services[i]);
+
+ /*
+ * GSS_C_NO_OID: let the library determine the name type.
+ * With Kerberos as the active mechanism, a fully-qualified
+ * principal like "svc/host@REALM" is parsed correctly.
+ */
+ major = gss_import_name(&minor, &service_buf,
+ GSS_C_NO_OID, &target_name);
+ if (GSS_ERROR(major)) {
+ logit_f("gss_import_name failed for %.200s",
+ services[i]);
+ log_gss_error(major, minor, "S4U2Proxy: gss_import_name");
+ continue;
}
+ debug_gss_name("target service name parsed as", target_name);
+
+ debug_f("calling gss_init_sec_context for %.200s", services[i]);
+ major = gss_init_sec_context(&minor,
+ gssapi_client.creds, /* proxy credential */
+ &ctx, target_name,
+ GSS_C_NO_OID, /* default mech (Kerberos) */
+ 0, /* no flags, no mutual auth */
+ lifetime,
+ GSS_C_NO_CHANNEL_BINDINGS,
+ GSS_C_NO_BUFFER, /* no input token */
+ NULL, /* actual_mech_type */
+ &output_token,
+ NULL, /* ret_flags */
+ NULL); /* time_rec */
+
+ gss_release_buffer(&minor, &output_token);
+ gss_release_name(&minor, &target_name);
+ if (ctx != GSS_C_NO_CONTEXT)
+ gss_delete_sec_context(&minor, &ctx, GSS_C_NO_BUFFER);
+
+ if (GSS_ERROR(major)) {
+ logit_f("S4U2Proxy for %.200s on behalf of %.200s failed",
+ services[i],
+ (char *)gssapi_client.displayname.value);
+ log_gss_error(major, minor,
+ "S4U2Proxy: gss_init_sec_context");
+ } else
+ debug_f("S4U2Proxy ticket obtained for %.200s",
+ services[i]);
}
+
+ /*
+ * Flush all proxy service tickets from the credential's internal
+ * ccache into the KRB5CCNAME ccache via gss_store_cred().
+ */
+ major = gss_store_cred(&minor, gssapi_client.creds, GSS_C_INITIATE,
+ GSS_C_NO_OID, 1 /* overwrite_cred */, 1 /* default_cred */,
+ NULL, NULL);
+ if (GSS_ERROR(major)) {
+ logit_f("gss_store_cred failed; proxy tickets may be missing");
+ log_gss_error(major, minor, "S4U2Proxy: gss_store_cred");
+ }
+
+ unsetenv("KRB5CCNAME");
+}
+
+#ifndef KRB5
+/* As user - called on fatal/exit; full implementation in gss-serv-krb5.c */
+void
+ssh_gssapi_cleanup_creds(void)
+{
+}
+
+/*
+ * Filter the user's ccache; full implementation in gss-serv-krb5.c.
+ */
+void
+ssh_gssapi_krb5_filter_ccache(u_int drop_flags,
+ char **proxy_services, u_int nproxy_services)
+{
}
+#endif /* !KRB5 */
/* As user */
int
diff --color -ruNp a/gss-serv-krb5.c b/gss-serv-krb5.c
--- a/gss-serv-krb5.c 2026-03-12 11:51:16.581364233 +0100
+++ b/gss-serv-krb5.c 2026-03-12 12:01:20.402569731 +0100
@@ -609,6 +609,231 @@ ssh_gssapi_krb5_updatecreds(ssh_gssapi_c
return 1;
}
+/*
+ * Check whether the user's default ccache already contains valid service
+ * tickets for all principals listed in services[]. Returns 1 if every
+ * listed service has a ticket with at least min_lifetime seconds remaining
+ * (pass GSS_C_INDEFINITE to accept any positive remaining lifetime), 0 if
+ * any ticket is missing or too close to expiry. Runs as the user.
+ *
+ * Unlike ssh_gssapi_user_has_valid_tgt(), this cannot use gss_acquire_cred()
+ * because service tickets are not initiator credentials — GSSAPI only
+ * surfaces TGTs via that API. We iterate the ccache with the krb5 API
+ * directly instead.
+ */
+int
+ssh_gssapi_user_has_valid_proxy_tickets(char **services, u_int nservices,
+ u_int min_lifetime)
+{
+ krb5_context ctx = NULL;
+ krb5_ccache cc = NULL;
+ krb5_cc_cursor cursor;
+ krb5_creds cred;
+ krb5_principal svc_princ;
+ int *found = NULL;
+ u_int i;
+ int all_found = 0;
+ time_t now;
+
+ if (nservices == 0)
+ return 0;
+
+ if (krb5_init_context(&ctx) != 0)
+ return 0;
+
+ found = xcalloc(nservices, sizeof(*found));
+ now = time(NULL);
+
+ if (krb5_cc_default(ctx, &cc) != 0)
+ goto out;
+
+ if (krb5_cc_start_seq_get(ctx, cc, &cursor) != 0) {
+ krb5_cc_close(ctx, cc);
+ cc = NULL;
+ goto out;
+ }
+
+ while (krb5_cc_next_cred(ctx, cc, &cursor, &cred) == 0) {
+ /*
+ * Use krb5_principal_compare() so that service names
+ * configured without an explicit realm (krb5_parse_name
+ * appends the default realm) still match the fully-
+ * qualified principal stored in the ccache.
+ */
+ for (i = 0; i < nservices; i++) {
+ if (found[i])
+ continue;
+ if (krb5_parse_name(ctx, services[i],
+ &svc_princ) != 0)
+ continue;
+ if (krb5_principal_compare(ctx,
+ cred.server, svc_princ)) {
+ krb5_deltat remaining =
+ cred.times.endtime - now;
+ if (remaining > 0 &&
+ (min_lifetime == GSS_C_INDEFINITE ||
+ (krb5_deltat)min_lifetime <= remaining))
+ found[i] = 1;
+ }
+ krb5_free_principal(ctx, svc_princ);
+ }
+ krb5_free_cred_contents(ctx, &cred);
+ }
+ krb5_cc_end_seq_get(ctx, cc, &cursor);
+ krb5_cc_close(ctx, cc);
+ cc = NULL;
+
+ all_found = 1;
+ for (i = 0; i < nservices; i++) {
+ if (!found[i]) {
+ all_found = 0;
+ break;
+ }
+ }
+out:
+ free(found);
+ if (ctx != NULL)
+ krb5_free_context(ctx);
+ return all_found;
+}
+
+/* As user - called on fatal/exit */
+void
+ssh_gssapi_cleanup_creds(void)
+{
+ ssh_gssapi_ccache *store = ssh_gssapi_get_ccache();
+ krb5_ccache ccache = NULL;
+ krb5_error_code problem;
+
+ if (store->data != NULL) {
+ if ((problem = krb5_cc_resolve(store->data,
+ store->envval, &ccache))) {
+ debug_f("krb5_cc_resolve(): %.100s",
+ krb5_get_err_text(store->data, problem));
+ } else if ((problem = krb5_cc_destroy(store->data, ccache))) {
+ debug_f("krb5_cc_destroy(): %.100s",
+ krb5_get_err_text(store->data, problem));
+ } else {
+ krb5_free_context(store->data);
+ store->data = NULL;
+ }
+ }
+}
+
+/*
+ * Filter the user's ccache by removing the ticket classes indicated by
+ * drop_flags (SSH_GSSAPI_CCFILTER_* bitmask). Each credential is
+ * categorised as one of:
+ * TGT - server principal matches "krbtgt/" prefix
+ * PROXY - server principal matches one of proxy_services[]
+ * SELF - everything else (the S4U2Self evidence ticket)
+ * Credentials in a flagged category are discarded; the rest are written
+ * back after reinitialising the ccache. Runs as user.
+ */
+void
+ssh_gssapi_krb5_filter_ccache(u_int drop_flags,
+ char **proxy_services, u_int nproxy_services)
+{
+ ssh_gssapi_ccache *store = ssh_gssapi_get_ccache();
+ krb5_context ctx = (krb5_context)store->data;
+ krb5_ccache cc = NULL;
+ krb5_cc_cursor cursor;
+ krb5_creds *keep = NULL;
+ krb5_principal princ = NULL;
+ krb5_error_code problem;
+ char *srvname;
+ u_int i, nkeep = 0, cap = 0;
+ int is_tgt, is_proxy, drop;
+
+ if (ctx == NULL || store->envval == NULL)
+ return;
+
+ if ((problem = krb5_cc_resolve(ctx, store->envval, &cc)) != 0) {
+ debug_f("krb5_cc_resolve: %.100s",
+ krb5_get_err_text(ctx, problem));
+ return;
+ }
+ if ((problem = krb5_cc_get_principal(ctx, cc, &princ)) != 0) {
+ debug_f("krb5_cc_get_principal: %.100s",
+ krb5_get_err_text(ctx, problem));
+ krb5_cc_close(ctx, cc);
+ return;
+ }
+ if ((problem = krb5_cc_start_seq_get(ctx, cc, &cursor)) != 0) {
+ debug_f("krb5_cc_start_seq_get: %.100s",
+ krb5_get_err_text(ctx, problem));
+ krb5_free_principal(ctx, princ);
+ krb5_cc_close(ctx, cc);
+ return;
+ }
+
+ {
+ krb5_creds cred;
+ krb5_principal svc_princ;
+ while (krb5_cc_next_cred(ctx, cc, &cursor, &cred) == 0) {
+ is_tgt = is_proxy = 0;
+ if (krb5_unparse_name(ctx, cred.server,
+ &srvname) == 0) {
+ is_tgt = strncmp(srvname, "krbtgt/", 7) == 0;
+ krb5_free_unparsed_name(ctx, srvname);
+ }
+ if (!is_tgt) {
+ /*
+ * Use krb5_principal_compare() rather than
+ * strcmp() so that a service name configured
+ * without an explicit realm (krb5_parse_name
+ * appends the default realm) still matches
+ * the fully-qualified name in the ccache.
+ */
+ for (i = 0; i < nproxy_services; i++) {
+ if (krb5_parse_name(ctx,
+ proxy_services[i], &svc_princ) != 0)
+ continue;
+ if (krb5_principal_compare(ctx,
+ cred.server, svc_princ))
+ is_proxy = 1;
+ krb5_free_principal(ctx, svc_princ);
+ if (is_proxy)
+ break;
+ }
+ }
+ if (is_tgt)
+ drop = drop_flags & SSH_GSSAPI_CCFILTER_TGT;
+ else if (is_proxy)
+ drop = drop_flags & SSH_GSSAPI_CCFILTER_PROXY;
+ else
+ drop = drop_flags & SSH_GSSAPI_CCFILTER_SELF;
+
+ if (!drop) {
+ if (nkeep >= cap) {
+ cap = cap ? cap * 2 : 4;
+ keep = xreallocarray(keep, cap,
+ sizeof(*keep));
+ }
+ keep[nkeep++] = cred;
+ } else
+ krb5_free_cred_contents(ctx, &cred);
+ }
+ }
+ krb5_cc_end_seq_get(ctx, cc, &cursor);
+
+ if ((problem = krb5_cc_initialize(ctx, cc, princ)) != 0) {
+ logit_f("krb5_cc_initialize: %.100s",
+ krb5_get_err_text(ctx, problem));
+ } else {
+ for (i = 0; i < nkeep; i++)
+ krb5_cc_store_cred(ctx, cc, &keep[i]);
+ debug_f("ccache filter 0x%x: retained %u ticket(s)",
+ drop_flags, nkeep);
+ }
+
+ for (i = 0; i < nkeep; i++)
+ krb5_free_cred_contents(ctx, &keep[i]);
+ free(keep);
+ krb5_free_principal(ctx, princ);
+ krb5_cc_close(ctx, cc);
+}
+
ssh_gssapi_mech gssapi_kerberos_mech = {
"toWM5Slw5Ew8Mqkay+al2g==",
"Kerberos",
diff --color -ruNp a/servconf.c b/servconf.c
--- a/servconf.c 2026-03-12 11:51:16.603765849 +0100
+++ b/servconf.c 2026-03-12 12:07:51.450992249 +0100
@@ -148,6 +148,9 @@ initialize_server_options(ServerOptions
options->gss_indicators = NULL;
options->gss_store_rekey = -1;
options->gss_kex_algorithms = NULL;
+ options->gss_allow_s4u2self = -1;
+ options->gss_proxy_services = NULL;
+ options->num_gss_proxy_services = 0;
options->use_kuserok = -1;
options->enable_k5users = -1;
options->password_authentication = -1;
@@ -407,6 +410,8 @@ fill_default_server_options(ServerOption
options->gss_deleg_creds = 1;
if (options->gss_strict_acceptor == -1)
options->gss_strict_acceptor = 1;
+ if (options->gss_allow_s4u2self == -1)
+ options->gss_allow_s4u2self = 0;
if (options->gss_store_rekey == -1)
options->gss_store_rekey = 0;
#ifdef GSSAPI
@@ -603,7 +608,7 @@ typedef enum {
sClientAliveInterval, sClientAliveCountMax, sAuthorizedKeysFile,
sGssAuthentication, sGssCleanupCreds, sGssDelegateCreds,
sGssEnablek5users, sGssStrictAcceptor,
- sGssKeyEx, sGssIndicators, sGssKexAlgorithms, sGssStoreRekey,
+ sGssKeyEx, sGssIndicators, sGssAllowS4U2Self, sGssProxyS4U2Services, sGssKexAlgorithms, sGssStoreRekey,
sAcceptEnv, sSetEnv, sPermitTunnel,
sMatch, sPermitOpen, sPermitListen, sForceCommand, sChrootDirectory,
sUsePrivilegeSeparation, sAllowAgentForwarding,
@@ -701,6 +706,8 @@ static struct {
{ "gssapikexalgorithms", sGssKexAlgorithms, SSHCFG_GLOBAL },
{ "gssapienablek5users", sGssEnablek5users, SSHCFG_ALL },
{ "gssapiindicators", sGssIndicators, SSHCFG_ALL },
+ { "gssapiallows4u2self", sGssAllowS4U2Self, SSHCFG_ALL },
+ { "gssapiproxys4u2services", sGssProxyS4U2Services, SSHCFG_ALL },
#else
{ "gssapiauthentication", sUnsupported, SSHCFG_ALL },
{ "gssapicleanupcredentials", sUnsupported, SSHCFG_GLOBAL },
@@ -712,6 +719,8 @@ static struct {
{ "gssapikexalgorithms", sUnsupported, SSHCFG_GLOBAL },
{ "gssapienablek5users", sUnsupported, SSHCFG_ALL },
{ "gssapiindicators", sUnsupported, SSHCFG_ALL },
+ { "gssapiallows4u2self", sUnsupported, SSHCFG_ALL },
+ { "gssapiproxys4u2services", sUnsupported, SSHCFG_ALL },
#endif
{ "gssusesessionccache", sUnsupported, SSHCFG_GLOBAL },
{ "gssapiusesessioncredcache", sUnsupported, SSHCFG_GLOBAL },
@@ -1742,6 +1751,44 @@ process_server_config_line_depth(ServerO
options->gss_indicators = xstrdup(arg);
break;
+ case sGssAllowS4U2Self:
+ arg = argv_next(&ac, &av);
+ if (!arg || *arg == '\0')
+ fatal("%s line %d: %s missing argument.",
+ filename, linenum, keyword);
+ if (strcasecmp(arg, "no") == 0)
+ value = 0;
+ else if (strcasecmp(arg, "yes") == 0)
+ value = INT_MAX;
+ else if ((value = convtime(arg)) <= 0)
+ fatal("%s line %d: invalid %s value \"%s\".",
+ filename, linenum, keyword, arg);
+ if (*activep && options->gss_allow_s4u2self == -1)
+ options->gss_allow_s4u2self = value;
+ break;
+
+ case sGssProxyS4U2Services:
+ while ((arg = argv_next(&ac, &av)) != NULL) {
+ if (*arg == '\0')
+ fatal("%s line %d: %s missing argument.",
+ filename, linenum, keyword);
+ if (strcasecmp(arg, "none") == 0) {
+ /* "none" clears any previous list */
+ for (i = 0; i < options->num_gss_proxy_services; i++)
+ free(options->gss_proxy_services[i]);
+ free(options->gss_proxy_services);
+ options->gss_proxy_services = NULL;
+ options->num_gss_proxy_services = 0;
+ break;
+ }
+ if (!*activep)
+ continue;
+ opt_array_append(filename, linenum, keyword,
+ &options->gss_proxy_services,
+ &options->num_gss_proxy_services, arg);
+ }
+ break;
+
case sPasswordAuthentication:
intptr = &options->password_authentication;
goto parse_flag;
@@ -3010,6 +3057,7 @@ copy_set_server_options(ServerOptions *d
M_CP_INTOPT(password_authentication);
M_CP_INTOPT(gss_authentication);
+ M_CP_INTOPT(gss_allow_s4u2self);
M_CP_INTOPT(pubkey_authentication);
M_CP_INTOPT(pubkey_auth_options);
M_CP_INTOPT(kerberos_authentication);
@@ -3364,6 +3412,15 @@ dump_config(ServerOptions *o)
dump_cfg_fmtint(sGssStoreRekey, o->gss_store_rekey);
dump_cfg_string(sGssKexAlgorithms, o->gss_kex_algorithms);
dump_cfg_string(sGssIndicators, o->gss_indicators);
+ if (o->gss_allow_s4u2self == 0)
+ printf("%s no\n", lookup_opcode_name(sGssAllowS4U2Self));
+ else if (o->gss_allow_s4u2self == INT_MAX)
+ printf("%s yes\n", lookup_opcode_name(sGssAllowS4U2Self));
+ else
+ printf("%s %d\n", lookup_opcode_name(sGssAllowS4U2Self),
+ o->gss_allow_s4u2self);
+ dump_cfg_strarray_oneline(sGssProxyS4U2Services, o->num_gss_proxy_services,
+ o->gss_proxy_services);
#endif
dump_cfg_fmtint(sPasswordAuthentication, o->password_authentication);
dump_cfg_fmtint(sKbdInteractiveAuthentication,
diff --color -ruNp a/servconf.h b/servconf.h
--- a/servconf.h 2026-03-12 11:51:16.604292877 +0100
+++ b/servconf.h 2026-03-12 12:02:44.689745248 +0100
@@ -160,6 +160,9 @@ typedef struct {
int gss_cleanup_creds; /* If true, destroy cred cache on logout */
int gss_deleg_creds; /* If true, accept delegated GSS credentials */
int gss_strict_acceptor; /* If true, restrict the GSSAPI acceptor name */
+ int gss_allow_s4u2self; /* 0=no, INT_MAX=yes (GSS_C_INDEFINITE), >0=ticket lifetime s */
+ char **gss_proxy_services; /* S4U2Proxy target service principals */
+ u_int num_gss_proxy_services;
int gss_store_rekey;
char *gss_kex_algorithms; /* GSSAPI kex methods to be offered by client. */
int password_authentication; /* If true, permit password
@@ -314,6 +317,7 @@ TAILQ_HEAD(include_list, include_item);
M_CP_STROPT(permit_user_env_allowlist); \
M_CP_STROPT(pam_service_name); \
M_CP_STROPT(gss_indicators); \
+ M_CP_STRARRAYOPT(gss_proxy_services, num_gss_proxy_services); \
M_CP_STRARRAYOPT(authorized_keys_files, num_authkeys_files); \
M_CP_STRARRAYOPT(allow_users, num_allow_users); \
M_CP_STRARRAYOPT(deny_users, num_deny_users); \
diff --color -ruNp a/sshd_config.5 b/sshd_config.5
--- a/sshd_config.5 2026-03-12 11:51:16.604904588 +0100
+++ b/sshd_config.5 2026-03-12 12:10:03.542653814 +0100
@@ -834,6 +834,76 @@ FIDO2-based pre-authentication in FreeIP
The default
.Dq none
is to not use GSSAPI authentication indicators for access decisions.
+.It Cm GSSAPIAllowS4U2Self
+Controls whether the SSH server performs a Kerberos protocol transition
+(S4U2Self) after a successful authentication using any other method.
+Accepted values are
+.Cm no ,
+.Cm yes ,
+or a time interval (see
+.Sx TIME FORMATS
+below).
+.Cm no
+disables S4U2Self entirely.
+.Cm yes
+enables S4U2Self and requests a ticket with the maximum lifetime
+permitted by the KDC.
+A time interval (e.g.\&
+.Cm 8h ,
+.Cm 1d )
+enables S4U2Self and requests a ticket valid for at most that duration.
+The option is a no-op when delegated GSSAPI credentials are already available.
+The obtained service ticket is stored in the default credentials cache and is
+accessible to any application that has access to the Kerberos host principal
+.Pq host/machine.fqdn@REALM
+credentials on the same host.
+.Pp
+The default is
+.Cm no .
+.It Cm GSSAPIProxyS4U2Services
+Specifies a list of Kerberos service principals for which constrained
+delegation (S4U2Proxy) tickets should be obtained after a successful
+S4U2Self protocol transition.
+Each entry must be a fully-qualified Kerberos principal name of the form
+.Ar service/host@REALM .
+Multiple principals may be listed, separated by whitespace.
+The keyword
+.Cm none
+clears any previously set list.
+.Pp
+This option may be used independently of
+.Cm GSSAPIAllowS4U2Self .
+When S4U2Self succeeds, the server iterates the list and calls
+.Xr gss_init_sec_context 3
+for each principal with the proxy credential obtained by S4U2Self as
+the initiator.
+The GSSAPI library presents the evidence ticket to the KDC via an
+S4U2Proxy TGS-REQ; if the host service holds the necessary constrained-
+delegation permission in the KDC, a service ticket from the user to
+the target service is issued.
+These tickets are accumulated and then flushed into the user's ccache
+via
+.Xr gss_store_cred 3 ,
+so that any application running in the user's session can use them
+without further interaction.
+The AP-REQ output token of each
+.Xr gss_init_sec_context 3
+call is discarded; no network connection to the target service is made.
+.Pp
+When used together with
+.Cm GSSAPIAllowS4U2Self ,
+the TGT and S4U2Self ticket are also stored in the user's ccache in
+addition to the S4U2Proxy service tickets.
+When used alone (without
+.Cm GSSAPIAllowS4U2Self ) ,
+only the S4U2Proxy service tickets are stored; the intermediate S4U2Self
+credential is not placed in the user's ccache.
+.Pp
+This option supports
+.Cm Match
+blocks, allowing per-user or per-host lists of delegation targets.
+.Pp
+The default is empty (no S4U2Proxy delegation is performed).
.It Cm HostbasedAcceptedAlgorithms
The default is handled system-wide by
.Xr crypto-policies 7 .
diff --color -ruNp a/sshd-session.c b/sshd-session.c
--- a/sshd-session.c 2026-03-12 11:51:16.597852696 +0100
+++ b/sshd-session.c 2026-03-12 12:03:24.375297257 +0100
@@ -1489,6 +1489,104 @@ main(int ac, char **av)
authctxt->krb5_set_env = ssh_gssapi_storecreds();
restore_uid();
}
+ /*
+ * GSSAPIAllowS4U2Self / GSSAPIProxyS4U2Services: if no credentials were stored
+ * above (i.e. no GSSAPI auth with delegation occurred), use S4U2Self
+ * to obtain an impersonated credential for the user, then optionally
+ * follow with S4U2Proxy for configured target services.
+ *
+ * GSSAPIAllowS4U2Self alone: store S4U2Self evidence ticket only;
+ * the host TGT is removed.
+ * GSSAPIProxyS4U2Services alone: store host TGT and S4U2Proxy service
+ * tickets; the S4U2Self evidence ticket
+ * is removed.
+ * Both: store host TGT, S4U2Self evidence ticket,
+ * and all S4U2Proxy service tickets.
+ *
+ * When S4U2Proxy tickets are present the host TGT must remain in the
+ * ccache; applications check for TGT presence to determine whether
+ * Kerberos credentials are available. Only in GSSAPIAllowS4U2Self-alone
+ * mode (no proxy tickets) is the host TGT removed.
+ *
+ * Skip S4U2Self when the user already has credentials covering the
+ * requested lifetime: check for a valid TGT in the GSSAPIAllowS4U2Self-
+ * alone case, or for valid proxy tickets for every configured service
+ * otherwise.
+ */
+ if ((options.gss_allow_s4u2self || options.num_gss_proxy_services > 0) &&
+ !ssh_gssapi_credentials_stored()) {
+ u_int lifetime = (!options.gss_allow_s4u2self ||
+ options.gss_allow_s4u2self == INT_MAX) ?
+ GSS_C_INDEFINITE : (u_int)options.gss_allow_s4u2self;
+ int skip = 0;
+
+ temporarily_use_uid(authctxt->pw);
+ if (options.gss_allow_s4u2self &&
+ options.num_gss_proxy_services == 0) {
+ /* S4U2Self-alone: skip if user already has a valid TGT */
+ skip = ssh_gssapi_user_has_valid_tgt(lifetime);
+ } else if (options.num_gss_proxy_services > 0) {
+ /*
+ * Proxy-only or both: skip if every configured service
+ * already has a valid ticket in the user's ccache.
+ * Service tickets are not GSSAPI initiator credentials,
+ * so gss_acquire_cred() cannot be used; iterate the
+ * ccache with the krb5 API instead.
+ */
+ skip = ssh_gssapi_user_has_valid_proxy_tickets(
+ options.gss_proxy_services,
+ options.num_gss_proxy_services,
+ lifetime);
+ }
+ restore_uid();
+
+ if (skip) {
+ debug_f("user %.100s already has valid Kerberos "
+ "credentials, skipping S4U2Self",
+ authctxt->user);
+ } else if (ssh_gssapi_s4u2self(authctxt->user, lifetime) == 0) {
+ u_int filter;
+
+ temporarily_use_uid(authctxt->pw);
+ /*
+ * Always create the ccache via storecreds_s4u2self so
+ * that s4u2proxy has a ccache to store tickets into.
+ * gss_krb5_copy_ccache() copies the host service's own
+ * TGT along with the evidence ticket; filter_ccache
+ * removes the ticket classes that should not be kept.
+ */
+ ssh_gssapi_storecreds_s4u2self();
+ if (options.num_gss_proxy_services > 0)
+ ssh_gssapi_s4u2proxy(
+ options.gss_proxy_services,
+ options.num_gss_proxy_services,
+ lifetime);
+
+ /*
+ * Remove the host TGT only in GSSAPIAllowS4U2Self-alone
+ * mode; when proxy tickets are present the TGT must
+ * stay so that applications recognise the ccache as
+ * holding live Kerberos credentials.
+ * Remove the S4U2Self evidence ticket in proxy-only
+ * mode (GSSAPIProxyS4U2Services without GSSAPIAllowS4U2Self).
+ */
+ filter = 0;
+ if (options.gss_allow_s4u2self &&
+ options.num_gss_proxy_services == 0)
+ filter = SSH_GSSAPI_CCFILTER_TGT |
+ SSH_GSSAPI_CCFILTER_PROXY;
+ else if (!options.gss_allow_s4u2self)
+ filter = SSH_GSSAPI_CCFILTER_SELF;
+ if (filter != 0)
+ ssh_gssapi_krb5_filter_ccache(filter,
+ options.gss_proxy_services,
+ options.num_gss_proxy_services);
+ restore_uid();
+ } else {
+ logit("S4U2Self failed for user %.100s, continuing",
+ authctxt->user);
+ }
+ }
#endif
#ifdef WITH_SELINUX
sshd_selinux_setup_exec_context(authctxt->pw->pw_name,
diff --color -ruNp a/ssh-gss.h b/ssh-gss.h
--- a/ssh-gss.h 2026-03-12 11:51:16.583291666 +0100
+++ b/ssh-gss.h 2026-03-12 12:03:11.541329687 +0100
@@ -119,6 +119,7 @@ typedef struct {
int used;
int updated;
char **indicators; /* auth indicators */
+ int allow_self; /* allow protocol transition */
} ssh_gssapi_client;
typedef struct ssh_gssapi_mech_struct {
@@ -199,6 +200,18 @@ OM_uint32 ssh_gssapi_checkmic(Gssctxt *,
void ssh_gssapi_do_child(char ***, u_int *);
void ssh_gssapi_cleanup_creds(void);
int ssh_gssapi_storecreds(void);
+int ssh_gssapi_credentials_stored(void);
+ssh_gssapi_ccache *ssh_gssapi_get_ccache(void);
+int ssh_gssapi_user_has_valid_tgt(u_int);
+int ssh_gssapi_user_has_valid_proxy_tickets(char **, u_int, u_int);
+int ssh_gssapi_s4u2self(const char *, u_int);
+void ssh_gssapi_storecreds_s4u2self(void);
+void ssh_gssapi_s4u2proxy(char **, u_int, u_int);
+/* Flags for ssh_gssapi_krb5_filter_ccache(): which ticket classes to remove */
+#define SSH_GSSAPI_CCFILTER_TGT (1u << 0) /* krbtgt/... entries */
+#define SSH_GSSAPI_CCFILTER_SELF (1u << 1) /* S4U2Self evidence ticket */
+#define SSH_GSSAPI_CCFILTER_PROXY (1u << 2) /* S4U2Proxy service tickets */
+void ssh_gssapi_krb5_filter_ccache(u_int, char **, u_int);
const char *ssh_gssapi_displayname(void);
char *ssh_gssapi_server_mechanisms(void);