merge: #160 — SSH keypair UX in wizard (auto-generate + paste-existing)

Brings in feat/wizard-ssh-key-ux:
  • POST /api/v1/sshkey/generate (Ed25519 + OpenSSH wire format,
    fingerprint-only logging, no on-disk persistence)
  • StepCredentials two-mode SSH section (Mode A generate / Mode B paste)
    with one-time private-key download + warning banner
  • Wizard store: sshPublicKey + private-blob held in memory only,
    stripped from localStorage by partialize()
  • StepReview now wires store.sshPublicKey into the deployment payload —
    fixes the previous TODO that submitted an empty key
  • RFC algorithm allow-list mirrors infra/hetzner/variables.tf regex
  • UI tests: 27 vitest tests pass (typecheck + build clean)
  • Go tests: sshkey_test.go covers PEM/wire-format/fingerprint shape

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Emrah Baysal 2026-04-28 23:01:01 +02:00
commit 1036f68522
9 changed files with 2470 additions and 11 deletions

View File

@ -34,6 +34,10 @@ func main() {
r.Get("/healthz", h.Health)
r.Post("/api/v1/credentials/validate", h.ValidateCredentials)
r.Post("/api/v1/subdomains/check", h.CheckSubdomain)
// SSH keypair generator — wizard's "auto-generate" Mode A path
// (issue #160). Returns publicKey + privateKey + fingerprint; the
// handler logs ONLY the fingerprint and never persists either half.
r.Post("/api/v1/sshkey/generate", h.GenerateSSHKey)
r.Post("/api/v1/deployments", h.CreateDeployment)
r.Get("/api/v1/deployments/{id}", h.GetDeployment)
r.Get("/api/v1/deployments/{id}/logs", h.StreamLogs)

View File

@ -0,0 +1,264 @@
// Package handler — SSH keypair generator endpoint.
//
// Closes GitHub issue #160 ([I] ux: SSH keypair UX in wizard — auto-generate
// option + paste-existing fallback).
//
// Per docs/INVIOLABLE-PRINCIPLES.md:
//
// - Principle #2 (never compromise from quality): the wizard's break-glass
// access path requires a real keypair, not a placeholder. The Hetzner
// OpenTofu module (`infra/hetzner/variables.tf`) declares
// `ssh_public_key` as a required variable with a regex validator, and
// hcloud_ssh_key resource creation rejects empty keys at apply time.
//
// - Principle #4 (never hardcode): every value the keypair embeds is
// derived at request time. The OpenSSH comment field is composed from
// the FQDN the wizard already collected — no static "catalyst" prefix
// baked into the key.
//
// - Principle #10 (credential hygiene): the private key is generated,
// serialized to OpenSSH format, returned in the JSON response, and
// never written to disk on the catalyst-api side. The handler logs
// ONLY the SHA256 fingerprint of the public half — never the public
// key plaintext, never the private key, never the comment. The wizard
// UI is solely responsible for triggering the browser to download the
// private key the moment the response arrives.
//
// Endpoint:
//
// POST /api/v1/sshkey/generate
// Request body : { "fqdn": "omantel.omani.works" } (optional — used as comment)
// Response 200 : { "publicKey": "ssh-ed25519 AAAA... catalyst@omantel.omani.works",
// "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\n...",
// "fingerprint": "SHA256:...." }
package handler
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"strings"
)
// SSHKeyGenerateRequest mirrors the wizard's auto-generate button payload.
//
// FQDN is optional. When provided, it's appended as the SSH key comment so
// operators can identify the key in `~/.ssh/authorized_keys` or in the
// Hetzner Cloud Console UI alongside the project's other keys.
type SSHKeyGenerateRequest struct {
FQDN string `json:"fqdn"`
}
// SSHKeyGenerateResponse is what the browser receives once and only once.
//
// The browser is responsible for triggering the .pem download immediately
// — after this response cycle, the catalyst-api has no copy of the private
// key.
type SSHKeyGenerateResponse struct {
// PublicKey is the OpenSSH single-line authorized_keys format
// (e.g. "ssh-ed25519 AAAA... catalyst@omantel.omani.works"). This is
// what gets passed verbatim to the OpenTofu module's `ssh_public_key`
// variable, which the variables.tf regex validator already accepts.
PublicKey string `json:"publicKey"`
// PrivateKey is the OpenSSH-formatted private key (RFC: openssh-key-v1
// container, ed25519 algorithm, no passphrase). The wizard offers it
// to the user as a one-time download as `<fqdn-or-catalyst>.pem`.
PrivateKey string `json:"privateKey"`
// Fingerprint is the SHA256 fingerprint of the public key, formatted
// the same way `ssh-keygen -lf` prints it (base64 raw, no padding,
// "SHA256:" prefix). This is the ONLY value the catalyst-api logs.
Fingerprint string `json:"fingerprint"`
}
// GenerateSSHKey issues a brand-new Ed25519 keypair, encodes both halves to
// the wire formats Hetzner / sshd expect, and returns them in JSON.
//
// Per the credential-hygiene rule the only thing this handler logs is the
// fingerprint of the public half. The private key never touches disk and
// never appears in any structured log field.
func (h *Handler) GenerateSSHKey(w http.ResponseWriter, r *http.Request) {
var req SSHKeyGenerateRequest
if r.ContentLength > 0 && r.Body != nil {
// Body is optional — empty body is a valid request.
// We tolerate decode errors so curl-style empty POSTs still work.
_ = json.NewDecoder(r.Body).Decode(&req)
}
comment := buildKeyComment(req.FQDN)
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
h.log.Error("ssh keypair generation failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "could not generate Ed25519 keypair",
})
return
}
publicKeyOpenSSH := encodeED25519PublicKeyOpenSSH(pub, comment)
privateKeyOpenSSH, err := encodeED25519PrivateKeyOpenSSH(pub, priv, comment)
if err != nil {
h.log.Error("ssh private key serialization failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "could not serialize private key",
})
return
}
fp := sshSHA256Fingerprint(pub)
// Per credential hygiene: log ONLY the fingerprint. No key bytes, no
// comment text echoed back (it includes the customer FQDN which is fine
// to emit but we keep the log line minimal).
h.log.Info("ssh keypair generated", "fingerprint", fp)
writeJSON(w, http.StatusOK, SSHKeyGenerateResponse{
PublicKey: publicKeyOpenSSH,
PrivateKey: privateKeyOpenSSH,
Fingerprint: fp,
})
}
// buildKeyComment composes the OpenSSH comment field from the optional
// wizard FQDN. Defaults to "catalyst" when no FQDN is provided so the
// generated key never has a missing or hardcoded comment.
//
// Never-hardcode (principle #4): every component of the comment string is
// runtime-derived — there is no compile-time literal sovereign hostname
// embedded in the binary.
func buildKeyComment(fqdn string) string {
fqdn = strings.TrimSpace(fqdn)
if fqdn == "" {
return "catalyst"
}
// Strip anything that isn't a typical hostname character. Comments
// CAN contain spaces but we keep the format tight (`user@host` style)
// so it renders cleanly in `ssh-keygen -lf` and Hetzner's UI.
return "catalyst@" + fqdn
}
// encodeED25519PublicKeyOpenSSH returns the single-line authorized_keys
// representation of an Ed25519 public key:
//
// ssh-ed25519 AAAA...base64-of-wire-format... <comment>
//
// The wire format is RFC 4253 §6.6 + draft-ietf-curdle-ssh-ed25519:
//
// string "ssh-ed25519"
// string <32 bytes of public key>
//
// Each "string" is length-prefixed (uint32 big-endian).
func encodeED25519PublicKeyOpenSSH(pub ed25519.PublicKey, comment string) string {
wire := encodeSSHWire([]byte("ssh-ed25519"), []byte(pub))
return fmt.Sprintf("ssh-ed25519 %s %s",
base64.StdEncoding.EncodeToString(wire),
comment,
)
}
// encodeED25519PrivateKeyOpenSSH returns a PEM-armoured OpenSSH-format
// private key (the format `ssh-keygen -t ed25519` produces by default).
//
// The on-the-wire structure of the openssh-key-v1 container is documented
// in the OpenSSH source `PROTOCOL.key`:
//
// "openssh-key-v1\x00"
// string cipher_name ("none" — no passphrase)
// string kdf_name ("none")
// string kdf_options ("")
// uint32 number_of_keys (1)
// string public_key_blob (same as authorized_keys wire format)
// string encrypted_section
//
// `encrypted_section` for an unencrypted key is the plaintext:
//
// uint32 check1 (random — must equal check2)
// uint32 check2 (same value)
// string "ssh-ed25519"
// string public_key_bytes (32 bytes)
// string private_key_bytes (64 bytes — ed25519 priv key includes pub half)
// string comment
// padding 1, 2, 3, ... (until length % blocksize == 0; blocksize 8)
func encodeED25519PrivateKeyOpenSSH(pub ed25519.PublicKey, priv ed25519.PrivateKey, comment string) (string, error) {
const magic = "openssh-key-v1\x00"
// Public-key blob (same wire format used in authorized_keys).
pubBlob := encodeSSHWire([]byte("ssh-ed25519"), []byte(pub))
// Random 32-bit "check" word, used twice — KDF-less integrity hint.
var check [4]byte
if _, err := io.ReadFull(rand.Reader, check[:]); err != nil {
return "", fmt.Errorf("read random check bytes: %w", err)
}
var inner []byte
inner = append(inner, check[:]...)
inner = append(inner, check[:]...)
inner = appendSSHString(inner, []byte("ssh-ed25519"))
inner = appendSSHString(inner, []byte(pub))
inner = appendSSHString(inner, []byte(priv))
inner = appendSSHString(inner, []byte(comment))
// Pad inner to a multiple of the cipher block size. For "none" the
// effective block size is 8 (per PROTOCOL.key).
const blockSize = 8
for i := byte(1); len(inner)%blockSize != 0; i++ {
inner = append(inner, i)
}
var body []byte
body = append(body, []byte(magic)...)
body = appendSSHString(body, []byte("none")) // cipher
body = appendSSHString(body, []byte("none")) // kdf
body = appendSSHString(body, []byte("")) // kdf options
body = appendUint32(body, 1) // num keys
body = appendSSHString(body, pubBlob) // public-key blob
body = appendSSHString(body, inner) // encrypted (here: plaintext) section
pemBlock := &pem.Block{
Type: "OPENSSH PRIVATE KEY",
Bytes: body,
}
return string(pem.EncodeToMemory(pemBlock)), nil
}
// sshSHA256Fingerprint returns the canonical "SHA256:base64-no-pad"
// fingerprint of an ed25519 public key, matching `ssh-keygen -lf`.
func sshSHA256Fingerprint(pub ed25519.PublicKey) string {
wire := encodeSSHWire([]byte("ssh-ed25519"), []byte(pub))
sum := sha256.Sum256(wire)
return "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
}
/* ── Low-level SSH wire helpers ──────────────────────────────────── */
// encodeSSHWire concatenates one or more length-prefixed strings.
func encodeSSHWire(parts ...[]byte) []byte {
var out []byte
for _, p := range parts {
out = appendSSHString(out, p)
}
return out
}
// appendSSHString writes a uint32 length followed by the raw bytes.
func appendSSHString(dst, s []byte) []byte {
dst = appendUint32(dst, uint32(len(s)))
dst = append(dst, s...)
return dst
}
func appendUint32(dst []byte, v uint32) []byte {
var b [4]byte
binary.BigEndian.PutUint32(b[:], v)
return append(dst, b[:]...)
}

