diff --git a/CLAUDE.md b/CLAUDE.md index 7f8734a..5c8694d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,13 @@ 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, 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 Never set a SupabaseProject controller owner on the CNPG Cluster, backup or diff --git a/README.md b/README.md index 25b57f3..b83927a 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,20 @@ 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 +go run ./cmd/project-credentials-wizard +``` + +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 created separately): diff --git a/cmd/project-credentials-wizard/generator.go b/cmd/project-credentials-wizard/generator.go new file mode 100644 index 0000000..565af7a --- /dev/null +++ b/cmd/project-credentials-wizard/generator.go @@ -0,0 +1,185 @@ +/* +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/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/big" + "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 + PublishableKey string + SecretKey string + AnonRoleJWT string + ServiceRoleJWT string +} + +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/project-credentials-wizard/main.go b/cmd/project-credentials-wizard/main.go new file mode 100644 index 0000000..acd3db2 --- /dev/null +++ b/cmd/project-credentials-wizard/main.go @@ -0,0 +1,180 @@ +/* +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 + if err := clipboard.Clear(); err != nil { + _, _ = fmt.Fprintf( + os.Stderr, + "\nwarning: clipboard may still contain credential material: %v\n", + err, + ) + } + 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(); clearErr != nil { + err = errors.Join(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..c4c1ee9 --- /dev/null +++ b/cmd/project-credentials-wizard/main_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "bytes" + "crypto/rand" + "errors" + "strings" + "testing" + "time" + + projectsecrets "github.com/GuionAI/cloudnative-supabase/internal/resources/secrets" +) + +type recordingClipboard struct { + copied []string + current string + clears int + clearErr error +} + +func (clipboard *recordingClipboard) Copy(value string) error { + clipboard.copied = append(clipboard.copied, value) + clipboard.current = value + return nil +} + +func (clipboard *recordingClipboard) Clear() error { + clipboard.clears++ + if clipboard.clearErr != nil { + return clipboard.clearErr + } + clipboard.current = "" + 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 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} + 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