feat(tls): per-node certificate authority + Settings install flow
Demo images / Build & push demo images (push) Failing after 2m19s

The node served a bare self-signed leaf, so a browser exception had to be
granted per ORIGIN — scheme + host + port. The dashboard on :443 and an app
on :8334 are different origins, and a certificate interstitial CANNOT be
accepted inside an iframe, so a gated app embedded over HTTPS could never
render no matter how many warnings the user clicked through. (Mixed content
blocks the plain-HTTP variant first, before the SameSite cookie question the
symptom was originally filed under.)

A CA fixes it structurally: ports are not part of a certificate's identity, so
one leaf with the right SANs covers every port on the host, and one installed
CA trusts them all.

- scripts/setup-node-ca.sh generates the CA (4096-bit, pathlen:0, keyCertSign
  only) and issues a 397-day leaf covering archipelago.local, the hostname, the
  Tailscale MagicDNS name and every global address the host holds. Idempotent —
  re-running reuses the CA and only reissues the leaf, so gaining an address
  does not invalidate copies users already installed. --force-ca is the
  deliberate escape hatch and says what it costs.
- nginx serves the public CA at /ca.crt on both schemes, unauthenticated by
  design: a device fetches it before it can validate the node, so gating it
  behind HTTPS or a login would be a chicken-and-egg.
- Settings → System shows the fingerprint and per-platform install steps.
  crypto.subtle does not exist outside a secure context — precisely the case
  this feature exists to fix — so an HTTP dashboard gets the openssl command
  to verify by hand instead of a blank field.

Verified locally: chain validates, key pairs with the leaf, CA:TRUE/CA:FALSE
are correct, keys are 0600. Two TLS servers on different ports both verify
(ssl_verify_result=0) against the CA alone and are rejected without it — the
one-CA-covers-every-port claim, tested rather than assumed.

