Index | Thread | Search

From:
Job Snijders <job@bsd.nl>
Subject:
rpki-client: give priority to confirmed certification paths
To:
tech@openbsd.org
Date:
Mon, 5 Oct 2026 10:47:53 +0000

Download raw body.

Thread
  • Job Snijders:

    rpki-client: give priority to confirmed certification paths

Dear all,

Because SKI might not be globally unique, certificates are tracked by an
internal identifier since 2024. Duplicate SKI might appear because of
reuse of a keypair (a poor CA practise, but not illegal), or because of
'public key stealing', i.e., an adversarial CA issues a certificate with
the victim CA's SPKI and SIA. RPs cannot easily distinguish the causes
of duplicate SKIs, and there is no proof of possession in the RPKI.

Jacobsen et al., 2026, suggested that rpki-client's certification path
loop protection might be exploited if an adversary can race the victim.
This race can happen in the discovery of the logical certification
topology, unrelated to speedrunning repository loading. Any payload
carrying objects (ROAs, ASPAs, etc) discovered through the adversary's
certification path will fail resource containment checks and the loop
guard (entity_process()->filepath_add()) necessarily enforces each
object to be accessed only once. The result is that the victim's
payloads disappear from view if they lose the race.

The Jacobsen paper incorrectly asserts that RPKI certificates cannot
be pinned to a logical location. While indeed AIAs are merely of an
advisory quality, CRLDPs are not. RFC 9829 clarifies that a resource
certificate cannot be validated without consulting the current manifest
of the certificate's issuer, RFC 9981 section 4 specifies anchoring of
manifests, and RFC 6480, section 4.2, illustrates how all valid products
in the same CA repository (i.e., from the same issuer, listed on the
same manifest) will point to the same CRL. CRLs and CA certificates are
referenced from manifests by SHA-256. In short: a standards-compliant RP
will verify CA/EE CRLDP and manifest SIA alignment and thus can robustly
construct a concept of placement of certificates in the logical topology.

Based on the above, an effective mitigation against certification path
poisoning is to keep track of all the certificate->manifest segments
that yielded validated payloads in the past and prioritise discovery
through those 'confirmed' segments in the future. CA certificates become
'confirmed' once a valid non-inheriting end-entity certificate is found.
I think this mitigation will work well in practise because potential
adversaries and victims won't have authority for the same resources.

The listing of confirmed CAs is stored in the cache directory for use in
the next run, somewhat similar to what's done for non-functional CAs.
Each entry contains a AKI, SKI, certificate location (this is not an
AIA), the manifest location, and confirmation timestamp. Unconfirmed CAs
are deprioritised by adding the respective manifest entity to a tail
queue which is processed only after all confirmed CAs are processed.
Through SIGINFO one can nicely see the two queue approach in action.

Statistics are collected on the number of unconfirmed CAs per repo &
tal. These new stats shows that at the moment of writing a few thousand
RPKI CAs do not publish any ROAs, ASPAs, SPLs, or BGPsec router keys,
and therefore could not (yet) be confirmed. This is as expected.

OK?

Kind regards,

Job

Index: Makefile
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/Makefile,v
diff -u -p -r1.43 Makefile
--- Makefile	4 Oct 2026 09:39:02 -0000	1.43
+++ Makefile	5 Oct 2026 08:13:26 -0000
@@ -8,6 +8,7 @@ SRCS+=	aspa.c
 SRCS+=	bs_ber.c
 SRCS+=	bs_cbb.c
 SRCS+=	bs_cbs.c
+SRCS+=	cca.c
 SRCS+=	ccr.c
 SRCS+=	cert.c
 SRCS+=	cms.c
