/* * Copyright (C) 2019 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ #include "config.h" #include "client-certificate.h" #include #include #include #include #include #include #include #include #include #include #ifdef HAVE_PIDFD_GETPID #include #endif #include #include #include #include #include #include #include "session-utils.h" #define CLIENT_CERTIFICATE_DIRECTORY "/run/cockpit/tls/clients" static int open_proc_pid (pid_t pid) { char path[100]; int r = snprintf (path, sizeof path, "/proc/%lu", (unsigned long) pid); if (r < 0 || r >= sizeof path) errx (EX, "memory error"); int fd = open (path, O_DIRECTORY | O_NOFOLLOW | O_PATH | O_CLOEXEC); if (fd < 0) err (EX, "failed to open %s", path); return fd; } static size_t read_proc_file (int dirfd, const char *name, char *buffer, size_t bufsize) { int fd = openat (dirfd, name, O_RDONLY | O_CLOEXEC); if (fd < 0) err (EX, "Failed to open %s proc file", name); /* we don't accept/expect EINTR or short reads here: this is /proc, and we don't have * signal handlers which survive the login */ ssize_t len = read (fd, buffer, bufsize); if (len < 0) err (EX, "Failed to read /proc file %s", name); close (fd); if (len >= bufsize) errx (EX, "proc file %s exceeds buffer size %zu", name, bufsize); buffer[len] = '\0'; return len; } /* This is a bit lame, but having a hard limit on peer certificates is * desirable: Let's not get DoSed by huge certs */ #define MAX_PEER_CERT_SIZE 100000 /* Reads the cgroupsv2-style /proc/[pid]/cgroup file of the process, * including "0::" prefix and newline. * NB: the kernel doesn't allow newlines in cgroup names. */ static char * read_proc_pid_cgroup (int dirfd, size_t *out_length) { char buffer[1024]; size_t len = read_proc_file (dirfd, "cgroup", buffer, sizeof buffer); if (strncmp (buffer, "0::/", 4) == 0 && /* must be a cgroupsv2 */ buffer[len - 1] == '\n') /* must end with a newline */ { *out_length = len; return strdupx (buffer); } warnx ("unexpected cgroups content, certificate matching only supports cgroup v2: '%s'", buffer); exit_init_problem ("authentication-unavailable", "certificate matching only supports cgroup v2"); } static unsigned long long get_proc_pid_start_time (int dirfd) { char buffer[4096]; read_proc_file (dirfd, "stat", buffer, sizeof buffer); /* start time is the token at index 19 after the '(process name)' entry - since only this * field can contain the ')' character, search backwards for this to avoid malicious * processes trying to fool us; See proc_pid_stat(5) */ const char *p = strrchr (buffer, ')'); if (p == NULL) errx (EX, "Failed to find process name in /proc/pid/stat: %s", buffer); for (int i = 0; i <= 19; i++) /* NB: ')' is the first token */ { p = strchr (p, ' '); if (p == NULL) errx (EX, "Failed to find start time in /proc/pid/stat"); ++p; /* skip over the space */ } char *endptr; unsigned long long start_time = strtoull (p, &endptr, 10); if (*endptr != ' ') errx (EX, "Failed to parse start time in /proc/pid/stat from %s", p); return start_time; } /* Fallback for get_ws_proc_fd() on older kernels which don't support enough pidfd API */ static int get_ws_proc_fd_pid_time (int unix_fd) { struct ucred ucred; socklen_t ucred_len = sizeof ucred; if (getsockopt (unix_fd, SOL_SOCKET, SO_PEERCRED, &ucred, &ucred_len) != 0 || /* this is an inout parameter, be extra suspicious */ ucred_len != sizeof ucred) { debug ("failed to read stdin peer credentials: %m; not in socket mode?"); warnx ("Certificate authentication only supported with cockpit-session.socket"); exit_init_problem ("authentication-unavailable", "Certificate authentication only supported with cockpit-session.socket"); } debug ("unix socket mode, ws peer pid %d", ucred.pid); int ws_proc_dirfd = open_proc_pid (ucred.pid); unsigned long long ws_start_time = get_proc_pid_start_time (ws_proc_dirfd); int my_pid_dirfd = open_proc_pid (getpid ()); unsigned long long my_start_time = get_proc_pid_start_time (my_pid_dirfd); close (my_pid_dirfd); debug ("peer start time: %llu, my start time: %llu", ws_start_time, my_start_time); /* Guard against pid recycling: If a malicious user captures ws, keeps the socket in a forked child and exits * the original pid, they can trick a different user to login, get the old pid (pointing to their cgroup), and * capture their session. To prevent that, require that ws must have started earlier than ourselves. */ if (my_start_time < ws_start_time) { warnx ("start time of this process (%llu) is older than cockpit-ws (%llu), pid recycling attack?", my_start_time, ws_start_time); close (ws_proc_dirfd); exit_init_problem ("access-denied", "implausible cockpit-ws start time"); } return ws_proc_dirfd; } /* Get a /proc/[pid] dirfd for our Unix socket peer (i.e. cockpit-ws). * We only support being called via cockpit-session.socket (i.e. Unix socket). */ static int get_ws_proc_fd (int unix_fd) { #if defined(SO_PEERPIDFD) && defined(HAVE_PIDFD_GETPID) int pidfd = -1; socklen_t socklen = sizeof pidfd; /* this is always the pidfd for the process that started the communication, it cannot be recycled */ if (getsockopt (unix_fd, SOL_SOCKET, SO_PEERPIDFD, &pidfd, &socklen) < 0) { if (errno == ENOPROTOOPT) { debug ("SO_PEERPIDFD not supported: %m, falling back to pid/time check"); return get_ws_proc_fd_pid_time (unix_fd); } warn ("Failed to get peer pidfd"); exit_init_problem ("access-denied", "Failed to get peer pidfd"); } /* this is an inout parameter, be extra suspicious; this really Should Not Happen™, so bomb out */ if (socklen != sizeof pidfd) errx (EX, "SO_PEERPIDFD returned too small result"); /* get pid for pidfd; from here on this is racy and could suffer from PID recycling */ pid_t pid = pidfd_getpid (pidfd); if (pid < 0) { /* be *very* strict here. This could theoretically ENOSYS if glibc has pidfd_getpid() but the kernel doesn't * support it; but err on the side of denying access rather than falling back */ warn ("Failed to get pid from pidfd"); exit_init_problem ("access-denied", "Failed to get pid from pidfd"); } debug ("pid from ws peer pidfd: %i", (int) pid); int ws_proc_dirfd = open_proc_pid (pid); /* check that the pid is still valid to guard against recycling */ if (pidfd_getpid (pidfd) != pid) { warn ("original pid %i is not valid any more", (int) pid); exit_init_problem ("access-denied", "Failed to get cockpit-ws pid"); } close (pidfd); return ws_proc_dirfd; #else debug ("not built with pidfd support, falling back to pid/time check"); return get_ws_proc_fd_pid_time (unix_fd); #endif } /* valid_256_bit_hex_string: * @str: a string * * Ensures that str is a hexadecimal character string, exactly 64 * characters in length. */ static bool valid_256_bit_hex_string (const char *str) { size_t length = strspn (str, "0123456789abcdef"); return str[length] == '\0' && length == 64; } /** * read_cert_file: * @contents: a buffer to read the certificate into * @contents_size: the size of @contents * * Reads the contents of the certificate file into @contents (of size @contents_size). * The buffer must be large enough for the contents of the certificate file, plus * a nul terminator (which will be added). * * On success, the size of the certificate file (excluding nul * terminator) is returned. This value is never 0. On error, -1 is * returned with errno not guaranteed to be set (but a message will be * logged). */ static ssize_t read_cert_file (const char *filename, char *contents, size_t contents_size) { int dirfd = -1, filefd = -1; ssize_t result = -1; struct stat buf; ssize_t r; /* No tricky stuff, please */ if (!valid_256_bit_hex_string (filename)) { warnx ("tls-cert authentication token is invalid"); goto out; } dirfd = open (CLIENT_CERTIFICATE_DIRECTORY, O_PATH | O_DIRECTORY | O_NOFOLLOW); if (dirfd == -1) { warn ("Failed to open " CLIENT_CERTIFICATE_DIRECTORY); goto out; } filefd = openat (dirfd, filename, O_RDONLY | O_NOFOLLOW); if (filefd == -1) { warn ("Failed to open certificate file %s/%s", CLIENT_CERTIFICATE_DIRECTORY, filename); goto out; } if (fstat (filefd, &buf) != 0) { warn ("Failed to stat certificate file %s/%s", CLIENT_CERTIFICATE_DIRECTORY, filename); goto out; } if (!S_ISREG (buf.st_mode)) { warnx ("Could not read certificate: %s/%s is not a regular file", CLIENT_CERTIFICATE_DIRECTORY, filename); goto out; } if (buf.st_size == 0) { warnx ("Could not read certificate: %s/%s is empty", CLIENT_CERTIFICATE_DIRECTORY, filename); goto out; } /* Strictly less than, since we will add a nul */ if (!(buf.st_size < contents_size)) { warnx ("Insufficient space in read buffer for %s/%s", CLIENT_CERTIFICATE_DIRECTORY, filename); goto out; } do r = pread (filefd, contents, buf.st_size, 0); while (r == -1 && errno == EINTR); if (r == -1) { warn ("Could not read certificate file %s/%s", CLIENT_CERTIFICATE_DIRECTORY, filename); goto out; } if (r != buf.st_size) { warnx ("Read incomplete contents of certificate file %s/%s: %zu of %zu bytes", CLIENT_CERTIFICATE_DIRECTORY, filename, r, (size_t) buf.st_size); goto out; } contents[buf.st_size] = '\0'; if (strlen (contents) != buf.st_size) { warnx ("Certificate file %s/%s contains nul characters", CLIENT_CERTIFICATE_DIRECTORY, filename); goto out; } result = buf.st_size; out: if (filefd != -1) close (filefd); if (dirfd != -1) close (dirfd); return result; } static bool sssd_map_certificate (const char *certificate, char** username) { int result = false; sd_bus_error err = SD_BUS_ERROR_NULL; sd_bus *bus = NULL; sd_bus_message *user_obj_msg = NULL; const char *user_obj_path = NULL; int r; assert (username); assert (!*username); r = sd_bus_open_system (&bus); if (r < 0) { warnx ("Failed to connect to system bus: %s", strerror (-r)); goto out; } /* sssd 2.6.1 introduces certificate validation against the configured CA. This version is in all supported distros */ r = sd_bus_call_method (bus, "org.freedesktop.sssd.infopipe", "/org/freedesktop/sssd/infopipe/Users", "org.freedesktop.sssd.infopipe.Users", "FindByValidCertificate", &err, &user_obj_msg, "s", certificate); if (r < 0) { /* The error name is a bit confusing, and this is the common case; translate to readable error */ if (sd_bus_error_has_name (&err, "sbus.Error.NotFound")) { warnx ("No matching user for certificate"); goto out; } warnx ("Failed to map certificate to user: [%s] %s", err.name, err.message); goto out; } assert (user_obj_msg); r = sd_bus_message_read (user_obj_msg, "o", &user_obj_path); if (r < 0) { warnx ("Failed to parse response message: %s", strerror (-r)); goto out; } debug ("certificate mapped to user object path %s", user_obj_path); r = sd_bus_get_property_string (bus, "org.freedesktop.sssd.infopipe", user_obj_path, "org.freedesktop.sssd.infopipe.Users.User", "name", &err, username); if (r < 0) { warnx ("Failed to map user object to name: [%s] %s", err.name, err.message); goto out; } assert (*username); debug ("mapped certificate to user %s", *username); result = true; out: sd_bus_error_free (&err); sd_bus_message_unref (user_obj_msg); sd_bus_unref (bus); return result; } /** * cockpit_session_client_certificate_map_user * * Read the given certificate file, ensure that it belongs to our own cgroup, and ask * sssd to map it to a user. If everything matches as expected, return the user name. * Otherwise exit the process with sending an appropriate error to stdout using the * Cockpit protocol. */ char * cockpit_session_client_certificate_map_user (const char *client_certificate_filename) { char cert_pem[MAX_PEER_CERT_SIZE]; char *sssd_user = NULL; /* read the certificate file from disk */ if (read_cert_file (client_certificate_filename, cert_pem, sizeof cert_pem) < 0) { warnx ("No https instance certificate present"); exit_init_problem ("authentication-unavailable", "No https instance certificate present"); } /* We need to check the cgroup of cockpit-ws (our peer); we are systemd socket activated, so stdin is a socket */ int ws_proc_dirfd = get_ws_proc_fd (STDIN_FILENO); size_t ws_cgroup_length; char *ws_cgroup = read_proc_pid_cgroup (ws_proc_dirfd, &ws_cgroup_length); assert (ws_cgroup); close (ws_proc_dirfd); /* read_proc_pid_cgroup() already ensures that, but just in case we refactor this: this is *essential* for the * subsequent comparison */ if (ws_cgroup[ws_cgroup_length - 1] != '\n') errx (EX, "cgroup does not end in newline"); /* A simple prefix comparison is appropriate here because ws_cgroup * contains exactly one newline (at the end), and the expected * value of ws_cgroup is on the first line in cert_pem. */ if (strncmp (cert_pem, ws_cgroup, ws_cgroup_length) != 0) { warnx ("This client certificate is only meant to be used from another cgroup"); free (ws_cgroup); exit_init_problem ("access-denied", "mismatching client certificate"); } free (ws_cgroup); /* ask sssd to map cert to a user */ if (!sssd_map_certificate (cert_pem + ws_cgroup_length, &sssd_user)) exit_init_problem ("authentication-failed", "sssd does not know this certificate"); return sssd_user; }