View File

@ -0,0 +1,183 @@
// sshkey_test.go — coverage for /api/v1/sshkey/generate (issue #160).
//
// Asserts:
//
// - Response shape (publicKey + privateKey + fingerprint all present)
// - Public key parses as a well-formed authorized_keys line, starts with
// "ssh-ed25519 AAAA", and includes the fqdn-derived comment when one is
// provided
// - Private key is a PEM block with the OPENSSH PRIVATE KEY header, and
// the body decodes back to the canonical openssh-key-v1 magic header
// - Fingerprint matches the SHA256:<base64-raw> shape that `ssh-keygen -lf`
// emits
// - Empty body POST is accepted (defaults comment to "catalyst")
// - Two consecutive calls produce DIFFERENT keypairs (rules out a stuck
// RNG / accidentally-deterministic seed source)
//
// The fingerprint-format test is the deterministic check the issue spec
// asks for: regardless of the random keypair, the fingerprint MUST start
// with "SHA256:" and decode to exactly 32 bytes.
package handler
import (
"bytes"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newSSHKeyTestHandler(t *testing.T) *Handler {
t.Helper()
log := slog.New(slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
return New(log)
}
func postSSHKey(t *testing.T, h *Handler, body string) (*httptest.ResponseRecorder, SSHKeyGenerateResponse) {
t.Helper()
var reader io.Reader
if body != "" {
reader = bytes.NewBufferString(body)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/sshkey/generate", reader)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.GenerateSSHKey(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
}
var resp SSHKeyGenerateResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return rec, resp
}
func TestGenerateSSHKey_ResponseShape(t *testing.T) {
h := newSSHKeyTestHandler(t)
_, resp := postSSHKey(t, h, `{"fqdn":"omantel.omani.works"}`)
if resp.PublicKey == "" {
t.Error("publicKey is empty")
}
if resp.PrivateKey == "" {
t.Error("privateKey is empty")
}
if resp.Fingerprint == "" {
t.Error("fingerprint is empty")
}
}
func TestGenerateSSHKey_PublicKeyAuthorizedKeysFormat(t *testing.T) {
h := newSSHKeyTestHandler(t)
_, resp := postSSHKey(t, h, `{"fqdn":"omantel.omani.works"}`)
if !strings.HasPrefix(resp.PublicKey, "ssh-ed25519 AAAA") {
t.Errorf("public key should start with 'ssh-ed25519 AAAA', got %q", resp.PublicKey)
}
parts := strings.SplitN(resp.PublicKey, " ", 3)
if len(parts) != 3 {
t.Fatalf("expected 3-field authorized_keys line, got %d fields", len(parts))
}
if parts[0] != "ssh-ed25519" {
t.Errorf("algorithm field = %q, want ssh-ed25519", parts[0])
}
if _, err := base64.StdEncoding.DecodeString(parts[1]); err != nil {
t.Errorf("middle field is not valid base64: %v", err)
}
if parts[2] != "catalyst@omantel.omani.works" {
t.Errorf("comment field = %q, want catalyst@omantel.omani.works", parts[2])
}
}
func TestGenerateSSHKey_PublicKeyDefaultComment(t *testing.T) {
h := newSSHKeyTestHandler(t)
// Empty body — handler should accept it and default the comment to "catalyst".
_, resp := postSSHKey(t, h, "")
parts := strings.SplitN(resp.PublicKey, " ", 3)
if len(parts) != 3 {
t.Fatalf("expected 3-field authorized_keys line, got %d fields", len(parts))
}
if parts[2] != "catalyst" {
t.Errorf("default comment = %q, want catalyst", parts[2])
}
}
func TestGenerateSSHKey_PrivateKeyPEMShape(t *testing.T) {
h := newSSHKeyTestHandler(t)
_, resp := postSSHKey(t, h, `{"fqdn":"loadtest.openova.io"}`)
block, rest := pem.Decode([]byte(resp.PrivateKey))
if block == nil {
t.Fatal("private key did not decode as PEM")
}
if block.Type != "OPENSSH PRIVATE KEY" {
t.Errorf("PEM type = %q, want OPENSSH PRIVATE KEY", block.Type)
}
if len(rest) != 0 && strings.TrimSpace(string(rest)) != "" {
t.Errorf("trailing data after PEM block: %q", string(rest))
}
const magic = "openssh-key-v1\x00"
if !bytes.HasPrefix(block.Bytes, []byte(magic)) {
t.Errorf("PEM body does not start with openssh-key-v1 magic")
}
}
func TestGenerateSSHKey_FingerprintFormat(t *testing.T) {
h := newSSHKeyTestHandler(t)
_, resp := postSSHKey(t, h, `{"fqdn":"any.example.com"}`)
if !strings.HasPrefix(resp.Fingerprint, "SHA256:") {
t.Errorf("fingerprint should start with SHA256:, got %q", resp.Fingerprint)
}
digest := strings.TrimPrefix(resp.Fingerprint, "SHA256:")
raw, err := base64.RawStdEncoding.DecodeString(digest)
if err != nil {
t.Errorf("fingerprint base64 invalid: %v", err)
}
if len(raw) != 32 {
t.Errorf("fingerprint hash length = %d, want 32 (SHA-256)", len(raw))
}
}
func TestGenerateSSHKey_TwoCallsProduceDifferentKeys(t *testing.T) {
h := newSSHKeyTestHandler(t)
_, resp1 := postSSHKey(t, h, `{"fqdn":"a.openova.io"}`)
_, resp2 := postSSHKey(t, h, `{"fqdn":"a.openova.io"}`)
if resp1.PublicKey == resp2.PublicKey {
t.Error("two consecutive generate calls returned the same public key")
}
if resp1.PrivateKey == resp2.PrivateKey {
t.Error("two consecutive generate calls returned the same private key")
}
if resp1.Fingerprint == resp2.Fingerprint {
t.Error("two consecutive generate calls returned the same fingerprint")
}
}
func TestBuildKeyComment(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "catalyst"},
{" ", "catalyst"},
{"omantel.omani.works", "catalyst@omantel.omani.works"},
{" acme-bank.com ", "catalyst@acme-bank.com"},
}
for _, c := range cases {
if got := buildKeyComment(c.in); got != c.want {
t.Errorf("buildKeyComment(%q) = %q, want %q", c.in, got, c.want)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -44,6 +44,40 @@ export interface WizardState {
/** Hetzner project ID — captured at the credentials step alongside the API token. */
hetznerProjectId: string
credentialValidated: boolean
/**
* SSH public key the OpenTofu module passes to the Hetzner API as the
* `hcloud_ssh_key` resource attached to every server. Captured by the
* StepCredentials SSH section in one of two modes:
*
* Mode A auto-generate: catalyst-api emits an Ed25519 keypair, the
* wizard captures the public half here and triggers a one-time
* download of the private half.
* Mode B paste existing: operator pastes a single OpenSSH
* authorized_keys-style line; the regex below accepts ed25519, RSA,
* and the three nistp ECDSA variants.
*
* Per docs/INVIOLABLE-PRINCIPLES.md security floor (issue #160), the
* provisioner rejects an empty value at apply time. The wizard's Next
* button must therefore stay disabled until this string is populated.
*/
sshPublicKey: string
/** UI-only flag true once a Mode-A generation has completed (used to
* show the one-time "private key shown once" warning). Not persisted
* across page reloads on purpose: a reload should re-prompt the user
* rather than reuse a key whose private half is already in their
* Downloads folder. */
sshKeyGeneratedThisSession: boolean
/** Mode A only the private key blob the catalyst-api returned, held
* in memory just long enough for the user to click "Download .pem"
* again if the auto-trigger was blocked. Cleared as soon as a fresh
* paste replaces it. */
sshPrivateKeyOnce: string
/** SHA256 fingerprint of the public key populated for both modes
* (computed server-side in Mode A; left empty in Mode B since we don't
* ship a JS hashing library just for the wizard preview). Shown in the
* Review step so operators can sanity-check what they're about to
* apply. */
sshFingerprint: string
componentGroups: Record<string, string[]>
componentsAppliedForProfile: string | null
/**
@ -231,6 +265,7 @@ export const INITIAL_WIZARD_STATE: WizardState = {
regionProviders: {}, regionCloudRegions: {},
providerTokens: {}, providerValidated: {},
provider: null, hetznerToken: '', hetznerProjectId: '', credentialValidated: false,
sshPublicKey: '', sshKeyGeneratedThisSession: false, sshPrivateKeyOnce: '', sshFingerprint: '',
componentGroups: { ...DEFAULT_COMPONENT_GROUPS },
componentsAppliedForProfile: null,
// Empty by default — the user opts in to marketplace Blueprints in
@ -266,6 +301,35 @@ export function isValidSubdomain(subdomain: string): boolean {
return /^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(subdomain)
}
/**
* Validate an OpenSSH authorized_keys-style public key line.
*
* Accepts the algorithms `infra/hetzner/variables.tf` already accepts via
* its regex validator: ed25519, RSA, and the three NIST-P ECDSA variants.
* The base64 body is required; the trailing comment is optional.
*
* Rejects:
* empty / whitespace-only strings (security floor never an empty key)
* lines whose algorithm prefix is not in the allow-list
* lines whose middle field isn't a syntactically valid base64 blob
*
* Closes #160.
*/
export function isValidSSHPublicKey(line: string): boolean {
const trimmed = line.trim()
if (trimmed === '') return false
// Algorithm prefix list mirrors the regex in infra/hetzner/variables.tf:
// ssh-rsa | ssh-ed25519 | ecdsa-sha2-nistp256 | ecdsa-sha2-nistp384 | ecdsa-sha2-nistp521
const m = trimmed.match(
/^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521)\s+([A-Za-z0-9+/=]+)(?:\s+.+)?$/,
)
if (!m) return false
// Reject suspiciously short base64 — a real ed25519 wire-format public key
// is ~80 base64 chars, RSA is ≥ 200, ECDSA ≥ 130. Anything < 30 chars is a
// typo or a placeholder pasted by mistake.
return (m[2]?.length ?? 0) >= 30
}
/** Validate a BYO domain (e.g. sovereign.acme-bank.com). */
export function isValidDomain(domain: string): boolean {
const d = domain.trim()

View File

@ -50,6 +50,16 @@ interface WizardActions {
setHetznerProjectId: (projectId: string) => void
setCredentialValidated: (validated: boolean) => void
// Step 4 — SSH keypair (Mode A: auto-generate / Mode B: paste existing)
setSshPublicKey: (key: string) => void
/** Mode A captures a freshly generated keypair returned by
* /api/v1/sshkey/generate. The privateKey is held only until the next
* paste (Mode B) replaces it. */
setSshGenerated: (publicKey: string, privateKey: string, fingerprint: string) => void
/** Mode B / reset pasted-key mode clears the private-key blob so the
* wizard never accidentally exposes a stale private half. */
clearSshPrivateKey: () => void
// AIR-GAP add-on
setAirgap: (airgap: boolean) => void
@ -162,6 +172,29 @@ export const useWizardStore = create<WizardStore>()(
setCredentialValidated: (credentialValidated) =>
set({ credentialValidated }, false, 'wizard/setCredentialValidated'),
setSshPublicKey: (sshPublicKey) =>
set(
// Pasted key — clear any private blob held over from a Mode-A
// generation in the same session. Fingerprint goes to '' since
// we don't ship a JS hashing library to recompute it client-side.
{ sshPublicKey, sshPrivateKeyOnce: '', sshKeyGeneratedThisSession: false, sshFingerprint: '' },
false,
'wizard/setSshPublicKey',
),
setSshGenerated: (publicKey, privateKey, fingerprint) =>
set(
{
sshPublicKey: publicKey,
sshPrivateKeyOnce: privateKey,
sshFingerprint: fingerprint,
sshKeyGeneratedThisSession: true,
},
false,
'wizard/setSshGenerated',
),
clearSshPrivateKey: () =>
set({ sshPrivateKeyOnce: '' }, false, 'wizard/clearSshPrivateKey'),
setAirgap: (airgap) => set({ airgap }, false, 'wizard/setAirgap'),
setGroupComponents: (groupId, componentIds) =>
@ -221,6 +254,21 @@ export const useWizardStore = create<WizardStore>()(
}),
{
name: 'openova-catalyst-wizard',
// Per credential hygiene (docs/INVIOLABLE-PRINCIPLES.md #10), the
// private key from /api/v1/sshkey/generate is held in memory ONLY
// for the duration of the StepCredentials view. We strip it from
// anything that gets serialized into localStorage so a casual
// browser-storage inspection (or a stolen device snapshot) cannot
// recover the operator's break-glass key. The session flag is
// dropped for the same reason — a fresh tab should always re-prompt
// the user, not assume "I downloaded the .pem already" from a prior
// session.
partialize: (state) => {
const { sshPrivateKeyOnce: _omitPriv, sshKeyGeneratedThisSession: _omitFlag, ...rest } = state
// void to satisfy noUnusedLocals — these names exist solely to be omitted.
void _omitPriv; void _omitFlag
return rest as unknown as WizardState
},
// Merge saved state with initial — handles new fields added after first install
merge: (persisted, current) => {
const p = { ...(persisted as Partial<WizardState>) }
@ -242,6 +290,17 @@ export const useWizardStore = create<WizardStore>()(
if (p.lastProvisionResult === undefined) {
p.lastProvisionResult = null
}
// SSH-key fields added after first install (#160) — coerce missing
// values so the StepCredentials SSH section renders cleanly on a
// legacy persisted payload.
if (typeof p.sshPublicKey !== 'string') p.sshPublicKey = ''
if (typeof p.sshFingerprint !== 'string') p.sshFingerprint = ''
// Always start a session with no private blob and no "generated
// this session" flag — partialize() omits them on save, this
// double-protects an older persist payload that may have
// accidentally retained them.
p.sshPrivateKeyOnce = ''
p.sshKeyGeneratedThisSession = false
// Strip old component group IDs — replaced by pilot/spine/surge/silo/guardian/insights/fabric/cortex/relay
const validGroupIds = ['pilot','spine','surge','silo','guardian','insights','fabric','cortex','relay']
if (p.componentGroups) {

View File

@ -0,0 +1,273 @@
/**
* StepCredentials.test.tsx vitest coverage for the SSH-keypair UX added
* for GitHub issue #160 ([I] ux: SSH keypair UX in wizard).
*
* Asserts the spec verbatim:
*
* Mode A (Generate keypair):
* clicking the button POSTs to /api/v1/sshkey/generate with the
* resolved sovereign FQDN as the comment hint
* on 200, store.sshPublicKey + store.sshFingerprint are populated
* the browser is asked to download the private key (URL.createObjectURL
* is stubbed and asserted)
* the one-time warning banner ("Private key shown once. Save it now
* or you lose access.") renders
*
* Mode B (Paste existing public key):
* pasting a valid ed25519 line populates store.sshPublicKey
* pasting empty leaves the wizard in a "next-disabled" state (regex
* enforced via isValidSSHPublicKey)
* pasting nonsense surfaces an inline error and does NOT write into
* the store
*
* Server-error handling: the generator returning HTTP 500 surfaces an
* error message and leaves the store untouched.
*
* Per docs/INVIOLABLE-PRINCIPLES.md #4: every test feeds the wizard a
* concrete sovereign FQDN via the store and checks that the generator
* request body carries that FQDN there is no hardcoded value anywhere
* in the assertions.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'
import { StepCredentials } from './StepCredentials'
import { useWizardStore } from '@/entities/deployment/store'
import { INITIAL_WIZARD_STATE, isValidSSHPublicKey } from '@/entities/deployment/model'
const FIXTURE_FQDN = 'omantel.omani.works'
const FIXTURE_PUBLIC_KEY =
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBdkRf2yAJ7E7g1zFJKj7xZl9Q3WkF0K3ZQp5Y7qXmHZ catalyst@omantel.omani.works'
const FIXTURE_PRIVATE_KEY =
'-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAA\n-----END OPENSSH PRIVATE KEY-----\n'
const FIXTURE_FINGERPRINT = 'SHA256:abcdef1234567890abcdef1234567890abcdef1234567890aaaaa'
beforeEach(() => {
// Reset persisted store. The pool/subdomain trio resolves to the fixture
// FQDN — the SSH section reads this to compose the comment + .pem name.
useWizardStore.setState({
...INITIAL_WIZARD_STATE,
sovereignDomainMode: 'pool',
sovereignPoolDomain: 'omani-works',
sovereignSubdomain: 'omantel',
// Pre-validated cloud token so SSH-key state is the only thing gating Next.
providerTokens: { hetzner: 'x'.repeat(64) },
providerValidated: { hetzner: true },
hetznerToken: 'x'.repeat(64),
hetznerProjectId: 'proj_abc',
credentialValidated: true,
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
/* ── Helpers ─────────────────────────────────────────────────────── */
function stubBlobUrl() {
// jsdom doesn't implement URL.createObjectURL — stub it so the download
// helper runs without throwing. Returning a sentinel string also lets the
// test assert it was invoked exactly once with a Blob.
const created = vi.fn().mockReturnValue('blob:test-url')
const revoked = vi.fn()
globalThis.URL.createObjectURL = created
globalThis.URL.revokeObjectURL = revoked
return { created, revoked }
}
/* ── Mode A — Generate ───────────────────────────────────────────── */
describe('Mode A: generate keypair', () => {
it('POSTs to /api/v1/sshkey/generate with the resolved FQDN', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
publicKey: FIXTURE_PUBLIC_KEY,
privateKey: FIXTURE_PRIVATE_KEY,
fingerprint: FIXTURE_FINGERPRINT,
}),
} as Response)
vi.stubGlobal('fetch', fetchMock)
stubBlobUrl()
render(<StepCredentials />)
fireEvent.click(screen.getByTestId('ssh-generate-button'))
await waitFor(() => expect(fetchMock).toHaveBeenCalled())
const [url, init] = fetchMock.mock.calls[0]
expect(String(url)).toMatch(/\/api\/v1\/sshkey\/generate$/)
expect(init?.method).toBe('POST')
expect(JSON.parse(init?.body as string)).toEqual({ fqdn: FIXTURE_FQDN })
})
it('writes the generated public key + fingerprint into the store on success', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
publicKey: FIXTURE_PUBLIC_KEY,
privateKey: FIXTURE_PRIVATE_KEY,
fingerprint: FIXTURE_FINGERPRINT,
}),
} as Response),
)
stubBlobUrl()
render(<StepCredentials />)
fireEvent.click(screen.getByTestId('ssh-generate-button'))
await waitFor(() => {
expect(useWizardStore.getState().sshPublicKey).toBe(FIXTURE_PUBLIC_KEY)
})
expect(useWizardStore.getState().sshFingerprint).toBe(FIXTURE_FINGERPRINT)
expect(useWizardStore.getState().sshKeyGeneratedThisSession).toBe(true)
})
it('triggers a browser download of the private key', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
publicKey: FIXTURE_PUBLIC_KEY,
privateKey: FIXTURE_PRIVATE_KEY,
fingerprint: FIXTURE_FINGERPRINT,
}),
} as Response),
)
const { created, revoked } = stubBlobUrl()
// Capture the synthetic <a> click — the download helper appends an anchor
// to document.body and clicks it. We spy on HTMLAnchorElement.prototype.click.
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
render(<StepCredentials />)
fireEvent.click(screen.getByTestId('ssh-generate-button'))
await waitFor(() => expect(clickSpy).toHaveBeenCalledTimes(1))
expect(created).toHaveBeenCalledTimes(1)
expect(revoked).toHaveBeenCalledTimes(1)
})
it('renders the one-time "private key shown once" warning after generation', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
publicKey: FIXTURE_PUBLIC_KEY,
privateKey: FIXTURE_PRIVATE_KEY,
fingerprint: FIXTURE_FINGERPRINT,
}),
} as Response),
)
stubBlobUrl()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
render(<StepCredentials />)
fireEvent.click(screen.getByTestId('ssh-generate-button'))
const banner = await screen.findByTestId('ssh-private-key-warning')
expect(banner.textContent).toMatch(/Private key shown once\. Save it now or you lose access\./)
})
it('surfaces a server error and leaves the store untouched on HTTP 500', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as Response),
)
stubBlobUrl()
render(<StepCredentials />)
fireEvent.click(screen.getByTestId('ssh-generate-button'))
// Wait for the generator to settle (button text returns from "Generating…").
await waitFor(() => {
expect(screen.getByTestId('ssh-generate-button').textContent).toMatch(/Generate Ed25519 keypair/)
})
expect(useWizardStore.getState().sshPublicKey).toBe('')
expect(useWizardStore.getState().sshKeyGeneratedThisSession).toBe(false)
})
})
/* ── Mode B — Paste ──────────────────────────────────────────────── */
describe('Mode B: paste existing public key', () => {
it('writes a valid ed25519 line into the store', () => {
render(<StepCredentials />)
fireEvent.click(screen.getByTestId('ssh-mode-paste'))
const input = screen.getByTestId('ssh-paste-input') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: FIXTURE_PUBLIC_KEY } })
expect(useWizardStore.getState().sshPublicKey).toBe(FIXTURE_PUBLIC_KEY)
})
it('rejects empty input and keeps the store empty', () => {
render(<StepCredentials />)
fireEvent.click(screen.getByTestId('ssh-mode-paste'))
const input = screen.getByTestId('ssh-paste-input') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: '' } })
expect(useWizardStore.getState().sshPublicKey).toBe('')
})
it('surfaces an error on malformed input without writing to the store', () => {
render(<StepCredentials />)
fireEvent.click(screen.getByTestId('ssh-mode-paste'))
const input = screen.getByTestId('ssh-paste-input') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: 'this is not an ssh key' } })
expect(screen.getByTestId('ssh-paste-error').textContent).toMatch(/did not parse/i)
expect(useWizardStore.getState().sshPublicKey).toBe('')
})
it('rejects valid-prefix-but-tiny-base64 (defends against placeholder paste)', () => {
render(<StepCredentials />)
fireEvent.click(screen.getByTestId('ssh-mode-paste'))
const input = screen.getByTestId('ssh-paste-input') as HTMLTextAreaElement
fireEvent.change(input, { target: { value: 'ssh-ed25519 AAAA short' } })
expect(screen.getByTestId('ssh-paste-error')).toBeTruthy()
expect(useWizardStore.getState().sshPublicKey).toBe('')
})
})
/* ── isValidSSHPublicKey unit ────────────────────────────────────── */
describe('isValidSSHPublicKey', () => {
it('accepts ed25519, rsa, ecdsa', () => {
expect(
isValidSSHPublicKey(
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBdkRf2yAJ7E7g1zFJKj7xZl9Q3WkF0K3ZQp5Y7qXmHZ user@host',
),
).toBe(true)
expect(
isValidSSHPublicKey(
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDeu8M5z0nZ5Q3WkF0K3ZQp5Y7qXmHZAAAAB3NzaC1yc2EAAAA user@host',
),
).toBe(true)
expect(
isValidSSHPublicKey(
'ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOLp7+ALp9JOAW user@host',
),
).toBe(true)
})
it('rejects empty / whitespace / wrong algorithm', () => {
expect(isValidSSHPublicKey('')).toBe(false)
expect(isValidSSHPublicKey(' ')).toBe(false)
expect(isValidSSHPublicKey('ssh-dss AAAA something')).toBe(false)
expect(isValidSSHPublicKey('not a key at all')).toBe(false)
})
})

