diff --git a/usr.sbin/certctl/Makefile b/usr.sbin/certctl/Makefile --- a/usr.sbin/certctl/Makefile +++ b/usr.sbin/certctl/Makefile @@ -1,6 +1,6 @@ - PACKAGE= certctl -SCRIPTS=certctl.sh +PROG= certctl MAN= certctl.8 +LIBADD= crypto .include diff --git a/usr.sbin/certctl/certctl.8 b/usr.sbin/certctl/certctl.8 --- a/usr.sbin/certctl/certctl.8 +++ b/usr.sbin/certctl/certctl.8 @@ -24,7 +24,7 @@ .\" IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE .\" POSSIBILITY OF SUCH DAMAGE. .\" -.Dd July 13, 2022 +.Dd October 22, 2023 .Dt CERTCTL 8 .Os .Sh NAME @@ -32,10 +32,10 @@ .Nd "tool for managing trusted and untrusted TLS certificates" .Sh SYNOPSIS .Nm -.Op Fl v +.Op Fl lv .Ic list .Nm -.Op Fl v +.Op Fl lv .Ic untrusted .Nm .Op Fl nUv @@ -60,33 +60,40 @@ Specify the DESTDIR (overriding values from the environment). .It Fl d Ar distbase Specify the DISTBASE (overriding values from the environment). +.It Fl l +When listing installed (trusted or untrusted) certificates, show the +full path and distinguished name for each certificate. .It Fl M Ar metalog Specify the path of the METALOG file (default: $DESTDIR/METALOG). .It Fl n -No-Op mode, do not actually perform any actions. +Dryrun mode. +Do not actually perform any actions. .It Fl v -Be verbose, print details about actions before performing them. +Verbose mode. +Print detailed information about each action taken. .It Fl U -Unprivileged mode, do not change the ownership of created links. -Do record the ownership in the METALOG file. +Unprivileged mode. +Record each file created in the METALOG. .El .Pp Primary command functions: .Bl -tag -width untrusted .It Ic list -List all currently trusted certificate authorities. +List all currently trusted certificates. .It Ic untrusted List all currently untrusted certificates. .It Ic rehash -Rebuild the list of trusted certificate authorities by scanning all directories +Rebuild the list of trusted certificates by scanning all directories in .Ev TRUSTPATH and all untrusted certificates in .Ev UNTRUSTPATH . -A symbolic link to each trusted certificate is placed in +A copy of each trusted certificate is placed in .Ev CERTDESTDIR and each untrusted certificate in .Ev UNTRUSTDESTDIR . +In addition, a bundle containing the trusted certificates is placed in +.Ev BUNDLEFILE . .It Ic untrust Add the specified file to the untrusted list. .It Ic trust diff --git a/usr.sbin/certctl/certctl.c b/usr.sbin/certctl/certctl.c new file mode 100644 --- /dev/null +++ b/usr.sbin/certctl/certctl.c @@ -0,0 +1,690 @@ +/*- + * Copyright (c) 2023 Dag-Erling Smørgrav + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#define info(fmt, ...) do { \ + if (verbose) \ + fprintf(stderr, fmt "\n", ##__VA_ARGS__); \ + } while (0) + +static char * +xasprintf(const char *fmt, ...) +{ + va_list ap; + char *str; + int ret; + + va_start(ap, fmt); + ret = vasprintf(&str, fmt, ap); + va_end(ap); + if (ret < 0 || str == NULL) + err(1, NULL); + return (str); +} + +static char * +xstrdup(const char *str) +{ + char *dup; + + if ((dup = strdup(str)) == NULL) + err(1, NULL); + return (dup); +} + +static void usage(void); + +static bool dryrun; +static bool longnames; +static bool unprivileged; +static bool verbose; + +static const char *localbase; +static const char *destdir; +static const char *distbase; +static const char *metalog; + +static const char *const trusted_paths[] = { + "/usr/share/certs/trusted", + "%L/share/certs", + "%L/etc/ssl/certs", +}; + +static const char *const untrusted_paths[] = { + "/usr/share/certs/untrusted", + "%L/etc/ssl/untrusted", + "%L/etc/ssl/blacklisted", +}; + +#define SSL_PATH "/etc/ssl" +#define TRUSTED_SUBDIR "certs" +#define TRUSTED_PATH SSL_PATH "/" TRUSTED_SUBDIR +#define UNTRUSTED_SUBDIR "untrusted" +#define UNTRUSTED_PATH SSL_PATH "/" UNTRUSTED_SUBDIR +#define LEGACY_SUBDIR "blacklisted" +#define LEGACY_PATH SSL_PATH "/" LEGACY_SUBDIR +#define BUNDLE_FILE "cert.pem" +#define BUNDLE_PATH SSL_PATH "/" BUNDLE_FILE + +static FILE *mlf; + +static char * +expand_path(const char *path) +{ + if (path[0] == '%' && path[1] == 'L') + return (xasprintf("%s%s%s", destdir, localbase, path + 2)); + return (xasprintf("%s%s%s", destdir, distbase, path)); +} + +/* + * If destdir is a prefix of path, returns a pointer to the rest of path, + * otherwise returns path. + */ +static const char * +unexpand_path(const char *path) +{ + const char *p = path; + const char *q = destdir; + + while (*p && *p == *q) { + p++; + q++; + } + return (*q == '\0' && *p == '/' ? p : path); +} + +/* + * Create a directory and all its ancestors. The string must be writable. + */ +static int +mkpath(char *path) +{ + struct stat st; + char *sep; + int ret; + + if ((ret = mkdir(path, 0755)) == 0) + return (ret); + if (errno == EEXIST && stat(path, &st) == 0) { + if (S_ISDIR(st.st_mode)) + return (0); + errno = EEXIST; + return (-1); + } + if ((sep = strrchr(path, '/')) == NULL) { + errno = EINVAL; + return (-1); + } + while (sep > path && *sep == '/') + sep--; + if (sep > path) { + sep[1] = '\0'; + ret = mkpath(path); + sep[1] = '/'; + if (ret != 0) + return (ret); + } + info("creating %s", path); + return (mkdir(path, 0755)); +} + +/* + * Delete a directory and its contents recursively. + */ +static int +rmpath(const char *path) +{ + const char *paths[] = { path, NULL }; + FTS *fts; + FTSENT *ent; + int fts_options = FTS_PHYSICAL | FTS_NOSTAT; + int ret = 0; + + if ((fts = fts_open((char * const *)(uintptr_t)paths, fts_options, NULL)) == NULL) + err(1, "fts_open()"); + while ((ent = fts_read(fts)) != NULL) { + switch (ent->fts_info) { + case FTS_ERR: + warnc(ent->fts_errno, "fts_read()"); + break; + case FTS_DNR: + warnc(ent->fts_errno, "%s", ent->fts_accpath); + break; + case FTS_D: + /* ignore */ + break; + case FTS_DP: + default: + info("removing %s", ent->fts_path); + if (remove(ent->fts_accpath) != 0 && errno != ENOENT) { + warn("%s", ent->fts_accpath); + ret = -1; + } + break; + } + } + fts_close(fts); + return (ret); +} + +/* + * X509 certificate in a rank-balanced tree + */ +struct cert { + RB_ENTRY(cert) entry; + unsigned long hash; + char *name; + X509 *x509; + char *path; +}; + +static void +free_cert(struct cert *cert) +{ + free(cert->name); + X509_free(cert->x509); + free(cert->path); + free(cert); +} + +static int +certcmp(const struct cert *a, const struct cert *b) +{ + return (X509_cmp(a->x509, b->x509)); +} + +static RB_HEAD(cert_tree, cert) trusted, untrusted; +RB_GENERATE_STATIC(cert_tree, cert, entry, certcmp); + +static void +free_certs(struct cert_tree *tree) +{ + struct cert *cert, *tmp; + + RB_FOREACH_SAFE(cert, cert_tree, tree, tmp) { + RB_REMOVE(cert_tree, tree, cert); + free_cert(cert); + } +} + +static struct cert * +find_cert(struct cert_tree *haystack, X509 *x509) +{ + struct cert needle = { .x509 = x509 }; + + return (RB_FIND(cert_tree, haystack, &needle)); +} + +/* + * Load all certificates found in the specified path into a tree, + * optionally excluding those that already exist in a different tree. + */ +static unsigned int +load_certs(const char *path, struct cert_tree *tree, struct cert_tree *exclude) +{ + char *paths[] = { (char *)(uintptr_t)path, NULL }; + FTS *fts; + FTSENT *ent; + FILE *f; + X509 *x509; + struct cert *cert; + unsigned long hash; + int fts_options = FTS_LOGICAL | FTS_NOCHDIR; + int n, total = 0; + + if ((fts = fts_open(paths, fts_options, NULL)) == NULL) + err(1, "fts_open()"); + while ((ent = fts_read(fts)) != NULL) { + if (ent->fts_info != FTS_F) { + if (ent->fts_info == FTS_ERR) + warnc(ent->fts_errno, "fts_read()"); + continue; + } + info("found %s", ent->fts_accpath); + if ((f = fopen(ent->fts_accpath, "r")) == NULL) { + warn("%s", ent->fts_accpath); + continue; + } + for (n = 0; (x509 = PEM_read_X509(f, NULL, NULL, NULL)); n++) { + hash = X509_subject_name_hash(x509); + if (exclude && find_cert(exclude, x509)) { + info("%08lx: excluded", hash); + X509_free(x509); + continue; + } + if (find_cert(tree, x509)) { + info("%08lx: duplicate", hash); + X509_free(x509); + continue; + } + if ((cert = calloc(1, sizeof(*cert))) == NULL) + err(1, NULL); + cert->x509 = x509; + cert->hash = X509_subject_name_hash(x509); + cert->name = X509_NAME_oneline(X509_get_subject_name(x509), NULL, 0); + cert->path = xstrdup(unexpand_path(ent->fts_path)); + if (RB_INSERT(cert_tree, tree, cert) != NULL) + errx(1, "unexpected duplicate"); + info("%08lx: %s", cert->hash, strrchr(cert->name, '=') + 1); + total++; + } + if (n == 0) + warnx("%s: no valid certificates found", ent->fts_accpath); + fclose(f); + } + fts_close(fts); + return (total); +} + +/* + * Save the contents of a cert tree to disk + */ +static int +save_certs(const char *dir, const char *subdir, struct cert_tree *tree) +{ + char *path; + struct cert *cert; + FILE *f; + long len; + int c, fd, ret; + mode_t mode = 0444; + + path = xasprintf("%s/%s", dir, subdir); + if (mkpath(path) != 0) + return (-1); + if ((len = pathconf(dir, _PC_PATH_MAX)) < 0) + len = PATH_MAX; + len++; + RB_FOREACH(cert, cert_tree, tree) { + free(cert->path); + if ((cert->path = malloc(len)) == NULL) { + warn(NULL); + ret = -1; + break; + } + c = 0; +again: + if (snprintf(cert->path, len, "%s/%08lx.%d", path, cert->hash, c) >= len) { + warnc(ENAMETOOLONG, "%s/%08lx.%d", path, cert->hash, c); + free(cert->path); + cert->path = NULL; + ret = -1; + continue; + } + if ((fd = open(cert->path, O_WRONLY|O_CREAT|O_EXCL, mode)) < 0) { + if (errno == EEXIST) { + c++; + goto again; + } + warn("%s", cert->path); + ret = -1; + continue; + } + info("writing %s", cert->path); + if ((f = fdopen(fd, "w")) == NULL) + err(1, NULL); + if (!PEM_write_X509(f, cert->x509)) { + warn("%s", cert->path); + fclose(f); + ret = -1; + continue; + } + if (mlf != NULL) { + fprintf(mlf, + "%s type=file mode=%#o size=%ld\n", + unexpand_path(cert->path), mode, ftell(f)); + } + fclose(f); + } + free(path); + return (ret); +} + +/* + * Save all certs in a tree to a single file (bundle) + */ +static int +save_bundle(const char *dir, const char *file, struct cert_tree *tree) +{ + struct cert *cert; + char *path; + FILE *f; + int fd, ret; + mode_t mode = 0444; + + path = xasprintf("%s/%s", dir, file); + if ((fd = open(path, O_WRONLY|O_CREAT|O_EXCL, mode)) < 0) { + warn("%s", path); + free(path); + return (-1); + } + info("writing %s", path); + if ((f = fdopen(fd, "w")) == NULL) + err(1, NULL); + ret = 0; + RB_FOREACH(cert, cert_tree, tree) { + if (!PEM_write_X509(f, cert->x509)) { + warn("%s", cert->path); + ret = -1; + break; + } + } + if (mlf != NULL) { + fprintf(mlf, + "%s type=file mode=%#o size=%ld\n", + unexpand_path(path), mode, ftell(f)); + } + fclose(f); + free(path); + return (ret); +} + +/* + * List the contents of a certificate tree. + */ +static void +list_certs(struct cert_tree *tree) +{ + struct cert *cert; + char *path, *name; + + RB_FOREACH(cert, cert_tree, tree) { + path = longnames ? NULL : strrchr(cert->path, '/'); + name = longnames ? NULL : strrchr(cert->name, '='); + printf("%s\t%s\n", path ? path + 1 : cert->path, + name ? name + 1 : cert->name); + } +} + +/* + * Load installed trusted certificates, then list them. + */ +static int +certctl_list(int argc, char **argv __unused) +{ + char *path; + int n; + + if (argc > 1) + usage(); + /* load trusted certificates */ + path = expand_path(TRUSTED_PATH); + n = load_certs(path, &trusted, NULL); + free(path); + info("%d trusted certificates found", n); + /* list them */ + list_certs(&trusted); + free_certs(&trusted); + return (0); +} + +/* + * Load installed untrusted certificates, then list them. + */ +static int +certctl_untrusted(int argc, char **argv __unused) +{ + char *path; + int n; + + if (argc > 1) + usage(); + /* load untrusted certificates */ + path = expand_path(UNTRUSTED_PATH); + n = load_certs(path, &untrusted, NULL); + free(path); + /* ...including legacy ones */ + path = expand_path(LEGACY_PATH); + n += load_certs(path, &untrusted, NULL); + free(path); + info("%d untrusted certificates found", n); + /* list them */ + list_certs(&untrusted); + free_certs(&untrusted); + return (0); +} + +/* + * Load trusted and untrusted certificates from all sources, then + * regenerate both the hashed directories and the bundle. + */ +static int +certctl_rehash(int argc, char **argv __unused) +{ + char *path, *newpath, *oldpath; + unsigned int i, n; + int ret; + + if (argc > 1) + usage(); + + if (unprivileged && (mlf = fopen(metalog, "a")) == NULL) { + warn("%s", metalog); + return (-1); + } + + /* load untrusted certs first */ + for (i = n = 0; i < nitems(untrusted_paths); i++) { + path = expand_path(untrusted_paths[i]); + n += load_certs(path, &untrusted, NULL); + free(path); + } + /* ...and manually untrusted certs */ + path = expand_path(UNTRUSTED_PATH); + n += load_certs(UNTRUSTED_PATH, &untrusted, NULL); + free(path); + /* ...and legacy untrusted certs */ + path = expand_path(LEGACY_PATH); + n += load_certs(LEGACY_PATH, &untrusted, NULL); + free(path); + info("%d untrusted certificates found", n); + /* load trusted certs, excluding any that are also untrusted */ + for (i = n = 0; i < nitems(trusted_paths); i++) { + path = expand_path(trusted_paths[i]); + n += load_certs(path, &trusted, &untrusted); + free(path); + } + info("%d trusted certificates found", n); + /* create new directory */ + path = expand_path(SSL_PATH); + if (!unprivileged) { + newpath = xasprintf("%s.new", path); + oldpath = xasprintf("%s.old", path); + if (rmpath(newpath) != 0) { + warn("failed to delete old %s", newpath); + ret = -1; + goto priv_end; + } + } + ret = 0; + /* wipe and save untrusted certs */ + ret |= save_certs(unprivileged ? path : newpath, UNTRUSTED_SUBDIR, &untrusted); + /* wipe and save trusted certs */ + ret |= save_certs(unprivileged ? path : newpath, TRUSTED_SUBDIR, &trusted); + /* save bundle */ + ret |= save_bundle(unprivileged ? path : newpath, BUNDLE_FILE, &trusted); + /* rotate */ + if (!unprivileged && ret == 0) { + if (rename(path, oldpath) != 0 && errno != ENOENT) { + warn("failed to rename %s to %s", path, oldpath); + if (rmpath(newpath) != 0) + warn("failed to delete %s", newpath); + ret = -1; + } else if (rename(newpath, path) != 0) { + warn("failed to rename %s to %s", newpath, path); + /* put it back! */ + if (rename(oldpath, path) != 0) + warn("failed to rename %s back to %s", oldpath, path); + ret = -1; + } else { + if (rmpath(oldpath) != 0) + warn("failed to delete %s", oldpath); + } + free(oldpath); + free(newpath); + } else if (!unprivileged) { + if (rmpath(newpath) != 0) + warn("failed to delete %s", newpath); +priv_end: + free(oldpath); + free(newpath); + } + /* clean up */ + free(path); + free_certs(&untrusted); + free_certs(&trusted); + if (mlf != NULL) + fclose(mlf); + return (ret); +} + +static void +set_defaults(void) +{ + const char *value; + char *str; + size_t len; + + if (localbase == NULL) { + if ((str = malloc((len = PATH_MAX) + 1)) == NULL) + err(1, NULL); + while (sysctlbyname("user.localbase", str, &len, NULL, 0) < 0) { + if (errno != ENOMEM) + err(1, "sysctl(user.localbase)"); + if ((str = realloc(str, len + 1)) == NULL) + err(1, NULL); + } + str[len] = '\0'; + localbase = str; + } + if (destdir == NULL) { + if ((destdir = getenv("DESTDIR")) == NULL) { + destdir = ""; + } + } + if (distbase == NULL) { + if ((distbase = getenv("DISTBASE")) == NULL) { + distbase = ""; + } + } + if (unprivileged && metalog == NULL) { + if ((metalog = getenv("METALOG")) == NULL) + metalog = xasprintf("%s/METALOG", destdir); + } + if (!verbose) { + if ((value = getenv("CERTCTL_VERBOSE")) != NULL) { + if (value[0] != '\0') { + verbose = true; + } + } + } + info("localbase:\t%s", localbase); + info("destdir:\t%s", destdir); + info("distbase:\t%s", distbase); + info("unprivileged:\t%s", unprivileged ? "true" : "false"); + info("verbose:\t%s", verbose ? "true" : "false"); +} + +typedef int (*main_t)(int, char **); + +static struct { + const char *name; + main_t func; +} commands[] = { + { "list", certctl_list }, + { "untrusted", certctl_untrusted }, + { "rehash", certctl_rehash }, +// { "untrust", certctl_untrust }, +// { "trust", certctl_trust }, +}; + +static void +usage(void) +{ + fprintf(stderr, "usage: certctl [-lv] [-D destdir] [-d distbase] list\n" + " certctl [-lv] [-D destdir] [-d distbase] untrusted\n" + " certctl [-nUv] [-D destdir] [-d distbase] [-M metalog] rehash\n" + " certctl [-nv] [-D destdir] [-d distbase] untrust \n" + " certctl [-nv] [-D destdir] [-d distbase] trust \n"); + exit(1); +} + +int +main(int argc, char *argv[]) +{ + const char *command; + int opt; + + while ((opt = getopt(argc, argv, "nD:d:lL:M:Uv")) != -1) + switch (opt) { + case 'D': + destdir = optarg; + break; + case 'd': + distbase = optarg; + break; + case 'l': + longnames = true; + break; + case 'L': + localbase = optarg; + break; + case 'M': + metalog = optarg; + break; + case 'n': + dryrun = true; + break; + case 'U': + unprivileged = true; + break; + case 'v': + verbose = true; + break; + default: + usage(); + } + + argc -= optind; + argv += optind; + + if (argc < 1) + usage(); + + command = *argv; + + if (unprivileged && strcmp(command, "rehash") != 0) + usage(); + if (!unprivileged && metalog != NULL) { + warnx("-M may only be used in conjunction with -U"); + usage(); + } + if (dryrun) + errx(1, "-n is not yet implemented"); + + set_defaults(); + + for (unsigned i = 0; i < nitems(commands); i++) + if (strcmp(command, commands[i].name) == 0) + exit(!!commands[i].func(argc, argv)); + usage(); +}