From 14d610de952689f6639b9f55db9d90f0c46e74c2 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 5 Sep 2026 12:12:00 +0800 Subject: [PATCH 1/3] feat(credentials): add reusable bundle wizard --- CLAUDE.md | 6 + README.md | 13 ++ cmd/generate-project-credentials/main.go | 199 ++++++++++++++++++ cmd/generate-project-credentials/main_test.go | 49 +++++ hack/project-credentials-wizard.fish | 83 ++++++++ 5 files changed, 350 insertions(+) create mode 100644 cmd/generate-project-credentials/main.go create mode 100644 cmd/generate-project-credentials/main_test.go create mode 100755 hack/project-credentials-wizard.fish diff --git a/CLAUDE.md b/CLAUDE.md index 7f8734a..4938921 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,12 @@ not rotate ES256 signing keys or invalidate sessions. JWT environment names are rejected in `goTrueEnv`; provider-specific settings remain supported. +For a new or rotated external credential bundle, use +`fish hack/project-credentials-wizard.fish`. It atomically generates and +production-validates all five fields before handing them to the human through +the `copy` clipboard function. Use the complete output from one run; ad hoc or +field-by-field generators can create internally inconsistent bundles. + ## Durable ownership Never set a SupabaseProject controller owner on the CNPG Cluster, backup or diff --git a/README.md b/README.md index 25b57f3..1abb115 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,19 @@ Infisical Kubernetes operator to synchronize them into the orphaned Secret above. Keep `signingKeys` as its JSON string; do not wrap the five values in a second JSON document or store the derived JWKS in Infisical. +Generate a complete bundle with the repository wizard instead of assembling +the fields independently: + +```bash +fish hack/project-credentials-wizard.fish +``` + +The wizard requires `go`, `jq`, and a Fish function or command named `copy`. +It generates the values in memory, validates the complete bundle with the +operator's production validator, and copies each field in sequence without +printing or writing credentials to disk. A rerun creates a new atomic bundle; +never combine fields from separate runs. + For example, an Infisical `InfisicalStaticSecret` can target the same namespace with `creationPolicy: Orphan` (the auth objects and credentials are created separately): diff --git a/cmd/generate-project-credentials/main.go b/cmd/generate-project-credentials/main.go new file mode 100644 index 0000000..a7bf138 --- /dev/null +++ b/cmd/generate-project-credentials/main.go @@ -0,0 +1,199 @@ +/* +Copyright 2026 GuionAI. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "os" + "time" + + corev1 "k8s.io/api/core/v1" + + projectsecrets "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" + projectcrypto "github.com/GuionAI/cloudnative-supabase/pkg/crypto" +) + +const opaqueChecksumContext = "supabase-self-hosted" + +var rawURL = base64.RawURLEncoding + +type credentialBundle struct { + SigningKeys string `json:"signingKeys"` + PublishableKey string `json:"publishableKey"` + SecretKey string `json:"secretKey"` + AnonRoleJWT string `json:"anonRoleJwt"` + ServiceRoleJWT string `json:"serviceRoleJwt"` +} + +func main() { + bundle, err := generateCredentialBundle(time.Now(), rand.Reader) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "generate project credentials: %v\n", err) + os.Exit(1) + } + if err := json.NewEncoder(os.Stdout).Encode(bundle); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "encode project credentials: %v\n", err) + os.Exit(1) + } +} + +func generateCredentialBundle(now time.Time, random io.Reader) (credentialBundle, error) { + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), random) + if err != nil { + return credentialBundle{}, fmt.Errorf("generate ES256 key: %w", err) + } + kid, err := generateKeyID(random) + if err != nil { + return credentialBundle{}, err + } + + ext := true + jwk := projectcrypto.JWK{ + Kty: "EC", + Alg: projectcrypto.ES256, + Use: "sig", + Crv: "P-256", + X: encodeCoordinate(privateKey.X), + Y: encodeCoordinate(privateKey.Y), + D: encodeCoordinate(privateKey.D), + Kid: kid, + KeyOps: []string{"sign"}, + Ext: &ext, + } + signingKeys, err := json.Marshal([]projectcrypto.JWK{jwk}) + if err != nil { + return credentialBundle{}, fmt.Errorf("encode signing key: %w", err) + } + + publishableKey, err := generateOpaqueKey("sb_publishable_", random) + if err != nil { + return credentialBundle{}, fmt.Errorf("generate publishable key: %w", err) + } + secretKey, err := generateOpaqueKey("sb_secret_", random) + if err != nil { + return credentialBundle{}, fmt.Errorf("generate secret key: %w", err) + } + + expiresAt := now.AddDate(5, 0, 0).Unix() + anonRoleJWT, err := signRoleJWT(privateKey, kid, "anon", now.Unix(), expiresAt, random) + if err != nil { + return credentialBundle{}, fmt.Errorf("sign anon role JWT: %w", err) + } + serviceRoleJWT, err := signRoleJWT(privateKey, kid, "service_role", now.Unix(), expiresAt, random) + if err != nil { + return credentialBundle{}, fmt.Errorf("sign service role JWT: %w", err) + } + + bundle := credentialBundle{ + SigningKeys: string(signingKeys), + PublishableKey: publishableKey, + SecretKey: secretKey, + AnonRoleJWT: anonRoleJWT, + ServiceRoleJWT: serviceRoleJWT, + } + if _, err := projectsecrets.ValidateProjectCredentials(bundle.secret()); err != nil { + return credentialBundle{}, fmt.Errorf("generated bundle failed operator validation: %w", err) + } + return bundle, nil +} + +func (bundle credentialBundle) secret() *corev1.Secret { + return &corev1.Secret{StringData: map[string]string{ + projectsecrets.ProjectCredentialsSigningKeysKey: bundle.SigningKeys, + projectsecrets.ProjectCredentialsPublishableKey: bundle.PublishableKey, + projectsecrets.ProjectCredentialsSecretKey: bundle.SecretKey, + projectsecrets.ProjectCredentialsAnonRoleJWTKey: bundle.AnonRoleJWT, + projectsecrets.ProjectCredentialsServiceRoleJWTKey: bundle.ServiceRoleJWT, + }} +} + +func generateOpaqueKey(prefix string, random io.Reader) (string, error) { + randomBytes := make([]byte, 17) + if _, err := io.ReadFull(random, randomBytes); err != nil { + return "", err + } + randomSegment := rawURL.EncodeToString(randomBytes)[:22] + base := prefix + randomSegment + digest := sha256.Sum256([]byte(opaqueChecksumContext + "|" + base)) + checksum := rawURL.EncodeToString(digest[:])[:8] + return base + "_" + checksum, nil +} + +func generateKeyID(random io.Reader) (string, error) { + value := make([]byte, 16) + if _, err := io.ReadFull(random, value); err != nil { + return "", fmt.Errorf("generate key ID: %w", err) + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + value[0:4], value[4:6], value[6:8], value[8:10], value[10:16]), nil +} + +func signRoleJWT( + privateKey *ecdsa.PrivateKey, + kid, role string, + issuedAt, expiresAt int64, + random io.Reader, +) (string, error) { + header, err := json.Marshal(projectcrypto.JWTHeader{ + Alg: projectcrypto.ES256, + Typ: "JWT", + Kid: kid, + }) + if err != nil { + return "", err + } + claims, err := json.Marshal(projectcrypto.JWTClaims{ + Role: role, + Aud: projectcrypto.RequiredRoleAudience, + Iss: "supabase", + Iat: issuedAt, + Exp: expiresAt, + }) + if err != nil { + return "", err + } + + message := rawURL.EncodeToString(header) + "." + rawURL.EncodeToString(claims) + digest := sha256.Sum256([]byte(message)) + r, s, err := ecdsa.Sign(random, privateKey, digest[:]) + if err != nil { + return "", err + } + signature := append(paddedInteger(r), paddedInteger(s)...) + return message + "." + rawURL.EncodeToString(signature), nil +} + +func encodeCoordinate(value *big.Int) string { + return rawURL.EncodeToString(paddedInteger(value)) +} + +func paddedInteger(value *big.Int) []byte { + result := make([]byte, 32) + encoded := value.Bytes() + copy(result[len(result)-len(encoded):], encoded) + return result +} diff --git a/cmd/generate-project-credentials/main_test.go b/cmd/generate-project-credentials/main_test.go new file mode 100644 index 0000000..b9b0ba9 --- /dev/null +++ b/cmd/generate-project-credentials/main_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "crypto/rand" + "encoding/json" + "testing" + "time" + + projectsecrets "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" +) + +func TestGeneratedCredentialBundlePassesOperatorValidation(t *testing.T) { + bundle, err := generateCredentialBundle(time.Now(), rand.Reader) + if err != nil { + t.Fatalf("generateCredentialBundle() error = %v", err) + } + + projection, err := projectsecrets.ValidateProjectCredentials(bundle.secret()) + if err != nil { + t.Fatalf("generated bundle failed operator validation: %v", err) + } + if projection.SigningKeyID == "" || projection.PublicJWKS == "" { + t.Fatal("generated bundle did not produce signing metadata") + } +} + +func TestCredentialBundleJSONContract(t *testing.T) { + bundle, err := generateCredentialBundle(time.Now(), rand.Reader) + if err != nil { + t.Fatalf("generateCredentialBundle() error = %v", err) + } + encoded, err := json.Marshal(bundle) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + + var values map[string]string + if err := json.Unmarshal(encoded, &values); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if len(values) != len(projectsecrets.RequiredProjectCredentialKeys) { + t.Fatalf("JSON field count = %d, want %d", len(values), len(projectsecrets.RequiredProjectCredentialKeys)) + } + for _, key := range projectsecrets.RequiredProjectCredentialKeys { + if values[key] == "" { + t.Fatalf("JSON field %q is missing or empty", key) + } + } +} diff --git a/hack/project-credentials-wizard.fish b/hack/project-credentials-wizard.fish new file mode 100755 index 0000000..e4e87ef --- /dev/null +++ b/hack/project-credentials-wizard.fish @@ -0,0 +1,83 @@ +#!/usr/bin/env fish + +set -g credential_bundle "" + +function clear_clipboard + printf '' | copy + return $pipestatus[-1] +end + +function clear_credential_clipboard --on-event fish_exit + if type -q copy + clear_clipboard + end + set -e credential_bundle +end + +function fail --argument-names message + printf '\nError: %s\n' "$message" >&2 + exit 1 +end + +function wait_for_enter --argument-names prompt + read --local --prompt-str "$prompt" ignored + or fail "input ended before the bundle was saved" +end + +function copy_bundle_field --argument-names position field + set --local value (printf '%s' "$credential_bundle" | jq --exit-status --raw-output --arg field "$field" '.[$field]') + or fail "could not read $field from the generated bundle" + + printf '%s' "$value" | copy + set --local copy_status $pipestatus[-1] + set -e value + test $copy_status -eq 0 + or fail "copy failed for $field" + + printf '\n[%s/5] %s is now in your clipboard.\n' "$position" "$field" + wait_for_enter "Paste and save it under the exact key '$field', then press Enter: " + clear_clipboard + or fail "could not clear the clipboard after $field" +end + +for dependency in go jq + type -q "$dependency" + or fail "required command '$dependency' was not found" +end +type -q copy +or fail "Fish function or command 'copy' was not found" + +set --local repository_root (path resolve (dirname (status filename))/..) + +printf 'CloudNative Supabase project credential bundle\n' +printf '==============================================\n\n' +printf 'This generates one atomic five-field bundle in memory.\n' +printf 'No secret is printed or written to disk. Each field is copied only when needed.\n\n' +wait_for_enter 'Open the target project/environment/path in your secret manager, then press Enter: ' + +printf '\nGenerating and validating the complete bundle...\n' +set -g credential_bundle (cd "$repository_root"; go run ./cmd/generate-project-credentials) +or fail "credential generation or operator validation failed" + +printf '%s' "$credential_bundle" | jq --exit-status ' + type == "object" and + (keys | sort) == (["anonRoleJwt", "publishableKey", "secretKey", "serviceRoleJwt", "signingKeys"] | sort) and + all(.[]; type == "string" and length > 0) +' >/dev/null +or fail "generator returned an unexpected bundle" + +printf 'Bundle validated. Keep all five values from this run together.\n' + +copy_bundle_field 1 signingKeys +copy_bundle_field 2 publishableKey +copy_bundle_field 3 secretKey +copy_bundle_field 4 anonRoleJwt +copy_bundle_field 5 serviceRoleJwt + +printf '\nAll five fields were copied in sequence.\n' +wait_for_enter 'Confirm the destination contains all five exact keys, then press Enter to finish: ' +clear_clipboard +or fail "could not clear the clipboard" +set -e credential_bundle + +printf '\nDone. The in-memory bundle and clipboard have been cleared.\n' From 75b207cc245d8a8a43fad2adf7f9de5b1cc5c0c7 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 5 Sep 2026 12:24:14 +0800 Subject: [PATCH 2/3] fix(credentials): keep wizard secrets off stdout --- CLAUDE.md | 9 +- README.md | 13 +- cmd/generate-project-credentials/main_test.go | 49 ----- .../generator.go} | 24 +-- cmd/project-credentials-wizard/main.go | 174 ++++++++++++++++++ cmd/project-credentials-wizard/main_test.go | 92 +++++++++ go.mod | 2 +- hack/project-credentials-wizard.fish | 83 --------- 8 files changed, 284 insertions(+), 162 deletions(-) delete mode 100644 cmd/generate-project-credentials/main_test.go rename cmd/{generate-project-credentials/main.go => project-credentials-wizard/generator.go} (90%) create mode 100644 cmd/project-credentials-wizard/main.go create mode 100644 cmd/project-credentials-wizard/main_test.go delete mode 100755 hack/project-credentials-wizard.fish diff --git a/CLAUDE.md b/CLAUDE.md index 4938921..5c8694d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,10 +86,11 @@ not rotate ES256 signing keys or invalidate sessions. JWT environment names are rejected in `goTrueEnv`; provider-specific settings remain supported. -For a new or rotated external credential bundle, use -`fish hack/project-credentials-wizard.fish`. It atomically generates and -production-validates all five fields before handing them to the human through -the `copy` clipboard function. Use the complete output from one run; ad hoc or +For a new or rotated external credential bundle, have the human run +`go run ./cmd/project-credentials-wizard` in a trusted interactive terminal. +The wizard atomically generates and production-validates all five fields before +copying them through OSC52. Agents hand off this command and never run it on +the human's behalf. Use the complete output from one run; ad hoc or field-by-field generators can create internally inconsistent bundles. ## Durable ownership diff --git a/README.md b/README.md index 1abb115..b83927a 100644 --- a/README.md +++ b/README.md @@ -92,14 +92,15 @@ Generate a complete bundle with the repository wizard instead of assembling the fields independently: ```bash -fish hack/project-credentials-wizard.fish +go run ./cmd/project-credentials-wizard ``` -The wizard requires `go`, `jq`, and a Fish function or command named `copy`. -It generates the values in memory, validates the complete bundle with the -operator's production validator, and copies each field in sequence without -printing or writing credentials to disk. A rerun creates a new atomic bundle; -never combine fields from separate runs. +Run it yourself in a trusted interactive terminal; it refuses redirected input +or output. The wizard generates the values in process memory, validates the +complete bundle with the operator's production validator, and copies each +field through OSC52 without rendering plaintext or writing credentials to +disk. A rerun creates a new atomic bundle; never combine fields from separate +runs. For example, an Infisical `InfisicalStaticSecret` can target the same namespace with `creationPolicy: Orphan` (the auth objects and credentials are diff --git a/cmd/generate-project-credentials/main_test.go b/cmd/generate-project-credentials/main_test.go deleted file mode 100644 index b9b0ba9..0000000 --- a/cmd/generate-project-credentials/main_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import ( - "crypto/rand" - "encoding/json" - "testing" - "time" - - projectsecrets "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" -) - -func TestGeneratedCredentialBundlePassesOperatorValidation(t *testing.T) { - bundle, err := generateCredentialBundle(time.Now(), rand.Reader) - if err != nil { - t.Fatalf("generateCredentialBundle() error = %v", err) - } - - projection, err := projectsecrets.ValidateProjectCredentials(bundle.secret()) - if err != nil { - t.Fatalf("generated bundle failed operator validation: %v", err) - } - if projection.SigningKeyID == "" || projection.PublicJWKS == "" { - t.Fatal("generated bundle did not produce signing metadata") - } -} - -func TestCredentialBundleJSONContract(t *testing.T) { - bundle, err := generateCredentialBundle(time.Now(), rand.Reader) - if err != nil { - t.Fatalf("generateCredentialBundle() error = %v", err) - } - encoded, err := json.Marshal(bundle) - if err != nil { - t.Fatalf("json.Marshal() error = %v", err) - } - - var values map[string]string - if err := json.Unmarshal(encoded, &values); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if len(values) != len(projectsecrets.RequiredProjectCredentialKeys) { - t.Fatalf("JSON field count = %d, want %d", len(values), len(projectsecrets.RequiredProjectCredentialKeys)) - } - for _, key := range projectsecrets.RequiredProjectCredentialKeys { - if values[key] == "" { - t.Fatalf("JSON field %q is missing or empty", key) - } - } -} diff --git a/cmd/generate-project-credentials/main.go b/cmd/project-credentials-wizard/generator.go similarity index 90% rename from cmd/generate-project-credentials/main.go rename to cmd/project-credentials-wizard/generator.go index a7bf138..565af7a 100644 --- a/cmd/generate-project-credentials/main.go +++ b/cmd/project-credentials-wizard/generator.go @@ -19,14 +19,12 @@ package main import ( "crypto/ecdsa" "crypto/elliptic" - "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "math/big" - "os" "time" corev1 "k8s.io/api/core/v1" @@ -40,23 +38,11 @@ const opaqueChecksumContext = "supabase-self-hosted" var rawURL = base64.RawURLEncoding type credentialBundle struct { - SigningKeys string `json:"signingKeys"` - PublishableKey string `json:"publishableKey"` - SecretKey string `json:"secretKey"` - AnonRoleJWT string `json:"anonRoleJwt"` - ServiceRoleJWT string `json:"serviceRoleJwt"` -} - -func main() { - bundle, err := generateCredentialBundle(time.Now(), rand.Reader) - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "generate project credentials: %v\n", err) - os.Exit(1) - } - if err := json.NewEncoder(os.Stdout).Encode(bundle); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "encode project credentials: %v\n", err) - os.Exit(1) - } + SigningKeys string + PublishableKey string + SecretKey string + AnonRoleJWT string + ServiceRoleJWT string } func generateCredentialBundle(now time.Time, random io.Reader) (credentialBundle, error) { diff --git a/cmd/project-credentials-wizard/main.go b/cmd/project-credentials-wizard/main.go new file mode 100644 index 0000000..eaf8414 --- /dev/null +++ b/cmd/project-credentials-wizard/main.go @@ -0,0 +1,174 @@ +/* +Copyright 2026 GuionAI. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "bufio" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "os/signal" + "syscall" + "time" + + "golang.org/x/term" +) + +type clipboard interface { + Copy(string) error + Clear() error +} + +type osc52Clipboard struct { + terminal io.Writer +} + +func (clipboard osc52Clipboard) Copy(value string) error { + encoded := base64.StdEncoding.EncodeToString([]byte(value)) + _, err := fmt.Fprintf(clipboard.terminal, "\x1b]52;c;%s\a", encoded) + return err +} + +func (clipboard osc52Clipboard) Clear() error { + _, err := io.WriteString(clipboard.terminal, "\x1b]52;c;\a") + return err +} + +func main() { + if err := run(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "project credential wizard: %v\n", err) + os.Exit(1) + } +} + +func run() (err error) { + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { + return errors.New("run directly in an interactive terminal without redirected input or output") + } + + terminal, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("open controlling terminal: %w", err) + } + defer func() { + if closeErr := terminal.Close(); err == nil && closeErr != nil { + err = fmt.Errorf("close controlling terminal: %w", closeErr) + } + }() + + clipboard := osc52Clipboard{terminal: terminal} + interrupts := make(chan os.Signal, 1) + signal.Notify(interrupts, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(interrupts) + go func() { + <-interrupts + _ = clipboard.Clear() + os.Exit(130) + }() + + return runWizard(os.Stdin, os.Stdout, clipboard, time.Now(), rand.Reader) +} + +func runWizard( + input io.Reader, + output io.Writer, + clipboard clipboard, + now time.Time, + random io.Reader, +) (err error) { + defer func() { + if clearErr := clipboard.Clear(); err == nil && clearErr != nil { + err = fmt.Errorf("clear clipboard: %w", clearErr) + } + }() + + reader := bufio.NewReader(input) + _, _ = fmt.Fprintln(output, "CloudNative Supabase project credential bundle") + _, _ = fmt.Fprintln(output, "==============================================") + _, _ = fmt.Fprintln(output) + _, _ = fmt.Fprintln(output, "This generates one atomic five-field bundle in process memory.") + _, _ = fmt.Fprintln(output, "Credential values are sent to the terminal clipboard without rendering plaintext.") + _, _ = fmt.Fprintln(output) + destinationPrompt := "Open the target project/environment/path in your secret manager, " + + "then press Enter: " + if err := waitForEnter(reader, output, destinationPrompt); err != nil { + return err + } + + _, _ = fmt.Fprintln(output) + _, _ = fmt.Fprintln(output, "Generating and validating the complete bundle...") + bundle, err := generateCredentialBundle(now, random) + if err != nil { + return err + } + _, _ = fmt.Fprintln(output, "Bundle validated. Keep all five values from this run together.") + + fields := []struct { + name string + value string + }{ + {name: "signingKeys", value: bundle.SigningKeys}, + {name: "publishableKey", value: bundle.PublishableKey}, + {name: "secretKey", value: bundle.SecretKey}, + {name: "anonRoleJwt", value: bundle.AnonRoleJWT}, + {name: "serviceRoleJwt", value: bundle.ServiceRoleJWT}, + } + for index, field := range fields { + if err := clipboard.Copy(field.value); err != nil { + return fmt.Errorf("copy %s: %w", field.name, err) + } + _, _ = fmt.Fprintf(output, "\n[%d/%d] %s is now in your clipboard.\n", index+1, len(fields), field.name) + fieldPrompt := fmt.Sprintf( + "Paste and save it under the exact key %q, then press Enter: ", + field.name, + ) + if err := waitForEnter(reader, output, fieldPrompt); err != nil { + return err + } + if err := clipboard.Clear(); err != nil { + return fmt.Errorf("clear clipboard after %s: %w", field.name, err) + } + } + + _, _ = fmt.Fprintln(output) + _, _ = fmt.Fprintln(output, "All five fields were copied in sequence.") + confirmationPrompt := "Confirm the destination contains all five exact keys, " + + "then press Enter to finish: " + if err := waitForEnter(reader, output, confirmationPrompt); err != nil { + return err + } + if err := clipboard.Clear(); err != nil { + return fmt.Errorf("clear clipboard: %w", err) + } + + _, _ = fmt.Fprintln(output) + _, _ = fmt.Fprintln(output, "Done. The clipboard is clear; the credential process will now exit.") + return nil +} + +func waitForEnter(reader *bufio.Reader, output io.Writer, prompt string) error { + if _, err := io.WriteString(output, prompt); err != nil { + return fmt.Errorf("write prompt: %w", err) + } + if _, err := reader.ReadString('\n'); err != nil { + return errors.New("input ended before the bundle was saved") + } + return nil +} diff --git a/cmd/project-credentials-wizard/main_test.go b/cmd/project-credentials-wizard/main_test.go new file mode 100644 index 0000000..e8e18d8 --- /dev/null +++ b/cmd/project-credentials-wizard/main_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "bytes" + "crypto/rand" + "strings" + "testing" + "time" + + projectsecrets "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" +) + +type recordingClipboard struct { + copied []string + current string + clears int +} + +func (clipboard *recordingClipboard) Copy(value string) error { + clipboard.copied = append(clipboard.copied, value) + clipboard.current = value + return nil +} + +func (clipboard *recordingClipboard) Clear() error { + clipboard.current = "" + clipboard.clears++ + return nil +} + +func TestWizardCopiesValidatedBundleWithoutRenderingCredentials(t *testing.T) { + var output bytes.Buffer + clipboard := &recordingClipboard{} + input := strings.NewReader(strings.Repeat("\n", 7)) + + if err := runWizard(input, &output, clipboard, time.Now(), rand.Reader); err != nil { + t.Fatalf("runWizard() error = %v", err) + } + if len(clipboard.copied) != len(projectsecrets.RequiredProjectCredentialKeys) { + t.Fatalf("copied field count = %d, want %d", len(clipboard.copied), len(projectsecrets.RequiredProjectCredentialKeys)) + } + + bundle := credentialBundle{ + SigningKeys: clipboard.copied[0], + PublishableKey: clipboard.copied[1], + SecretKey: clipboard.copied[2], + AnonRoleJWT: clipboard.copied[3], + ServiceRoleJWT: clipboard.copied[4], + } + if _, err := projectsecrets.ValidateProjectCredentials(bundle.secret()); err != nil { + t.Fatalf("copied bundle failed operator validation: %v", err) + } + for _, value := range clipboard.copied { + if strings.Contains(output.String(), value) { + t.Fatal("wizard rendered a credential value") + } + } + if clipboard.current != "" || clipboard.clears < len(clipboard.copied)+1 { + t.Fatal("wizard did not clear the clipboard") + } +} + +func TestWizardClearsClipboardWhenInputEnds(t *testing.T) { + clipboard := &recordingClipboard{} + err := runWizard(strings.NewReader("\n"), &bytes.Buffer{}, clipboard, time.Now(), rand.Reader) + if err == nil { + t.Fatal("runWizard() accepted incomplete input") + } + if len(clipboard.copied) != 1 { + t.Fatalf("copied field count = %d, want 1", len(clipboard.copied)) + } + if clipboard.current != "" { + t.Fatal("wizard left a credential in the clipboard after failure") + } +} + +func TestOSC52ClipboardWritesOnlyToItsTerminal(t *testing.T) { + var terminal bytes.Buffer + clipboard := osc52Clipboard{terminal: &terminal} + if err := clipboard.Copy("test"); err != nil { + t.Fatalf("Copy() error = %v", err) + } + if got, want := terminal.String(), "\x1b]52;c;dGVzdA==\a"; got != want { + t.Fatalf("Copy() output = %q, want %q", got, want) + } + if err := clipboard.Clear(); err != nil { + t.Fatalf("Clear() error = %v", err) + } + if !strings.HasSuffix(terminal.String(), "\x1b]52;c;\a") { + t.Fatal("Clear() did not emit an empty OSC52 payload") + } +} diff --git a/go.mod b/go.mod index 3028ae0..2c0eed4 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/cloudnative-pg/plugin-barman-cloud v0.10.0 github.com/onsi/ginkgo/v2 v2.27.3 github.com/onsi/gomega v1.38.3 + golang.org/x/term v0.38.0 k8s.io/api v0.35.0 k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 @@ -96,7 +97,6 @@ require ( golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.39.0 // indirect diff --git a/hack/project-credentials-wizard.fish b/hack/project-credentials-wizard.fish deleted file mode 100755 index e4e87ef..0000000 --- a/hack/project-credentials-wizard.fish +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env fish - -set -g credential_bundle "" - -function clear_clipboard - printf '' | copy - return $pipestatus[-1] -end - -function clear_credential_clipboard --on-event fish_exit - if type -q copy - clear_clipboard - end - set -e credential_bundle -end - -function fail --argument-names message - printf '\nError: %s\n' "$message" >&2 - exit 1 -end - -function wait_for_enter --argument-names prompt - read --local --prompt-str "$prompt" ignored - or fail "input ended before the bundle was saved" -end - -function copy_bundle_field --argument-names position field - set --local value (printf '%s' "$credential_bundle" | jq --exit-status --raw-output --arg field "$field" '.[$field]') - or fail "could not read $field from the generated bundle" - - printf '%s' "$value" | copy - set --local copy_status $pipestatus[-1] - set -e value - test $copy_status -eq 0 - or fail "copy failed for $field" - - printf '\n[%s/5] %s is now in your clipboard.\n' "$position" "$field" - wait_for_enter "Paste and save it under the exact key '$field', then press Enter: " - clear_clipboard - or fail "could not clear the clipboard after $field" -end - -for dependency in go jq - type -q "$dependency" - or fail "required command '$dependency' was not found" -end -type -q copy -or fail "Fish function or command 'copy' was not found" - -set --local repository_root (path resolve (dirname (status filename))/..) - -printf 'CloudNative Supabase project credential bundle\n' -printf '==============================================\n\n' -printf 'This generates one atomic five-field bundle in memory.\n' -printf 'No secret is printed or written to disk. Each field is copied only when needed.\n\n' -wait_for_enter 'Open the target project/environment/path in your secret manager, then press Enter: ' - -printf '\nGenerating and validating the complete bundle...\n' -set -g credential_bundle (cd "$repository_root"; go run ./cmd/generate-project-credentials) -or fail "credential generation or operator validation failed" - -printf '%s' "$credential_bundle" | jq --exit-status ' - type == "object" and - (keys | sort) == (["anonRoleJwt", "publishableKey", "secretKey", "serviceRoleJwt", "signingKeys"] | sort) and - all(.[]; type == "string" and length > 0) -' >/dev/null -or fail "generator returned an unexpected bundle" - -printf 'Bundle validated. Keep all five values from this run together.\n' - -copy_bundle_field 1 signingKeys -copy_bundle_field 2 publishableKey -copy_bundle_field 3 secretKey -copy_bundle_field 4 anonRoleJwt -copy_bundle_field 5 serviceRoleJwt - -printf '\nAll five fields were copied in sequence.\n' -wait_for_enter 'Confirm the destination contains all five exact keys, then press Enter to finish: ' -clear_clipboard -or fail "could not clear the clipboard" -set -e credential_bundle - -printf '\nDone. The in-memory bundle and clipboard have been cleared.\n' From ed54096d964ce33f0373a76b4f082f8e6b084ee3 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 5 Sep 2026 13:13:07 +0800 Subject: [PATCH 3/3] fix(credentials): report clipboard cleanup failures --- cmd/project-credentials-wizard/main.go | 12 ++++++++--- cmd/project-credentials-wizard/main_test.go | 24 +++++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/cmd/project-credentials-wizard/main.go b/cmd/project-credentials-wizard/main.go index eaf8414..acd3db2 100644 --- a/cmd/project-credentials-wizard/main.go +++ b/cmd/project-credentials-wizard/main.go @@ -79,7 +79,13 @@ func run() (err error) { defer signal.Stop(interrupts) go func() { <-interrupts - _ = clipboard.Clear() + if err := clipboard.Clear(); err != nil { + _, _ = fmt.Fprintf( + os.Stderr, + "\nwarning: clipboard may still contain credential material: %v\n", + err, + ) + } os.Exit(130) }() @@ -94,8 +100,8 @@ func runWizard( random io.Reader, ) (err error) { defer func() { - if clearErr := clipboard.Clear(); err == nil && clearErr != nil { - err = fmt.Errorf("clear clipboard: %w", clearErr) + if clearErr := clipboard.Clear(); clearErr != nil { + err = errors.Join(err, fmt.Errorf("clear clipboard: %w", clearErr)) } }() diff --git a/cmd/project-credentials-wizard/main_test.go b/cmd/project-credentials-wizard/main_test.go index e8e18d8..c4c1ee9 100644 --- a/cmd/project-credentials-wizard/main_test.go +++ b/cmd/project-credentials-wizard/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "crypto/rand" + "errors" "strings" "testing" "time" @@ -11,9 +12,10 @@ import ( ) type recordingClipboard struct { - copied []string - current string - clears int + copied []string + current string + clears int + clearErr error } func (clipboard *recordingClipboard) Copy(value string) error { @@ -23,8 +25,11 @@ func (clipboard *recordingClipboard) Copy(value string) error { } func (clipboard *recordingClipboard) Clear() error { - clipboard.current = "" clipboard.clears++ + if clipboard.clearErr != nil { + return clipboard.clearErr + } + clipboard.current = "" return nil } @@ -74,6 +79,17 @@ func TestWizardClearsClipboardWhenInputEnds(t *testing.T) { } } +func TestWizardReportsInputAndClipboardCleanupFailures(t *testing.T) { + clipboard := &recordingClipboard{clearErr: errors.New("sentinel clipboard failure")} + err := runWizard(strings.NewReader("\n"), &bytes.Buffer{}, clipboard, time.Now(), rand.Reader) + if err == nil { + t.Fatal("runWizard() accepted incomplete input and failed cleanup") + } + if !strings.Contains(err.Error(), "input ended") || !strings.Contains(err.Error(), "clear clipboard") { + t.Fatalf("runWizard() error did not report both failures: %v", err) + } +} + func TestOSC52ClipboardWritesOnlyToItsTerminal(t *testing.T) { var terminal bytes.Buffer clipboard := osc52Clipboard{terminal: &terminal}