From 274eddfb55ba5e537888fec51d1406f75281f25a Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Tue, 13 Oct 2026 04:28:23 +0000 Subject: [PATCH] docs(spec): peer-authentication-hardening: option D, SSH-style ed25519 key pairs Per-host identity without a CA; only public keys move, and the trusted list can ride on the replicated cluster config. Recommended over the phased per-role-keys/PKI plan; decision still deferred. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../peer-authentication-hardening/brief.md | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/.kiro/specs/peer-authentication-hardening/brief.md b/.kiro/specs/peer-authentication-hardening/brief.md index dfb89d6f..0d2f9de6 100644 --- a/.kiro/specs/peer-authentication-hardening/brief.md +++ b/.kiro/specs/peer-authentication-hardening/brief.md @@ -84,7 +84,8 @@ accident too: pointed at a production cluster, they bypass the apiserver. `cluster.bin`, logs or the pushed config. ## Open decision: secret type -Three candidates. All are verified at join only; afterwards the +Four candidates (D added 2026-10-12 from the user's question "or just +rsa/ed25519?"). All are verified at join only; afterwards the long-lived connection is the identity (connections are never re-used across roles). @@ -113,6 +114,54 @@ Notes: host then still needs its own file, so it shares C's distribution problem without C's revocation. +### D. Plain key pairs, SSH style (ed25519), no CA +Each component has its own key pair, generated on its host; the private +key never leaves it. The controller holds a list of trusted public keys, +each with its role (and, for a worker, its host name), like SSH's +`authorized_keys`. Clients pin the controllers' public keys, like +`known_hosts`. + +- **Proof at join:** the controller sends a random challenge; the peer + signs (challenge, TLS exporter value, role, host name) with its private + key; the controller verifies it against the listed public key and checks + that role and host match the entry. The controller signs the client's + challenge the same way, so clients verify the controller. +- **Compared with the others:** + + | | D. Key pairs (ed25519) | + |---|---| + | A stolen worker key lets you be | that one host, as a worker (like C) | + | Per-host identity | yes | + | Revoke one host | delete its line from the trusted list | + | Secret material that must move between hosts | **none**: only public keys move, and they are not secret | + | Where the trusted list lives | can be in the cluster config (e.g. a `pubkey` per host in `hosts.yaml`, plus apiserver/scheduler/admin keys), so it is replicated to every controller by the existing config path and changed by `xceadmin config push` | + | Expiry outages | none (keys don't expire; rotation is add new, remove old) | + | Cost | a custom signed challenge (as A/B), key generation per host, collecting public keys | + +- **Bootstrap trust:** controllers need each other's public keys and at + least one admin public key before any config is pushed, so those sit in + each controller's local startup config (next to `--peers`). Everything + else comes with the cluster config, which only an `admin` key can push. +- **Collecting public keys** (the distribution question becomes this): + config management fetches each host's `.pub` file, or, for elastic + hosts, a new host registers its own fresh public key once with a + short-lived token (distribution 2, but much simpler than issuing certs: + the controller only records a public key, it signs nothing and holds no + CA key). +- **Algorithm: ed25519 rather than RSA.** 32-byte keys, fast signing and + verification, deterministic signatures (no random-number pitfalls), no + parameters to choose. Supported by the OpenSSL 3 already linked (EVP), + Go `crypto/ed25519`, and Python `cryptography`. RSA (3072+) or ECDSA + P-256 only if a site's FIPS-validated crypto module lacks ed25519 + (FIPS 186-5 approves it, older modules may not), so keep the algorithm + name in each trusted-list entry. +- **Implementation detail:** TLS with raw public keys (RFC 7250, OpenSSL + 3.2+) could do this inside TLS, but Python's `ssl` (xceadmin) and Go's + `crypto/tls` don't support it, so an application-level signed challenge + in the join exchange is the portable choice. Python needs the TLS + exporter value: `ssl` doesn't expose it, pyOpenSSL does (new xceadmin + dependency). + ## Open decision: key distribution | | 1. Operator copies files | 2. Bootstrap token, then enrolment | 3. xce-node-manager places them | |---|---|---|---| @@ -123,7 +172,17 @@ Notes: | HA | each controller gets the same files | the issuing secret (CA key or root key) must be on every controller to enrol through any of them; it is a file on each controller, never replicated through the WAL | n/a | ## Recommendation (for decision) -Phase it, with the authenticator pluggable like the apiserver auth backends: +**Revised 2026-10-12: D (ed25519 key pairs) is the better fit.** It gives +C's per-host identity without a CA, certificates or expiry outages; no +secret ever moves between hosts; the trusted list rides on the existing +replicated cluster config; elastic hosts only need a token flow that +registers a public key. Its cost over B is small (signatures instead of +HMAC, collecting public keys). Unless that changes, D with public keys +collected by config management, plus token registration for elastic +hosts, replaces the phased plan below. + +Earlier phased plan (kept for comparison), with the authenticator +pluggable like the apiserver auth backends: 1. **Now: B (per-role keys) + distribution 1 (files), referenced by the node manager (3).** Smallest change that delivers everything in "Desired Outcome": apiserver-only user requests, the `admin` role,