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) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-13 04:28:23 +00:00
parent 20ebef6d28
commit 274eddfb55
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C

View file

@ -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,