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 #endif +#ifdef HAVE_GSSAPI_EXT_H +# include +#endif +#ifdef HAVE_GSSAPI_KRB5_H +# include +#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);