Index: cca.c
===================================================================
RCS file: cca.c
diff -N cca.c
--- /dev/null	1 Jan 1970 00:00:00 -0000
+++ cca.c	5 Oct 2026 08:13:26 -0000
@@ -0,0 +1,450 @@
+/*	$OpenBSD$	*/
+/*
+ * Copyright (c) 2026 Job Snijders <job@bsd.nl>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/stat.h>
+#include <sys/tree.h>
+
+#include <assert.h>
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <stdlib.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "extern.h"
+
+/*
+ * Structure to hold identifier details used to confirm segments of
+ * certification paths. A cert-to-mft segment becomes 'confirmed' once a
+ * valid non-inheriting end-entity certificate is found through that path.
+ */
+struct cca {
+	RB_ENTRY(cca) entry;
+	char *aki;
+	char *ski;
+	char *location;
+	char *mfturi;
+	enum cert_purpose purpose;
+	int certid;
+	int issuerid;
+	int talid;
+	unsigned int repoid;
+	time_t since;
+	int confirmed;
+};
+
+static RB_HEAD(cca_tree, cca) ccas = RB_INITIALIZER(&ccas);
+
+static inline int
+certid_cmp(const struct cca *a, const struct cca *b)
+{
+	if (a->certid < b->certid)
+		return -1;
+	if (a->certid > b->certid)
+		return 1;
+
+	return 0;
+}
+
+RB_GENERATE_STATIC(cca_tree, cca, entry, certid_cmp);
+
+static RB_HEAD(prev_cca_tree, cca) prev_ccas = RB_INITIALIZER(&prev_ccas);
+
+static inline int
+prev_cca_cmp(const struct cca *a, const struct cca *b)
+{
+	int cmp;
+
+	cmp = strcmp(a->ski, b->ski);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	cmp = strcmp(a->aki, b->aki);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	cmp = strcmp(a->location, b->location);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	cmp = strcmp(a->mfturi, b->mfturi);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	return 0;
+}
+
+RB_GENERATE_STATIC(prev_cca_tree, cca, entry, prev_cca_cmp);
+
+static time_t
+cca_lookup_since(const struct cert *cert)
+{
+	struct cca *found, needle;
+	time_t since = 0;
+
+	needle.aki = cert->aki;
+	needle.ski = cert->ski;
+	needle.location = cert->path;
+	needle.mfturi = cert->mft;
+
+	if ((found = RB_FIND(prev_cca_tree, &prev_ccas, &needle)) != NULL)
+		since = found->since;
+
+	return since;
+}
+
+static void
+cca_free(struct cca *cca)
+{
+	free(cca->aki);
+	free(cca->ski);
+	free(cca->location);
+	free(cca->mfturi);
+	free(cca);
+}
+
+/*
+ * Add identifiers from the given cert into the tree of to-be-confirmed CAs.
+ */
+void
+cca_tree_insert(const struct cert *cert)
+{
+	struct cca *cca;
+
+	assert(cert->purpose == CERT_PURPOSE_TA ||
+	    cert->purpose == CERT_PURPOSE_CA);
+
+	if ((cca = calloc(1, sizeof(*cca))) == NULL)
+		err(1, NULL);
+
+	if (cert->purpose == CERT_PURPOSE_CA) {
+		if ((cca->aki = strdup(cert->aki)) == NULL)
+			err(1, NULL);
+	}
+
+	if ((cca->ski = strdup(cert->ski)) == NULL)
+		err(1, NULL);
+	if ((cca->location = strdup(cert->path)) == NULL)
+		err(1, NULL);
+	if ((cca->mfturi = strdup(cert->mft)) == NULL)
+		err(1, NULL);
+
+	cca->certid = cert->certid;
+	cca->issuerid = cert->issuerid;
+	cca->talid = cert->talid;
+	cca->repoid = cert->repoid;
+	cca->purpose = cert->purpose;
+	cca->since = cca_lookup_since(cert);
+	cca->confirmed = 0;
+
+	if (RB_INSERT(cca_tree, &ccas, cca) != NULL) {
+		warnx("duplicate CA in cca_tree at %s", cca->location);
+		cca_free(cca);
+	}
+}
+
+/*
+ * Check whether a CA was previously confirmed.
+ * Returns 1 if confirmed, 0 otherwise.
+ */
+int
+cca_was_confirmed(int cid)
+{
+	struct cca *found, needle = { .certid = cid };
+
+	if ((found = RB_FIND(cca_tree, &ccas, &needle)) != NULL) {
+		if (found->confirmed || found->since != 0)
+			return 1;
+	}
+
+	return 0;
+}
+
+/*
+ * Mark the given CA and its parents as confirmed.
+ * Return 1 if the state changed, 0 otherwise.
+ */
+void
+cca_confirm_ca(int cid)
+{
+	struct cca *found, needle = { .certid = cid };
+	time_t now = get_current_time();
+
+	found = RB_FIND(cca_tree, &ccas, &needle);
+	assert(found != NULL);
+
+	if (!found->confirmed && found->purpose == CERT_PURPOSE_CA)
+		cca_confirm_ca(found->issuerid);
+
+	if (found->since == 0)
+		found->since = now;
+
+	found->confirmed = 1;
+}
+
+static void
+prev_cca_tree_free(void)
+{
+	struct cca *cca, *cca_tmp;
+
+	RB_FOREACH_SAFE(cca, prev_cca_tree, &prev_ccas, cca_tmp) {
+		RB_REMOVE(prev_cca_tree, &prev_ccas, cca);
+		cca_free(cca);
+	}
+}
+
+void
+cca_load(void)
+{
+	FILE *f;
+	char *line = NULL;
+	size_t linesize = 0;
+	ssize_t linelen;
+	const char *errstr;
+	struct cca *cca = NULL;
+	time_t now = get_current_time();
+
+	if ((f = fopen(CONFIRMED_CA, "r")) == NULL) {
+		if (errno == ENOENT)
+			return;
+		err(1, "failed to open %s", CONFIRMED_CA);
+	}
+
+	while ((linelen = getline(&line, &linesize, f)) != -1) {
+		char *l, *aki, *ski, *since, *loc, *mfturi;
+		size_t loc_len, mfturi_len;
+
+		if (line[linelen - 1] == '\n')
+			line[linelen - 1] = '\0';
+
+		if ((cca = calloc(1, sizeof(*cca))) == NULL)
+			err(1, NULL);
+
+		l = line;
+
+		if ((aki = strsep(&l, " ")) == NULL)
+			goto err;
+		if ((cca->aki = strdup(aki)) == NULL)
+			err(1, NULL);
+
+		if ((ski = strsep(&l, " ")) == NULL)
+			goto err;
+		if ((cca->ski = strdup(ski)) == NULL)
+			err(1, NULL);
+
+		if ((since = strsep(&l, " ")) == NULL)
+			goto err;
+		cca->since = strtonum(since, 1, LLONG_MAX, &errstr);
+		if (errstr != NULL)
+			goto err;
+		if (cca->since > now)
+			goto err;
+
+		if ((loc = strsep(&l, " ")) == NULL)
+			goto err;
+
+		/* minimal example cert location: ab.cd/a/b.cer */
+		if ((loc_len = strlen(loc)) < 13)
+			goto err;
+		if (strcmp(loc + loc_len - 4, ".cer") != 0)
+			goto err;
+		if (!valid_uri(loc, strlen(loc), NULL))
+			goto err;
+		if ((cca->location = strdup(loc)) == NULL)
+			err(1, NULL);
+
+		mfturi = l;
+		if (mfturi == NULL)
+			goto err;
+
+		/* minimal example mft location: rsync://a.bc/d/e.mft */
+		if ((mfturi_len = strlen(mfturi)) < 20)
+			goto err;
+		if (strcmp(mfturi + mfturi_len - 4, ".mft") != 0)
+			goto err;
+		if (!valid_uri(mfturi, strlen(mfturi), RSYNC_PROTO))
+			goto err;
+		if ((cca->mfturi = strdup(mfturi)) == NULL)
+			err(1, NULL);
+
+		if (RB_INSERT(prev_cca_tree, &prev_ccas, cca) != NULL) {
+			warnx("duplicate entry for cca_tree at %s",
+			    cca->location);
+			cca_free(cca);
+			cca = NULL;
+		}
+	}
+
+	if (ferror(f))
+		goto err;
+
+	fclose(f);
+	free(line);
+
+	return;
+
+ err:
+	warnx("error reading %s", CONFIRMED_CA);
+	fclose(f);
+	unlink(CONFIRMED_CA);
+
+	free(line);
+
+	cca_free(cca);
+
+	prev_cca_tree_free();
+}
+
+static int
+ccas_sorted_cmp(const void *a, const void *b)
+{
+	int cmp;
+	const struct cca *cca_a = *(const struct cca **)a;
+	const struct cca *cca_b = *(const struct cca **)b;
+
+	cmp = strcmp(cca_a->location, cca_b->location);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	cmp = strcmp(cca_a->mfturi, cca_b->mfturi);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	cmp = strcmp(cca_a->aki, cca_b->aki);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	cmp = strcmp(cca_a->ski, cca_b->ski);
+	if (cmp > 0)
+		return 1;
+	if (cmp < 0)
+		return -1;
+
+	return 0;
+}
+
+static void
+cca_tree_free(void)
+{
+	struct cca *cca, *cca_tmp;
+
+	RB_FOREACH_SAFE(cca, cca_tree, &ccas, cca_tmp) {
+		RB_REMOVE(cca_tree, &ccas, cca);
+		cca_free(cca);
+	}
+}
+
+void
+cca_save(time_t buildtime)
+{
+	char temp[] = CONFIRMED_CA ".XXXXXXXX";
+	FILE *f = NULL;
+	int fd;
+	struct cca *cca, *cca_tmp, **ccas_sorted = NULL;
+	size_t ccas_num = 0, idx = 0;
+	struct timespec ts[2];
+
+	prev_cca_tree_free();
+
+	if (RB_EMPTY(&ccas)) {
+		unlink(CONFIRMED_CA);
+		return;
+	}
+
+	if ((fd = mkostemp(temp, O_CLOEXEC)) == -1)
+		goto err;
+	(void)fchmod(fd, 0644);
+
+	if ((f = fdopen(fd, "w")) == NULL)
+		err(1, "fopen");
+
+	RB_FOREACH_SAFE(cca, cca_tree, &ccas, cca_tmp) {
+		if (cca->confirmed && cca->purpose == CERT_PURPOSE_CA)
+			ccas_num++;
+		else {
+			RB_REMOVE(cca_tree, &ccas, cca);
+			cca_free(cca);
+			cca = NULL;
+		}
+	}
+
+	if ((ccas_sorted = calloc(ccas_num, sizeof(ccas_sorted[0]))) == NULL)
+		err(1, NULL);
+
+	RB_FOREACH(cca, cca_tree, &ccas) {
+		repo_stat_dec_cca(cca->repoid, cca->talid);
+		ccas_sorted[idx++] = cca;
+	}
+
+	qsort(ccas_sorted, ccas_num, sizeof(ccas_sorted[0]), ccas_sorted_cmp);
+
+	for (idx = 0; idx < ccas_num; idx++) {
+		cca = ccas_sorted[idx];
+
+		if (fprintf(f, "%s %s %lld %s %s\n", cca->aki, cca->ski,
+		    (long long)cca->since, cca->location, cca->mfturi) < 0)
+			goto err;
+	}
+
+	if (fclose(f) != 0) {
+		f = NULL;
+		goto err;
+	}
+	f = NULL;
+
+	ts[0].tv_nsec = UTIME_OMIT;
+	ts[1].tv_sec = buildtime;
+	ts[1].tv_nsec = 0;
+
+	if (utimensat(AT_FDCWD, temp, ts, 0) == -1)
+		goto err;
+
+	if (rename(temp, CONFIRMED_CA) == -1)
+		goto err;
+
+	free(ccas_sorted);
+	cca_tree_free();
+
+	return;
+
+ err:
+	warn("error saving confirmed CAs to %s", temp);
+	if (f != NULL)
+		fclose(f);
+	unlink(temp);
+	free(ccas_sorted);
+	cca_tree_free();
+}
Index: extern.h
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/extern.h,v
diff -u -p -r1.300 extern.h
--- extern.h	29 Sep 2026 21:59:14 -0000	1.300
+++ extern.h	5 Oct 2026 08:13:26 -0000
@@ -138,6 +138,7 @@ struct cert {
 	size_t		 num_ases;
 	int		 talid; /* cert is covered by which TAL */
 	int		 certid;
+	int		 issuerid; /* id of the issuer of this cert */
 	unsigned int	 repoid; /* repository of this cert file */
 	char		*path; /* filename without .rrdp and .rsync prefix */
 	char		*repo; /* CA repository (rsync:// uri) */
@@ -314,6 +315,7 @@ struct roa {
 	uint32_t	 asid; /* asID of ROA (if 0, RFC 6483 sec 4) */
 	struct roa_ip	*ips;	/* IP prefixes */
 	size_t		 num_ips;
+	int		 issuerid; /* certid of the issuer */
 	int		 talid; /* ROAs are covered by which TAL */
 	int		 valid; /* validated resources */
 	time_t		 signtime; /* CMS signing-time attribute */
@@ -357,6 +359,7 @@ struct spl {
 	uint32_t	 asid;
 	struct spl_pfx	*prefixes;
 	size_t		 num_prefixes;
+	int		 issuerid; /* certid of the issuer */
 	int		 talid;
 	time_t		 signtime; /* CMS signing-time attribute */
 	time_t		 expires; /* when the certification path expires */
@@ -393,6 +396,7 @@ struct tak {
  */
 struct aspa {
 	int			 valid; /* contained in issuer auth */
+	int			 issuerid; /* certid of the issuer */
 	int			 talid; /* TAL the ASPA is chained up to */
 	uint32_t		 custasid; /* the customerASID */
 	uint32_t		*providers; /* the providers */
@@ -648,6 +652,7 @@ enum stype {
 	STYPE_PROVIDERS,
 	STYPE_OVERFLOW,
 	STYPE_SEQNUM_GAP,
+	STYPE_UNCONFIRMED,
 };
 
 struct repo;
@@ -663,6 +668,7 @@ struct repotalstats {
 	uint32_t	 certs_fail; /* invalid certificate */
 	uint32_t	 certs_nonfunc; /* non-functional CA certificates */
 	uint32_t	 certs_nonfunc_deferred;
+	uint32_t	 certs_unconfirmed;
 	uint32_t	 mfts; /* total number of manifests */
 	uint32_t	 mfts_gap; /* manifests with sequence gaps */
 	uint32_t	 mfts_fail; /* failing syntactic parse */
@@ -773,6 +779,12 @@ struct cert	*ta_validate(const char *, s
 struct cert	*cert_read(struct ibuf *);
 void		 cert_insert_brks(struct brk_tree *, struct cert *);
 
+void		 cca_load(void);
+void		 cca_save(time_t);
+void		 cca_tree_insert(const struct cert *);
+void		 cca_confirm_ca(int);
+int		 cca_was_confirmed(int);
+
 void		 nca_history_load(void);
 void		 nca_history_save(struct nca_tree *, time_t);
 int		 nca_skip_sync(struct nca_tree *, const struct cert *);
@@ -931,12 +943,13 @@ struct repo	*ta_lookup(int, struct tal *
 struct repo	*repo_lookup(int, const char *, const char *);
 struct repo	*repo_byid(unsigned int);
 int		 repo_queued(struct repo *, struct entity *);
-void		 repo_printinfo(size_t);
+void		 repo_printinfo(size_t, size_t);
 void		 repo_cleanup(struct filepath_tree *, int);
 int		 repo_check_timeout(int);
 void		 repostats_new_files_inc(struct repo *, const char *);
 void		 repo_stat_inc(struct repo *, int, enum rtype, enum stype);
 void		 repo_stat_add_nca(struct nonfunc_ca *);
+void		 repo_stat_dec_cca(unsigned int, int);
 void		 repo_tal_stats_collect(void (*)(const struct repo *,
 		    const struct repotalstats *, void *), int, void *);
 void		 repo_stats_collect(void (*)(const struct repo *,
@@ -1135,6 +1148,7 @@ int	mkpathat(int, const char *);
 #define RSYNC_PROTO		"rsync://"
 #define RSYNC_PROTO_LEN		(sizeof(RSYNC_PROTO) - 1)
 
+#define CONFIRMED_CA		".confirmed_ca"
 #define NCA_HISTORY		".nca_history"
 
 /* Compat helpers for OpenSSL < 4 and LibreSSL. */
Index: main.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/main.c,v
diff -u -p -r1.316 main.c
--- main.c	30 Sep 2026 20:48:11 -0000	1.316
+++ main.c	5 Oct 2026 08:13:27 -0000
@@ -54,7 +54,7 @@ unsigned int	 talrepocnt[TALSZ_MAX];
 struct repotalstats talstats[TALSZ_MAX];
 int		 talsz;
 
-size_t	entity_queue;
+size_t	entity_queue, entity_lowprio_queue;
 int	timeout = 60*60;
 volatile sig_atomic_t killme;
 volatile sig_atomic_t printinfo;
@@ -64,6 +64,8 @@ static struct filepath_tree	fpt = RB_INI
 static struct msgbuf		*procq, *rsyncq, *httpq, *rrdpq;
 static int			cachefd, outdirfd;
 
+static struct entityq	lowprio_queue = TAILQ_HEAD_INITIALIZER(lowprio_queue);
+
 int	verbose;
 int	noop;
 int	excludeas0 = 1;
@@ -288,6 +290,17 @@ entityq_add(char *path, char *file, enum
 	p->data = data;
 	p->datasz = (data != NULL) ? datasz : 0;
 
+	/*
+	 * Deprioritise and delay processing of manifest pointers from CAs which
+	 * haven't (yet) been confirmed to be part of valid certification
+	 * chains towards non-inheriting EE certs.
+	 */
+	if (type == RTYPE_MFT && !cca_was_confirmed(certid)) {
+		TAILQ_INSERT_TAIL(&lowprio_queue, p, entries);
+		entity_lowprio_queue++;
+		return;
+	}
+
 	entity_queue++;
 
 	/*
@@ -663,14 +676,19 @@ entity_process(struct ibuf *b, struct va
 		cert = cert_read(b);
 		switch (cert->purpose) {
 		case CERT_PURPOSE_TA:
+			cca_tree_insert(cert);
+			cca_confirm_ca(cert->certid);
 			queue_add_from_cert(cert, &vd->ncas);
 			ccr_insert_tas(&vd->ccr.tas, cert);
 			break;
 		case CERT_PURPOSE_CA:
+			cca_tree_insert(cert);
+			repo_stat_inc(rp, talid, type, STYPE_UNCONFIRMED);
 			queue_add_from_cert(cert, &vd->ncas);
 			ccr_insert_mft_sub(&vd->ccr.mfts, cert);
 			break;
 		case CERT_PURPOSE_BGPSEC_ROUTER:
+			cca_confirm_ca(cert->issuerid);
 			cert_insert_brks(&vd->brks, cert);
 			repo_stat_inc(rp, talid, type, STYPE_BGPSEC);
 			break;
@@ -706,6 +724,7 @@ entity_process(struct ibuf *b, struct va
 		if (roa->valid) {
 			roa_insert_vrps(&vd->vrps, roa, rp);
 			ccr_insert_roa(&vd->ccr.vrps, roa);
+			cca_confirm_ca(roa->issuerid);
 		} else
 			repo_stat_inc(rp, talid, type, STYPE_INVALID);
 		roa_free(roa);
@@ -717,9 +736,10 @@ entity_process(struct ibuf *b, struct va
 			break;
 		}
 		aspa = aspa_read(b);
-		if (aspa->valid)
+		if (aspa->valid) {
 			aspa_insert_vaps(file, &vd->vaps, aspa, rp);
-		else
+			cca_confirm_ca(aspa->issuerid);
+		} else
 			repo_stat_inc(rp, talid, type, STYPE_INVALID);
 		aspa_free(aspa);
 		break;
@@ -731,9 +751,10 @@ entity_process(struct ibuf *b, struct va
 			break;
 		}
 		spl = spl_read(b);
-		if (spl->valid)
+		if (spl->valid) {
 			spl_insert_vsps(&vd->vsps, spl, rp);
-		else
+			cca_confirm_ca(spl->issuerid);
+		} else
 			repo_stat_inc(rp, talid, type, STYPE_INVALID);
 		spl_free(spl);
 		break;
@@ -826,6 +847,7 @@ sum_stats(const struct repo *rp, const s
 	out->certs_fail += in->certs_fail;
 	out->certs_nonfunc += in->certs_nonfunc;
 	out->certs_nonfunc_deferred += in->certs_nonfunc_deferred;
+	out->certs_unconfirmed += in->certs_unconfirmed;
 	out->roas += in->roas;
 	out->roas_fail += in->roas_fail;
 	out->roas_invalid += in->roas_invalid;
@@ -1364,14 +1386,36 @@ main(int argc, char *argv[])
 	if (fchdir(cachefd) == -1)
 		err(1, "fchdir");
 
+	cca_load();
+
 	nca_history_load();
 
-	while (entity_queue > 0 && !killme) {
+	while ((entity_queue > 0 || entity_lowprio_queue > 0) && !killme) {
 		int polltim;
 
 		if (printinfo) {
 			printinfo = 0;
-			repo_printinfo(entity_queue);
+			repo_printinfo(entity_queue, entity_lowprio_queue);
+		}
+
+		/*
+		 * If there isn't much else to do, work the low priority queue.
+		 */
+		if (entity_queue == 0 && entity_lowprio_queue > 0) {
+			struct entity *e, *e_tmp;
+			struct repo *rp;
+
+			TAILQ_FOREACH_SAFE(e, &lowprio_queue, entries, e_tmp) {
+				TAILQ_REMOVE(&lowprio_queue, e, entries);
+				entity_lowprio_queue--;
+
+				entity_queue++;
+				rp = repo_byid(e->repoid);
+				if (!repo_queued(rp, e)) {
+					entity_write_req(e);
+					entity_free(e);
+				}
+			}
 		}
 
 		polltim = repo_check_timeout(INFTIM);
@@ -1493,7 +1537,7 @@ main(int argc, char *argv[])
 
 	signal(SIGALRM, SIG_DFL);
 	if (killme) {
-		repo_printinfo(entity_queue);
+		repo_printinfo(entity_queue, entity_lowprio_queue);
 		syslog(LOG_CRIT|LOG_DAEMON,
 		    "excessive runtime (%d seconds), giving up", timeout);
 		errx(1, "excessive runtime (%d seconds), giving up", timeout);
@@ -1549,7 +1593,7 @@ main(int argc, char *argv[])
 
 	/* processing did not finish because of error */
 	if (entity_queue != 0) {
-		repo_printinfo(entity_queue);
+		repo_printinfo(entity_queue, entity_lowprio_queue);
 		errx(1, "not all files processed, giving up");
 	}
 
@@ -1582,8 +1626,10 @@ main(int argc, char *argv[])
 
 	vd.buildtime = get_current_time();
 
-	if (!noop && rc == 0)
+	if (!noop && rc == 0) {
+		cca_save(vd.buildtime);
 		nca_history_save(&vd.ncas, vd.buildtime);
+	}
 
 	/* change working directory to the output directory */
 	if (fchdir(outdirfd) == -1)
@@ -1625,10 +1671,11 @@ main(int argc, char *argv[])
 	}
 	printf("BGPsec Router Certificates: %u\n", stats.repo_tal_stats.brks);
 	printf("Certificates: %u (%u invalid, %u non-functional, %u sync "
-	    "deferred)\n", stats.repo_tal_stats.certs,
+	    "deferred, %u unconfirmed)\n", stats.repo_tal_stats.certs,
 	    stats.repo_tal_stats.certs_fail,
 	    stats.repo_tal_stats.certs_nonfunc,
-	    stats.repo_tal_stats.certs_nonfunc_deferred);
+	    stats.repo_tal_stats.certs_nonfunc_deferred,
+	    stats.repo_tal_stats.certs_unconfirmed);
 	printf("Trust Anchor Locators: %u (%u invalid)\n",
 	    stats.tals, talsz - stats.tals);
 	printf("Manifests: %u (%u failed parse, %u seqnum gaps)\n",
Index: parser.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/parser.c,v
diff -u -p -r1.189 parser.c
--- parser.c	24 Sep 2026 14:44:03 -0000	1.189
+++ parser.c	5 Oct 2026 08:13:27 -0000
@@ -225,6 +225,7 @@ proc_parser_roa(char *file, const unsign
 		goto out;
 	}
 
+	roa->issuerid = entp->certid;
 	roa->talid = a->cert->talid;
 
 	roa->expires = x509_find_expires(cert->notafter, a, &crls);
@@ -267,6 +268,7 @@ proc_parser_spl(char *file, const unsign
 		goto out;
 	}
 
+	spl->issuerid = entp->certid;
 	spl->talid = a->cert->talid;
 
 	spl->expires = x509_find_expires(cert->notafter, a, &crls);
@@ -631,6 +633,10 @@ proc_parser_cert(char *file, const unsig
 		goto out;
 	}
 
+	if (cert->purpose == CERT_PURPOSE_TA)
+		cert->issuerid = a->cert->talid;
+	else
+		cert->issuerid = a->cert->certid;
 	cert->talid = a->cert->talid;
 
 	cert->path = parse_filepath(entp->repoid, entp->path, entp->file,
@@ -791,6 +797,7 @@ proc_parser_aspa(char *file, const unsig
 		goto out;
 	}
 
+	aspa->issuerid = entp->certid;
 	aspa->talid = a->cert->talid;
 
 	aspa->expires = x509_find_expires(cert->notafter, a, &crls);
Index: repo.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/repo.c,v
diff -u -p -r1.92 repo.c
--- repo.c	1 Oct 2026 13:06:56 -0000	1.92
+++ repo.c	5 Oct 2026 08:13:27 -0000
@@ -1442,11 +1442,12 @@ repo_queued(struct repo *rp, struct enti
 }
 
 void
-repo_printinfo(size_t qlen)
+repo_printinfo(size_t qlen, size_t lpqlen)
 {
 	struct repo	*rp;
 
-	warnx("%zu outstanding entities", qlen);
+	warnx("Outstanding entities: %zu regular, %zu lower priority", qlen,
+	    lpqlen);
 
 	SLIST_FOREACH(rp, &repos, entry) {
 		if (TAILQ_EMPTY(&rp->queue))
@@ -1563,6 +1564,19 @@ repo_stat_add_nca(struct nonfunc_ca *nca
 	}
 }
 
+void
+repo_stat_dec_cca(unsigned int rid, int tid)
+{
+	struct repo *rp;
+
+	SLIST_FOREACH(rp, &repos, entry) {
+		if (rp->id == rid) {
+			rp->stats[tid].certs_unconfirmed--;
+			break;
+		}
+	}
+}
+
 /*
  * Update stats object of repository depending on rtype and subtype.
  */
@@ -1578,6 +1592,8 @@ repo_stat_inc(struct repo *rp, int talid
 			rp->stats[talid].certs++;
 		if (subtype == STYPE_FAIL)
 			rp->stats[talid].certs_fail++;
+		if (subtype == STYPE_UNCONFIRMED)
+			rp->stats[talid].certs_unconfirmed++;
 		if (subtype == STYPE_BGPSEC) {
 			rp->stats[talid].certs--;
 			rp->stats[talid].brks++;
@@ -1880,9 +1896,12 @@ repo_cleanup_entry(FTSENT *e, struct fil
 	path = skip_dotslash(e->fts_path);
 	switch (e->fts_info) {
 	case FTS_NSOK:
-		if (e->fts_level == 1 && fts_state.type == BASE_DIR &&
-		    strcmp(e->fts_name, NCA_HISTORY) == 0)
-			break;
+		if (e->fts_level == 1) {
+			if (strcmp(e->fts_name, CONFIRMED_CA) == 0)
+				break;
+			if (strcmp(e->fts_name, NCA_HISTORY) == 0)
+				break;
+		}
 		if (filepath_exists(tree, path)) {
 			e->fts_parent->fts_number++;
 			break;
Index: cert.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/cert.c,v
diff -u -p -r1.244 cert.c
--- cert.c	13 Jul 2026 10:53:12 -0000	1.244
+++ cert.c	5 Oct 2026 08:13:28 -0000
@@ -1823,6 +1823,7 @@ cert_buffer(struct ibuf *b, const struct
 	io_simple_buffer(b, &cert->notafter, sizeof(cert->notafter));
 	io_simple_buffer(b, &cert->purpose, sizeof(cert->purpose));
 	io_simple_buffer(b, &cert->talid, sizeof(cert->talid));
+	io_simple_buffer(b, &cert->issuerid, sizeof(cert->issuerid));
 	io_simple_buffer(b, &cert->certid, sizeof(cert->certid));
 	io_simple_buffer(b, &cert->repoid, sizeof(cert->repoid));
 	io_simple_buffer(b, &cert->num_ips, sizeof(cert->num_ips));
@@ -1878,6 +1879,7 @@ cert_read(struct ibuf *b)
 	io_read_buf(b, &cert->notafter, sizeof(cert->notafter));
 	io_read_buf(b, &cert->purpose, sizeof(cert->purpose));
 	io_read_buf(b, &cert->talid, sizeof(cert->talid));
+	io_read_buf(b, &cert->issuerid, sizeof(cert->issuerid));
 	io_read_buf(b, &cert->certid, sizeof(cert->certid));
 	io_read_buf(b, &cert->repoid, sizeof(cert->repoid));
 	io_read_buf(b, &cert->num_ips, sizeof(cert->num_ips));
Index: aspa.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/aspa.c,v
diff -u -p -r1.47 aspa.c
--- aspa.c	14 Sep 2026 09:21:41 -0000	1.47
+++ aspa.c	5 Oct 2026 08:13:28 -0000
@@ -244,6 +244,7 @@ aspa_buffer(struct ibuf *b, const struct
 {
 	io_simple_buffer(b, &aspa->valid, sizeof(aspa->valid));
 	io_simple_buffer(b, &aspa->custasid, sizeof(aspa->custasid));
+	io_simple_buffer(b, &aspa->issuerid, sizeof(aspa->issuerid));
 	io_simple_buffer(b, &aspa->talid, sizeof(aspa->talid));
 	io_simple_buffer(b, &aspa->expires, sizeof(aspa->expires));
 
@@ -267,6 +268,7 @@ aspa_read(struct ibuf *b)
 
 	io_read_buf(b, &aspa->valid, sizeof(aspa->valid));
 	io_read_buf(b, &aspa->custasid, sizeof(aspa->custasid));
+	io_read_buf(b, &aspa->issuerid, sizeof(aspa->issuerid));
 	io_read_buf(b, &aspa->talid, sizeof(aspa->talid));
 	io_read_buf(b, &aspa->expires, sizeof(aspa->expires));
 
Index: roa.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/roa.c,v
diff -u -p -r1.93 roa.c
--- roa.c	14 Sep 2026 09:21:41 -0000	1.93
+++ roa.c	5 Oct 2026 08:13:28 -0000
@@ -301,6 +301,7 @@ roa_buffer(struct ibuf *b, const struct 
 {
 	io_simple_buffer(b, &roa->valid, sizeof(roa->valid));
 	io_simple_buffer(b, &roa->asid, sizeof(roa->asid));
+	io_simple_buffer(b, &roa->issuerid, sizeof(roa->issuerid));
 	io_simple_buffer(b, &roa->talid, sizeof(roa->talid));
 	io_simple_buffer(b, &roa->num_ips, sizeof(roa->num_ips));
 	io_simple_buffer(b, &roa->expires, sizeof(roa->expires));
@@ -323,6 +324,7 @@ roa_read(struct ibuf *b)
 
 	io_read_buf(b, &roa->valid, sizeof(roa->valid));
 	io_read_buf(b, &roa->asid, sizeof(roa->asid));
+	io_read_buf(b, &roa->issuerid, sizeof(roa->issuerid));
 	io_read_buf(b, &roa->talid, sizeof(roa->talid));
 	io_read_buf(b, &roa->num_ips, sizeof(roa->num_ips));
 	io_read_buf(b, &roa->expires, sizeof(roa->expires));
Index: spl.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/spl.c,v
diff -u -p -r1.20 spl.c
--- spl.c	3 Sep 2026 17:19:30 -0000	1.20
+++ spl.c	5 Oct 2026 08:13:28 -0000
@@ -302,6 +302,7 @@ spl_buffer(struct ibuf *b, const struct 
 {
 	io_simple_buffer(b, &s->valid, sizeof(s->valid));
 	io_simple_buffer(b, &s->asid, sizeof(s->asid));
+	io_simple_buffer(b, &s->issuerid, sizeof(s->issuerid));
 	io_simple_buffer(b, &s->talid, sizeof(s->talid));
 	io_simple_buffer(b, &s->num_prefixes, sizeof(s->num_prefixes));
 	io_simple_buffer(b, &s->expires, sizeof(s->expires));
@@ -325,6 +326,7 @@ spl_read(struct ibuf *b)
 
 	io_read_buf(b, &s->valid, sizeof(s->valid));
 	io_read_buf(b, &s->asid, sizeof(s->asid));
+	io_read_buf(b, &s->issuerid, sizeof(s->issuerid));
 	io_read_buf(b, &s->talid, sizeof(s->talid));
 	io_read_buf(b, &s->num_prefixes, sizeof(s->num_prefixes));
 	io_read_buf(b, &s->expires, sizeof(s->expires));
Index: output.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/output.c,v
diff -u -p -r1.48 output.c
--- output.c	29 Sep 2026 21:59:14 -0000	1.48
+++ output.c	5 Oct 2026 08:13:28 -0000
@@ -285,14 +285,16 @@ outputheader(FILE *out, struct validatio
 	    "# Route Origin Authorizations: %u (%u failed parse, %u invalid)\n"
 	    "# BGPsec Router Certificates: %u\n"
 	    "# Certificates: %u (%u invalid, %u non-functional, %u sync "
-	    "deferred)\n", hn, tbuf, (long long)st->elapsed_time.tv_sec,
+	    "deferred, %u unconfirmed)\n",
+	    hn, tbuf, (long long)st->elapsed_time.tv_sec,
 	    (long long)st->user_time.tv_sec, (long long)st->system_time.tv_sec,
 	    vd->ccr.mfts_hash, vd->ccr.vrps_hash, vd->ccr.vaps_hash,
 	    st->repo_tal_stats.roas, st->repo_tal_stats.roas_fail,
 	    st->repo_tal_stats.roas_invalid, st->repo_tal_stats.brks,
 	    st->repo_tal_stats.certs, st->repo_tal_stats.certs_fail,
 	    st->repo_tal_stats.certs_nonfunc,
-	    st->repo_tal_stats.certs_nonfunc_deferred) < 0)
+	    st->repo_tal_stats.certs_nonfunc_deferred,
+	    st->repo_tal_stats.certs_unconfirmed) < 0)
 		return -1;
 
 	if (fprintf(out,
Index: output-json.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/output-json.c,v
diff -u -p -r1.61 output-json.c
--- output-json.c	7 Jul 2026 13:38:54 -0000	1.61
+++ output-json.c	5 Oct 2026 08:13:28 -0000
@@ -65,6 +65,7 @@ outputheader_json(struct validation_data
 	json_do_int("invalidcertificates", st->repo_tal_stats.certs_fail);
 	json_do_int("nonfunctionalcas", st->repo_tal_stats.certs_nonfunc);
 	json_do_int("deferredcas", st->repo_tal_stats.certs_nonfunc_deferred);
+	json_do_int("unconfirmedcas", st->repo_tal_stats.certs_unconfirmed);
 	json_do_int("taks", st->repo_tal_stats.taks);
 	json_do_int("tals", st->tals);
 	json_do_int("invalidtals", talsz - st->tals);
Index: output-ometric.c
===================================================================
RCS file: /cvs/src/usr.sbin/rpki-client/output-ometric.c,v
diff -u -p -r1.17 output-ometric.c
--- output-ometric.c	24 Jun 2026 09:06:20 -0000	1.17
+++ output-ometric.c	5 Oct 2026 08:13:28 -0000
@@ -46,6 +46,8 @@ set_common_stats(const struct repotalsta
 	    OKV("type", "state"), OKV("cert", "non-functional"), ol);
 	ometric_set_int_with_labels(metric, in->certs_nonfunc_deferred,
 	    OKV("type", "state"), OKV("cert", "sync-deferred"), ol);
+	ometric_set_int_with_labels(metric, in->certs_unconfirmed,
+	    OKV("type", "state"), OKV("cert", "unconfirmed"), ol);
 
 	ometric_set_int_with_labels(metric, in->mfts,
 	    OKV("type", "state"), OKV("manifest", "valid"), ol);