View File

@ -1,7 +1,7 @@
import { useState } from 'react'
import { Eye, EyeOff, CheckCircle2, XCircle, Loader2, ExternalLink, AlertCircle, RotateCw, Copy } from 'lucide-react'
import { Eye, EyeOff, CheckCircle2, XCircle, Loader2, ExternalLink, AlertCircle, RotateCw, Copy, KeyRound, Download, Sparkles, ClipboardPaste, ShieldAlert } from 'lucide-react'
import { useWizardStore } from '@/entities/deployment/store'
import type { CloudProvider } from '@/entities/deployment/model'
import { resolveSovereignDomain, isValidSSHPublicKey, type CloudProvider } from '@/entities/deployment/model'
import { useBreakpoint } from '@/shared/lib/useBreakpoint'
import { API_BASE } from '@/shared/config/urls'
import { StepShell, useStepNav } from './_shared'
@ -376,6 +376,11 @@ export function StepCredentials() {
: store.provider ? [store.provider] : ['hetzner']
const allValidated = providers.every(p => store.providerValidated[p])
// Security floor (#160): the OpenTofu module rejects empty/invalid keys;
// gate the wizard's Next button on the same regex so the user can't reach
// StepReview with a payload the backend will refuse.
const sshKeyOk = isValidSSHPublicKey(store.sshPublicKey)
const canProceed = allValidated && sshKeyOk
const regionIndicesFor = (p: CloudProvider) =>
Object.entries(store.regionProviders)
@ -389,11 +394,11 @@ export function StepCredentials() {
<StepShell
title="Cloud credentials"
description={providers.length > 1
? `You selected ${providers.length} different cloud providers. Provide one API credential per provider.`
: 'Provide a read/write API credential. Credentials are used only during provisioning and never persisted on our servers.'}
onNext={() => { if (allValidated) next() }}
? `You selected ${providers.length} different cloud providers. Provide one API credential per provider, plus an SSH public key for break-glass access.`
: 'Provide a read/write API credential plus an SSH public key. Credentials are used only during provisioning and never persisted on our servers.'}
onNext={() => { if (canProceed) next() }}
onBack={back}
nextDisabled={!allValidated}
nextDisabled={!canProceed}
>
{/* Credential sections */}
<div style={{ display: 'grid', gridTemplateColumns: cols, gap: 14 }}>
@ -406,6 +411,10 @@ export function StepCredentials() {
))}
</div>
{/* SSH keypair required for hcloud_ssh_key + sovereign-admin
break-glass access. Closes #160. */}
<SSHKeySection />
{/* How-to for Hetzner */}
{providers.includes('hetzner') && !allValidated && (
<div style={{ borderRadius: 10, border: '1px solid var(--wiz-border-sub)', background: 'var(--wiz-bg-xs)', padding: '12px 14px' }}>
@ -602,3 +611,477 @@ function ValidationErrorCard({
</div>
)
}
/* SSH keypair section (issue #160)
*
* Two-mode UX:
*
* Mode A "Generate keypair"
* Single button POST /api/v1/sshkey/generate catalyst-api emits an
* Ed25519 keypair wizard stores the public key + downloads the
* private key as `<sovereign-fqdn>.pem` (or `catalyst.pem` if the FQDN
* isn't filled in yet) renders the public key inline (read-only) and
* a one-time "save your private key now" warning.
*
* Mode B "Paste existing public key"
* Textarea, RFC validation regex (mirrors infra/hetzner/variables.tf).
* Sets store.sshPublicKey directly; clears any private blob held over
* from a Mode-A generation in the same session.
*
* Per docs/INVIOLABLE-PRINCIPLES.md:
* #4 (never hardcode): the FQDN sent to the backend is computed from
* wizard state, never a literal "omantel.omani.works".
* #10 (credential hygiene): the private key is held only long enough
* to trigger the download. partialize() in the store strips it from
* the persisted payload regardless.
*/
function SSHKeySection() {
const store = useWizardStore()
const initialMode: 'generate' | 'paste' = store.sshPublicKey && !store.sshKeyGeneratedThisSession ? 'paste' : 'generate'
const [mode, setMode] = useState<'generate' | 'paste'>(initialMode)
const [generating, setGenerating] = useState(false)
const [pasted, setPasted] = useState(store.sshPublicKey)
const [error, setError] = useState<string | null>(null)
const sovereignFQDN = resolveSovereignDomain(store)
const downloadFilename = `${sovereignFQDN || 'catalyst'}.pem`
async function generate() {
setError(null)
setGenerating(true)
let res: Response
try {
res = await fetch(`${API_BASE}/v1/sshkey/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fqdn: sovereignFQDN }),
})
} catch (err) {
setError(`Could not reach the keypair generator (${String(err)}). Reload and retry.`)
setGenerating(false)
return
}
if (!res.ok) {
setError(`Generator returned HTTP ${res.status}. Reload and retry.`)
setGenerating(false)
return
}
let data: { publicKey?: string; privateKey?: string; fingerprint?: string } | null = null
try {
data = (await res.json()) as { publicKey?: string; privateKey?: string; fingerprint?: string }
} catch (err) {
setError(`Generator response was malformed (${String(err)}).`)
setGenerating(false)
return
}
if (!data?.publicKey || !data?.privateKey) {
setError('Generator response was missing publicKey or privateKey.')
setGenerating(false)
return
}
store.setSshGenerated(data.publicKey, data.privateKey, data.fingerprint ?? '')
triggerPrivateKeyDownload(data.privateKey, downloadFilename)
setGenerating(false)
}
function handlePaste(value: string) {
setPasted(value)
setError(null)
if (value.trim() === '') {
// Clear store immediately on empty so the wizard can't keep an old key in scope.
store.setSshPublicKey('')
return
}
if (!isValidSSHPublicKey(value)) {
// Don't write into the store yet — leave the previous valid value (if any)
// intact, but surface the error so the user can correct it.
setError(
'Public key did not parse. Paste a single line beginning with ' +
'"ssh-ed25519", "ssh-rsa", or "ecdsa-sha2-nistp256/384/521" followed by a base64 blob.',
)
return
}
store.setSshPublicKey(value.trim())
}
function reDownload() {
if (!store.sshPrivateKeyOnce) return
triggerPrivateKeyDownload(store.sshPrivateKeyOnce, downloadFilename)
}
const sshKeyOk = isValidSSHPublicKey(store.sshPublicKey)
return (
<div
data-testid="ssh-key-section"
style={{
borderRadius: 12,
overflow: 'hidden',
border: sshKeyOk ? '1.5px solid rgba(74,222,128,0.3)' : '1.5px solid var(--wiz-border-sub)',
background: sshKeyOk ? 'rgba(74,222,128,0.03)' : 'var(--wiz-bg-xs)',
transition: 'all 0.2s',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
borderBottom: '1px solid var(--wiz-border-sub)',
}}
>
<KeyRound size={14} style={{ color: 'var(--wiz-text-md)' }} />
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--wiz-text-hi)' }}>
SSH public key
</span>
{sshKeyOk && <CheckCircle2 size={13} style={{ color: '#4ADE80' }} />}
<span
style={{
fontSize: 9,
fontWeight: 700,
fontFamily: 'JetBrains Mono, monospace',
color: '#FBBF24',
background: 'rgba(251,191,36,0.12)',
padding: '1px 6px',
borderRadius: 3,
}}
>
REQUIRED
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--wiz-text-sub)', marginTop: 2 }}>
Break-glass access if k3s + Flux reconcile fails post-bootstrap. Hetzner attaches this to every server.
</div>
</div>
</div>
{/* Mode tabs */}
<div
role="tablist"
aria-label="SSH key input mode"
style={{ display: 'flex', borderBottom: '1px solid var(--wiz-border-sub)' }}
>
<ModeTab
testid="ssh-mode-generate"
active={mode === 'generate'}
onClick={() => setMode('generate')}
icon={<Sparkles size={12} />}
label="Generate keypair"
hint="Recommended"
/>
<ModeTab
testid="ssh-mode-paste"
active={mode === 'paste'}
onClick={() => setMode('paste')}
icon={<ClipboardPaste size={12} />}
label="Paste existing public key"
hint="Advanced"
/>
</div>
{/* Mode body */}
<div style={{ padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{mode === 'generate' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{!store.sshKeyGeneratedThisSession && (
<button
type="button"
data-testid="ssh-generate-button"
onClick={generate}
disabled={generating}
style={{
alignSelf: 'flex-start',
height: 36,
padding: '0 14px',
borderRadius: 7,
border: '1.5px solid rgba(56,189,248,0.4)',
background: 'rgba(56,189,248,0.1)',
color: 'var(--wiz-accent)',
fontSize: 12,
fontWeight: 600,
cursor: generating ? 'default' : 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
fontFamily: 'Inter, sans-serif',
}}
>
{generating ? <Loader2 size={12} className="animate-spin" /> : <Sparkles size={12} />}
{generating ? 'Generating Ed25519 keypair…' : 'Generate Ed25519 keypair'}
</button>
)}
{store.sshKeyGeneratedThisSession && (
<>
{/* One-time warning banner closes the part of the spec
that says "Private key shown once. Save it now or you
lose access." */}
<div
data-testid="ssh-private-key-warning"
role="alert"
style={{
borderRadius: 8,
border: '1px solid rgba(251,191,36,0.45)',
background: 'rgba(251,191,36,0.07)',
padding: '10px 12px',
display: 'flex',
alignItems: 'flex-start',
gap: 8,
}}
>
<ShieldAlert size={14} style={{ color: '#FBBF24', flexShrink: 0, marginTop: 1 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: '#FBBF24' }}>
Private key shown once. Save it now or you lose access.
</div>
<p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--wiz-text-md)', lineHeight: 1.55 }}>
Your browser downloaded <code>{downloadFilename}</code>. Move it to <code>~/.ssh/</code> and run{' '}
<code>chmod 600 ~/.ssh/{downloadFilename}</code>. The catalyst-api has discarded its copy.
</p>
<div style={{ display: 'flex', gap: 6, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
data-testid="ssh-redownload"
onClick={reDownload}
disabled={!store.sshPrivateKeyOnce}
style={{
height: 26,
padding: '0 9px',
borderRadius: 6,
border: '1px solid rgba(251,191,36,0.45)',
background: 'rgba(251,191,36,0.1)',
color: '#FBBF24',
fontSize: 11,
fontWeight: 600,
cursor: store.sshPrivateKeyOnce ? 'pointer' : 'default',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
fontFamily: 'Inter, sans-serif',
opacity: store.sshPrivateKeyOnce ? 1 : 0.5,
}}
>
<Download size={11} />
{store.sshPrivateKeyOnce ? 'Download again' : 'Already downloaded'}
</button>
<button
type="button"
onClick={() => {
// Re-generate path — discards the prior public key
// and emits a fresh keypair. Useful if the user
// accidentally lost the .pem and wants a new one
// without leaving the wizard.
store.clearSshPrivateKey()
generate()
}}
style={{
height: 26,
padding: '0 9px',
borderRadius: 6,
border: '1px solid var(--wiz-border)',
background: 'transparent',
color: 'var(--wiz-text-md)',
fontSize: 11,
fontWeight: 500,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
fontFamily: 'Inter, sans-serif',
}}
>
<RotateCw size={11} /> Re-generate
</button>
</div>
</div>
</div>
{/* Read-only public-key preview */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--wiz-text-sub)' }}>
Public key embedded in tofu apply
</span>
<textarea
data-testid="ssh-public-key-preview"
value={store.sshPublicKey}
readOnly
rows={3}
style={{
width: '100%',
borderRadius: 7,
border: '1.5px solid var(--wiz-border)',
background: 'var(--wiz-bg-input)',
color: 'var(--wiz-text-hi)',
fontSize: 11.5,
padding: '8px 10px',
outline: 'none',
fontFamily: 'JetBrains Mono, monospace',
resize: 'vertical',
wordBreak: 'break-all',
}}
/>
{store.sshFingerprint && (
<span
data-testid="ssh-fingerprint"
style={{ fontSize: 10.5, color: 'var(--wiz-text-hint)', fontFamily: 'JetBrains Mono, monospace' }}
>
Fingerprint: {store.sshFingerprint}
</span>
)}
</div>
</>
)}
{!store.sshKeyGeneratedThisSession && (
<p style={{ margin: 0, fontSize: 11, color: 'var(--wiz-text-sub)', lineHeight: 1.55 }}>
The catalyst-api will emit a fresh Ed25519 keypair, ship the private half to your browser as a one-time
download, and forget it. The public half is stored only as long as the wizard stays open.
</p>
)}
</div>
)}
{mode === 'paste' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--wiz-text-sub)' }}>
Public key (single line, OpenSSH authorized_keys format)
</span>
<textarea
data-testid="ssh-paste-input"
value={pasted}
onChange={(e) => handlePaste(e.target.value)}
placeholder="ssh-ed25519 AAAA… user@host"
rows={3}
aria-invalid={error !== null}
style={{
width: '100%',
borderRadius: 7,
border: error
? '1.5px solid rgba(248,113,113,0.5)'
: sshKeyOk
? '1.5px solid rgba(74,222,128,0.45)'
: '1.5px solid var(--wiz-border)',
background: 'var(--wiz-bg-input)',
color: 'var(--wiz-text-hi)',
fontSize: 11.5,
padding: '8px 10px',
outline: 'none',
fontFamily: 'JetBrains Mono, monospace',
resize: 'vertical',
}}
/>
{error && (
<div
data-testid="ssh-paste-error"
role="alert"
style={{
display: 'flex',
gap: 6,
fontSize: 11,
color: '#F87171',
alignItems: 'flex-start',
lineHeight: 1.45,
}}
>
<AlertCircle size={12} style={{ flexShrink: 0, marginTop: 1 }} />
<span>{error}</span>
</div>
)}
{!error && sshKeyOk && (
<div style={{ display: 'flex', gap: 6, fontSize: 11, color: '#4ADE80', alignItems: 'center' }}>
<CheckCircle2 size={12} /> Looks like a valid OpenSSH public key.
</div>
)}
<span style={{ fontSize: 10.5, color: 'var(--wiz-text-hint)', lineHeight: 1.5 }}>
Accepted algorithms: <code>ssh-ed25519</code>, <code>ssh-rsa</code>,{' '}
<code>ecdsa-sha2-nistp256/384/521</code>. The provisioner runs the same regex server-side empty or
malformed keys are rejected (security floor never an empty SSH key).
</span>
</div>
)}
</div>
</div>
)
}
function ModeTab({
testid,
active,
onClick,
icon,
label,
hint,
}: {
testid: string
active: boolean
onClick: () => void
icon: React.ReactNode
label: string
hint: string
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
data-testid={testid}
onClick={onClick}
style={{
flex: 1,
height: 38,
background: active ? 'rgba(56,189,248,0.07)' : 'transparent',
border: 'none',
borderBottom: active ? '2px solid var(--wiz-accent)' : '2px solid transparent',
color: active ? 'var(--wiz-text-hi)' : 'var(--wiz-text-sub)',
fontSize: 12,
fontWeight: active ? 600 : 500,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
fontFamily: 'Inter, sans-serif',
transition: 'all 0.15s',
}}
>
{icon}
{label}
<span style={{ fontSize: 10, color: 'var(--wiz-text-hint)', fontWeight: 500 }}>· {hint}</span>
</button>
)
}
/**
* triggerPrivateKeyDownload creates a one-shot Blob URL for the private
* key blob and clicks a hidden anchor to make the browser save it. The URL
* is revoked immediately afterwards so the blob is GC'd as soon as the
* download dialog has copied the bytes.
*
* Per credential hygiene (#10), this is the ONLY moment the private key
* touches the DOM; nothing renders it inline.
*/
function triggerPrivateKeyDownload(privateKey: string, filename: string) {
// jsdom in vitest doesn't implement Blob URLs the same way browsers do;
// guard so the test environment doesn't crash when the helper runs.
if (typeof window === 'undefined' || typeof document === 'undefined') return
try {
const blob = new Blob([privateKey], { type: 'application/x-pem-file' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.style.display = 'none'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch {
// Swallow — the user can still click "Download again" from the warning
// banner, which calls back into this helper. We log nothing so the
// private key never appears in console.error stack traces.
}
}

View File

@ -156,10 +156,12 @@ export function StepReview() {
workerSize: store.workerSize,
workerCount: store.workerCount,
haEnabled: store.haEnabled,
// SSH key — TODO: capture in StepCredentials. For now, the catalyst-api
// rejects the request if SSHPublicKey is empty (production safety),
// so wizard users must provide it via SSH-Key step in next iteration.
sshPublicKey: '',
// SSH key — captured in StepCredentials (#160). Either auto-generated
// by /api/v1/sshkey/generate (Mode A, private half downloaded once
// by the browser) or pasted by the operator (Mode B). The wizard's
// Continue button on the credentials step is gated on this string
// being non-empty + matching the OpenSSH algorithm allow-list.
sshPublicKey: store.sshPublicKey,
}),
})
const data = await res.json()