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:
commit
1036f68522
@ -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)
|
||||
|
||||
264
products/catalyst/bootstrap/api/internal/handler/sshkey.go
Normal file
264
products/catalyst/bootstrap/api/internal/handler/sshkey.go
Normal 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[:]...)
|
||||
}
|
||||
183
products/catalyst/bootstrap/api/internal/handler/sshkey_test.go
Normal file
183
products/catalyst/bootstrap/api/internal/handler/sshkey_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
1129
products/catalyst/bootstrap/ui/package-lock.json
generated
1129
products/catalyst/bootstrap/ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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()
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user