/*
 * Copyright (C) 2021 Red Hat, Inc.
 * SPDX-License-Identifier: LGPL-2.1-or-later
 */

/*
 * This file deals with authentication based on client certificates.  It
 * contains a peer certificate verification function and a series of
 * functions for dealing with the session-scoped client certificate file
 * we store on the disk.
 *
 * This file is responsible for determining the cockpit-ws instance
 * identifiers: the client certificate files are limited in scope to a
 * particular cgroup, which is determined based on the instance
 * identifier: that logic is also in this file.
 *
 * Higher layers (cockpit-tls → cockpit-ws → cockpit-session) are
 * responsible for transporting the client certificate filename from
 * here to the counterpart of this file which performs the actual
 * checks: src/session/client-certificate.c.  The filename is
 * required information for authentication, but it's not sufficient:
 * the cgroup of the wsinstance must also match the one found in the
 * client certificate file.
 */

#include "config.h"

#include "client-certificate.h"

#include "common/cockpitmemory.h"
#include "common/cockpithex.h"
#include "tls/utils.h" /* for SHA256_NIL */

#include <assert.h>
#include <err.h>
#include <fcntl.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/random.h>
#include <unistd.h>


/**
 * client_certificate_verify: Custom client certificate validation function
 *
 * cockpit-tls ignores CA/trusted owner and leaves that to e. g. sssd. But
 * validate the other properties such as expiry, unsafe algorithms, etc.
 * This combination cannot be done with gnutls_session_set_verify_cert().
 */
int
client_certificate_verify (gnutls_session_t session)
{
  unsigned status;
  int ret;

  do
    ret = gnutls_certificate_verify_peers2 (session, &status);
  while (ret == GNUTLS_E_INTERRUPTED);

  if (ret == 0)
    {
      /* ignore CA/trusted owner and leave that to e. g. sssd */
      status &= ~(GNUTLS_CERT_INVALID | GNUTLS_CERT_SIGNER_NOT_FOUND | GNUTLS_CERT_SIGNER_NOT_CA);
      if (status != 0)
        {
          gnutls_datum_t msg;
          ret = gnutls_certificate_verification_status_print (status, gnutls_certificate_type_get (session), &msg, 0);
          if (ret != GNUTLS_E_SUCCESS)
            errx (EXIT_FAILURE, "Failed to print verification status: %s", gnutls_strerror (ret));
          warnx ("Invalid TLS peer certificate: %s", msg.data);
          gnutls_free (msg.data);
#ifdef GNUTLS_E_CERTIFICATE_VERIFICATION_ERROR
          return GNUTLS_E_CERTIFICATE_VERIFICATION_ERROR;
#else  /* fallback for GnuTLS < 3.4.4 */
          return GNUTLS_E_CERTIFICATE_ERROR;
#endif
        }
    }
  else if (ret != GNUTLS_E_NO_CERTIFICATE_FOUND)
    {
      warnx ("Verifying TLS peer failed: %s", gnutls_strerror (ret));
      return ret;
    }

  return GNUTLS_E_SUCCESS;
}

/**
 * client_certificate_get_wsinstance:
 * @certificate: the certificate presented by the peer
 *
 * Determines the correct cockpit-ws instance for handling connections
 * for this @certificate (must be non-%NULL).
 *
 * Currently, the full SHA256 fingerprint of the peer certificate is
 * used.  This is a pure design decision that nothing else depends on,
 * and it could be changed to something else.
 *
 * This function never fails.  Any internal failure will abort the
 * program.
 *
 * The return value of this function needs to be free()d.
 */
static char *
client_certificate_get_wsinstance (const gnutls_datum_t *certificate)
{
  unsigned char digest_data[256 / 8];
  size_t digest_size = sizeof digest_data;
  int r = gnutls_fingerprint (GNUTLS_DIG_SHA256, certificate, digest_data, &digest_size);
  if (r != GNUTLS_E_SUCCESS)
    errx (EXIT_FAILURE, "Could not generate fingerprint of peer certificate: %s",
          gnutls_strerror (r));
  assert (digest_size == sizeof digest_data);

  return cockpit_hex_encode (digest_data, digest_size);
}

/**
 * client_certificate_random_filename:
 *
 * Generates a (high quality) random hexadecimal string to use as a
 * client certificate filename.
 *
 * Currently, the filename will be 64 characters in length.  This is a
 * pure design decision that nothing else depends on, and it could be
 * changed to something else.
 *
 * This function never fails.  Any internal failure will abort the
 * program.
 *
 * The return value of this function needs to be free()d.
 */
static char *
client_certificate_random_filename (void)
{
  /* This is guaranteed to succeed, but we check it anyway. */
  unsigned char random_data[256 / 8];
  ssize_t s = getrandom (random_data, sizeof random_data, 0);
  if (s != sizeof random_data)
    err (EXIT_FAILURE, "Could not read random data from the kernel");

  return cockpit_hex_encode (random_data, sizeof random_data);
}

/**
 * client_certificate_write_data:
 * @fd: a writable file descriptor
 * @data: data to write
 * @size: the length of @data
 * @description: description of @data, added to error messages
 *
 * Writes @data to @fd.
 *
 * There's no EINTR handling or support for partial writes.  Any kind of
 * result other than a complete success on the first try is treated as
 * an error — this is tmpfs, after all.
 *
 * In case of failure, a message will be written to stderr.
 */
static bool
client_certificate_write_data (int         fd,
                               const void *data,
                               size_t      size,
                               const char *description)
{
  ssize_t s = write (fd, data, size);

  if (s == size)
    return true;

  if (s == -1)
    warn ("Couldn't write %s to certificate file", description);
  else
    warnx ("Partial write of %s to certificate file: %zu of %zu", description, s, size);

  return false;
}