Not yet wired: app ports still serve plain HTTP. Putting TLS on them is the
next step and is what actually closes the iframe-login bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 14:38:30 -04:00
co-authored by Claude Opus 5
parent c65ee03a5c
commit aab74127f5
4 changed files with 299 additions and 0 deletions
@@ -18,6 +18,17 @@ server {
root /opt/archipelago/web-ui;
index index.html;
# This node's CA, for devices that have not trusted it yet. Deliberately
# unauthenticated and served over plain HTTP: a device fetches this BEFORE
# it can validate the node's own certificate, so requiring HTTPS or a login
# here would be a chicken-and-egg. It is a public certificate — never a key
# — and the dashboard shows its fingerprint so it can be checked on sight.
location = /ca.crt {
alias /etc/archipelago/ssl/ca-download.crt;
default_type application/x-x509-ca-cert;
add_header Content-Disposition 'attachment; filename="archipelago-node-ca.crt"';
}
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
@@ -934,6 +945,13 @@ server {
index index.html;
include snippets/archipelago-pwa.conf;
# Same CA download over HTTPS — see the note in the HTTP block above.
location = /ca.crt {
alias /etc/archipelago/ssl/ca-download.crt;
default_type application/x-x509-ca-cert;
add_header Content-Disposition 'attachment; filename="archipelago-node-ca.crt"';
}
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
// This node signs its own certificates with a CA that never leaves it. Install
// that CA once per device and every port on this node is trusted — which is what
// lets a gated app load inside the dashboard's frame at all: a cert warning
// cannot be clicked through inside an iframe, so an untrusted app port simply
// fails to render.
const fingerprint = ref('')
const fingerprintError = ref('')
const loading = ref(true)
const caAvailable = ref(false)
// SHA-256 over the DER bytes — the same number `openssl x509 -fingerprint
// -sha256` prints, so the two can be compared character for character.
async function computeFingerprint(pem: string): Promise<string> {
const body = pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, '')
const der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0))
const digest = await crypto.subtle.digest('SHA-256', der)
return Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, '0').toUpperCase())
.join(':')
}
onMounted(async () => {
try {
const res = await fetch('/ca.crt', { cache: 'no-store' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const pem = await res.text()
if (!pem.includes('BEGIN CERTIFICATE')) throw new Error('not a certificate')
caAvailable.value = true
// crypto.subtle only exists in a secure context. That is exactly the case
// this feature is meant to fix, so an HTTP dashboard lands here — say so
// and give the offline command rather than showing nothing.
if (!window.crypto?.subtle) {
fingerprintError.value =
'The fingerprint cannot be computed over a plain HTTP connection. Verify it on the node instead: openssl x509 -in /etc/archipelago/ssl/ca.crt -noout -fingerprint -sha256'
} else {
fingerprint.value = await computeFingerprint(pem)
}
} catch {
caAvailable.value = false
} finally {
loading.value = false
}
})
</script>
<template>
<div class="mb-6">
<h3 class="text-base font-medium text-white/90 mb-1">Node certificate</h3>
<p class="text-sm text-white/60 mb-4">
Install this node's certificate on a device and it stops warning you about
this node — on every port, not just the dashboard. Apps that open inside
the dashboard need this: a certificate warning cannot be accepted inside an
embedded frame, so an untrusted app shows nothing at all.
</p>
<div v-if="loading" class="text-sm text-white/50">Checking…</div>
<div
v-else-if="!caAvailable"
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
>
This node has not generated a certificate authority yet. Run
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">scripts/setup-node-ca.sh</code>
on the node, then reload this page.
</div>
<div v-else class="space-y-4">
<div>
<a
href="/ca.crt"
download="archipelago-node-ca.crt"
class="inline-flex items-center gap-2 px-4 py-3 glass-button rounded-lg text-sm font-semibold"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Download this node's certificate
</a>
</div>
<div>
<p class="text-sm font-medium text-white/80 mb-1">Fingerprint (SHA-256)</p>
<p v-if="fingerprint" class="font-mono text-xs text-white/70 break-all select-all">{{ fingerprint }}</p>
<p v-else class="text-xs text-orange-300/80">{{ fingerprintError }}</p>
<p class="text-xs text-white/50 mt-2">
Check this matches the fingerprint the node itself prints before you trust
it. If they differ, something is intercepting the connection do not install it.
</p>
</div>
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-white/80 py-2">
How to install it
</summary>
<div class="mt-2 space-y-3 text-sm text-white/60">
<p><strong class="text-white/80">macOS</strong> open the file, add it to the
<em>login</em> keychain, then find it in Keychain Access, open it, expand Trust
and set When using this certificate to <em>Always Trust</em>.</p>
<p><strong class="text-white/80">iOS / iPadOS</strong> download it in Safari and
allow the profile, then Settings General VPN &amp; Device Management to
install it, and finally Settings General About Certificate Trust Settings
to switch it on. Both steps are required.</p>
<p><strong class="text-white/80">Windows</strong> right-click Install
Certificate Local Machine place it in <em>Trusted Root Certification
Authorities</em>.</p>
<p><strong class="text-white/80">Android</strong> Settings Security
Encryption &amp; credentials Install a certificate CA certificate.</p>
<p><strong class="text-white/80">Linux</strong> copy to
<code class="px-1 py-0.5 bg-black/30 rounded text-xs">/usr/local/share/ca-certificates/</code>
and run <code class="px-1 py-0.5 bg-black/30 rounded text-xs">sudo update-ca-certificates</code>.
Firefox keeps its own store add it under Settings Privacy &amp; Security
View Certificates Authorities.</p>
<p class="text-white/50">
You are trusting this node, not a company. The signing key stays on the node
and only ever signs this node's own address. Anyone who takes the node also
takes that key remove the certificate from your devices if you retire it.
</p>
</div>
</details>
</div>
</div>
</template>
@@ -5,6 +5,7 @@ import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
import BackupSection from '@/views/settings/BackupSection.vue'
import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
</script>
@@ -16,6 +17,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
<AIDataAccessSection />
<WebhookSection />
<TelemetrySection />
<NodeCertificateSection />
<BackupSection />
<SystemDangerZone />
</template>
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Per-node certificate authority.
#
# WHY THIS EXISTS
#
# The node used to serve a bare self-signed leaf (setup-https-dev.sh). A browser
# can be told to trust that, but the exception is granted per ORIGIN — scheme +
# host + PORT. The dashboard on :443 and an app on :8334 are different origins,
# so each app port needed its own click-through, and a cert interstitial CANNOT
# be accepted inside an iframe: the embedded app just fails.
#
# A CA fixes that structurally. The user installs ONE certificate; every leaf it
# signs is then trusted, on every port, with no further prompts. Ports are not
# part of a certificate's identity — one leaf with the right SANs covers every
# port on the host — so this is what makes gated apps embeddable over HTTPS.
#
# The CA private key never leaves the node and signs nothing but this node's own
# leaf. Installing it means trusting THIS node, not a third party.
#
# Idempotent: re-running reuses an existing CA and only reissues the leaf (which
# is what you want when the node gains an address). Pass --force-ca to start over
# — that invalidates every copy users have already installed.
set -euo pipefail
SSL_DIR="${ARCHY_SSL_DIR:-/etc/archipelago/ssl}"
CA_CRT="$SSL_DIR/ca.crt"
CA_KEY="$SSL_DIR/ca.key"
CA_SRL="$SSL_DIR/ca.srl"
LEAF_CRT="$SSL_DIR/archipelago.crt"
LEAF_KEY="$SSL_DIR/archipelago.key"
CA_DAYS="${ARCHY_CA_DAYS:-3650}"
# Public CAs cap leaves at 398 days and browsers enforce it. That limit applies
# to publicly-trusted roots, not a privately-installed one, but a shorter leaf
# still bounds the damage from a key leak — and reissuing costs nothing here
# because this script is re-run on address changes anyway.
LEAF_DAYS="${ARCHY_LEAF_DAYS:-397}"
FORCE_CA=false
[ "${1:-}" = "--force-ca" ] && FORCE_CA=true
NODE_NAME="$(hostname -s 2>/dev/null || echo archipelago)"
log() { echo " $*"; }
mkdir -p "$SSL_DIR"
chmod 755 "$SSL_DIR"
# --- Subject alternative names -----------------------------------------------
# Every name/address the node can be reached by must be in the leaf, because a
# certificate is scoped to names, not ports. Missing one here means that access
# path still throws a warning even after the CA is installed.
collect_sans() {
local -a dns=() ips=()
dns+=("archipelago.local" "$NODE_NAME" "$NODE_NAME.local" "localhost")
# Tailscale gives a stable MagicDNS name; include it so tailnet access is clean.
if command -v tailscale >/dev/null 2>&1; then
local ts_name
ts_name="$(tailscale status --json 2>/dev/null \
| python3 -c 'import json,sys; d=json.load(sys.stdin); print((d.get("Self") or {}).get("DNSName","").rstrip("."))' 2>/dev/null || true)"
[ -n "$ts_name" ] && dns+=("$ts_name")
fi
# Every non-loopback address the host currently holds, plus loopback itself.
ips+=("127.0.0.1" "::1")
while read -r addr; do
[ -n "$addr" ] && ips+=("$addr")
done < <(ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | sort -u)
local out="" i=1 j=1
for d in $(printf '%s\n' "${dns[@]}" | awk 'NF' | sort -u); do
out="${out}DNS.$i:$d,"; i=$((i+1))
done
for a in $(printf '%s\n' "${ips[@]}" | awk 'NF' | sort -u); do
out="${out}IP.$j:$a,"; j=$((j+1))
done
echo "${out%,}"
}
SAN="$(collect_sans)"
[ -z "$SAN" ] && { echo "ERROR: no SANs resolved — refusing to issue a useless cert" >&2; exit 1; }
# --- CA ----------------------------------------------------------------------
if [ "$FORCE_CA" = true ] && [ -f "$CA_CRT" ]; then
log "--force-ca: replacing the existing CA (previously installed copies stop working)"
rm -f "$CA_CRT" "$CA_KEY" "$CA_SRL"
fi
if [ -f "$CA_CRT" ] && [ -f "$CA_KEY" ]; then
log "Reusing the existing node CA (installed copies keep working)"
else
log "Creating this node's certificate authority…"
openssl req -x509 -nodes -newkey rsa:4096 -sha256 -days "$CA_DAYS" \
-keyout "$CA_KEY" -out "$CA_CRT" \
-subj "/CN=Archipelago Node CA ($NODE_NAME)/O=Archipelago/OU=Node CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null
chmod 600 "$CA_KEY"
chmod 644 "$CA_CRT"
fi
# --- Leaf --------------------------------------------------------------------
log "Issuing the server certificate for: $SAN"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
openssl req -nodes -newkey rsa:2048 -sha256 \
-keyout "$TMP/leaf.key" -out "$TMP/leaf.csr" \
-subj "/CN=$NODE_NAME/O=Archipelago" 2>/dev/null
cat >"$TMP/leaf.ext" <<EOF
basicConstraints=CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=$SAN
EOF
openssl x509 -req -in "$TMP/leaf.csr" -CA "$CA_CRT" -CAkey "$CA_KEY" \
-CAcreateserial -CAserial "$CA_SRL" \
-out "$TMP/leaf.crt" -days "$LEAF_DAYS" -sha256 -extfile "$TMP/leaf.ext" 2>/dev/null
# Swap in place only once both halves exist, so a failure mid-run cannot leave
# nginx pointing at a cert whose key is gone.
install -m 644 "$TMP/leaf.crt" "$LEAF_CRT"
install -m 600 "$TMP/leaf.key" "$LEAF_KEY"
# The dashboard serves this for download; it is a public certificate, never the key.
install -m 644 "$CA_CRT" "$SSL_DIR/ca-download.crt"
FP="$(openssl x509 -in "$CA_CRT" -noout -fingerprint -sha256 | cut -d= -f2)"
log "CA fingerprint (SHA-256): $FP"
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet nginx; then
if nginx -t >/dev/null 2>&1; then
systemctl reload nginx && log "nginx reloaded"
else
echo "WARNING: nginx config test failed — NOT reloading. Certs are in place; fix nginx and reload." >&2
fi
fi
cat <<EOF
Done. Install $CA_CRT on each device that should reach this node without warnings.
The dashboard serves it at /ca.crt (Settings → Node certificate).
Verify the fingerprint above matches what the dashboard shows before trusting it.
EOF