caproni ca trust on Linux keeps only the last profile's dev CA trusted

Overview

On Linux, caproni ca trust installs every profile's dev CA under the same file name, caproni-dev-ca.crt (internal/truststore/system_linux.go).

With more than one profile, for example through cluster.name.strategy: directory-hash or the cells setup, only the most recently trusted profile's CA is trusted on the host.

caproni ca untrust in any profile removes whichever CA is currently installed.

The docs describe trust as per profile. The quick start calls caproni ca trust "a one-time step per profile", and each profile has its own CA under ~/.local/share/caproni/<profile>/pki/.

Note

On macOS this works better, because every add-trusted-cert adds a separate System keychain entry.

Expected behavior

  • caproni ca trust in profile B leaves profile A's CA trusted, so both profiles' services verify from the host.
  • caproni ca untrust in a profile removes only that profile's CA.

Actual behavior

  • caproni ca trust in profile B runs sudo install -m 0644 <B>/pki/current/ca.crt /usr/local/share/ca-certificates/caproni-dev-ca.crt (or /etc/pki/ca-trust/source/anchors/ on RHEL-like systems), followed by update-ca-certificates / update-ca-trust. This overwrites profile A's CA, so HTTPS to A's hostnames fails verification on the host until A is trusted again, and that in turn breaks B.
  • caproni ca untrust in profile A runs sudo rm -f .../caproni-dev-ca.crt, which removes B's CA if B was trusted last.
  • The Firefox/Chrome NSS step also uses one fixed nickname, Caproni Dev CA (internal/truststore/nss.go). How several profiles interact there is not tested.

Reproduction steps

Verified on Ubuntu 26.04.1 LTS (aarch64, UTM VM) with caproni built from main at 6bd40802. No cluster is needed: the external driver points at a kubeconfig context that doesn't exist, so the cluster reports stopped and ca trust uses the configured cluster_domain. The external driver needs kubectl on PATH, even for that.

  1. Create two profiles, each in its own directory:

    mkdir -p ~/caproni-test/a ~/caproni-test/b
    for p in a b; do cat > ~/caproni-test/$p/caproni.yaml <<Y
    version: 2
    cluster:
      driver: external
      name:
        strategy: directory-hash
      external:
        context: does-not-exist
      network:
        hostname: $p.caproni.test
        cluster_domain: cluster.local
    Y
    done

    ~/caproni-test/a/caproni.yaml then reads:

    version: 2
    cluster:
      driver: external
      name:
        strategy: directory-hash
      external:
        context: does-not-exist
      network:
        hostname: a.caproni.test
        cluster_domain: cluster.local
  2. Generate each profile's CA and note the paths:

    (cd ~/caproni-test/a && caproni ca inspect)   # Path: ~/.local/share/caproni/caproni-a-<hash>/pki/current/ca.crt
    (cd ~/caproni-test/b && caproni ca inspect)   # Path: ~/.local/share/caproni/caproni-b-<hash>/pki/current/ca.crt
    A=$(ls ~/.local/share/caproni/caproni-a-*/pki/current/ca.crt)
    B=$(ls ~/.local/share/caproni/caproni-b-*/pki/current/ca.crt)
    F=/usr/local/share/ca-certificates/caproni-dev-ca.crt
    check() {
      openssl x509 -noout -fingerprint -sha256 -in "$F" 2>/dev/null || echo "$F absent"
      openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt "$A" >/dev/null 2>&1 && echo "A trusted" || echo "A NOT trusted"
      openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt "$B" >/dev/null 2>&1 && echo "B trusted" || echo "B NOT trusted"
    }
    openssl x509 -noout -fingerprint -sha256 -in "$A"; openssl x509 -noout -fingerprint -sha256 -in "$B"
  3. Trust A, then B, then untrust A, running check after each step:

    (cd ~/caproni-test/a && caproni ca trust); check
    (cd ~/caproni-test/b && caproni ca trust); check
    (cd ~/caproni-test/a && caproni ca untrust); check

    Observed:

    Step caproni-dev-ca.crt holds A trusted B trusted
    before absent no no
    ca trust in a/ A yes no
    ca trust in b/ B (A's file overwritten) no yes
    ca untrust in a/ absent (B's CA deleted) no no

    When B replaces A, update-ca-certificates prints 0 added, 0 removed, although the trusted bundle does change.

  4. NSS (sudo apt-get install libnss3-tools), with a fresh Chrome database. Repeat step 3 and list the store after each command:

    mkdir -p ~/.pki/nssdb && certutil -d sql:$HOME/.pki/nssdb -N --empty-password
    certutil -L -d sql:$HOME/.pki/nssdb
    certutil -L -d sql:$HOME/.pki/nssdb -n "Caproni Dev CA" -a | openssl x509 -noout -fingerprint -sha256   # first entry only

    Observed:

    Step Entries under nickname Caproni Dev CA
    ca trust in a/ A (C,,)
    ca trust in b/ A and B, both C,,
    ca untrust in a/ A only: certutil -D -n "Caproni Dev CA" removed B's entry, not A's

Workaround

Re-run caproni ca trust in the profile you're working in whenever you switch profiles. Alternatively, copy each profile's pki/current/ca.crt into the trust anchors directory by hand under distinct names and run update-ca-certificates.

Potential solutions

  • Install one file per profile, for example caproni-dev-ca-<profile>.crt, and have untrust remove only that file. Do the same for the NSS nickname.
  • Every profile's CA has the same subject, CN=Caproni Dev CA, O=Caproni. Tools that read the bundle file (ca-certificates.crt), and Go, choose between same-subject roots by key identifier, so they're fine. Lookup through a hashed directory (CApath, <hash>.0, <hash>.1) may only try the first root (openssl#16304). A per-profile subject for newly generated CAs, as mkcert does with user@host, would remove that ambiguity. Existing CAs have to keep their subject, or the certificates they issued stop verifying.
Edited by Manuel Schönlaub