static bool
client_certificate_write_cgroup_header (int         fd,
                                        const char *wsinstance)
{
  char header[200];
  int s = snprintf (header, sizeof header,
                    "0::/system.slice/system-cockpithttps.slice/cockpit-wsinstance-https@%s.service\n",
                    wsinstance);
  assert (s < sizeof header);

  return client_certificate_write_data (fd, header, s, "cgroup header");
}

static bool
client_certificate_write_pem (int   fd,
                              const gnutls_datum_t *der)
{
  gnutls_datum_t pem = { NULL, 0 };
  int r = gnutls_pem_base64_encode2 ("CERTIFICATE", der, &pem);
  if (r != GNUTLS_E_SUCCESS)
    {
      warnx ("Couldn't base64 encode certificate: %s", gnutls_strerror (r));
      return false;
    }

  bool result = client_certificate_write_data (fd, pem.data, pem.size, "PEM data");

  /* Make sure we get the function version and not the weird
   * side-effecting macro version.
   */
  (gnutls_free) (pem.data);

  return result;
}

/**
 * client_certificate_link_fd_to_random_name:
 * @dirfd: the directory to link the file
 * @fd: the file created with O_TMPFILE
 * @out_filename: the filename that was chosen
 *
 * Links the O_TMPFILE referred to by @fd to a random filename in
 * @dirfd.
 *
 * On success, %true is returned and @out_filename is set to the
 * filename that was used.  It must be free()d.
 *
 * On failure, %false is returned and a message will have been logged.
 */
static bool
client_certificate_link_fd_to_random_name (int    dirfd,
                                           int    fd,
                                           char **out_filename)
{
  char *filename = client_certificate_random_filename ();

  /* "the usual tricks" — see openat(2) and linkat(2) */
  char path[PATH_MAX];
  snprintf(path, PATH_MAX,  "/proc/self/fd/%d", fd);

  if (linkat (AT_FDCWD, path, dirfd, filename, AT_SYMLINK_FOLLOW) == 0)
    {
      *out_filename = filename;
      return true;
    }
  else
    {
      warn ("Unable to link client certificate file to /run/cockpit/tls/%s", filename);
      free (filename);
      return false;
    }
}

/**
 * client_certificate_create_tmpfile:
 * @dirfd: the directory that will eventually contain the file
 * @out_fd: a write-mode fd for the created file
 */
static bool
client_certificate_create_tmpfile (int  dirfd,
                                   int *out_fd)
{
  int fd = openat (dirfd, ".", O_TMPFILE | O_WRONLY, 0400);
  if (fd == -1)
    {
      warn ("Couldn't create temporary file for client certificate");
      return false;
    }

  *out_fd = fd;
  return true;
}

/**
 * client_certificate_accept:
 * @session: a post-handshake gnutls session
 * @dirfd: the directory for session-scoped client certificates
 * @out_wsinstance: the instance of cockpit-ws to connect to
 * @out_filename: the filename where the client certificate was written
 *
 * Called immediately after completing the handshake with an incoming
 * HTTPS connection.
 *
 * If no client certificate was presented, this function writes %NULL to
 * @out_filename, but still provides a (hard-coded) instance identifier
 * to @out_wsinstance.
 *
 * If a client certificate was presented, the @out_wsinstance will
 * correspond to the SHA256 of the peer certificate.  In this case, a
 * file with a random filename will be written to the directory
 * referenced by @dirfd.  This file will contain the expected cgroup of
 * the cockpit-ws instance in question, plus the client certificate.
 * That data is interpreted by the counterpart to this code, living in
 * src/session/client-certificate.c.
 *
 * In any case, %true will be returned in case of success, and %false
 * will be returned in case of an error.  In case of success, any values
 * returned in @out_wsinstance or @out_filename need to be free()d.  In
 * case of error, the connection should be terminated: a message will
 * already have been logged.
 */
bool
client_certificate_accept (gnutls_session_t   session,
                           int                dirfd,
                           char             **out_wsinstance,
                           char             **out_filename)
{
  const gnutls_datum_t *peer_certificate = gnutls_certificate_get_peers (session, NULL);

  if (peer_certificate == NULL)
    {
      *out_wsinstance = strdupx (SHA256_NIL);
      *out_filename = NULL;

      return true;
    }

  char *wsinstance = client_certificate_get_wsinstance (peer_certificate);

  int fd = -1;
  bool success =
    client_certificate_create_tmpfile (dirfd, &fd) &&
    client_certificate_write_cgroup_header (fd, wsinstance) &&
    client_certificate_write_pem (fd, peer_certificate) &&
    client_certificate_link_fd_to_random_name (dirfd, fd, out_filename);

  if (fd != -1)
    close (fd);

  if (success)
    *out_wsinstance = wsinstance;
  else
    free (wsinstance);

  if (!success)
    warnx ("Disconnecting client due to above failure.");

  return success;
}

/**
 * client_certificate_unlink_and_free:
 * @dirfd: the directory for session-scoped client certificates
 * @inout_filename: the name of the client certificate file
 *
 * Glorified wrapper around unlinkat().
 *
 * Frees @inout_filename.
 *
 * If the operation fails, the program will be aborted.
 */
void
client_certificate_unlink_and_free (int   dirfd,
                                    char *filename)
{
  if (unlinkat (dirfd, filename, 0) != 0)
    {
      /* We can't leave stale certificate files hanging around after
       * they should have been deleted, and we're really not expecting a
       * failure here, so let's abort the entire service.  This should
       * cause any running -ws instances to be terminated, and will
       * cause systemd to delete the entire runtime directory as well.
       */
      err (EXIT_FAILURE, "Failed to unlink client certificate file %s", filename);
    }

  free (filename);
}
