fix: cross-platform CA trust (Arch/Fedora + Windows) (#41)

* fix: cross-platform CA trust (Arch/Fedora + Windows)

Closes #35.

trust_ca_linux now detects which trust store the distro ships and
runs the matching refresh command, instead of hardcoding Debian's
update-ca-certificates. Detection walks a const table in priority
order, picking the first whose anchor dir exists:

  - debian: /usr/local/share/ca-certificates  (update-ca-certificates)
  - pki:    /etc/pki/ca-trust/source/anchors  (update-ca-trust extract)
  - p11kit: /etc/ca-certificates/trust-source/anchors (trust extract-compat)

Falls back with a clear error listing every backend tried.

Adds Windows support via certutil -addstore Root / -delstore Root,
removing the silent CA-trust gap on numa install (previously the
service installed but the trust step quietly errored, leaving every
HTTPS .numa request throwing browser warnings).

Refactor: trust_ca and untrust_ca are now thin dispatchers calling
per-platform helpers. CA_COMMON_NAME and CA_FILE_NAME are centralized
in tls.rs and reused from system_dns.rs and api.rs. untrust_ca_linux
no longer pre-checks file existence (TOCTOU) and skips the refresh
when no file was actually removed.

Test: tests/docker/install-trust.sh runs the install/uninstall
contract against debian:stable, fedora:latest, and archlinux:latest
in containers, asserting the cert lands in (and is removed from)
the system bundle. All three pass locally.

README notes the Firefox/NSS limitation (separate trust store).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: rustfmt fixes for trust_ca_linux helpers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: macOS CA trust contract test (manual)

Adds tests/manual/install-trust-macos.sh — a sudo bash script that
mirrors trust_ca_macos / untrust_ca_macos against a fixture cert with
a unique CN. Designed to coexist with a running production numa:

- Refuses to run if a real "Numa Local CA" is already in System.keychain
  (fail-closed protection for dogfood installs)
- Uses a unique CN ("Numa Local CA Test <pid-timestamp>") so the test
  cert can never collide with production
- Mirrors the by-hash deletion loop from untrust_ca_macos
- Trap-cleanup on success or interrupt

Lives under tests/manual/ to signal "host-mutating, dev-only" — distinct
from tests/docker/install-trust.sh which is hermetic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: relax bail-out in macOS trust test (safe alongside production)

The bail-out was overly defensive. The test cert uses a unique CN
("Numa Local CA Test <pid-ts>") that is strictly longer than the
production CN, so `security find-certificate -c $TEST_CN` cannot
substring-match the production cert. All deletes are by-hash, which
can only target the test cert's specific hash. Coexistence is
provably safe; document the reasoning in the header comment block
and replace the refusal with an informational notice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #41.
This commit is contained in:
Razvan Dimescu
2026-04-08 15:18:01 +03:00
committed by GitHub
parent 1b2f682026
commit 039254280b
6 changed files with 411 additions and 80 deletions

123
tests/docker/install-trust.sh Executable file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
#
# Cross-distro CA trust contract test for issue #35.
#
# Runs the exact shell commands `src/system_dns.rs::trust_ca_linux` would run
# on each Linux trust-store family (Debian, Fedora pki, Arch p11-kit), and
# asserts the certificate ends up in (and is removed from) the system bundle.
#
# This is a contract test, not an integration test: it doesn't drive the Rust
# code (that would need systemd-in-container). It verifies the assumptions in
# `LINUX_TRUST_STORES` against the real distro behavior. If you change that
# table in src/system_dns.rs, update the per-distro cases below to match.
#
# Requirements: docker, openssl (host).
# Usage: ./tests/docker/install-trust.sh
set -euo pipefail
cd "$(dirname "$0")/../.."
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
# Self-signed CA fixture, mounted into each container as ca.pem.
# basicConstraints=CA:TRUE is required — without it, Debian's
# update-ca-certificates silently skips the cert during bundle build.
FIXTURE_DIR=$(mktemp -d)
trap 'rm -rf "$FIXTURE_DIR"' EXIT
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout "$FIXTURE_DIR/ca.key" \
-out "$FIXTURE_DIR/ca.pem" \
-subj "/CN=Numa Local CA Test $(date +%s)" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1
# Distro bundles store certs differently — Debian writes raw PEM only,
# Fedora prepends "# CN" comment headers, Arch via extract-compat is
# raw PEM. To detect cert presence uniformly we grep for a deterministic
# substring of the base64 body (first base64 line is unique per cert).
CERT_TAG=$(sed -n '2p' "$FIXTURE_DIR/ca.pem")
PASSED=0; FAILED=0
run_case() {
local distro="$1"; shift
local image="$1"; shift
local platform="$1"; shift
local script="$1"
printf "── %s (%s) ──\n" "$distro" "$image"
if docker run --rm \
--platform "$platform" \
--security-opt seccomp=unconfined \
-e CERT_TAG="$CERT_TAG" \
-e DEBIAN_FRONTEND=noninteractive \
-v "$FIXTURE_DIR/ca.pem:/fixture/ca.pem:ro" \
"$image" bash -c "$script"; then
printf "${GREEN}${RESET} %s\n\n" "$distro"
PASSED=$((PASSED + 1))
else
printf "${RED}${RESET} %s\n\n" "$distro"
FAILED=$((FAILED + 1))
fi
}
# Debian / Ubuntu / Mint — anchor: /usr/local/share/ca-certificates/*.crt
run_case "debian" "debian:stable" "linux/amd64" '
set -e
apt-get update -qq
apt-get install -qq -y ca-certificates >/dev/null
install -m 0644 /fixture/ca.pem /usr/local/share/ca-certificates/numa-local-ca.crt
update-ca-certificates >/dev/null 2>&1
grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt
echo " install: cert present in bundle"
rm /usr/local/share/ca-certificates/numa-local-ca.crt
update-ca-certificates --fresh >/dev/null 2>&1
if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then
echo " uninstall: cert STILL present (regression)" >&2
exit 1
fi
echo " uninstall: cert removed from bundle"
'
# Fedora / RHEL / CentOS / SUSE — anchor: /etc/pki/ca-trust/source/anchors/*.pem
run_case "fedora" "fedora:latest" "linux/amd64" '
set -e
dnf install -q -y ca-certificates >/dev/null
install -m 0644 /fixture/ca.pem /etc/pki/ca-trust/source/anchors/numa-local-ca.pem
update-ca-trust extract
grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem
echo " install: cert present in bundle"
rm /etc/pki/ca-trust/source/anchors/numa-local-ca.pem
update-ca-trust extract
if grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem; then
echo " uninstall: cert STILL present (regression)" >&2
exit 1
fi
echo " uninstall: cert removed from bundle"
'
# Arch / Manjaro — anchor: /etc/ca-certificates/trust-source/anchors/*.pem
# archlinux:latest is x86_64-only; --platform forces emulation on Apple Silicon.
run_case "arch" "archlinux:latest" "linux/amd64" '
set -e
# pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu emulation.
sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf
pacman -Sy --noconfirm --needed ca-certificates p11-kit >/dev/null 2>&1
install -m 0644 /fixture/ca.pem /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem
trust extract-compat
grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt
echo " install: cert present in bundle"
rm /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem
trust extract-compat
if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then
echo " uninstall: cert STILL present (regression)" >&2
exit 1
fi
echo " uninstall: cert removed from bundle"
'
printf "── summary ──\n"
printf " ${GREEN}passed${RESET}: %d\n" "$PASSED"
printf " ${RED}failed${RESET}: %d\n" "$FAILED"
[ "$FAILED" -eq 0 ]

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
#
# Manual macOS CA trust contract test.
#
# Mirrors src/system_dns.rs::trust_ca_macos / untrust_ca_macos by running
# the same `security` shell commands against a fixture cert with a unique
# CN. Safe to run alongside a production numa install:
#
# - Test cert CN = "Numa Local CA Test <pid-ts>", always strictly longer
# than the production CN "Numa Local CA". `security find-certificate -c`
# does substring matching, so the test's search for $TEST_CN can never
# match the production cert (the search term is longer than the prod CN).
# - All deletes use `delete-certificate -Z <hash>`, which only touches the
# cert with that exact hash. Production and test certs have different
# hashes by construction (different key material), so the delete cannot
# reach the production cert even if a CN search somehow returned both.
#
# Mutates the System keychain (briefly). Cleans up on success or interrupt.
# Requires sudo for `security add-trusted-cert` and `delete-certificate`.
#
# Usage: ./tests/manual/install-trust-macos.sh
set -euo pipefail
if [[ "$OSTYPE" != darwin* ]]; then
echo "This test is macOS-only." >&2
exit 1
fi
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
# Production constant from src/tls.rs::CA_COMMON_NAME — keep in sync.
PROD_CN="Numa Local CA"
KEYCHAIN="/Library/Keychains/System.keychain"
# Notice if production numa is already installed. We proceed regardless —
# see header for why coexistence is safe (unique CN + by-hash deletion).
if security find-certificate -c "$PROD_CN" "$KEYCHAIN" >/dev/null 2>&1; then
echo " note: production '$PROD_CN' detected — proceeding alongside (test cert can't touch it)"
echo
fi
# Unique CN ensures the test cert can never collide with production.
TEST_CN="Numa Local CA Test $$-$(date +%s)"
FIXTURE_DIR=$(mktemp -d)
cleanup() {
# Best-effort: remove any test certs by hash if still present.
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
echo " cleanup: removing leftover test cert"
security find-certificate -c "$TEST_CN" -a -Z "$KEYCHAIN" 2>/dev/null \
| awk '/^SHA-1 hash:/ {print $NF}' \
| while read -r hash; do
sudo security delete-certificate -Z "$hash" "$KEYCHAIN" >/dev/null 2>&1 || true
done
fi
rm -rf "$FIXTURE_DIR"
}
trap cleanup EXIT
echo "── generating fixture CA ──"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout "$FIXTURE_DIR/ca.key" \
-out "$FIXTURE_DIR/ca.pem" \
-subj "/CN=$TEST_CN" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1
echo " CN: $TEST_CN"
echo
echo "── trust step (mirrors trust_ca_macos) ──"
sudo security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN" "$FIXTURE_DIR/ca.pem"
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
printf " ${GREEN}${RESET} test cert found in keychain\n"
else
printf " ${RED}${RESET} test cert NOT found after add-trusted-cert\n"
exit 1
fi
echo
echo "── untrust step (mirrors untrust_ca_macos) ──"
security find-certificate -c "$TEST_CN" -a -Z "$KEYCHAIN" 2>/dev/null \
| awk '/^SHA-1 hash:/ {print $NF}' \
| while read -r hash; do
sudo security delete-certificate -Z "$hash" "$KEYCHAIN" >/dev/null
done
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
printf " ${RED}${RESET} test cert STILL present after delete (regression)\n"
exit 1
fi
printf " ${GREEN}${RESET} test cert removed from keychain\n"
echo
printf "${GREEN}all checks passed${RESET}\n"