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.c b/usr.sbin/certctl/certctl.c new file mode 100644 --- /dev/null +++ b/usr.sbin/certctl/certctl.c @@ -0,0 +1,563 @@ +/*- + * 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 bool dryrun; +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", + NULL, +}; + +static const char *const untrusted_paths[] = { + "/usr/share/certs/untrusted", + "%L/etc/ssl/untrusted", + "%L/etc/ssl/blacklisted", + NULL, +}; + +#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 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)); +} + +static char ** +expand_paths(const char *const *paths) +{ + char **pathv; + size_t len; + + for (len = 0; paths[len] != NULL; len++) + /* nothing */ ; + if ((pathv = calloc(len + 1, sizeof(*paths))) == NULL) + err(1, NULL); + for (unsigned int i = 0; i < len; i++) + pathv[i] = expand_path(paths[i]); + return (pathv); +} + +static void +free_paths(char **pathv) +{ + for (char **pathp = pathv; *pathp != NULL; pathp++) + free(*pathp); + free(pathv); +} + +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 listed paths into a tree, optionally + * excluding those that already exist in a different tree. + */ +static int +load_certs(const char *const *paths, struct cert_tree *tree, + struct cert_tree *exclude) +{ + FTS *fts; + FTSENT *ent; + FILE *f; + X509 *x509; + struct cert *cert; + unsigned long hash; + int fts_options; + int n, total = 0; + + fts_options = FTS_LOGICAL | FTS_NOCHDIR; + if ((fts = fts_open((char *const *)(uintptr_t)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); + 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); +} + +/* + * 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 void +rmpath(const char *path) +{ + const char *paths[] = { path, NULL }; + FTS *fts; + FTSENT *ent; + int fts_options; + + fts_options = FTS_PHYSICAL | FTS_NOSTAT; + 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); + break; + } + } + fts_close(fts); +} + +/* + * 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; + + 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); + cert->path = malloc(len); + 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, 0444)) < 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 (unprivileged) { + fprintf(mlf, + "%s/%lx.%d type=file mode=0444 size=%ld\n", + TRUSTED_PATH, cert->hash, c, 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; + + path = xasprintf("%s/%s", dir, file); + if ((fd = open(path, O_WRONLY|O_CREAT|O_EXCL, 0444)) < 0) { + warn("%s", path); + free(path); + return (-1); + } + 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; + } + } + fclose(f); + free(path); + return (ret); +} + +/* + * Load trusted and untrusted certificates from all sources, then + * regenerate both the hashed directories and the bundle. + */ +static int +certctl_rehash(void) +{ + char **paths; + char *path, *newpath, *oldpath; + int n, ret; + + /* load untrusted certs first */ + paths = expand_paths(untrusted_paths); + n = load_certs((const char * const *)paths, &untrusted, NULL); + n += load_certs((const char * const[]){ UNTRUSTED_PATH, NULL }, &untrusted, NULL); + info("%d untrusted certificates found", n); + free_paths(paths); + /* load trusted certs, excluding any that are also untrusted */ + paths = expand_paths(trusted_paths); + n = load_certs((const char * const *)paths, &trusted, &untrusted); + info("%d trusted certificates found", n); + free_paths(paths); + /* create new directory */ + path = expand_path(SSL_PATH); + if (!unprivileged) { + newpath = xasprintf("%s.new", path); + oldpath = xasprintf("%s.old", path); + rmpath(newpath); + } + 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); + rmpath(newpath); + exit(1); + } + 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); + exit(1); + } + rmpath(oldpath); + free(oldpath); + free(newpath); + } else if (!unprivileged) { + rmpath(newpath); + free(oldpath); + free(newpath); + } + /* clean up */ + free(path); + free_certs(&untrusted); + free_certs(&trusted); + 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"); +} + +static void +usage(void) +{ + fprintf(stderr, "usage: certctl [-v] [-D destdir] [-d distbase] list\n" + " certctl [-v] [-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[]) +{ + int opt, ret; + + while ((opt = getopt(argc, argv, "nD:d:L:M:Uv")) != -1) + switch (opt) { + case 'D': + destdir = optarg; + break; + case 'd': + distbase = optarg; + 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(); + if (unprivileged && strcmp(argv[0], "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(); + + if (unprivileged && (mlf = fopen(metalog, "a")) == NULL) + err(1, "%s", metalog); + + ret = -1; + if (argc == 1) { + if (strcmp(argv[0], "rehash") == 0) { + ret = certctl_rehash(); + } else { + usage(); + } + } else { + usage(); + } + if (unprivileged) + fclose(mlf); + exit(ret == 0 ? 0 : 1); +}